refactor: Prettier 格式化全量前端、Library 服务端分页筛选、Agent 工具输出可视化与 .agent 目录统一
代码质量:
- 新增 .prettierrc + eslint-plugin-prettier,全量格式化所有 TSX/TS/CSS 文件
- npm scripts 新增 format / format:check / lint:fix
Logo 重设计:
- SVG logo 从简单星月改为望远镜+轨道+三角架+星光,favicon 同步更新
Library 服务端分页与多维筛选:
- LibraryQueryParams 支持 q(全局搜索)/status(下载状态)/doctype/author/year/journal/sort_by
- API 返回 {items, total},默认每页 12 条
- 前端新增筛选面板:搜索栏、状态下拉、文献类型、排序、分页控件
Agent 工具输出可视化:
- 新增 SpecialToolRenderers.tsx:query_target 天体参数卡片(含 SIMBAD/VizieR 链接)、search_papers
文献列表(内嵌下载/阅读/星系跳转按钮)
- 系统内部工具(compress_context 等)无错误时自动隐藏,减少时间线噪音
- ToolCallCard 与 Reader/Citation 打通:可在工具输出中直接打开文献或跳转引用星系
.agent 目录统一:
- memory/tool-results/trajectories/images 全部归入 library/.agent/ 子目录
PDF 加载修复:
- BilingualViewer 改用 fetch → blob:// URL 加载 PDF,绕过 iframe SameSite Cookie 限制
This commit is contained in:
parent
26d3b2fc02
commit
0ba85d7749
11
CLAUDE.md
11
CLAUDE.md
@ -46,6 +46,9 @@ cargo test test_name
|
||||
npm run dev # HMR dev server on :5173, proxies /api to :8000
|
||||
npm run build # TypeScript check + Vite production build → dashboard/dist/
|
||||
npm run lint # ESLint
|
||||
npm run lint:fix # ESLint with auto-fix
|
||||
npm run format # Prettier format all source files
|
||||
npm run format:check # Check formatting without modifying files
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
@ -170,12 +173,13 @@ Session-level modes configure the agent's identity, tool access, step limits, th
|
||||
Three built-in modes (registered in `ModeRegistry::builtins()`):
|
||||
|
||||
| Mode | ID | Tools | Max Steps | Thinking | Permission Profile |
|
||||
|------|-----|-------|-----------|----------|---------------------|
|
||||
| ------------ | --------------------- | ---------------------------------- | ----------- | --------------- | ------------------ |
|
||||
| 通用科研助手 | `default` | All (unrestricted) | 8 (default) | user-controlled | none |
|
||||
| 深度研究 | `deep-research` | All | 16 | forced on | `research` |
|
||||
| 文献阅读助手 | `literature-reader` | Allowlist (read-only + literature) | 6 | forced off | `readonly` |
|
||||
|
||||
Key types in `modes/mod.rs`:
|
||||
|
||||
- **`AgentMode`** — static definition struct: id, name, description, icon, identity/principles overrides, extra sections, `ToolSet`, `ModeConfig`
|
||||
- **`ToolSet`** — `All`, `Allowlist(&[&str])`, or `Except(&[&str])` — constrains which tools are available
|
||||
- **`ModeConfig`** — optional overrides for `max_steps`, `enable_thinking`, `tool_timeout_secs`, `permission_profile`
|
||||
@ -190,6 +194,7 @@ The mode is stored in the `agent_sessions.mode` column (migration `2026062400000
|
||||
Tool execution is gated by a priority-ordered rule chain: **Deny > Allow > Ask** (first match wins). Rules support content-level pattern matching (e.g., `run_bash(rm *)`).
|
||||
|
||||
**Permission modes** (set via `AGENT_PERMISSION_MODE` env or mode's `permission_profile`):
|
||||
|
||||
- `default` — full rule chain evaluation
|
||||
- `accept_edits` — auto-allow `file_write`/`file_edit` within the working directory
|
||||
- `bypass` — skip all Ask checks (Deny rules still enforced)
|
||||
@ -198,6 +203,7 @@ Tool execution is gated by a priority-ordered rule chain: **Deny > Allow > Ask**
|
||||
**Permission profiles** (`src/agent/runtime/permission_profile.rs`) are named presets loaded by `AGENT_PERMISSION_MODE` or a mode's `permission_profile` field. Profiles `research` and `readonly` are used by deep-research and literature-reader modes respectively.
|
||||
|
||||
**Configuration** (in `.env`):
|
||||
|
||||
- `AGENT_PERMISSIONS_DENY` — comma-separated deny rules (e.g., `run_bash(rm *),run_bash(sudo *)`)
|
||||
- `AGENT_PERMISSIONS_ALLOW` — comma-separated allow rules
|
||||
- `AGENT_PERMISSIONS_ASK` — comma-separated ask rules
|
||||
@ -208,7 +214,7 @@ Tool execution is gated by a priority-ordered rule chain: **Deny > Allow > Ask**
|
||||
The system supports three LLM tiers with cascade fallback:
|
||||
|
||||
| Tier | Env Prefix | Purpose | Fallback |
|
||||
|------|-----------|---------|----------|
|
||||
| ------- | --------------- | ----------------------------------- | ------------------ |
|
||||
| Primary | `LLM_` | Main agent reasoning | — |
|
||||
| Medium | `LLM_MEDIUM_` | Translation, RAG | Primary LLM config |
|
||||
| Fast | `LLM_FAST_` | Memory extraction, background tasks | Medium LLM config |
|
||||
@ -224,6 +230,7 @@ When `LLM_VISION_MODEL` env is set, the `analyze_image` tool (`src/agent/tools/a
|
||||
### Auto Memory Extraction
|
||||
|
||||
Controlled by env vars:
|
||||
|
||||
- `EXTRACT_MEMORY_ENABLED` (default `false`) — enables automatic memory extraction at session end and during compaction
|
||||
- `EXTRACT_MEMORY_THROTTLE_TURNS` (default `3`) — extract every N turns
|
||||
- `EXTRACT_MEMORY_MAX_STEPS` (default `3`) — sub-agent max steps for extraction
|
||||
|
||||
@ -87,6 +87,7 @@ COPY --from=backend-builder /usr/local/bin/astroresearch /app/
|
||||
COPY --from=frontend-builder /app/dashboard/dist/ ./dashboard/dist/
|
||||
COPY skills/ ./skills/
|
||||
COPY migrations/ ./migrations/
|
||||
COPY dictionary.txt ./
|
||||
|
||||
RUN mkdir -p /app/library /app/logs /app/bin
|
||||
|
||||
|
||||
@ -79,6 +79,7 @@ FROM gcr.io/distroless/cc-debian12:nonroot
|
||||
COPY --from=frontend-builder /app/dashboard/dist/ /app/dashboard/dist/
|
||||
COPY skills/ /app/skills/
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY dictionary.txt /app/
|
||||
|
||||
# 二进制
|
||||
COPY --from=backend-builder /usr/local/bin/astroresearch /app/astroresearch
|
||||
|
||||
11
dashboard/.prettierrc
Normal file
11
dashboard/.prettierrc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@ -3,6 +3,7 @@ import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import prettier from 'eslint-plugin-prettier'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
@ -18,5 +19,11 @@ export default defineConfig([
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
prettier,
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': 'warn',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
108
dashboard/package-lock.json
generated
108
dashboard/package-lock.json
generated
@ -34,10 +34,13 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.9.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
@ -1112,6 +1115,18 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgr/core": {
|
||||
"version": "0.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz",
|
||||
"integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
@ -3188,6 +3203,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-prettier": {
|
||||
"version": "10.1.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-config-prettier"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-prettier": {
|
||||
"version": "5.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
|
||||
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"prettier-linter-helpers": "^1.0.1",
|
||||
"synckit": "^0.11.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint-plugin-prettier"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/eslint": ">=8.0.0",
|
||||
"eslint": ">=8.0.0",
|
||||
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
|
||||
"prettier": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/eslint": {
|
||||
"optional": true
|
||||
},
|
||||
"eslint-config-prettier": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
||||
@ -3343,6 +3403,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
@ -5664,6 +5730,33 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.4",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz",
|
||||
"integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier-linter-helpers": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
|
||||
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
@ -6144,6 +6237,21 @@
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/synckit": {
|
||||
"version": "0.11.13",
|
||||
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
|
||||
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@pkgr/core": "^0.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/synckit"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||
|
||||
@ -7,6 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,md}\"",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
@ -38,10 +41,13 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.9.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
|
||||
@ -1,25 +1,85 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||
<!-- Inner Ring -->
|
||||
<circle cx="24" cy="24" r="18" stroke="#bae6fd" stroke-width="1.5" />
|
||||
<!-- Outer dotted Orbit ring -->
|
||||
<circle cx="24" cy="24" r="21" stroke="#0284c7" stroke-width="1.5" stroke-dasharray="2 3" />
|
||||
<!-- Outer Orbit Ring (Light Blue) -->
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="21.5"
|
||||
stroke="#38bdf8"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.6"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
|
||||
<!-- Stylized Telescope lens crosshair / Stellar coordinates -->
|
||||
<line x1="24" y1="2" x2="24" y2="46" stroke="#f1f5f9" stroke-width="1" />
|
||||
<line x1="2" y1="24" x2="46" y2="24" stroke="#f1f5f9" stroke-width="1" />
|
||||
<!-- Secondary Ellipse Orbit (Light Blue, tilted along top-left to bottom-right diagonal) -->
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="21"
|
||||
ry="8.5"
|
||||
transform="rotate(36 24 24)"
|
||||
stroke="#0284c7"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.5"
|
||||
/>
|
||||
|
||||
<!-- The main Star in the center: 8-pointed star -->
|
||||
<path d="M24 9C24 18 24 18 33 24C24 24 24 24 24 33C24 24 24 24 15 24C24 18 24 18 24 9Z" fill="url(#starGrad)" />
|
||||
<!-- Main Ellipse Orbit (tilted along bottom-left to top-right diagonal, major axis slightly larger than outer circle diameter) -->
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="23.5"
|
||||
ry="9.5"
|
||||
transform="rotate(-36 24 24)"
|
||||
stroke="#0284c7"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- Orbiting planet / Electron-like ring -->
|
||||
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" stroke-width="2" />
|
||||
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" stroke-width="1.5" />
|
||||
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
|
||||
<!-- 4-pointed Sparkle Star with Glow -->
|
||||
<path
|
||||
d="M34 9.5 Q34 15 39.5 15 Q34 15 34 20.5 Q34 15 28.5 15 Q34 15 34 9.5 Z"
|
||||
fill="url(#starGrad)"
|
||||
filter="url(#logoGlow)"
|
||||
/>
|
||||
|
||||
<!-- Telescope Group (rotated -36 deg to match the main ellipse) -->
|
||||
<g transform="rotate(-36 24 23)" fill="#0284c7">
|
||||
<!-- Main Tube -->
|
||||
<rect x="15" y="20.5" width="14" height="5" rx="0.5" />
|
||||
<!-- Objective Lens Cap -->
|
||||
<rect x="29" y="19" width="3.2" height="8" rx="0.6" />
|
||||
<!-- Eyepiece Base -->
|
||||
<rect x="12" y="21.5" width="3" height="3" />
|
||||
<!-- Eyepiece Cup -->
|
||||
<rect x="10" y="22" width="2" height="2" rx="0.3" />
|
||||
<!-- Focus Knob -->
|
||||
<circle cx="13.5" cy="19.8" r="0.8" />
|
||||
</g>
|
||||
|
||||
<!-- Tripod Mount & Legs -->
|
||||
<g stroke="#0284c7" stroke-width="1.8" stroke-linecap="round">
|
||||
<!-- Mount Joint -->
|
||||
<circle cx="24" cy="25.5" r="0.6" fill="#0284c7" stroke-width="0" />
|
||||
<!-- Legs -->
|
||||
<line x1="24" y1="25.5" x2="20.5" y2="35.5" />
|
||||
<line x1="24" y1="25.5" x2="24" y2="37.5" />
|
||||
<line x1="24" y1="25.5" x2="27.5" y2="35.5" />
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<linearGradient id="starGrad" x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#0284c7" />
|
||||
<stop offset="100%" stop-color="#0369a1" />
|
||||
<!-- Glow filter for the star -->
|
||||
<filter id="logoGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="0.8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="starGrad"
|
||||
x1="28.5"
|
||||
y1="9.5"
|
||||
x2="39.5"
|
||||
y2="20.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="100%" stop-color="#0284c7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 416 KiB After Width: | Height: | Size: 46 KiB |
87
dashboard/scratch/temp_logo.svg
Normal file
87
dashboard/scratch/temp_logo.svg
Normal file
@ -0,0 +1,87 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512" fill="none">
|
||||
<!-- Transparent Background (rect removed) -->
|
||||
|
||||
<!-- Outer Orbit Ring (Light Blue) -->
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="21.5"
|
||||
stroke="#38bdf8"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.6"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
|
||||
<!-- Secondary Ellipse Orbit (Light Blue, tilted along top-left to bottom-right diagonal) -->
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="21"
|
||||
ry="8.5"
|
||||
transform="rotate(36 24 24)"
|
||||
stroke="#38bdf8"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.5"
|
||||
/>
|
||||
|
||||
<!-- Main Ellipse Orbit (Brand Sky Blue, tilted along bottom-left to top-right diagonal, major axis slightly larger than outer circle diameter) -->
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="23.5"
|
||||
ry="9.5"
|
||||
transform="rotate(-36 24 24)"
|
||||
stroke="#0284c7"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- 4-pointed Sparkle Star with Glow (using Brand Gradient) -->
|
||||
<path
|
||||
d="M34 9.5 Q34 15 39.5 15 Q34 15 34 20.5 Q34 15 28.5 15 Q34 15 34 9.5 Z"
|
||||
fill="url(#starGrad)"
|
||||
filter="url(#logoGlow)"
|
||||
/>
|
||||
|
||||
<!-- Telescope Group (Brand Sky Blue, rotated -36 deg to match the main ellipse) -->
|
||||
<g transform="rotate(-36 24 23)" fill="#0284c7">
|
||||
<!-- Main Tube -->
|
||||
<rect x="15" y="20.5" width="14" height="5" rx="0.5" />
|
||||
<!-- Objective Lens Cap -->
|
||||
<rect x="29" y="19" width="3.2" height="8" rx="0.6" />
|
||||
<!-- Eyepiece Base -->
|
||||
<rect x="12" y="21.5" width="3" height="3" />
|
||||
<!-- Eyepiece Cup -->
|
||||
<rect x="10" y="22" width="2" height="2" rx="0.3" />
|
||||
<!-- Focus Knob -->
|
||||
<circle cx="13.5" cy="19.8" r="0.8" />
|
||||
</g>
|
||||
|
||||
<!-- Tripod Mount & Legs (Brand Sky Blue) -->
|
||||
<g stroke="#0284c7" stroke-width="1.8" stroke-linecap="round">
|
||||
<!-- Mount Joint -->
|
||||
<circle cx="24" cy="25.5" r="0.6" fill="#0284c7" stroke-width="0" />
|
||||
<!-- Legs -->
|
||||
<line x1="24" y1="25.5" x2="20.5" y2="35.5" />
|
||||
<line x1="24" y1="25.5" x2="24" y2="37.5" />
|
||||
<line x1="24" y1="25.5" x2="27.5" y2="35.5" />
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<!-- Glow filter for the star -->
|
||||
<filter id="logoGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="0.8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="starGrad"
|
||||
x1="28.5"
|
||||
y1="9.5"
|
||||
x2="39.5"
|
||||
y2="20.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="100%" stop-color="#0284c7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
91
dashboard/scratch/temp_logo_maskable.svg
Normal file
91
dashboard/scratch/temp_logo_maskable.svg
Normal file
@ -0,0 +1,91 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512" height="512" fill="none">
|
||||
<!-- Rounded White Background (rx=9, ry=9 for a smooth squircle app icon shape) -->
|
||||
<rect width="100%" height="100%" rx="9" ry="9" fill="#ffffff" />
|
||||
|
||||
<!-- Scaled Group (scaled to 78% and centered to fit within the Safe Zone) -->
|
||||
<g transform="translate(5.28, 5.28) scale(0.78)">
|
||||
<!-- Outer Orbit Ring (Light Blue) -->
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="21.5"
|
||||
stroke="#38bdf8"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.6"
|
||||
stroke-dasharray="2 2"
|
||||
/>
|
||||
|
||||
<!-- Secondary Ellipse Orbit (Light Blue, tilted along top-left to bottom-right diagonal) -->
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="21"
|
||||
ry="8.5"
|
||||
transform="rotate(36 24 24)"
|
||||
stroke="#38bdf8"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.5"
|
||||
/>
|
||||
|
||||
<!-- Main Ellipse Orbit (Brand Sky Blue, tilted along bottom-left to top-right diagonal, major axis slightly larger than outer circle diameter) -->
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="23.5"
|
||||
ry="9.5"
|
||||
transform="rotate(-36 24 24)"
|
||||
stroke="#0284c7"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- 4-pointed Sparkle Star with Glow (using Brand Gradient) -->
|
||||
<path
|
||||
d="M34 9.5 Q34 15 39.5 15 Q34 15 34 20.5 Q34 15 28.5 15 Q34 15 34 9.5 Z"
|
||||
fill="url(#starGrad)"
|
||||
filter="url(#logoGlow)"
|
||||
/>
|
||||
|
||||
<!-- Telescope Group (Brand Sky Blue, rotated -36 deg to match the main ellipse) -->
|
||||
<g transform="rotate(-36 24 23)" fill="#0284c7">
|
||||
<!-- Main Tube -->
|
||||
<rect x="15" y="20.5" width="14" height="5" rx="0.5" />
|
||||
<!-- Objective Lens Cap -->
|
||||
<rect x="29" y="19" width="3.2" height="8" rx="0.6" />
|
||||
<!-- Eyepiece Base -->
|
||||
<rect x="12" y="21.5" width="3" height="3" />
|
||||
<!-- Eyepiece Cup -->
|
||||
<rect x="10" y="22" width="2" height="2" rx="0.3" />
|
||||
<!-- Focus Knob -->
|
||||
<circle cx="13.5" cy="19.8" r="0.8" />
|
||||
</g>
|
||||
|
||||
<!-- Tripod Mount & Legs (Brand Sky Blue) -->
|
||||
<g stroke="#0284c7" stroke-width="1.8" stroke-linecap="round">
|
||||
<!-- Mount Joint -->
|
||||
<circle cx="24" cy="25.5" r="0.6" fill="#0284c7" stroke-width="0" />
|
||||
<!-- Legs -->
|
||||
<line x1="24" y1="25.5" x2="20.5" y2="35.5" />
|
||||
<line x1="24" y1="25.5" x2="24" y2="37.5" />
|
||||
<line x1="24" y1="25.5" x2="27.5" y2="35.5" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<!-- Glow filter for the star -->
|
||||
<filter id="logoGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="0.8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="starGrad"
|
||||
x1="28.5"
|
||||
y1="9.5"
|
||||
x2="39.5"
|
||||
y2="20.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stop-color="#38bdf8" />
|
||||
<stop offset="100%" stop-color="#0284c7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@ -45,20 +45,34 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showConfirm = useCallback((message: string, onConfirm: () => void, title = '确认操作') => {
|
||||
const showConfirm = useCallback(
|
||||
(message: string, onConfirm: () => void, title = '确认操作') => {
|
||||
setDialog({
|
||||
type: 'confirm',
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 2. 局部状态定义 (多组件共用或全局网络进度状态)
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'>(() => {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
|
||||
>(() => {
|
||||
const saved = localStorage.getItem('astro_active_tab');
|
||||
const validTabs = ['search', 'library', 'reader', 'citation', 'sync', 'agent'] as const;
|
||||
return (validTabs.includes(saved as typeof validTabs[number]) ? saved : 'search') as typeof validTabs[number];
|
||||
const validTabs = [
|
||||
'search',
|
||||
'library',
|
||||
'reader',
|
||||
'citation',
|
||||
'sync',
|
||||
'agent',
|
||||
] as const;
|
||||
return (
|
||||
validTabs.includes(saved as (typeof validTabs)[number]) ? saved : 'search'
|
||||
) as (typeof validTabs)[number];
|
||||
});
|
||||
|
||||
const [englishText, setEnglishText] = useState('');
|
||||
@ -80,7 +94,8 @@ export default function App() {
|
||||
// 协调:进入阅读器方法 (需要拉取正文和笔记)
|
||||
const libraryRef = useRef<ReturnType<typeof useLibrary> | null>(null);
|
||||
|
||||
const openReader = useCallback(async (paper: StandardPaper, skipTabSwitch = false) => {
|
||||
const openReader = useCallback(
|
||||
async (paper: StandardPaper, skipTabSwitch = false) => {
|
||||
const lib = libraryRef.current;
|
||||
if (!lib) return;
|
||||
lib.setSelectedPaper(paper);
|
||||
@ -98,8 +113,12 @@ export default function App() {
|
||||
|
||||
// 异步加载文献详情正文
|
||||
try {
|
||||
const res = await axios.get<{ paper: StandardPaper, english_content?: string, translation_content?: string }>('/api/paper', {
|
||||
params: { bibcode: paper.bibcode }
|
||||
const res = await axios.get<{
|
||||
paper: StandardPaper;
|
||||
english_content?: string;
|
||||
translation_content?: string;
|
||||
}>('/api/paper', {
|
||||
params: { bibcode: paper.bibcode },
|
||||
});
|
||||
if (res.data.english_content) {
|
||||
setEnglishText(res.data.english_content);
|
||||
@ -113,12 +132,16 @@ export default function App() {
|
||||
|
||||
// 异步加载文献已存笔记
|
||||
try {
|
||||
const nRes = await axios.get<NoteRecord[]>('/api/notes', { params: { bibcode: paper.bibcode } });
|
||||
const nRes = await axios.get<NoteRecord[]>('/api/notes', {
|
||||
params: { bibcode: paper.bibcode },
|
||||
});
|
||||
notes.setNotes(nRes.data);
|
||||
} catch (e) {
|
||||
console.error('加载笔记失败', e);
|
||||
}
|
||||
}, [notes]);
|
||||
},
|
||||
[notes]
|
||||
);
|
||||
|
||||
const library = useLibrary({
|
||||
isAuthenticated: auth.isAuthenticated,
|
||||
@ -127,12 +150,31 @@ export default function App() {
|
||||
openReader,
|
||||
});
|
||||
|
||||
useEffect(() => { libraryRef.current = library; });
|
||||
useEffect(() => {
|
||||
libraryRef.current = library;
|
||||
});
|
||||
|
||||
// 当切换到引用星系页并且有选中文献时,若引用图谱为空或与当前选中文献不匹配,则自动拉取引用拓扑数据
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab === 'citation' &&
|
||||
library.selectedPaper &&
|
||||
!citations.loadingCitations
|
||||
) {
|
||||
const isMismatched =
|
||||
!citations.citationNetwork ||
|
||||
citations.citationNetwork.bibcode !== library.selectedPaper.bibcode;
|
||||
if (isMismatched) {
|
||||
citations.loadCitations(library.selectedPaper.bibcode, true);
|
||||
}
|
||||
}
|
||||
}, [activeTab, library.selectedPaper, citations]);
|
||||
|
||||
const search = useSearch({ showAlert });
|
||||
|
||||
// 协调:从 RAG 问答结果跳转至目标文献段落并高亮
|
||||
const handleJumpToSource = useCallback(async (bibcode: string, paragraphIndex: number) => {
|
||||
const handleJumpToSource = useCallback(
|
||||
async (bibcode: string, paragraphIndex: number) => {
|
||||
setActiveTab('reader');
|
||||
|
||||
// 如果当前选中的不是目标文献,则先加载它
|
||||
@ -143,8 +185,12 @@ export default function App() {
|
||||
notes.setShowNotesPanel(false);
|
||||
|
||||
try {
|
||||
const paperRes = await axios.get<{ paper: StandardPaper, english_content?: string, translation_content?: string }>('/api/paper', {
|
||||
params: { bibcode }
|
||||
const paperRes = await axios.get<{
|
||||
paper: StandardPaper;
|
||||
english_content?: string;
|
||||
translation_content?: string;
|
||||
}>('/api/paper', {
|
||||
params: { bibcode },
|
||||
});
|
||||
|
||||
library.setSelectedPaper(paperRes.data.paper);
|
||||
@ -155,7 +201,9 @@ export default function App() {
|
||||
setChineseText(paperRes.data.translation_content);
|
||||
}
|
||||
|
||||
const nRes = await axios.get<NoteRecord[]>('/api/notes', { params: { bibcode } });
|
||||
const nRes = await axios.get<NoteRecord[]>('/api/notes', {
|
||||
params: { bibcode },
|
||||
});
|
||||
notes.setNotes(nRes.data);
|
||||
} catch (e) {
|
||||
console.error('加载跳转文献失败:', e);
|
||||
@ -168,7 +216,9 @@ export default function App() {
|
||||
|
||||
// 延迟等 DOM 渲染完成进行平滑滚动与闪烁高亮
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(`paragraph-block-${paragraphIndex}`);
|
||||
const element = document.getElementById(
|
||||
`paragraph-block-${paragraphIndex}`
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
element.classList.add('bg-amber-100', 'transition-all');
|
||||
@ -177,22 +227,36 @@ export default function App() {
|
||||
}, 2500);
|
||||
}
|
||||
}, 400);
|
||||
}, [library.selectedPaper, notes]);
|
||||
},
|
||||
[library, notes]
|
||||
);
|
||||
|
||||
// 协调:触发文献解析
|
||||
const handleParse = async (bibcode: string, force = false) => {
|
||||
setParsing(true);
|
||||
try {
|
||||
const res = await axios.post<{ markdown: string }>('/api/parse', { bibcode, force });
|
||||
const res = await axios.post<{ markdown: string }>('/api/parse', {
|
||||
bibcode,
|
||||
force,
|
||||
});
|
||||
setEnglishText(res.data.markdown);
|
||||
|
||||
library.setLibrary(prev => prev.map(p => p.bibcode === bibcode ? { ...p, has_markdown: true } : p));
|
||||
library.setLibrary((prev) =>
|
||||
prev.map((p) =>
|
||||
p.bibcode === bibcode ? { ...p, has_markdown: true } : p
|
||||
)
|
||||
);
|
||||
if (library.selectedPaper?.bibcode === bibcode) {
|
||||
library.setSelectedPaper(prev => prev ? { ...prev, has_markdown: true } : null);
|
||||
library.setSelectedPaper((prev) =>
|
||||
prev ? { ...prev, has_markdown: true } : null
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('文献解析失败', e);
|
||||
showAlert('文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。', '解析失败');
|
||||
showAlert(
|
||||
'文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。',
|
||||
'解析失败'
|
||||
);
|
||||
} finally {
|
||||
setParsing(false);
|
||||
}
|
||||
@ -202,16 +266,28 @@ export default function App() {
|
||||
const handleTranslate = async (bibcode: string, force = false) => {
|
||||
setTranslating(true);
|
||||
try {
|
||||
const res = await axios.post<{ translation: string }>('/api/translate', { bibcode, force });
|
||||
const res = await axios.post<{ translation: string }>('/api/translate', {
|
||||
bibcode,
|
||||
force,
|
||||
});
|
||||
setChineseText(res.data.translation);
|
||||
|
||||
library.setLibrary(prev => prev.map(p => p.bibcode === bibcode ? { ...p, has_translation: true } : p));
|
||||
library.setLibrary((prev) =>
|
||||
prev.map((p) =>
|
||||
p.bibcode === bibcode ? { ...p, has_translation: true } : p
|
||||
)
|
||||
);
|
||||
if (library.selectedPaper?.bibcode === bibcode) {
|
||||
library.setSelectedPaper(prev => prev ? { ...prev, has_translation: true } : null);
|
||||
library.setSelectedPaper((prev) =>
|
||||
prev ? { ...prev, has_translation: true } : null
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('文献翻译失败', e);
|
||||
showAlert('翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。', '翻译失败');
|
||||
showAlert(
|
||||
'翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。',
|
||||
'翻译失败'
|
||||
);
|
||||
} finally {
|
||||
setTranslating(false);
|
||||
}
|
||||
@ -221,16 +297,30 @@ export default function App() {
|
||||
const handleVectorize = async (bibcode: string) => {
|
||||
setVectorizing(true);
|
||||
try {
|
||||
const res = await axios.post<{ chunk_count: number }>('/api/embed', { bibcode });
|
||||
const res = await axios.post<{ chunk_count: number }>('/api/embed', {
|
||||
bibcode,
|
||||
});
|
||||
|
||||
library.setLibrary(prev => prev.map(p => p.bibcode === bibcode ? { ...p, has_vector: true } : p));
|
||||
library.setLibrary((prev) =>
|
||||
prev.map((p) =>
|
||||
p.bibcode === bibcode ? { ...p, has_vector: true } : p
|
||||
)
|
||||
);
|
||||
if (library.selectedPaper?.bibcode === bibcode) {
|
||||
library.setSelectedPaper(prev => prev ? { ...prev, has_vector: true } : null);
|
||||
library.setSelectedPaper((prev) =>
|
||||
prev ? { ...prev, has_vector: true } : null
|
||||
);
|
||||
}
|
||||
showAlert(`文献已成功录入馆藏智能库,共提炼并录入 ${res.data.chunk_count} 个核心知识节点。`, '知识入库成功');
|
||||
showAlert(
|
||||
`文献已成功录入馆藏智能库,共提炼并录入 ${res.data.chunk_count} 个核心知识节点。`,
|
||||
'知识入库成功'
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('知识入库失败', e);
|
||||
showAlert('知识入库失败,请检查 .env 中的 Embedding API 配置。', '知识入库失败');
|
||||
showAlert(
|
||||
'知识入库失败,请检查 .env 中的 Embedding API 配置。',
|
||||
'知识入库失败'
|
||||
);
|
||||
} finally {
|
||||
setVectorizing(false);
|
||||
}
|
||||
@ -245,7 +335,9 @@ export default function App() {
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#f4f6f9]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader className="w-6 h-6 text-[#106ba3] animate-spin" />
|
||||
<span className="text-xs font-bold text-[#0a2540] tracking-wider font-sans">正在校验系统安全凭证...</span>
|
||||
<span className="text-xs font-bold text-[#0a2540] tracking-wider font-sans">
|
||||
正在校验系统安全凭证...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -262,18 +354,24 @@ export default function App() {
|
||||
<div className="w-14 h-14 mb-3">
|
||||
<Logo gradientId="loginStarGrad" />
|
||||
</div>
|
||||
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">AstroResearch</h2>
|
||||
<p className="text-[10px] text-[#5c6b84] font-medium tracking-wide">天文学科研辅助系统 · 安全登录</p>
|
||||
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">
|
||||
AstroResearch
|
||||
</h2>
|
||||
<p className="text-[10px] text-[#5c6b84] font-medium tracking-wide">
|
||||
天文学科研辅助系统 · 安全登录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={auth.handleLogin} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold text-[#0a2540] block">访问密码</label>
|
||||
<label className="text-[11px] font-bold text-[#0a2540] block">
|
||||
访问密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="password"
|
||||
value={auth.password}
|
||||
onChange={e => auth.setPassword(e.target.value)}
|
||||
onChange={(e) => auth.setPassword(e.target.value)}
|
||||
placeholder="请输入系统访问密码"
|
||||
autoFocus
|
||||
disabled={auth.loggingIn}
|
||||
@ -314,7 +412,6 @@ export default function App() {
|
||||
// 5. 正常工作面板布局装配
|
||||
return (
|
||||
<div className="flex h-[100dvh] overflow-hidden text-slate-800 bg-slate-100 select-text">
|
||||
|
||||
{/* 导航左侧栏 */}
|
||||
<Sidebar
|
||||
activeTab={activeTab}
|
||||
@ -343,7 +440,9 @@ export default function App() {
|
||||
<div className="w-6 h-6">
|
||||
<Logo gradientId="mobileHeaderStarGrad" />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-800 tracking-wider">AstroResearch</span>
|
||||
<span className="text-xs font-bold text-slate-800 tracking-wider">
|
||||
AstroResearch
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-sans tracking-wide uppercase">
|
||||
@ -356,14 +455,24 @@ export default function App() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className={`flex-1 relative w-full flex flex-col ${
|
||||
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent')
|
||||
<div
|
||||
className={`flex-1 relative w-full flex flex-col ${
|
||||
activeTab === 'reader' ||
|
||||
activeTab === 'citation' ||
|
||||
activeTab === 'agent'
|
||||
? 'overflow-hidden p-0 sm:p-4 md:p-6 lg:p-8'
|
||||
: 'overflow-y-auto p-4 sm:p-6 md:p-8'
|
||||
}`}>
|
||||
<div className={`w-full flex-1 flex flex-col min-h-0 ${
|
||||
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent') ? 'max-w-none' : 'max-w-7xl mx-auto'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-full flex-1 flex flex-col min-h-0 ${
|
||||
activeTab === 'reader' ||
|
||||
activeTab === 'citation' ||
|
||||
activeTab === 'agent'
|
||||
? 'max-w-none'
|
||||
: 'max-w-7xl mx-auto'
|
||||
}`}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
{activeTab === 'search' && (
|
||||
<SearchPanel
|
||||
@ -386,14 +495,22 @@ export default function App() {
|
||||
exporting={search.exporting}
|
||||
bibtexContent={search.bibtexContent}
|
||||
downloadingBibcodes={library.downloadingBibcodes}
|
||||
handleDownload={(bibcode, force) => library.handleDownload(bibcode, force, search.setSearchResults)}
|
||||
handleDownload={(bibcode, force) =>
|
||||
library.handleDownload(
|
||||
bibcode,
|
||||
force,
|
||||
search.setSearchResults
|
||||
)
|
||||
}
|
||||
selectedPaper={library.selectedPaper}
|
||||
setSelectedPaper={library.setSelectedPaper}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
loadCitations={citations.loadCitations}
|
||||
showAlert={showAlert}
|
||||
onShowDetail={(paper) => library.setDetailBibcode(paper.bibcode)}
|
||||
onShowDetail={(paper) =>
|
||||
library.setDetailBibcode(paper.bibcode)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -402,14 +519,42 @@ export default function App() {
|
||||
library={library.library}
|
||||
fetchLibrary={library.fetchLibrary}
|
||||
setActiveTab={setActiveTab}
|
||||
onShowDetail={(paper) => library.setDetailBibcode(paper.bibcode)}
|
||||
onShowDetail={(paper) =>
|
||||
library.setDetailBibcode(paper.bibcode)
|
||||
}
|
||||
onOpenReader={openReader}
|
||||
onOpenCitation={(paper) => library.openCitation(paper, setActiveTab, citations.loadCitations)}
|
||||
onOpenCitation={(paper) =>
|
||||
library.openCitation(
|
||||
paper,
|
||||
setActiveTab,
|
||||
citations.loadCitations
|
||||
)
|
||||
}
|
||||
searchTerm={library.searchTerm}
|
||||
setSearchTerm={library.setSearchTerm}
|
||||
filterStatus={library.filterStatus}
|
||||
setFilterStatus={library.setFilterStatus}
|
||||
filterDoctype={library.filterDoctype}
|
||||
setFilterDoctype={library.setFilterDoctype}
|
||||
sortBy={library.sortBy}
|
||||
setSortBy={library.setSortBy}
|
||||
filterAuthor={library.filterAuthor}
|
||||
setFilterAuthor={library.setFilterAuthor}
|
||||
filterYear={library.filterYear}
|
||||
setFilterYear={library.setFilterYear}
|
||||
filterJournal={library.filterJournal}
|
||||
setFilterJournal={library.setFilterJournal}
|
||||
currentPage={library.currentPage}
|
||||
setCurrentPage={library.setCurrentPage}
|
||||
pageSize={library.pageSize}
|
||||
setPageSize={library.setPageSize}
|
||||
totalCount={library.totalCount}
|
||||
loading={library.loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'reader' && (
|
||||
library.selectedPaper ? (
|
||||
{activeTab === 'reader' &&
|
||||
(library.selectedPaper ? (
|
||||
<ReaderPanel
|
||||
selectedPaper={library.selectedPaper}
|
||||
library={library.library}
|
||||
@ -435,7 +580,9 @@ export default function App() {
|
||||
setNewNoteColor={notes.setNewNoteColor}
|
||||
newNoteText={notes.newNoteText}
|
||||
setNewNoteText={notes.setNewNoteText}
|
||||
handleCreateNote={() => notes.handleCreateNote(library.selectedPaper)}
|
||||
handleCreateNote={() =>
|
||||
notes.handleCreateNote(library.selectedPaper)
|
||||
}
|
||||
handleDeleteNote={notes.handleDeleteNote}
|
||||
showConfirm={showConfirm}
|
||||
onJumpToSource={handleJumpToSource}
|
||||
@ -445,7 +592,9 @@ export default function App() {
|
||||
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
|
||||
<BookOpen className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2">未选定阅读文献</h3>
|
||||
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2">
|
||||
未选定阅读文献
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
|
||||
双语对比阅读功能需要基于已下载的馆藏文献。请前往"馆藏管理"选择或上传一篇文献,然后开启沉浸式双语精读。
|
||||
</p>
|
||||
@ -456,16 +605,21 @@ export default function App() {
|
||||
选择文献
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
|
||||
{activeTab === 'citation' && (
|
||||
library.selectedPaper ? (
|
||||
{activeTab === 'citation' &&
|
||||
(library.selectedPaper ? (
|
||||
<CitationPanel
|
||||
selectedPaper={library.selectedPaper}
|
||||
library={library.library}
|
||||
recentlySelected={library.recentlySelected}
|
||||
onSwitchPaper={(paper) => library.openCitation(paper, setActiveTab, citations.loadCitations)}
|
||||
onSwitchPaper={(paper) =>
|
||||
library.openCitation(
|
||||
paper,
|
||||
setActiveTab,
|
||||
citations.loadCitations
|
||||
)
|
||||
}
|
||||
loadingCitations={citations.loadingCitations}
|
||||
citationNetwork={citations.citationNetwork}
|
||||
citationHistory={citations.citationHistory}
|
||||
@ -477,9 +631,12 @@ export default function App() {
|
||||
<div className="w-16 h-16 rounded-2xl bg-sky-50 border border-sky-100 flex items-center justify-center text-sky-600 mb-5 shadow-2xs">
|
||||
<GitFork className="w-8 h-8" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2">未选定中心文献</h3>
|
||||
<h3 className="text-base font-bold text-slate-800 tracking-wide mb-2">
|
||||
未选定中心文献
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
|
||||
引用星系拓扑图谱用于展示单篇文献的"引用 - 被引"星系脉络。请前往"馆藏管理"选择一篇文献并生成其引用图谱。
|
||||
引用星系拓扑图谱用于展示单篇文献的"引用 -
|
||||
被引"星系脉络。请前往"馆藏管理"选择一篇文献并生成其引用图谱。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('library')}
|
||||
@ -488,17 +645,18 @@ export default function App() {
|
||||
选择文献
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
|
||||
{activeTab === 'sync' && (
|
||||
<SyncPanel />
|
||||
)}
|
||||
{activeTab === 'sync' && <SyncPanel />}
|
||||
|
||||
{activeTab === 'agent' && (
|
||||
<ResearchAgentPanel
|
||||
showConfirm={showConfirm}
|
||||
showAlert={showAlert}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
citations={citations}
|
||||
library={library}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
@ -507,10 +665,7 @@ export default function App() {
|
||||
</main>
|
||||
|
||||
{/* 全局统一 Alert / Confirm 弹窗 */}
|
||||
<GlobalDialog
|
||||
dialog={dialog}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
<GlobalDialog dialog={dialog} onClose={() => setDialog(null)} />
|
||||
|
||||
{/* 未入库文献操作引导弹窗 */}
|
||||
<UncachedPaperModal
|
||||
@ -525,9 +680,20 @@ export default function App() {
|
||||
onClose={() => library.setDetailBibcode(null)}
|
||||
uploadingBibcode={library.uploadingBibcode}
|
||||
downloadingBibcodes={library.downloadingBibcodes}
|
||||
handleManualUpload={(bibcode, type, file) => library.handleManualUpload(bibcode, type, file, search.setSearchResults)}
|
||||
handleMarkNoResource={(bibcode, clear) => library.handleMarkNoResource(bibcode, clear, search.setSearchResults)}
|
||||
handleDownload={(bibcode, force) => library.handleDownload(bibcode, force, search.setSearchResults)}
|
||||
handleManualUpload={(bibcode, type, file) =>
|
||||
library.handleManualUpload(
|
||||
bibcode,
|
||||
type,
|
||||
file,
|
||||
search.setSearchResults
|
||||
)
|
||||
}
|
||||
handleMarkNoResource={(bibcode, clear) =>
|
||||
library.handleMarkNoResource(bibcode, clear, search.setSearchResults)
|
||||
}
|
||||
handleDownload={(bibcode, force) =>
|
||||
library.handleDownload(bibcode, force, search.setSearchResults)
|
||||
}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
setSelectedPaper={library.setSelectedPaper}
|
||||
|
||||
@ -2,15 +2,29 @@
|
||||
// 文献 AI 问答助手 — 使用共享 AgentMarkdown / useAgentSSE / 子代理路由 / 渲染组件
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Send, Loader, X, BookOpen, AlertCircle, Compass,
|
||||
Brain, Square, Paperclip,
|
||||
Send,
|
||||
Loader,
|
||||
X,
|
||||
BookOpen,
|
||||
AlertCircle,
|
||||
Compass,
|
||||
Brain,
|
||||
Square,
|
||||
Paperclip,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
AgentMarkdown, useAgentSSE, useAutoScroll,
|
||||
ThoughtCard, ToolCallCard, SubAgentContainer,
|
||||
AgentMarkdown,
|
||||
useAgentSSE,
|
||||
useAutoScroll,
|
||||
ThoughtCard,
|
||||
ToolCallCard,
|
||||
SubAgentContainer,
|
||||
PARENT_COLORS,
|
||||
routeThought, routeToolCall, routeToolResult, routeTextDelta,
|
||||
routeThought,
|
||||
routeToolCall,
|
||||
routeToolResult,
|
||||
routeTextDelta,
|
||||
} from './agent';
|
||||
import type { SSEEventHandlers, TimelineItem } from './agent';
|
||||
|
||||
@ -51,13 +65,22 @@ export function AIAssistantPanel({
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
// 折叠状态(共享 ThoughtCard / ToolCallCard / SubAgentContainer 的展开控制)
|
||||
const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
|
||||
const [expandedThoughts, setExpandedThoughts] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
|
||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
||||
const [collapsedSubAgents, setCollapsedSubAgents] = useState<Record<string, boolean>>({});
|
||||
const [expandedResults, setExpandedResults] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [collapsedSubAgents, setCollapsedSubAgents] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const [pendingImage, setPendingImage] = useState<{
|
||||
data?: string; path?: string; mime_type: string; name: string;
|
||||
data?: string;
|
||||
path?: string;
|
||||
mime_type: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@ -67,13 +90,13 @@ export function AIAssistantPanel({
|
||||
|
||||
// ── 折叠控制 ──
|
||||
const toggleThought = (key: string) =>
|
||||
setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
setExpandedThoughts((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
const toggleArgs = (tcId: string) =>
|
||||
setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
setExpandedArgs((prev) => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
const toggleResult = (tcId: string) =>
|
||||
setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
setExpandedResults((prev) => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
const toggleSubAgent = (id: string) =>
|
||||
setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
setCollapsedSubAgents((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
|
||||
// ── 图片处理 ──
|
||||
const attachImage = (file: File) => {
|
||||
@ -122,7 +145,11 @@ export function AIAssistantPanel({
|
||||
const handleStop = async () => {
|
||||
sseStop();
|
||||
if (sessionId) {
|
||||
try { await axios.post(`/api/chat/sessions/${sessionId}/stop`); } catch { /* noop */ }
|
||||
try {
|
||||
await axios.post(`/api/chat/sessions/${sessionId}/stop`);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -138,18 +165,23 @@ export function AIAssistantPanel({
|
||||
text: questionText,
|
||||
timeline: [],
|
||||
imageUrl: figure
|
||||
? (figure.path
|
||||
? figure.path
|
||||
? `/api/files/${figure.path}`
|
||||
: `data:${figure.mime_type};base64,${figure.data}`)
|
||||
: `data:${figure.mime_type};base64,${figure.data}`
|
||||
: undefined,
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setShouldAutoScroll(true);
|
||||
|
||||
// AI 消息占位符(用 TimelineItem[] 替代旧 AgentStep[])
|
||||
const aiMsg: Message = { sender: 'ai', text: '', timeline: [], sources: [] };
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
const aiMsg: Message = {
|
||||
sender: 'ai',
|
||||
text: '',
|
||||
timeline: [],
|
||||
sources: [],
|
||||
};
|
||||
setMessages((prev) => [...prev, aiMsg]);
|
||||
|
||||
// ── SSE 事件处理器(使用共享子代理路由)──
|
||||
const handlers: SSEEventHandlers = {
|
||||
@ -157,47 +189,79 @@ export function AIAssistantPanel({
|
||||
if (!sessionId) setSessionId(sid);
|
||||
},
|
||||
onThought: (step, content) => {
|
||||
setMessages(prev => updateAiTimeline(prev, (timeline) =>
|
||||
routeThought(timeline, step, content),
|
||||
));
|
||||
setMessages((prev) =>
|
||||
updateAiTimeline(prev, (timeline) =>
|
||||
routeThought(timeline, step, content)
|
||||
)
|
||||
);
|
||||
},
|
||||
onToolCall: (step, _id, name, args) => {
|
||||
setMessages(prev => updateAiTimeline(prev, (timeline) =>
|
||||
routeToolCall(timeline, step, _id, name, args),
|
||||
));
|
||||
setMessages((prev) =>
|
||||
updateAiTimeline(prev, (timeline) =>
|
||||
routeToolCall(timeline, step, _id, name, args)
|
||||
)
|
||||
);
|
||||
},
|
||||
onToolResult: (_step, tcId, name, _output, isError, metadata) => {
|
||||
setExpandedResults(prev => ({ ...prev, [tcId]: true }));
|
||||
setMessages(prev => {
|
||||
setExpandedResults((prev) => ({ ...prev, [tcId]: true }));
|
||||
setMessages((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.sender !== 'ai') return prev;
|
||||
const newTimeline = routeToolResult(last.timeline, tcId, name, _output, isError);
|
||||
const newTimeline = routeToolResult(
|
||||
last.timeline,
|
||||
tcId,
|
||||
name,
|
||||
_output,
|
||||
isError
|
||||
);
|
||||
// RAG 来源收集
|
||||
const sources = [...(last.sources || [])];
|
||||
if (name === 'rag_search' && metadata?.sources && Array.isArray(metadata.sources)) {
|
||||
for (const s of metadata.sources as Array<{ bibcode: string; paragraph_index: number; preview?: string; distance?: number }>) {
|
||||
if (!sources.some(
|
||||
c => c.bibcode === s.bibcode && c.paragraph_index === s.paragraph_index,
|
||||
)) {
|
||||
if (
|
||||
name === 'rag_search' &&
|
||||
metadata?.sources &&
|
||||
Array.isArray(metadata.sources)
|
||||
) {
|
||||
for (const s of metadata.sources as Array<{
|
||||
bibcode: string;
|
||||
paragraph_index: number;
|
||||
preview?: string;
|
||||
distance?: number;
|
||||
}>) {
|
||||
if (
|
||||
!sources.some(
|
||||
(c) =>
|
||||
c.bibcode === s.bibcode &&
|
||||
c.paragraph_index === s.paragraph_index
|
||||
)
|
||||
) {
|
||||
sources.push({
|
||||
bibcode: s.bibcode, paragraph_index: s.paragraph_index,
|
||||
content: s.preview || '', distance: s.distance ?? 0,
|
||||
bibcode: s.bibcode,
|
||||
paragraph_index: s.paragraph_index,
|
||||
content: s.preview || '',
|
||||
distance: s.distance ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...prev.slice(0, -1), { ...last, timeline: newTimeline, sources }];
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, timeline: newTimeline, sources },
|
||||
];
|
||||
});
|
||||
},
|
||||
onTextDelta: (content, toolCallId) => {
|
||||
if (toolCallId) {
|
||||
setExpandedResults(prev => ({ ...prev, [toolCallId]: true }));
|
||||
setExpandedResults((prev) => ({ ...prev, [toolCallId]: true }));
|
||||
}
|
||||
setMessages(prev => {
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.sender !== 'ai') return prev;
|
||||
const { timeline, answerDelta } = routeTextDelta(last.timeline, content, toolCallId);
|
||||
const { timeline, answerDelta } = routeTextDelta(
|
||||
last.timeline,
|
||||
content,
|
||||
toolCallId
|
||||
);
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, timeline, text: last.text + answerDelta },
|
||||
@ -211,7 +275,8 @@ export function AIAssistantPanel({
|
||||
|
||||
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
|
||||
const requestQuery = questionText.includes(bibcode)
|
||||
? questionText : `${contextPrefix}${questionText}`;
|
||||
? questionText
|
||||
: `${contextPrefix}${questionText}`;
|
||||
|
||||
let imagePayload: { data: string; mime_type: string } | undefined;
|
||||
if (figure?.data) {
|
||||
@ -227,12 +292,16 @@ export function AIAssistantPanel({
|
||||
? { data: imagePayload.data, mime_type: imagePayload.mime_type }
|
||||
: undefined,
|
||||
},
|
||||
handlers,
|
||||
handlers
|
||||
);
|
||||
};
|
||||
|
||||
// ── 时间线条目渲染(委托到共享组件)──
|
||||
const renderTimelineItem = (item: TimelineItem, idx: number, isStreaming: boolean) => {
|
||||
const renderTimelineItem = (
|
||||
item: TimelineItem,
|
||||
idx: number,
|
||||
isStreaming: boolean
|
||||
) => {
|
||||
switch (item.type) {
|
||||
case 'subagent_container':
|
||||
return (
|
||||
@ -305,7 +374,8 @@ export function AIAssistantPanel({
|
||||
<Compass className="w-10 h-10 mx-auto text-sky-500 opacity-60" />
|
||||
<h3 className="text-xs font-bold text-slate-800">文献阅读助手</h3>
|
||||
<p className="text-[11px] text-slate-500 leading-relaxed font-semibold">
|
||||
专注文献精读与理解的 AI 助手。支持语义检索、逐段解读、公式分析,严格只读模式保障文献安全。
|
||||
专注文献精读与理解的 AI
|
||||
助手。支持语义检索、逐段解读、公式分析,严格只读模式保障文献安全。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@ -363,7 +433,7 @@ export function AIAssistantPanel({
|
||||
</div>
|
||||
<div className="pl-4 border-l border-slate-200 space-y-2">
|
||||
{msg.timeline.map((item, tIdx) =>
|
||||
renderTimelineItem(item, tIdx, !!streaming),
|
||||
renderTimelineItem(item, tIdx, !!streaming)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -376,7 +446,9 @@ export function AIAssistantPanel({
|
||||
</div>
|
||||
) : (
|
||||
!streaming && (
|
||||
<span className="text-slate-400 italic">正在构建最终结论...</span>
|
||||
<span className="text-slate-400 italic">
|
||||
正在构建最终结论...
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@ -393,13 +465,17 @@ export function AIAssistantPanel({
|
||||
{msg.sources.map((src, sIdx) => (
|
||||
<button
|
||||
key={sIdx}
|
||||
onClick={() => onJumpToSource(src.bibcode, src.paragraph_index)}
|
||||
onClick={() =>
|
||||
onJumpToSource(src.bibcode, src.paragraph_index)
|
||||
}
|
||||
className="flex items-center justify-between text-left bg-white hover:bg-slate-100 border border-slate-200 rounded-md px-2.5 py-1.5 text-[10px] text-slate-655 font-semibold transition-all shadow-sm cursor-pointer hover:border-sky-300"
|
||||
title={src.content}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 truncate mr-2">
|
||||
<BookOpen className="w-3 h-3 text-sky-600 shrink-0" />
|
||||
<span className="font-bold text-slate-700 truncate">{src.bibcode}</span>
|
||||
<span className="font-bold text-slate-700 truncate">
|
||||
{src.bibcode}
|
||||
</span>
|
||||
<span className="text-slate-400 text-[9px] font-bold">
|
||||
§{src.paragraph_index + 1}
|
||||
</span>
|
||||
@ -416,10 +492,14 @@ export function AIAssistantPanel({
|
||||
))
|
||||
)}
|
||||
|
||||
{streaming && messages.length > 0 && !messages[messages.length - 1].text && (
|
||||
{streaming &&
|
||||
messages.length > 0 &&
|
||||
!messages[messages.length - 1].text && (
|
||||
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
||||
<Loader className="w-4 h-4 animate-spin text-sky-600" />
|
||||
<span className="text-[10px] font-bold">文献检索及推理结论构建中...</span>
|
||||
<span className="text-[10px] font-bold">
|
||||
文献检索及推理结论构建中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -456,7 +536,9 @@ export function AIAssistantPanel({
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
disabled={streaming}
|
||||
placeholder={pendingImage ? '针对选中图表提问...' : '向 AI 馆藏助手提问...'}
|
||||
placeholder={
|
||||
pendingImage ? '针对选中图表提问...' : '向 AI 馆藏助手提问...'
|
||||
}
|
||||
rows={2}
|
||||
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 leading-relaxed font-semibold min-h-[36px] max-h-32 pb-1"
|
||||
/>
|
||||
@ -541,11 +623,10 @@ export function AIAssistantPanel({
|
||||
// 更新最后一条 AI 消息的 timeline
|
||||
function updateAiTimeline(
|
||||
prev: Message[],
|
||||
fn: (timeline: TimelineItem[]) => TimelineItem[],
|
||||
fn: (timeline: TimelineItem[]) => TimelineItem[]
|
||||
): Message[] {
|
||||
if (prev.length === 0) return prev;
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.sender !== 'ai') return prev;
|
||||
return [...prev.slice(0, -1), { ...last, timeline: fn(last.timeline) }];
|
||||
}
|
||||
|
||||
|
||||
@ -27,7 +27,12 @@ interface Link {
|
||||
target: string;
|
||||
}
|
||||
|
||||
export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNodeClick }: CanvasProps) {
|
||||
export function CitationGalaxyCanvas({
|
||||
networks,
|
||||
activeNetwork,
|
||||
nodeLimit,
|
||||
onNodeClick,
|
||||
}: CanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -74,10 +79,16 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
if (nodes.length >= MAX_NODES) return;
|
||||
if (!allIds.has(ref)) {
|
||||
allIds.add(ref);
|
||||
const angle = (idx / Math.max(1, net.references.length)) * Math.PI * 2;
|
||||
const angle =
|
||||
(idx / Math.max(1, net.references.length)) * Math.PI * 2;
|
||||
const dist = 120 + Math.random() * 30;
|
||||
const centerNode = nodes.find(n => n.id === net.bibcode);
|
||||
const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, ref) : false;
|
||||
const centerNode = nodes.find((n) => n.id === net.bibcode);
|
||||
const inDb = activeNetwork.citation_counts
|
||||
? Object.prototype.hasOwnProperty.call(
|
||||
activeNetwork.citation_counts,
|
||||
ref
|
||||
)
|
||||
: false;
|
||||
nodes.push({
|
||||
id: ref,
|
||||
label: ref,
|
||||
@ -100,10 +111,16 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
if (nodes.length >= MAX_NODES) return;
|
||||
if (!allIds.has(cit)) {
|
||||
allIds.add(cit);
|
||||
const angle = (idx / Math.max(1, net.citations.length)) * Math.PI * 2 + Math.PI;
|
||||
const angle =
|
||||
(idx / Math.max(1, net.citations.length)) * Math.PI * 2 + Math.PI;
|
||||
const dist = 140 + Math.random() * 40;
|
||||
const centerNode = nodes.find(n => n.id === net.bibcode);
|
||||
const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, cit) : false;
|
||||
const centerNode = nodes.find((n) => n.id === net.bibcode);
|
||||
const inDb = activeNetwork.citation_counts
|
||||
? Object.prototype.hasOwnProperty.call(
|
||||
activeNetwork.citation_counts,
|
||||
cit
|
||||
)
|
||||
: false;
|
||||
nodes.push({
|
||||
id: cit,
|
||||
label: cit,
|
||||
@ -125,17 +142,20 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
|
||||
// 计算外围节点在当前渲染网络中的度数(连线数),动态微调其半径大小,凸显网络枢纽节点
|
||||
const degrees: Record<string, number> = {};
|
||||
links.forEach(l => {
|
||||
links.forEach((l) => {
|
||||
degrees[l.source] = (degrees[l.source] || 0) + 1;
|
||||
degrees[l.target] = (degrees[l.target] || 0) + 1;
|
||||
});
|
||||
|
||||
nodes.forEach(n => {
|
||||
nodes.forEach((n) => {
|
||||
if (n.type !== 'center') {
|
||||
const deg = degrees[n.id] || 0;
|
||||
const citeCount = activeNetwork.citation_counts?.[n.id] || 0;
|
||||
// 融合数据库中该文献的被引数量 (起步+citeCount/40) 与当前渲染网格连线度数,动态决定半径 (最大限制 18)
|
||||
n.radius = Math.min(18, Math.max(6, 6 + citeCount / 40 + Math.min(6, deg * 1.5)));
|
||||
n.radius = Math.min(
|
||||
18,
|
||||
Math.max(6, 6 + citeCount / 40 + Math.min(6, deg * 1.5))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -163,11 +183,17 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
if (nodes[i].type !== 'center' || nodes[i].id !== activeNetwork.bibcode) {
|
||||
if (
|
||||
nodes[i].type !== 'center' ||
|
||||
nodes[i].id !== activeNetwork.bibcode
|
||||
) {
|
||||
nodes[i].vx -= fx;
|
||||
nodes[i].vy -= fy;
|
||||
}
|
||||
if (nodes[j].type !== 'center' || nodes[j].id !== activeNetwork.bibcode) {
|
||||
if (
|
||||
nodes[j].type !== 'center' ||
|
||||
nodes[j].id !== activeNetwork.bibcode
|
||||
) {
|
||||
nodes[j].vx += fx;
|
||||
nodes[j].vy += fy;
|
||||
}
|
||||
@ -175,9 +201,9 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
}
|
||||
}
|
||||
|
||||
links.forEach(link => {
|
||||
const sourceNode = nodes.find(n => n.id === link.source);
|
||||
const targetNode = nodes.find(n => n.id === link.target);
|
||||
links.forEach((link) => {
|
||||
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||
const targetNode = nodes.find((n) => n.id === link.target);
|
||||
if (sourceNode && targetNode) {
|
||||
const dx = targetNode.x - sourceNode.x;
|
||||
const dy = targetNode.y - sourceNode.y;
|
||||
@ -186,18 +212,24 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
const fx = (dx / dist) * force;
|
||||
const fy = (dy / dist) * force;
|
||||
|
||||
if (sourceNode.type !== 'center' || sourceNode.id !== activeNetwork.bibcode) {
|
||||
if (
|
||||
sourceNode.type !== 'center' ||
|
||||
sourceNode.id !== activeNetwork.bibcode
|
||||
) {
|
||||
sourceNode.vx += fx;
|
||||
sourceNode.vy += fy;
|
||||
}
|
||||
if (targetNode.type !== 'center' || targetNode.id !== activeNetwork.bibcode) {
|
||||
if (
|
||||
targetNode.type !== 'center' ||
|
||||
targetNode.id !== activeNetwork.bibcode
|
||||
) {
|
||||
targetNode.vx -= fx;
|
||||
targetNode.vy -= fy;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
nodes.forEach((node) => {
|
||||
if (node.id !== activeNetwork.bibcode) {
|
||||
node.x += node.vx;
|
||||
node.y += node.vy;
|
||||
@ -222,7 +254,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
// 绘制背景宇宙引力线 & 刻度圈 - 极其素雅的学术引力线
|
||||
const centerNode = nodes.find(n => n.id === activeNetwork.bibcode);
|
||||
const centerNode = nodes.find((n) => n.id === activeNetwork.bibcode);
|
||||
if (centerNode) {
|
||||
ctx.strokeStyle = 'rgba(148, 163, 184, 0.08)';
|
||||
ctx.lineWidth = 0.75;
|
||||
@ -247,9 +279,9 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
|
||||
// 绘制连线 - 精细线宽
|
||||
ctx.lineWidth = 0.75;
|
||||
links.forEach(link => {
|
||||
const sourceNode = nodes.find(n => n.id === link.source);
|
||||
const targetNode = nodes.find(n => n.id === link.target);
|
||||
links.forEach((link) => {
|
||||
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||
const targetNode = nodes.find((n) => n.id === link.target);
|
||||
if (sourceNode && targetNode) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sourceNode.x, sourceNode.y);
|
||||
@ -261,14 +293,20 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
|
||||
// 绘制节点
|
||||
ctx.lineWidth = 1;
|
||||
nodes.forEach(node => {
|
||||
nodes.forEach((node) => {
|
||||
const isHovered = hoveredNode?.id === node.id;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = node.inDb ? 1.0 : 0.35; // 未入库文献透明度降为 0.35
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.radius + (isHovered ? 5 : 2), 0, Math.PI * 2);
|
||||
ctx.arc(
|
||||
node.x,
|
||||
node.y,
|
||||
node.radius + (isHovered ? 5 : 2),
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.fillStyle = node.color + (isHovered ? '20' : '0a');
|
||||
ctx.fill();
|
||||
|
||||
@ -278,7 +316,13 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, node.radius + (isHovered ? 3 : 1.5), 0, Math.PI * 2);
|
||||
ctx.arc(
|
||||
node.x,
|
||||
node.y,
|
||||
node.radius + (isHovered ? 3 : 1.5),
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.strokeStyle = node.color + '30';
|
||||
ctx.lineWidth = 0.75;
|
||||
ctx.stroke();
|
||||
@ -286,7 +330,11 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
ctx.fillStyle = isHovered ? '#0369a1' : '#475569';
|
||||
ctx.font = isHovered ? 'bold 9.5px sans-serif' : '9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(node.label, node.x, node.y + node.radius + (isHovered ? 16 : 12));
|
||||
ctx.fillText(
|
||||
node.label,
|
||||
node.x,
|
||||
node.y + node.radius + (isHovered ? 16 : 12)
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
@ -314,7 +362,8 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
const dx = node.x - gx;
|
||||
const dy = node.y - gy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < node.radius + 12) { // 移动端稍微加大触碰敏感区
|
||||
if (dist < node.radius + 12) {
|
||||
// 移动端稍微加大触碰敏感区
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@ -411,7 +460,10 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
dragStartY = e.touches[0].clientY - offsetY;
|
||||
|
||||
// 触碰选中节点
|
||||
hoveredNode = findNodeAtCoordinate(e.touches[0].clientX, e.touches[0].clientY);
|
||||
hoveredNode = findNodeAtCoordinate(
|
||||
e.touches[0].clientX,
|
||||
e.touches[0].clientY
|
||||
);
|
||||
} else if (e.touches.length === 2) {
|
||||
isDragging = false;
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
@ -479,7 +531,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
||||
canvas.removeEventListener('touchmove', handleTouchMove);
|
||||
canvas.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [networks, activeNetwork, onNodeClick]);
|
||||
}, [networks, activeNetwork, nodeLimit, onNodeClick]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
||||
@ -12,6 +12,7 @@ interface CustomSelectProps<T extends string | number = string | number> {
|
||||
options: SelectOption<T>[];
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function CustomSelect<T extends string | number = string | number>({
|
||||
@ -20,6 +21,7 @@ export function CustomSelect<T extends string | number = string | number>({
|
||||
options,
|
||||
className = '',
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
}: CustomSelectProps<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@ -27,7 +29,10 @@ export function CustomSelect<T extends string | number = string | number>({
|
||||
// Close when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
@ -39,7 +44,8 @@ export function CustomSelect<T extends string | number = string | number>({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value) || options[0];
|
||||
const selectedOption =
|
||||
options.find((opt) => opt.value === value) || options[0];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative inline-block ${className}`}>
|
||||
@ -47,7 +53,9 @@ export function CustomSelect<T extends string | number = string | number>({
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full flex items-center justify-between gap-2 bg-white border border-slate-250 hover:border-slate-350 rounded-md px-2.5 py-2 text-xs font-semibold text-slate-700 transition-all text-left outline-none cursor-pointer ${
|
||||
className={`w-full flex items-center justify-between gap-1.5 bg-white border border-slate-250 hover:border-slate-350 rounded-md text-xs font-semibold text-slate-700 transition-all text-left outline-none cursor-pointer ${
|
||||
size === 'sm' ? 'px-2 py-0.5' : 'px-2.5 py-2'
|
||||
} ${
|
||||
isOpen ? 'border-blueprint ring-1 ring-blueprint bg-white' : ''
|
||||
} ${disabled ? 'bg-slate-50 text-slate-450 cursor-not-allowed border-slate-200' : ''}`}
|
||||
>
|
||||
@ -60,8 +68,8 @@ export function CustomSelect<T extends string | number = string | number>({
|
||||
</button>
|
||||
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute left-0 mt-1.5 w-full min-w-[150px] bg-white border border-slate-200 rounded-md shadow-md py-1 z-50 max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{options.map(option => {
|
||||
<div className="absolute left-0 mt-1.5 w-full min-w-[80px] bg-white border border-slate-200 rounded-md shadow-md py-1 z-50 max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{options.map((option) => {
|
||||
const isSelected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
@ -71,7 +79,9 @@ export function CustomSelect<T extends string | number = string | number>({
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-xs transition-colors block cursor-pointer outline-none ${
|
||||
className={`w-full text-left text-xs transition-colors block cursor-pointer outline-none ${
|
||||
size === 'sm' ? 'px-2 py-1' : 'px-3 py-2'
|
||||
} ${
|
||||
isSelected
|
||||
? 'bg-blueprint/5 text-blueprint font-bold'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium'
|
||||
|
||||
@ -40,7 +40,9 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 border border-red-100 flex items-center justify-center text-red-500 mb-4">
|
||||
<AlertTriangle className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-sm font-bold text-slate-800 mb-2">组件渲染出错</h3>
|
||||
<h3 className="text-sm font-bold text-slate-800 mb-2">
|
||||
组件渲染出错
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 text-center max-w-sm mb-4">
|
||||
{this.state.error?.message || '发生了未知错误'}
|
||||
</p>
|
||||
|
||||
@ -3,19 +3,100 @@ interface LogoProps {
|
||||
gradientId?: string;
|
||||
}
|
||||
|
||||
export function Logo({ className = 'w-full h-full', gradientId = 'logoStarGrad' }: LogoProps) {
|
||||
export function Logo({
|
||||
className = 'w-full h-full',
|
||||
gradientId = 'logoStarGrad',
|
||||
}: LogoProps) {
|
||||
return (
|
||||
<svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<circle cx="24" cy="24" r="18" stroke="#bae6fd" strokeWidth="1.5" />
|
||||
<circle cx="24" cy="24" r="21" stroke="#0284c7" strokeWidth="1.5" strokeDasharray="2 3" />
|
||||
<path d="M24 9C24 18 24 18 33 24C24 24 24 24 24 33C24 24 24 24 15 24C24 18 24 18 24 9Z" fill={`url(#${gradientId})`} />
|
||||
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" strokeWidth="2" />
|
||||
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" strokeWidth="1.5" />
|
||||
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
{/* Outer Orbit Ring (Light Blue) */}
|
||||
<circle
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="21.5"
|
||||
stroke="#38bdf8"
|
||||
strokeWidth="1.2"
|
||||
strokeOpacity="0.6"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
|
||||
{/* Secondary Ellipse Orbit (Light Blue, tilted along top-left to bottom-right diagonal) */}
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="21"
|
||||
ry="8.5"
|
||||
transform="rotate(36 24 24)"
|
||||
stroke="#0284c7"
|
||||
strokeWidth="1.2"
|
||||
strokeOpacity="0.5"
|
||||
/>
|
||||
|
||||
{/* Main Ellipse Orbit (Adaptive Color, tilted along bottom-left to top-right diagonal, major axis slightly larger than outer circle diameter) */}
|
||||
<ellipse
|
||||
cx="24"
|
||||
cy="24"
|
||||
rx="23.5"
|
||||
ry="9.5"
|
||||
transform="rotate(-36 24 24)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* 4-pointed Sparkle Star with Glow (using Brand Gradient) */}
|
||||
<path
|
||||
d="M34 9.5 Q34 15 39.5 15 Q34 15 34 20.5 Q34 15 28.5 15 Q34 15 34 9.5 Z"
|
||||
fill={`url(#${gradientId})`}
|
||||
filter="url(#logoGlow)"
|
||||
/>
|
||||
|
||||
{/* Telescope Group (Adaptive Color, rotated -36 deg to match the main ellipse) */}
|
||||
<g transform="rotate(-36 24 23)" fill="currentColor">
|
||||
{/* Main Tube */}
|
||||
<rect x="15" y="20.5" width="14" height="5" rx="0.5" />
|
||||
{/* Objective Lens Cap */}
|
||||
<rect x="29" y="19" width="3.2" height="8" rx="0.6" />
|
||||
{/* Eyepiece Base */}
|
||||
<rect x="12" y="21.5" width="3" height="3" />
|
||||
{/* Eyepiece Cup */}
|
||||
<rect x="10" y="22" width="2" height="2" rx="0.3" />
|
||||
{/* Focus Knob */}
|
||||
<circle cx="13.5" cy="19.8" r="0.8" />
|
||||
</g>
|
||||
|
||||
{/* Tripod Mount & Legs (Adaptive Color) */}
|
||||
<g stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
{/* Mount Joint */}
|
||||
<circle cx="24" cy="25.5" r="0.6" fill="currentColor" strokeWidth="0" />
|
||||
{/* Legs */}
|
||||
<line x1="24" y1="25.5" x2="20.5" y2="35.5" />
|
||||
<line x1="24" y1="25.5" x2="24" y2="37.5" />
|
||||
<line x1="24" y1="25.5" x2="27.5" y2="35.5" />
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#0284c7" />
|
||||
<stop offset="100%" stopColor="#0369a1" />
|
||||
{/* Glow filter for the star */}
|
||||
<filter id="logoGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="0.8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="28.5"
|
||||
y1="9.5"
|
||||
x2="39.5"
|
||||
y2="20.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#38bdf8" />
|
||||
<stop offset="100%" stopColor="#0284c7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
@ -42,7 +42,8 @@ export function PaperCard({
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-semibold mt-1.5 uppercase">
|
||||
作者: {paper.authors.slice(0, 2).join(', ')}{paper.authors.length > 2 ? ' 等' : ''} | 发表年份: {paper.year}
|
||||
作者: {paper.authors.slice(0, 2).join(', ')}
|
||||
{paper.authors.length > 2 ? ' 等' : ''} | 发表年份: {paper.year}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -67,11 +68,15 @@ export function PaperCard({
|
||||
return (
|
||||
<div
|
||||
className={`console-panel p-6 rounded-lg border transition-all relative ${
|
||||
isSelected ? 'border-slate-800 bg-slate-50/30' : 'border-slate-200 hover:border-slate-350 hover:bg-white'
|
||||
isSelected
|
||||
? 'border-slate-800 bg-slate-50/30'
|
||||
: 'border-slate-200 hover:border-slate-350 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{/* Selection indicator side bar */}
|
||||
{isSelected && <div className="absolute top-0 left-0 w-1.5 h-full bg-slate-800 rounded-l-lg" />}
|
||||
{isSelected && (
|
||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-slate-800 rounded-l-lg" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-3">
|
||||
<div className="flex-1">
|
||||
@ -83,7 +88,17 @@ export function PaperCard({
|
||||
<span className="align-middle">{paper.title}</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 font-medium mt-2 leading-snug">
|
||||
作者: <span className="text-slate-800">{paper.authors.slice(0, 5).join(', ')}{paper.authors.length > 5 ? ' 等' : ''}</span> • 年份: <span className="text-slate-850 font-bold">{paper.year}</span> • 出版期刊: <span className="italic text-slate-700">{paper.pub_journal || '未标注'}</span>
|
||||
作者:{' '}
|
||||
<span className="text-slate-800">
|
||||
{paper.authors.slice(0, 5).join(', ')}
|
||||
{paper.authors.length > 5 ? ' 等' : ''}
|
||||
</span>{' '}
|
||||
• 年份:{' '}
|
||||
<span className="text-slate-850 font-bold">{paper.year}</span> •
|
||||
出版期刊:{' '}
|
||||
<span className="italic text-slate-700">
|
||||
{paper.pub_journal || '未标注'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -99,11 +114,7 @@ export function PaperCard({
|
||||
{/* Footer Area */}
|
||||
{(footerLeft || footerRight) && (
|
||||
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center border-t border-slate-100 pt-4 gap-3">
|
||||
{footerLeft && (
|
||||
<div className="flex gap-2">
|
||||
{footerLeft}
|
||||
</div>
|
||||
)}
|
||||
{footerLeft && <div className="flex gap-2">{footerLeft}</div>}
|
||||
{footerRight && (
|
||||
<div className="text-xs text-slate-450 flex flex-wrap gap-x-4 gap-y-1 font-mono">
|
||||
{footerRight}
|
||||
@ -114,4 +125,3 @@ export function PaperCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Brain, Compass, BookOpen, Send, Square, Paperclip, X
|
||||
Brain,
|
||||
Compass,
|
||||
BookOpen,
|
||||
Send,
|
||||
Square,
|
||||
Paperclip,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { AgentMode } from '../../hooks/useResearchAgent';
|
||||
|
||||
@ -13,8 +19,20 @@ interface AgentInputAreaProps {
|
||||
agentMode: string;
|
||||
setAgentMode: (val: string) => void;
|
||||
agentModes: AgentMode[];
|
||||
pendingImage: { data?: string; path?: string; mime_type: string; name: string } | null;
|
||||
setPendingImage: (val: { data?: string; path?: string; mime_type: string; name: string } | null) => void;
|
||||
pendingImage: {
|
||||
data?: string;
|
||||
path?: string;
|
||||
mime_type: string;
|
||||
name: string;
|
||||
} | null;
|
||||
setPendingImage: (
|
||||
val: {
|
||||
data?: string;
|
||||
path?: string;
|
||||
mime_type: string;
|
||||
name: string;
|
||||
} | null
|
||||
) => void;
|
||||
onSend: (text: string) => void;
|
||||
onStop: () => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
@ -25,10 +43,12 @@ interface AgentInputAreaProps {
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brain,
|
||||
Compass,
|
||||
BookOpen
|
||||
BookOpen,
|
||||
};
|
||||
|
||||
function getIconComponent(iconName: string): React.ComponentType<{ className?: string }> {
|
||||
function getIconComponent(
|
||||
iconName: string
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
return ICON_MAP[iconName] || Brain;
|
||||
}
|
||||
|
||||
@ -63,7 +83,11 @@ export function AgentInputArea({
|
||||
{pendingImage && (
|
||||
<div className="relative inline-block mb-1 group select-none self-start">
|
||||
<img
|
||||
src={pendingImage.path ? `/api/files/${pendingImage.path}` : `data:${pendingImage.mime_type};base64,${pendingImage.data}`}
|
||||
src={
|
||||
pendingImage.path
|
||||
? `/api/files/${pendingImage.path}`
|
||||
: `data:${pendingImage.mime_type};base64,${pendingImage.data}`
|
||||
}
|
||||
alt="Preview"
|
||||
className="w-12 h-12 object-cover rounded-lg border border-slate-200 shadow-sm"
|
||||
/>
|
||||
@ -138,7 +162,9 @@ export function AgentInputArea({
|
||||
: 'bg-transparent border-transparent text-slate-400 hover:text-blueprint'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<IconComp className={`w-3.5 h-3.5 ${isSelected ? 'text-blueprint' : ''}`} />
|
||||
<IconComp
|
||||
className={`w-3.5 h-3.5 ${isSelected ? 'text-blueprint' : ''}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">{m.name}</span>
|
||||
</button>
|
||||
);
|
||||
@ -153,14 +179,20 @@ export function AgentInputArea({
|
||||
type="button"
|
||||
onClick={() => setThinking(!thinking)}
|
||||
disabled={streaming}
|
||||
title={thinking ? '思考模式已开启(启用 LLM 推理过程)' : '思考模式已关闭(点击开启)'}
|
||||
title={
|
||||
thinking
|
||||
? '思考模式已开启(启用 LLM 推理过程)'
|
||||
: '思考模式已关闭(点击开启)'
|
||||
}
|
||||
className={`px-2.5 py-1.5 rounded-lg border text-[10px] font-bold transition-all cursor-pointer flex items-center gap-1 shrink-0 ${
|
||||
thinking
|
||||
? 'bg-blueprint/5 border-blueprint/30 text-blueprint shadow-3xs'
|
||||
: 'bg-white border-slate-200 text-slate-400 hover:text-blueprint hover:border-blueprint/20'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<Brain className={`w-3.5 h-3.5 ${thinking ? 'text-blueprint' : ''}`} />
|
||||
<Brain
|
||||
className={`w-3.5 h-3.5 ${thinking ? 'text-blueprint' : ''}`}
|
||||
/>
|
||||
<span className="hidden sm:inline">思考</span>
|
||||
</button>
|
||||
</>
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
PanelLeftOpen, BarChart3, ScrollText, Trash2, Loader, Compass, Rewind,
|
||||
CheckCircle2, AlertTriangle, RotateCcw, RefreshCw, GitBranch
|
||||
PanelLeftOpen,
|
||||
BarChart3,
|
||||
ScrollText,
|
||||
Trash2,
|
||||
Loader,
|
||||
Compass,
|
||||
Rewind,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { AgentMarkdown } from './AgentMarkdown';
|
||||
import { ThoughtCard } from './ThoughtCard';
|
||||
@ -12,17 +22,33 @@ import { PARENT_COLORS } from './constants';
|
||||
import type { ColorScheme } from './constants';
|
||||
import type { TimelineItem } from './types';
|
||||
|
||||
import type { ProcessedTurn, ActiveTurn, SessionSummary } from '../../hooks/useResearchAgent';
|
||||
import type {
|
||||
ProcessedTurn,
|
||||
ActiveTurn,
|
||||
SessionSummary,
|
||||
} from '../../hooks/useResearchAgent';
|
||||
import type { StandardPaper } from '../../types';
|
||||
import type { useCitations } from '../../hooks/useCitations';
|
||||
import type { useLibrary } from '../../hooks/useLibrary';
|
||||
import { AskUserQuestionCard } from './AskUserQuestionCard';
|
||||
import { PermissionRequestCard } from './PermissionRequestCard';
|
||||
import { AgentMetricsPanel } from './AgentMetricsPanel';
|
||||
import { AuditLogViewer } from './AuditLogViewer';
|
||||
|
||||
const QUICK_PROMPTS = [
|
||||
"检索 2023 年以后关于热亚矮星 (Subdwarf) 双星演化的研究成果",
|
||||
"读取文献 2024ApJ...960..123A 并总结其核心观测结论",
|
||||
"在馆藏中语义搜索关于白矮星非径向振动的讨论",
|
||||
"查询天体 GD 358 的物理参数"
|
||||
'检索 2023 年以后关于热亚矮星 (Subdwarf) 双星演化的研究成果',
|
||||
'读取文献 2024ApJ...960..123A 并总结其核心观测结论',
|
||||
'在馆藏中语义搜索关于白矮星非径向振动的讨论',
|
||||
'查询天体 GD 358 的物理参数',
|
||||
];
|
||||
|
||||
const SYSTEM_INTERNAL_TOOLS = [
|
||||
'compress_context',
|
||||
'load_skill',
|
||||
'save_memory',
|
||||
'load_memory',
|
||||
'bg_task_check',
|
||||
'search_history',
|
||||
];
|
||||
|
||||
interface AgentMessageListProps {
|
||||
@ -58,8 +84,21 @@ interface AgentMessageListProps {
|
||||
sidebarCollapsed: boolean;
|
||||
setSidebarCollapsed: (val: boolean) => void;
|
||||
sessions: SessionSummary[];
|
||||
handleDeleteSession: (sessionId: string, e: React.MouseEvent) => Promise<void>;
|
||||
loadSessionHistory: (sessionId: string, silent: boolean, onSuccess?: () => void) => Promise<void>;
|
||||
handleDeleteSession: (
|
||||
sessionId: string,
|
||||
e: React.MouseEvent
|
||||
) => Promise<void>;
|
||||
loadSessionHistory: (
|
||||
sessionId: string,
|
||||
silent: boolean,
|
||||
onSuccess?: () => void
|
||||
) => Promise<void>;
|
||||
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
|
||||
setActiveTab?: (
|
||||
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
|
||||
) => void;
|
||||
citations?: ReturnType<typeof useCitations>;
|
||||
library?: ReturnType<typeof useLibrary>;
|
||||
}
|
||||
|
||||
export function AgentMessageList({
|
||||
@ -97,13 +136,16 @@ export function AgentMessageList({
|
||||
sessions,
|
||||
handleDeleteSession,
|
||||
loadSessionHistory,
|
||||
openReader,
|
||||
setActiveTab,
|
||||
citations,
|
||||
library,
|
||||
}: AgentMessageListProps) {
|
||||
|
||||
const renderTimelineItem = (
|
||||
item: TimelineItem,
|
||||
idx: number,
|
||||
isStreaming: boolean,
|
||||
colors: ColorScheme = PARENT_COLORS,
|
||||
colors: ColorScheme = PARENT_COLORS
|
||||
) => {
|
||||
switch (item.type) {
|
||||
case 'subagent_container':
|
||||
@ -139,6 +181,13 @@ export function AgentMessageList({
|
||||
);
|
||||
}
|
||||
case 'tool_call': {
|
||||
// 如果是系统级内部工具,并且执行没有出错,则直接在前端隐藏,避免聊天时间线过于嘈杂
|
||||
if (
|
||||
SYSTEM_INTERNAL_TOOLS.includes(item.name) &&
|
||||
!item.result?.isError
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tcId = item.id;
|
||||
return (
|
||||
<ToolCallCard
|
||||
@ -153,6 +202,10 @@ export function AgentMessageList({
|
||||
isResultExpanded={expandedResults[tcId] === true}
|
||||
onToggleArgs={() => toggleArgs(tcId)}
|
||||
onToggleResult={() => toggleResult(tcId)}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
citations={citations}
|
||||
library={library}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -186,7 +239,8 @@ export function AgentMessageList({
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-slate-800 tracking-wide line-clamp-1">
|
||||
{currentSessionId
|
||||
? (sessions.find(s => s.session_id === currentSessionId)?.title || '未命名会话')
|
||||
? sessions.find((s) => s.session_id === currentSessionId)
|
||||
?.title || '未命名会话'
|
||||
: '探索性科研研讨'}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
|
||||
@ -197,7 +251,10 @@ export function AgentMessageList({
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 指标面板按钮 */}
|
||||
<button
|
||||
onClick={() => { setShowMetrics(!showMetrics); setShowAuditLog(false); }}
|
||||
onClick={() => {
|
||||
setShowMetrics(!showMetrics);
|
||||
setShowAuditLog(false);
|
||||
}}
|
||||
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||
showMetrics
|
||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||
@ -210,7 +267,10 @@ export function AgentMessageList({
|
||||
{/* 审计日志按钮 */}
|
||||
{currentSessionId && (
|
||||
<button
|
||||
onClick={() => { setShowAuditLog(!showAuditLog); setShowMetrics(false); }}
|
||||
onClick={() => {
|
||||
setShowAuditLog(!showAuditLog);
|
||||
setShowMetrics(false);
|
||||
}}
|
||||
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||
showAuditLog
|
||||
? 'bg-amber-50 border-amber-200 text-amber-700'
|
||||
@ -224,10 +284,10 @@ export function AgentMessageList({
|
||||
{currentSessionId && (
|
||||
<button
|
||||
onClick={(e) => handleDeleteSession(currentSessionId, e)}
|
||||
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 text-slate-500 hover:text-red-700 transition-all cursor-pointer"
|
||||
className="p-1.5 rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-red-600 hover:border-red-200 transition-all cursor-pointer flex items-center gap-1"
|
||||
title="删除会话"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>删除会话</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -255,22 +315,28 @@ export function AgentMessageList({
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className={`flex-1 overflow-y-auto p-5 space-y-6 bg-slate-50/50 scrollbar-thin transition-all duration-300 ${
|
||||
(hasPendingPermission || hasPendingQuestion) ? 'pb-80' : 'pb-36'
|
||||
hasPendingPermission || hasPendingQuestion ? 'pb-80' : 'pb-36'
|
||||
}`}
|
||||
>
|
||||
{turns.length === 0 && !activeTurn ? (
|
||||
<div className="py-12 space-y-8 max-w-xl mx-auto">
|
||||
<div className="text-center space-y-2">
|
||||
<Compass className="w-12 h-12 mx-auto text-sky-500 opacity-60" />
|
||||
<h3 className="text-sm font-extrabold text-slate-800 tracking-wider">智能天体物理科研助手</h3>
|
||||
<h3 className="text-sm font-extrabold text-slate-800 tracking-wider">
|
||||
智能天体物理科研助手
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 leading-relaxed font-semibold">
|
||||
具备 NASA ADS 跨文献库检索、PDF/HTML 原文结构化解析、SQLite-Vec 本地文献 RAG 语义检索、以及 CDS Sesame 真实天体参数解析等专业级工具。
|
||||
具备 NASA ADS 跨文献库检索、PDF/HTML 原文结构化解析、SQLite-Vec
|
||||
本地文献 RAG 语义检索、以及 CDS Sesame
|
||||
真实天体参数解析等专业级工具。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 快速提示词 */}
|
||||
<div className="space-y-3">
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-widest uppercase block">推荐研究提问方向:</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-widest uppercase block">
|
||||
推荐研究提问方向:
|
||||
</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{QUICK_PROMPTS.map((q, idx) => (
|
||||
<button
|
||||
@ -278,8 +344,12 @@ export function AgentMessageList({
|
||||
onClick={() => handleSend(q)}
|
||||
className="w-full text-left bg-white hover:bg-blueprint/5 border border-slate-200 hover:border-blueprint/30 rounded-lg p-3 text-xs text-slate-700 leading-relaxed font-medium transition-all shadow-2xs hover:shadow-xs cursor-pointer flex flex-col gap-1 group"
|
||||
>
|
||||
<span className="group-hover:text-blueprint font-semibold">{q}</span>
|
||||
<span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider">点击开始</span>
|
||||
<span className="group-hover:text-blueprint font-semibold">
|
||||
{q}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider">
|
||||
点击开始
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -292,7 +362,9 @@ export function AgentMessageList({
|
||||
<div key={turn.turn_index} className="space-y-4">
|
||||
{/* 用户提问 */}
|
||||
<div className="flex flex-col items-end space-y-1 group relative">
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1">我</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1">
|
||||
我
|
||||
</span>
|
||||
<div className="flex items-center gap-2 max-w-[85%]">
|
||||
{turn.questionMessageId && (
|
||||
<button
|
||||
@ -305,7 +377,11 @@ export function AgentMessageList({
|
||||
)}
|
||||
<div className="rounded-lg px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-blueprint text-white border-blueprint select-text flex-1 space-y-2">
|
||||
{turn.imagePath && (
|
||||
<img src={turn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-blueprint/30" />
|
||||
<img
|
||||
src={turn.imagePath}
|
||||
alt="用户上传的图片"
|
||||
className="max-h-48 rounded-lg border border-blueprint/30"
|
||||
/>
|
||||
)}
|
||||
<div>{turn.question}</div>
|
||||
</div>
|
||||
@ -315,7 +391,9 @@ export function AgentMessageList({
|
||||
{/* 时间线 — 思考/工具/答案 */}
|
||||
{turn.timeline.length > 0 && (
|
||||
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
|
||||
{turn.timeline.map((item: TimelineItem, idx: number) => renderTimelineItem(item, idx, false))}
|
||||
{turn.timeline.map((item: TimelineItem, idx: number) =>
|
||||
renderTimelineItem(item, idx, false)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -333,10 +411,16 @@ export function AgentMessageList({
|
||||
<div className="space-y-4">
|
||||
{/* 当前提问 */}
|
||||
<div className="flex flex-col items-end space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1">我</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1">
|
||||
我
|
||||
</span>
|
||||
<div className="max-w-[85%] rounded-lg px-4 py-3 text-xs leading-relaxed font-semibold shadow-2xs border bg-blueprint text-white border-blueprint select-text space-y-2">
|
||||
{activeTurn.imagePath && (
|
||||
<img src={activeTurn.imagePath} alt="用户上传的图片" className="max-h-48 rounded-lg border border-blueprint/30" />
|
||||
<img
|
||||
src={activeTurn.imagePath}
|
||||
alt="用户上传的图片"
|
||||
className="max-h-48 rounded-lg border border-blueprint/30"
|
||||
/>
|
||||
)}
|
||||
<div>{activeTurn.question}</div>
|
||||
</div>
|
||||
@ -345,7 +429,10 @@ export function AgentMessageList({
|
||||
{/* SSE Stream Timeline */}
|
||||
{activeTurn.timeline.length > 0 && (
|
||||
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
|
||||
{activeTurn.timeline.map((item: TimelineItem, idx: number) => renderTimelineItem(item, idx, true))}
|
||||
{activeTurn.timeline.map(
|
||||
(item: TimelineItem, idx: number) =>
|
||||
renderTimelineItem(item, idx, true)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -366,7 +453,9 @@ export function AgentMessageList({
|
||||
{streaming && !activeTurn.finalAnswer && !activeTurn.error && (
|
||||
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
||||
<Loader className="w-4 h-4 animate-spin text-blueprint" />
|
||||
<span className="text-[10px] font-bold">智能体正在搜集信息进行推理分析...</span>
|
||||
<span className="text-[10px] font-bold">
|
||||
智能体正在搜集信息进行推理分析...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -376,7 +465,9 @@ export function AgentMessageList({
|
||||
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-bold">查询发生错误</div>
|
||||
<div className="text-[10px] opacity-80 mt-0.5">{activeTurn.error}</div>
|
||||
<div className="text-[10px] opacity-80 mt-0.5">
|
||||
{activeTurn.error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -431,11 +522,15 @@ export function AgentMessageList({
|
||||
loadSessionHistory(currentSessionId, true);
|
||||
}
|
||||
}}
|
||||
onQuestionCountChange={(count: number) => setHasPendingQuestion(count > 0)}
|
||||
onQuestionCountChange={(count: number) =>
|
||||
setHasPendingQuestion(count > 0)
|
||||
}
|
||||
/>
|
||||
<PermissionRequestCard
|
||||
sessionId={currentSessionId}
|
||||
onRequestCountChange={(count: number) => setHasPendingPermission(count > 0)}
|
||||
onRequestCountChange={(count: number) =>
|
||||
setHasPendingPermission(count > 0)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,15 @@
|
||||
// dashboard/src/features/agent/AgentMetricsPanel.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { BarChart3, Activity, AlertTriangle, Zap, Brain, RefreshCw, Loader } from 'lucide-react';
|
||||
import {
|
||||
BarChart3,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
Brain,
|
||||
RefreshCw,
|
||||
Loader,
|
||||
} from 'lucide-react';
|
||||
import type { AgentMetricsResponse } from '../../types';
|
||||
|
||||
interface AgentMetricsPanelProps {
|
||||
@ -83,7 +91,10 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
setMetrics(res.data);
|
||||
} catch (e) {
|
||||
console.error('获取智能体指标失败:', e);
|
||||
showAlert?.('获取智能体运行指标失败,请确认后端服务状态。', '指标加载出错');
|
||||
showAlert?.(
|
||||
'获取智能体运行指标失败,请确认后端服务状态。',
|
||||
'指标加载出错'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -98,13 +109,18 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
if (active) setMetrics(res.data);
|
||||
} catch (e) {
|
||||
console.error('获取智能体指标失败:', e);
|
||||
showAlert?.('获取智能体运行指标失败,请确认后端服务状态。', '指标加载出错');
|
||||
showAlert?.(
|
||||
'获取智能体运行指标失败,请确认后端服务状态。',
|
||||
'指标加载出错'
|
||||
);
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [showAlert]);
|
||||
|
||||
// 提取工具调用排行(取前10)
|
||||
const toolBreakdown = metrics?.tool_call_breakdown
|
||||
@ -131,7 +147,9 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
className="p-1.5 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="刷新指标"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -183,11 +201,16 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
<div className="space-y-1.5">
|
||||
{toolBreakdown.map(([name, count]) => {
|
||||
const barWidth = Math.max((count / maxToolCalls) * 100, 2);
|
||||
const colorClass = CATEGORY_COLORS[name] || 'bg-slate-100 text-slate-700 border-slate-200';
|
||||
const colorClass =
|
||||
CATEGORY_COLORS[name] ||
|
||||
'bg-slate-100 text-slate-700 border-slate-200';
|
||||
const label = TOOL_LABELS[name] || name;
|
||||
return (
|
||||
<div key={name} className="flex items-center gap-2.5">
|
||||
<span className="text-[10px] text-slate-500 w-24 shrink-0 text-right font-medium truncate" title={label}>
|
||||
<span
|
||||
className="text-[10px] text-slate-500 w-24 shrink-0 text-right font-medium truncate"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-5 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
|
||||
@ -212,7 +235,9 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
||||
按功能域分布
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(getCategoryCounts(metrics.tool_call_breakdown)).map(([category, count]) => (
|
||||
{Object.entries(
|
||||
getCategoryCounts(metrics.tool_call_breakdown)
|
||||
).map(([category, count]) => (
|
||||
<span
|
||||
key={category}
|
||||
className="px-2.5 py-1 rounded-lg text-[10px] font-bold border bg-slate-50 text-slate-600 border-slate-200"
|
||||
@ -249,28 +274,57 @@ function MetricCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border p-3 ${colorMap[color] || colorMap.sky} transition-all`}>
|
||||
<div
|
||||
className={`rounded-md border p-3 ${colorMap[color] || colorMap.sky} transition-all`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<span className="opacity-60">{icon}</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span>
|
||||
</div>
|
||||
<div className="text-lg font-extrabold tracking-tight">
|
||||
{value}
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-extrabold tracking-tight">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按功能域分组统计
|
||||
function getCategoryCounts(breakdown: Record<string, number>): Record<string, number> {
|
||||
function getCategoryCounts(
|
||||
breakdown: Record<string, number>
|
||||
): Record<string, number> {
|
||||
const categories: Record<string, string[]> = {
|
||||
'文件系统': ['read_file', 'grep_files', 'glob_files', 'run_bash', 'file_write', 'file_edit'],
|
||||
'文献科研': ['search_papers', 'get_paper_metadata', 'download_paper', 'parse_paper', 'get_paper_content'],
|
||||
文件系统: [
|
||||
'read_file',
|
||||
'grep_files',
|
||||
'glob_files',
|
||||
'run_bash',
|
||||
'file_write',
|
||||
'file_edit',
|
||||
],
|
||||
文献科研: [
|
||||
'search_papers',
|
||||
'get_paper_metadata',
|
||||
'download_paper',
|
||||
'parse_paper',
|
||||
'get_paper_content',
|
||||
],
|
||||
'RAG/天体': ['rag_search', 'query_target', 'save_note'],
|
||||
'Agent控制': ['todo_write', 'compress_context', 'load_skill', 'subagent', 'delegate_research', 'ask_user'],
|
||||
'记忆系统': ['save_memory', 'load_memory'],
|
||||
'后台任务': ['bg_task_run', 'bg_task_check'],
|
||||
'团队协作': ['spawn_teammate', 'send_teammate_message', 'team_broadcast', 'check_team_inbox'],
|
||||
Agent控制: [
|
||||
'todo_write',
|
||||
'compress_context',
|
||||
'load_skill',
|
||||
'subagent',
|
||||
'delegate_research',
|
||||
'ask_user',
|
||||
],
|
||||
记忆系统: ['save_memory', 'load_memory'],
|
||||
后台任务: ['bg_task_run', 'bg_task_check'],
|
||||
团队协作: [
|
||||
'spawn_teammate',
|
||||
'send_teammate_message',
|
||||
'team_broadcast',
|
||||
'check_team_inbox',
|
||||
],
|
||||
};
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
@ -1,8 +1,19 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Clock, Plus, PanelLeftClose, Search, X, MessageSquare, BookOpen, Trash2, Loader
|
||||
Clock,
|
||||
Plus,
|
||||
PanelLeftClose,
|
||||
Search,
|
||||
X,
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
Trash2,
|
||||
Loader,
|
||||
} from 'lucide-react';
|
||||
import type { SessionSummary, SearchResult } from '../../hooks/useResearchAgent';
|
||||
import type {
|
||||
SessionSummary,
|
||||
SearchResult,
|
||||
} from '../../hooks/useResearchAgent';
|
||||
|
||||
interface AgentSessionSidebarProps {
|
||||
sessions: SessionSummary[];
|
||||
@ -37,7 +48,6 @@ export function AgentSessionSidebar({
|
||||
onNewSession,
|
||||
onDeleteSession,
|
||||
}: AgentSessionSidebarProps) {
|
||||
|
||||
const handleSelectSession = (id: string | null) => {
|
||||
setCurrentSessionId(id);
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||
@ -65,10 +75,10 @@ export function AgentSessionSidebar({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`bg-slate-50 flex flex-col justify-between shrink-0 select-none transition-all duration-300 ease-in-out fixed inset-y-0 left-0 z-40 lg:relative lg:translate-x-0 ${
|
||||
className={`bg-slate-50 flex flex-col justify-between shrink-0 select-none transition-all duration-300 ease-in-out fixed inset-y-0 left-0 z-40 w-64 lg:relative lg:translate-x-0 ${
|
||||
collapsed
|
||||
? '-translate-x-full lg:translate-x-0 lg:w-0 lg:overflow-hidden lg:opacity-0 lg:border-r-0'
|
||||
: 'translate-x-0 w-64 border-r border-slate-200 lg:w-64'
|
||||
: 'translate-x-0 border-r border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
@ -101,7 +111,7 @@ export function AgentSessionSidebar({
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索历史会话或消息内容..."
|
||||
className="w-full pl-8 pr-7 py-1.5 rounded-md bg-slate-50 border border-slate-200 text-slate-800 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-[11px] font-medium"
|
||||
/>
|
||||
@ -122,7 +132,7 @@ export function AgentSessionSidebar({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={searchScopeOnlyCurrent}
|
||||
onChange={e => setSearchScopeOnlyCurrent(e.target.checked)}
|
||||
onChange={(e) => setSearchScopeOnlyCurrent(e.target.checked)}
|
||||
className="rounded text-blueprint border-slate-300 focus:ring-blueprint w-3 h-3 cursor-pointer"
|
||||
/>
|
||||
<span>仅搜索当前会话</span>
|
||||
@ -145,7 +155,9 @@ export function AgentSessionSidebar({
|
||||
searchResults.map((result, idx) => {
|
||||
const isActive = result.session_id === currentSessionId;
|
||||
const isMessage = result.result_type.startsWith('message/');
|
||||
const role = isMessage ? result.result_type.split('/')[1] : null;
|
||||
const role = isMessage
|
||||
? result.result_type.split('/')[1]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -190,8 +202,7 @@ export function AgentSessionSidebar({
|
||||
);
|
||||
})
|
||||
)
|
||||
) : (
|
||||
// 正常的会话列表渲染
|
||||
) : // 正常的会话列表渲染
|
||||
loadingSessions ? (
|
||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
||||
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
|
||||
@ -202,7 +213,7 @@ export function AgentSessionSidebar({
|
||||
暂无历史会话记录
|
||||
</div>
|
||||
) : (
|
||||
sessions.map(session => {
|
||||
sessions.map((session) => {
|
||||
const isActive = session.session_id === currentSessionId;
|
||||
return (
|
||||
<button
|
||||
@ -219,7 +230,8 @@ export function AgentSessionSidebar({
|
||||
{session.title || '无标题会话'}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold block text-left">
|
||||
{session.turn_count} 轮交互 • {session.updated_at.split(' ')[0]}
|
||||
{session.turn_count} 轮交互 •{' '}
|
||||
{session.updated_at.split(' ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@ -232,7 +244,6 @@ export function AgentSessionSidebar({
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -10,7 +10,11 @@ interface AnswerCardProps {
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function AnswerCard({ content, colorScheme, isStreaming }: AnswerCardProps) {
|
||||
export function AnswerCard({
|
||||
content,
|
||||
colorScheme,
|
||||
isStreaming,
|
||||
}: AnswerCardProps) {
|
||||
return (
|
||||
<div className="relative space-y-2">
|
||||
<div
|
||||
@ -24,9 +28,7 @@ export function AnswerCard({ content, colorScheme, isStreaming }: AnswerCardProp
|
||||
<span>结论</span>
|
||||
</span>
|
||||
<div className="w-full rounded-2xl px-5 py-4 text-xs leading-relaxed font-semibold shadow-xs border bg-white text-slate-800 border-slate-200 prose prose-sm max-w-none select-text prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
|
||||
<AgentMarkdown>
|
||||
{content}
|
||||
</AgentMarkdown>
|
||||
<AgentMarkdown>{content}</AgentMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,8 +9,13 @@ interface AskUserQuestionCardProps {
|
||||
onQuestionCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUserQuestionCardProps) {
|
||||
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]);
|
||||
export function AskUserQuestionCard({
|
||||
onAnswered,
|
||||
onQuestionCountChange,
|
||||
}: AskUserQuestionCardProps) {
|
||||
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>(
|
||||
[]
|
||||
);
|
||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||
const [freeText, setFreeText] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
|
||||
@ -28,7 +33,7 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
setPendingQuestions(data);
|
||||
// 自动展开新问题
|
||||
setExpanded(prev => {
|
||||
setExpanded((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const q of data) {
|
||||
if (q && q.question_id && !(q.question_id in next)) {
|
||||
@ -56,14 +61,18 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
onQuestionCountChange?.(pendingQuestions.length);
|
||||
}, [pendingQuestions.length, onQuestionCountChange]);
|
||||
|
||||
const toggleOption = (questionId: string, optionLabel: string, multiSelect: boolean) => {
|
||||
setAnswers(prev => {
|
||||
const toggleOption = (
|
||||
questionId: string,
|
||||
optionLabel: string,
|
||||
multiSelect: boolean
|
||||
) => {
|
||||
setAnswers((prev) => {
|
||||
const current = prev[questionId] || [];
|
||||
if (multiSelect) {
|
||||
return {
|
||||
...prev,
|
||||
[questionId]: current.includes(optionLabel)
|
||||
? current.filter(o => o !== optionLabel)
|
||||
? current.filter((o) => o !== optionLabel)
|
||||
: [...current, optionLabel],
|
||||
};
|
||||
} else {
|
||||
@ -73,8 +82,8 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
};
|
||||
|
||||
const handleSubmit = async (questionId: string) => {
|
||||
setSubmitting(prev => ({ ...prev, [questionId]: true }));
|
||||
setError(prev => ({ ...prev, [questionId]: null }));
|
||||
setSubmitting((prev) => ({ ...prev, [questionId]: true }));
|
||||
setError((prev) => ({ ...prev, [questionId]: null }));
|
||||
try {
|
||||
await axios.post('/api/chat/answer', {
|
||||
question_id: questionId,
|
||||
@ -82,19 +91,21 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
free_text: freeText[questionId] || null,
|
||||
});
|
||||
// 移除已回答的问题
|
||||
setPendingQuestions(prev => prev.filter(q => q.question_id !== questionId));
|
||||
setPendingQuestions((prev) =>
|
||||
prev.filter((q) => q.question_id !== questionId)
|
||||
);
|
||||
// 清理状态
|
||||
setAnswers(prev => {
|
||||
setAnswers((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[questionId];
|
||||
return next;
|
||||
});
|
||||
setFreeText(prev => {
|
||||
setFreeText((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[questionId];
|
||||
return next;
|
||||
});
|
||||
setError(prev => {
|
||||
setError((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[questionId];
|
||||
return next;
|
||||
@ -103,27 +114,30 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
} catch (e: unknown) {
|
||||
console.error('提交答案失败:', e);
|
||||
const axiosError = e as { response?: { status?: number } };
|
||||
const msg = axiosError.response?.status === 410
|
||||
const msg =
|
||||
axiosError.response?.status === 410
|
||||
? '该问题已超时或已被回答'
|
||||
: axiosError.response?.status === 404
|
||||
? '未找到该问题'
|
||||
: '提交失败,请稍后重试';
|
||||
setError(prev => ({ ...prev, [questionId]: msg }));
|
||||
setError((prev) => ({ ...prev, [questionId]: msg }));
|
||||
} finally {
|
||||
setSubmitting(prev => ({ ...prev, [questionId]: false }));
|
||||
setSubmitting((prev) => ({ ...prev, [questionId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const dismissQuestion = (questionId: string) => {
|
||||
setPendingQuestions(prev => prev.filter(q => q.question_id !== questionId));
|
||||
setExpanded(prev => ({ ...prev, [questionId]: false }));
|
||||
setPendingQuestions((prev) =>
|
||||
prev.filter((q) => q.question_id !== questionId)
|
||||
);
|
||||
setExpanded((prev) => ({ ...prev, [questionId]: false }));
|
||||
};
|
||||
|
||||
if (pendingQuestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{pendingQuestions.map(q => {
|
||||
{pendingQuestions.map((q) => {
|
||||
if (!q || !q.question_id) return null;
|
||||
|
||||
const isExpanded = expanded[q.question_id] !== false;
|
||||
@ -144,7 +158,10 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
<span className="px-1.5 py-0.2 rounded bg-slate-200 border border-slate-300 text-[9px] font-bold text-slate-755 shrink-0">
|
||||
{q.header || '提问'}
|
||||
</span>
|
||||
<span className="font-extrabold text-slate-800 text-[11px] truncate flex-1" title={q.question || ''}>
|
||||
<span
|
||||
className="font-extrabold text-slate-800 text-[11px] truncate flex-1"
|
||||
title={q.question || ''}
|
||||
>
|
||||
{q.question || ''}
|
||||
</span>
|
||||
</div>
|
||||
@ -152,7 +169,12 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
{options.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))}
|
||||
onClick={() =>
|
||||
setExpanded((prev) => ({
|
||||
...prev,
|
||||
[q.question_id]: !prev[q.question_id],
|
||||
}))
|
||||
}
|
||||
className="text-[10px] font-bold text-blueprint hover:underline cursor-pointer select-none"
|
||||
>
|
||||
{isExpanded ? '收起选项' : '展开选项'}
|
||||
@ -172,14 +194,24 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
{options.map((option, idx) => {
|
||||
const label = typeof option?.label === 'string' ? option.label : String(option);
|
||||
const desc = typeof option?.description === 'string' ? option.description : '';
|
||||
const selected = (answers[q.question_id] || []).includes(label);
|
||||
const label =
|
||||
typeof option?.label === 'string'
|
||||
? option.label
|
||||
: String(option);
|
||||
const desc =
|
||||
typeof option?.description === 'string'
|
||||
? option.description
|
||||
: '';
|
||||
const selected = (
|
||||
answers[q.question_id] || []
|
||||
).includes(label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => toggleOption(q.question_id, label, multiSelect)}
|
||||
onClick={() =>
|
||||
toggleOption(q.question_id, label, multiSelect)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-md border text-[11px] font-semibold transition-all cursor-pointer flex items-center gap-2 ${
|
||||
selected
|
||||
@ -189,13 +221,19 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
>
|
||||
{/* 选择指示器 */}
|
||||
{multiSelect ? (
|
||||
selected
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-blueprint shrink-0" />
|
||||
: <Square className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
selected ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-blueprint shrink-0" />
|
||||
) : (
|
||||
<div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
|
||||
selected ? 'border-blueprint bg-blueprint' : 'border-slate-300'
|
||||
}`}>
|
||||
<Square className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
|
||||
selected
|
||||
? 'border-blueprint bg-blueprint'
|
||||
: 'border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-1 h-1 rounded-full bg-white" />
|
||||
@ -206,9 +244,14 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
|
||||
{/* 将 label 与 desc 合并在同一行呈现以压缩高度 */}
|
||||
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 truncate">
|
||||
<span className="font-extrabold text-slate-800 text-[11px] shrink-0 leading-normal">{label}</span>
|
||||
<span className="font-extrabold text-slate-800 text-[11px] shrink-0 leading-normal">
|
||||
{label}
|
||||
</span>
|
||||
{desc && (
|
||||
<span className="text-[10px] text-slate-500 font-medium truncate leading-normal" title={desc}>
|
||||
<span
|
||||
className="text-[10px] text-slate-500 font-medium truncate leading-normal"
|
||||
title={desc}
|
||||
>
|
||||
— {desc}
|
||||
</span>
|
||||
)}
|
||||
@ -224,7 +267,12 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
value={freeText[q.question_id] || ''}
|
||||
onChange={(e) => setFreeText(prev => ({ ...prev, [q.question_id]: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setFreeText((prev) => ({
|
||||
...prev,
|
||||
[q.question_id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
placeholder="补充说明(可选)..."
|
||||
rows={1}
|
||||
@ -274,4 +322,3 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
// dashboard/src/features/agent/AuditLogViewer.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { ScrollText, Clock, CheckCircle2, XCircle, AlertTriangle, Loader, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import {
|
||||
ScrollText,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Loader,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import type { AuditLogEntry } from '../../types';
|
||||
|
||||
interface AuditLogViewerProps {
|
||||
@ -48,7 +57,9 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPreview, setExpandedPreview] = useState<Record<number, boolean>>({});
|
||||
const [expandedPreview, setExpandedPreview] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
@ -57,7 +68,9 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await axios.get<AuditLogEntry[]>(`/api/chat/sessions/${sessionId}/audit`);
|
||||
const res = await axios.get<AuditLogEntry[]>(
|
||||
`/api/chat/sessions/${sessionId}/audit`
|
||||
);
|
||||
if (active) setEntries(res.data);
|
||||
} catch (e) {
|
||||
console.error('加载审计日志失败:', e);
|
||||
@ -66,11 +79,13 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const okCount = entries.filter(e => e.status === 'OK').length;
|
||||
const failCount = entries.filter(e => e.status === 'FAIL').length;
|
||||
const okCount = entries.filter((e) => e.status === 'OK').length;
|
||||
const failCount = entries.filter((e) => e.status === 'FAIL').length;
|
||||
const totalElapsed = entries.reduce((sum, e) => sum + e.elapsed_ms, 0);
|
||||
|
||||
return (
|
||||
@ -97,15 +112,21 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
{entries.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="rounded-md bg-emerald-50/40 border border-emerald-200 px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-emerald-700">{okCount}</div>
|
||||
<div className="text-xs font-extrabold text-emerald-700">
|
||||
{okCount}
|
||||
</div>
|
||||
<div className="text-[9px] font-bold text-emerald-500">成功</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-rose-50/40 border border-rose-200 px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-rose-700">{failCount}</div>
|
||||
<div className="text-xs font-extrabold text-rose-700">
|
||||
{failCount}
|
||||
</div>
|
||||
<div className="text-[9px] font-bold text-rose-500">失败</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-slate-50 border border-slate-200 px-3 py-2 text-center">
|
||||
<div className="text-xs font-extrabold text-slate-700">{(totalElapsed / 1000).toFixed(1)}s</div>
|
||||
<div className="text-xs font-extrabold text-slate-700">
|
||||
{(totalElapsed / 1000).toFixed(1)}s
|
||||
</div>
|
||||
<div className="text-[9px] font-bold text-slate-500">总耗时</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -137,17 +158,28 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left">
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-10">步骤</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider">工具</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-12">状态</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-16 text-right">耗时</th>
|
||||
<th className="pb-2 font-extrabold text-slate-400 uppercase tracking-wider">输出预览</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-10">
|
||||
步骤
|
||||
</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
工具
|
||||
</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-12">
|
||||
状态
|
||||
</th>
|
||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-16 text-right">
|
||||
耗时
|
||||
</th>
|
||||
<th className="pb-2 font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
输出预览
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(entry => {
|
||||
{entries.map((entry) => {
|
||||
const isExpanded = expandedPreview[entry.id] || false;
|
||||
const hasPreview = entry.output_preview && entry.output_preview.length > 0;
|
||||
const hasPreview =
|
||||
entry.output_preview && entry.output_preview.length > 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
@ -179,7 +211,9 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
FAIL
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400 text-[9px]">{entry.status}</span>
|
||||
<span className="text-slate-400 text-[9px]">
|
||||
{entry.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-right font-mono text-slate-500 align-top">
|
||||
@ -199,7 +233,12 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
{entry.output_preview}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: false }))}
|
||||
onClick={() =>
|
||||
setExpandedPreview((prev) => ({
|
||||
...prev,
|
||||
[entry.id]: false,
|
||||
}))
|
||||
}
|
||||
className="text-[9px] font-bold text-blueprint hover:underline cursor-pointer flex items-center gap-0.5"
|
||||
>
|
||||
<ChevronUp className="w-2.5 h-2.5" />
|
||||
@ -208,16 +247,28 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setExpandedPreview(prev => ({ ...prev, [entry.id]: true }))}
|
||||
onClick={() =>
|
||||
setExpandedPreview((prev) => ({
|
||||
...prev,
|
||||
[entry.id]: true,
|
||||
}))
|
||||
}
|
||||
className="text-left font-mono text-[9px] text-slate-500 hover:text-blueprint transition-colors cursor-pointer flex items-center gap-0.5 max-w-[200px]"
|
||||
>
|
||||
<ChevronDown className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="truncate">{(entry.output_preview ?? '').slice(0, 60)}{(entry.output_preview ?? '').length > 60 ? '...' : ''}</span>
|
||||
<span className="truncate">
|
||||
{(entry.output_preview ?? '').slice(0, 60)}
|
||||
{(entry.output_preview ?? '').length > 60
|
||||
? '...'
|
||||
: ''}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-400 italic text-[9px]">—</span>
|
||||
<span className="text-slate-400 italic text-[9px]">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -10,11 +10,18 @@ interface PermissionRequestCardProps {
|
||||
}
|
||||
|
||||
/** 显示待处理的工具执行权限请求卡片 */
|
||||
export function PermissionRequestCard({ sessionId, onRequestCountChange }: PermissionRequestCardProps) {
|
||||
export function PermissionRequestCard({
|
||||
sessionId,
|
||||
onRequestCountChange,
|
||||
}: PermissionRequestCardProps) {
|
||||
const [pending, setPending] = useState<PendingPermissionRequest[]>([]);
|
||||
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
|
||||
const [responses, setResponses] = useState<Record<string, 'allow' | 'deny' | null>>({});
|
||||
const [expandedParams, setExpandedParams] = useState<Record<string, boolean>>({});
|
||||
const [responses, setResponses] = useState<
|
||||
Record<string, 'allow' | 'deny' | null>
|
||||
>({});
|
||||
const [expandedParams, setExpandedParams] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
||||
// 轮询待处理权限请求
|
||||
useEffect(() => {
|
||||
@ -25,7 +32,7 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await axios.get<PendingPermissionRequest[]>(
|
||||
`/api/chat/sessions/${sessionId}/permissions`,
|
||||
`/api/chat/sessions/${sessionId}/permissions`
|
||||
);
|
||||
if (!cancelled) {
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
@ -47,41 +54,42 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
||||
const respond = async (
|
||||
toolCallId: string,
|
||||
allowed: boolean,
|
||||
allowAlways: boolean,
|
||||
allowAlways: boolean
|
||||
) => {
|
||||
setSubmitting(prev => ({ ...prev, [toolCallId]: true }));
|
||||
setSubmitting((prev) => ({ ...prev, [toolCallId]: true }));
|
||||
try {
|
||||
await axios.post(
|
||||
`/api/chat/sessions/${sessionId}/permissions/respond`,
|
||||
{ tool_call_id: toolCallId, allowed, allow_always: allowAlways },
|
||||
);
|
||||
setResponses(prev => ({
|
||||
await axios.post(`/api/chat/sessions/${sessionId}/permissions/respond`, {
|
||||
tool_call_id: toolCallId,
|
||||
allowed,
|
||||
allow_always: allowAlways,
|
||||
});
|
||||
setResponses((prev) => ({
|
||||
...prev,
|
||||
[toolCallId]: allowed ? 'allow' : 'deny',
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('权限响应发送失败:', err);
|
||||
} finally {
|
||||
setSubmitting(prev => ({ ...prev, [toolCallId]: false }));
|
||||
setSubmitting((prev) => ({ ...prev, [toolCallId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 不显示已处理的请求
|
||||
const activeRequests = pending.filter(p => !responses[p.tool_call_id]);
|
||||
const activeRequests = pending.filter((p) => !responses[p.tool_call_id]);
|
||||
|
||||
useEffect(() => {
|
||||
onRequestCountChange?.(activeRequests.length);
|
||||
}, [activeRequests.length, onRequestCountChange]);
|
||||
|
||||
const toggleParams = (id: string) => {
|
||||
setExpandedParams(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
setExpandedParams((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
if (activeRequests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeRequests.map(req => {
|
||||
{activeRequests.map((req) => {
|
||||
const hasArgs = Object.keys(req.arguments || {}).length > 0;
|
||||
const showArgs = expandedParams[req.permission_id] || false;
|
||||
|
||||
@ -94,8 +102,13 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
||||
<div className="mb-2.5 flex items-center justify-between border-b border-slate-200 pb-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Shield className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
<span className="font-bold text-slate-800 tracking-wide text-[11px] shrink-0">敏感操作授权</span>
|
||||
<code className="rounded bg-slate-200/80 border border-slate-300 px-1.5 py-0.5 text-[9px] font-mono font-bold text-slate-700 truncate" title={req.tool_name}>
|
||||
<span className="font-bold text-slate-800 tracking-wide text-[11px] shrink-0">
|
||||
敏感操作授权
|
||||
</span>
|
||||
<code
|
||||
className="rounded bg-slate-200/80 border border-slate-300 px-1.5 py-0.5 text-[9px] font-mono font-bold text-slate-700 truncate"
|
||||
title={req.tool_name}
|
||||
>
|
||||
{req.tool_name}
|
||||
</code>
|
||||
</div>
|
||||
@ -112,7 +125,9 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
||||
</div>
|
||||
|
||||
{/* 警告消息 */}
|
||||
<p className="mb-3 text-[11px] font-medium text-slate-750 leading-relaxed">{req.message}</p>
|
||||
<p className="mb-3 text-[11px] font-medium text-slate-750 leading-relaxed">
|
||||
{req.message}
|
||||
</p>
|
||||
|
||||
{/* 显示工具参数的简化预览 - 按需展开 */}
|
||||
{hasArgs && showArgs && (
|
||||
@ -157,5 +172,3 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
610
dashboard/src/components/agent/SpecialToolRenderers.tsx
Normal file
610
dashboard/src/components/agent/SpecialToolRenderers.tsx
Normal file
@ -0,0 +1,610 @@
|
||||
import {
|
||||
Orbit,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
GitFork,
|
||||
Download,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
import type { useCitations } from '../../hooks/useCitations';
|
||||
import type { useLibrary } from '../../hooks/useLibrary';
|
||||
|
||||
// ==========================================
|
||||
// 1. 天体参数卡片 (query_target)
|
||||
// ==========================================
|
||||
interface QueryTargetCardProps {
|
||||
metadata: {
|
||||
target_name: string;
|
||||
ra?: string;
|
||||
dec?: string;
|
||||
parallax?: number;
|
||||
spectral_type?: string;
|
||||
v_magnitude?: number;
|
||||
otype?: string;
|
||||
oname?: string;
|
||||
aliases?: string[];
|
||||
photometry?: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
export function QueryTargetCard({ metadata }: QueryTargetCardProps) {
|
||||
const {
|
||||
target_name,
|
||||
ra,
|
||||
dec,
|
||||
parallax,
|
||||
spectral_type,
|
||||
v_magnitude,
|
||||
otype,
|
||||
oname,
|
||||
aliases = [],
|
||||
photometry = {},
|
||||
} = metadata;
|
||||
|
||||
const simbadUrl = `http://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(target_name)}`;
|
||||
const vizierUrl = `https://vizier.cds.unistra.fr/viz-bin/VizieR-s?${encodeURIComponent(target_name)}`;
|
||||
|
||||
return (
|
||||
<div className="bg-sky-50/40 border border-sky-100 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
|
||||
<div className="flex items-center justify-between border-b border-sky-100/70 pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-sky-900 text-[11px] uppercase tracking-wide">
|
||||
<Orbit className="w-3.5 h-3.5 text-sky-600 animate-spin-slow" />
|
||||
<span>天体参数查询结果</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={simbadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 flex items-center gap-0.5 hover:underline"
|
||||
>
|
||||
<span>SIMBAD</span>
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
<a
|
||||
href={vizierUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 flex items-center gap-0.5 hover:underline"
|
||||
>
|
||||
<span>VizieR</span>
|
||||
<ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-slate-700">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
主要名称
|
||||
</span>
|
||||
<span className="font-semibold text-slate-900">
|
||||
{oname || target_name}
|
||||
</span>
|
||||
</div>
|
||||
{otype && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
天体类型
|
||||
</span>
|
||||
<span className="font-semibold text-slate-900">{otype}</span>
|
||||
</div>
|
||||
)}
|
||||
{ra && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
赤经 (RA)
|
||||
</span>
|
||||
<span className="font-mono font-medium text-slate-900">{ra}</span>
|
||||
</div>
|
||||
)}
|
||||
{dec && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
赤纬 (Dec)
|
||||
</span>
|
||||
<span className="font-mono font-medium text-slate-900">{dec}</span>
|
||||
</div>
|
||||
)}
|
||||
{v_magnitude !== undefined && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
V星等
|
||||
</span>
|
||||
<span className="font-mono font-semibold text-slate-900">
|
||||
{v_magnitude.toFixed(3)} mag
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{parallax !== undefined && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
视差 (Parallax)
|
||||
</span>
|
||||
<span className="font-mono font-medium text-slate-900">
|
||||
{parallax.toFixed(4)} mas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{spectral_type && (
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold block">
|
||||
光谱类型
|
||||
</span>
|
||||
<span className="font-mono font-bold text-slate-900">
|
||||
{spectral_type}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 别名 */}
|
||||
{aliases.length > 0 && (
|
||||
<div className="border-t border-sky-100/50 pt-2 text-[10px]">
|
||||
<span className="text-slate-400 font-bold block">常用别名</span>
|
||||
<span className="text-slate-600 leading-normal line-clamp-2">
|
||||
{aliases.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多波段测光 */}
|
||||
{Object.keys(photometry).length > 0 && (
|
||||
<div className="border-t border-sky-100/50 pt-2">
|
||||
<span className="text-[10px] text-slate-400 font-bold block mb-1">
|
||||
多波段测光数据
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(photometry)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([band, val]) => (
|
||||
<div
|
||||
key={band}
|
||||
className="bg-white/60 border border-sky-100/50 px-2 py-0.5 rounded flex items-center gap-1 font-mono text-[10px]"
|
||||
>
|
||||
<span className="font-bold text-sky-850">{band}</span>
|
||||
<span className="text-slate-700 font-semibold">
|
||||
{val.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. 文献检索列表卡片 (search_papers / search_local_library)
|
||||
// ==========================================
|
||||
interface PaperSearchResult {
|
||||
bibcode: string;
|
||||
title: string;
|
||||
first_author: string;
|
||||
year: string;
|
||||
citation_count?: number;
|
||||
pub_journal?: string;
|
||||
has_markdown?: boolean;
|
||||
}
|
||||
|
||||
interface PaperListCardProps {
|
||||
metadata: {
|
||||
count: number;
|
||||
papers?: PaperSearchResult[];
|
||||
};
|
||||
library?: ReturnType<typeof useLibrary>;
|
||||
citations?: ReturnType<typeof useCitations>;
|
||||
setActiveTab?: (
|
||||
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
|
||||
) => void;
|
||||
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
|
||||
}
|
||||
|
||||
export function PaperListCard({
|
||||
metadata,
|
||||
library,
|
||||
citations,
|
||||
setActiveTab,
|
||||
openReader,
|
||||
}: PaperListCardProps) {
|
||||
const papers = metadata.papers || [];
|
||||
|
||||
if (papers.length === 0) {
|
||||
return (
|
||||
<div className="text-[10px] text-slate-400 italic p-2 border border-dashed border-slate-200 rounded-md">
|
||||
未检索到匹配的文献结果。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 动作触发:下载
|
||||
const triggerDownload = (bibcode: string) => {
|
||||
if (library && typeof library.handleDownload === 'function') {
|
||||
library.handleDownload(bibcode, false);
|
||||
}
|
||||
};
|
||||
|
||||
// 动作触发:阅读
|
||||
const triggerRead = (bibcode: string) => {
|
||||
if (!library || !openReader) return;
|
||||
// 优先从本地馆藏找
|
||||
const localPaper = library.library?.find(
|
||||
(p: StandardPaper) => p.bibcode === bibcode
|
||||
);
|
||||
if (localPaper) {
|
||||
openReader(localPaper, false);
|
||||
} else {
|
||||
// 降级使用基础 paper 对象
|
||||
openReader(
|
||||
{
|
||||
bibcode,
|
||||
title: '',
|
||||
authors: [],
|
||||
year: '',
|
||||
pub_journal: '',
|
||||
keywords: [],
|
||||
abstract_text: '',
|
||||
doi: '',
|
||||
arxiv_id: '',
|
||||
citation_count: 0,
|
||||
reference_count: 0,
|
||||
is_downloaded: true,
|
||||
has_pdf: false,
|
||||
has_html: false,
|
||||
has_markdown: false,
|
||||
has_translation: false,
|
||||
has_vector: false,
|
||||
doctype: '',
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 动作触发:星系图谱
|
||||
const triggerGalaxy = (paper: PaperSearchResult) => {
|
||||
if (!library || !citations || !setActiveTab) return;
|
||||
const localPaper = library.library?.find(
|
||||
(p: StandardPaper) => p.bibcode === paper.bibcode
|
||||
) || {
|
||||
bibcode: paper.bibcode,
|
||||
title: paper.title,
|
||||
authors: [paper.first_author],
|
||||
year: paper.year,
|
||||
pub_journal: paper.pub_journal || '',
|
||||
keywords: [],
|
||||
abstract_text: '',
|
||||
doi: '',
|
||||
arxiv_id: '',
|
||||
citation_count: paper.citation_count || 0,
|
||||
reference_count: 0,
|
||||
is_downloaded: false,
|
||||
has_pdf: false,
|
||||
has_html: false,
|
||||
has_markdown: paper.has_markdown || false,
|
||||
has_translation: false,
|
||||
has_vector: false,
|
||||
doctype: '',
|
||||
};
|
||||
library.openCitation(localPaper, setActiveTab, citations.loadCitations);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-w-full select-none">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-slate-400 uppercase tracking-wider pl-1">
|
||||
<span>检索结果共 {metadata.count} 篇</span>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{papers.map((paper) => {
|
||||
// 在馆藏列表中查询下载状态
|
||||
const localPaper = library?.library?.find(
|
||||
(p: StandardPaper) => p.bibcode === paper.bibcode
|
||||
);
|
||||
const isDownloaded = localPaper ? localPaper.is_downloaded : false;
|
||||
const isDownloading =
|
||||
library?.downloadingBibcodes?.[paper.bibcode] || false;
|
||||
|
||||
const adsUrl = `https://ui.adsabs.harvard.edu/abs/${paper.bibcode}/abstract`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={paper.bibcode}
|
||||
className="bg-white border border-slate-200/80 rounded-lg p-3 hover:shadow-2xs transition-all flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs relative"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h4
|
||||
className="font-extrabold text-slate-800 leading-tight line-clamp-2 select-text"
|
||||
title={paper.title}
|
||||
>
|
||||
{paper.title}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-slate-500 font-semibold">
|
||||
<span className="font-bold text-slate-700">
|
||||
{paper.first_author} et al.
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{paper.year}</span>
|
||||
{paper.pub_journal && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span
|
||||
className="italic truncate max-w-[120px]"
|
||||
title={paper.pub_journal}
|
||||
>
|
||||
{paper.pub_journal}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{paper.citation_count !== undefined && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>被引 {paper.citation_count} 次</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-slate-400 font-bold select-all truncate">
|
||||
{paper.bibcode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 动作按钮组 */}
|
||||
<div className="flex items-center gap-1.5 shrink-0 self-end sm:self-center">
|
||||
{/* 1. 下载/阅读动作 */}
|
||||
{isDownloaded ? (
|
||||
<button
|
||||
onClick={() => triggerRead(paper.bibcode)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-700 text-white font-bold text-[10px] transition-colors cursor-pointer"
|
||||
title="立即在对比阅读器中打开"
|
||||
>
|
||||
<BookOpen className="w-3 h-3" />
|
||||
<span>阅读</span>
|
||||
</button>
|
||||
) : isDownloading ? (
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-slate-100 text-slate-400 border border-slate-200 font-bold text-[10px] cursor-not-allowed"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 animate-spin text-slate-400" />
|
||||
<span>下载中</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => triggerDownload(paper.bibcode)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded bg-white hover:bg-slate-50 text-sky-650 border border-sky-200 hover:border-sky-300 font-bold text-[10px] transition-colors cursor-pointer"
|
||||
title="下载文献资源到本地"
|
||||
>
|
||||
<Download className="w-3 h-3 text-sky-600" />
|
||||
<span>下载</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 2. 引用拓扑 */}
|
||||
<button
|
||||
onClick={() => triggerGalaxy(paper)}
|
||||
className="flex items-center justify-center p-1 rounded bg-white hover:bg-slate-50 text-slate-500 hover:text-sky-600 border border-slate-200 hover:border-slate-350 transition-colors cursor-pointer"
|
||||
title="生成并查看引用星系图"
|
||||
>
|
||||
<GitFork className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* 3. ADS 外链 */}
|
||||
<a
|
||||
href={adsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center p-1 rounded bg-white hover:bg-slate-50 text-slate-500 hover:text-slate-700 border border-slate-200 hover:border-slate-350 transition-colors"
|
||||
title="访问 NASA ADS 详情页"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. 任务规划看板卡片 (todo_write)
|
||||
// ==========================================
|
||||
interface TodoItem {
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
blockedBy?: string[];
|
||||
}
|
||||
|
||||
interface TodoTaskCardProps {
|
||||
todos: TodoItem[];
|
||||
}
|
||||
|
||||
export function TodoTaskCard({ todos }: TodoTaskCardProps) {
|
||||
if (!Array.isArray(todos) || todos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs select-none">
|
||||
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide border-b border-slate-200/70 pb-2">
|
||||
<Activity className="w-3.5 h-3.5 text-blueprint animate-pulse" />
|
||||
<span>科研研究任务看板</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => {
|
||||
const isCompleted = todo.status === 'completed';
|
||||
const isInProgress = todo.status === 'in_progress';
|
||||
|
||||
// 基于状态选择 Lucide 图标而不用 Emoji
|
||||
let statusIcon = (
|
||||
<Circle className="w-4.5 h-4.5 text-slate-350 shrink-0" />
|
||||
);
|
||||
let statusStyle = 'border-slate-200 bg-white text-slate-700';
|
||||
|
||||
if (isCompleted) {
|
||||
statusIcon = (
|
||||
<CheckCircle2 className="w-4.5 h-4.5 text-emerald-600 shrink-0" />
|
||||
);
|
||||
statusStyle =
|
||||
'border-emerald-100 bg-emerald-50/20 text-slate-400 line-through';
|
||||
} else if (isInProgress) {
|
||||
statusIcon = (
|
||||
<Loader2 className="w-4.5 h-4.5 text-blueprint shrink-0 animate-spin" />
|
||||
);
|
||||
statusStyle =
|
||||
'border-blueprint bg-blueprint/5 text-slate-900 font-bold border-l-4';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`border rounded-lg p-2.5 flex items-start gap-2.5 transition-all ${statusStyle}`}
|
||||
>
|
||||
{statusIcon}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[9px] bg-slate-200 text-slate-600 px-1 rounded font-extrabold leading-normal select-all">
|
||||
{todo.id}
|
||||
</span>
|
||||
{todo.blockedBy && todo.blockedBy.length > 0 && (
|
||||
<span className="font-mono text-[8px] bg-amber-50 text-amber-700 border border-amber-200 px-1 rounded flex items-center gap-0.5 leading-normal">
|
||||
<span>依赖:</span>
|
||||
<span className="font-bold">
|
||||
{todo.blockedBy.join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed select-text font-semibold">
|
||||
{todo.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 4. 异步后台任务进度卡片 (bg_task_run / bg_task_check)
|
||||
// ==========================================
|
||||
interface BgTaskProgressCardProps {
|
||||
task: {
|
||||
task_id: string;
|
||||
tool_name: string;
|
||||
bibcode: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
};
|
||||
}
|
||||
|
||||
export function BgTaskProgressCard({ task }: BgTaskProgressCardProps) {
|
||||
const { task_id, tool_name, bibcode, status } = task;
|
||||
|
||||
const isRunning = status === 'running';
|
||||
const isCompleted = status === 'completed';
|
||||
const isFailed = status === 'failed';
|
||||
|
||||
let statusText = '执行中';
|
||||
let progressColor = 'bg-blueprint animate-infinite-scroll';
|
||||
let statusIcon = <Cpu className="w-4 h-4 text-blueprint animate-spin-slow" />;
|
||||
let cardBg = 'bg-sky-50/20 border-sky-100';
|
||||
|
||||
if (isCompleted) {
|
||||
statusText = '已完成';
|
||||
progressColor = 'bg-emerald-600';
|
||||
statusIcon = <Check className="w-4.5 h-4.5 text-white" />;
|
||||
cardBg = 'bg-emerald-50/10 border-emerald-100';
|
||||
} else if (isFailed) {
|
||||
statusText = '执行失败';
|
||||
progressColor = 'bg-rose-600';
|
||||
statusIcon = <AlertTriangle className="w-4.5 h-4.5 text-rose-600" />;
|
||||
cardBg = 'bg-rose-50/10 border-rose-150';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg p-3.5 space-y-3.5 text-xs shadow-2xs select-none ${cardBg}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-200/50 pb-2">
|
||||
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
|
||||
{isRunning ? (
|
||||
<Loader2 className="w-3.5 h-3.5 text-blueprint animate-spin" />
|
||||
) : (
|
||||
<Activity className="w-3.5 h-3.5 text-slate-600" />
|
||||
)}
|
||||
<span>异步后台任务进度</span>
|
||||
</div>
|
||||
<span className="font-mono text-[9px] bg-slate-200 text-slate-600 px-1.5 py-0.2 rounded font-extrabold select-all">
|
||||
ID: {task_id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center border shrink-0 ${
|
||||
isRunning
|
||||
? 'bg-sky-100/60 border-sky-200'
|
||||
: isCompleted
|
||||
? 'bg-emerald-600 border-emerald-700'
|
||||
: 'bg-rose-100 border-rose-200'
|
||||
}`}
|
||||
>
|
||||
{statusIcon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-slate-500 font-semibold text-[10px]">
|
||||
<div>
|
||||
<span>后台工具:</span>
|
||||
<code className="text-slate-700 font-bold">{tool_name}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span>目标文献:</span>
|
||||
<code className="text-slate-700 font-bold truncate block select-all">
|
||||
{bibcode}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-[10px] font-bold ${
|
||||
isRunning
|
||||
? 'text-blueprint'
|
||||
: isCompleted
|
||||
? 'text-emerald-700'
|
||||
: 'text-rose-700'
|
||||
}`}
|
||||
>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${progressColor} ${
|
||||
isRunning ? 'w-2/3 animate-pulse' : 'w-full'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isRunning && (
|
||||
<p className="text-[9px] text-slate-400 font-medium leading-relaxed">
|
||||
任务在后台静默运行中,完成后将自动通知您,无需在此页等待。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -39,7 +39,7 @@ export function SubAgentContainer({
|
||||
}: SubAgentContainerProps) {
|
||||
const isComplete = status === 'complete';
|
||||
const stepCount = children.length;
|
||||
const toolCount = children.filter(c => c.type === 'tool_call').length;
|
||||
const toolCount = children.filter((c) => c.type === 'tool_call').length;
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2 select-none">
|
||||
@ -69,7 +69,9 @@ export function SubAgentContainer({
|
||||
{stepCount} 步骤 · {toolCount} 工具
|
||||
</span>
|
||||
)}
|
||||
{parentStreaming && <Loader className="w-3 h-3 text-violet-500 animate-spin" />}
|
||||
{parentStreaming && (
|
||||
<Loader className="w-3 h-3 text-violet-500 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-violet-600 hover:text-violet-800 shrink-0">
|
||||
{isCollapsed ? '展开' : '收起'}
|
||||
@ -112,7 +114,7 @@ export function SubAgentContainer({
|
||||
expandedResults,
|
||||
onToggleThought,
|
||||
onToggleArgs,
|
||||
onToggleResult,
|
||||
onToggleResult
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@ -133,7 +135,7 @@ function renderNestedTimelineItem(
|
||||
expandedResults: Record<string, boolean>,
|
||||
onToggleThought: (key: string) => void,
|
||||
onToggleArgs: (tcId: string) => void,
|
||||
onToggleResult: (tcId: string) => void,
|
||||
onToggleResult: (tcId: string) => void
|
||||
) {
|
||||
switch (item.type) {
|
||||
case 'thought': {
|
||||
|
||||
@ -21,7 +21,9 @@ export function ThoughtCard({
|
||||
onToggle,
|
||||
}: ThoughtCardProps) {
|
||||
const preview = content.length > 120 ? content.slice(0, 120) + '…' : content;
|
||||
const dotColor = isStreaming ? colorScheme.thought.active : colorScheme.thought.idle;
|
||||
const dotColor = isStreaming
|
||||
? colorScheme.thought.active
|
||||
: colorScheme.thought.idle;
|
||||
|
||||
// 从 colorScheme 推导包裹色(用于容器背景)
|
||||
const isSub = colorScheme.thought.idle.includes('violet');
|
||||
@ -30,7 +32,9 @@ export function ThoughtCard({
|
||||
: 'bg-purple-50/40 border border-purple-100/50';
|
||||
const textClass = isSub ? 'text-violet-600' : 'text-purple-600';
|
||||
const previewClass = isSub ? 'text-violet-400' : 'text-purple-400';
|
||||
const hoverClass = isSub ? 'text-violet-500 hover:text-violet-700' : 'text-purple-500 hover:text-purple-700';
|
||||
const hoverClass = isSub
|
||||
? 'text-violet-500 hover:text-violet-700'
|
||||
: 'text-purple-500 hover:text-purple-700';
|
||||
const mutedClass = isSub ? 'text-violet-400' : 'text-purple-400';
|
||||
|
||||
return (
|
||||
@ -61,7 +65,9 @@ export function ThoughtCard({
|
||||
{content}
|
||||
</p>
|
||||
) : (
|
||||
<p className={`text-[11px] italic font-medium leading-relaxed ${previewClass}`}>
|
||||
<p
|
||||
className={`text-[11px] italic font-medium leading-relaxed ${previewClass}`}
|
||||
>
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -5,18 +5,37 @@ import type { ColorScheme } from './constants';
|
||||
import { getToolDisplayName } from './toolDisplayNames';
|
||||
import { AgentMarkdown } from './AgentMarkdown';
|
||||
import { useAutoScroll } from './useAutoScroll';
|
||||
import {
|
||||
QueryTargetCard,
|
||||
PaperListCard,
|
||||
TodoTaskCard,
|
||||
BgTaskProgressCard,
|
||||
} from './SpecialToolRenderers';
|
||||
import type { StandardPaper } from '../../types';
|
||||
import type { useCitations } from '../../hooks/useCitations';
|
||||
import type { useLibrary } from '../../hooks/useLibrary';
|
||||
|
||||
interface ToolCallCardProps {
|
||||
step: number;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: { output: string; isError: boolean };
|
||||
result?: {
|
||||
output: string;
|
||||
isError: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
isStreaming: boolean;
|
||||
colorScheme: ColorScheme;
|
||||
isArgsExpanded: boolean;
|
||||
isResultExpanded: boolean;
|
||||
onToggleArgs: () => void;
|
||||
onToggleResult: () => void;
|
||||
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
|
||||
setActiveTab?: (
|
||||
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
|
||||
) => void;
|
||||
citations?: ReturnType<typeof useCitations>;
|
||||
library?: ReturnType<typeof useLibrary>;
|
||||
}
|
||||
|
||||
export function ToolCallCard({
|
||||
@ -30,19 +49,167 @@ export function ToolCallCard({
|
||||
isResultExpanded,
|
||||
onToggleArgs,
|
||||
onToggleResult,
|
||||
openReader,
|
||||
setActiveTab,
|
||||
citations,
|
||||
library,
|
||||
}: ToolCallCardProps) {
|
||||
const hasResult = !!result;
|
||||
const dotColor = isStreaming ? colorScheme.tool_call.active : colorScheme.tool_call.idle;
|
||||
const dotColor = isStreaming
|
||||
? colorScheme.tool_call.active
|
||||
: colorScheme.tool_call.idle;
|
||||
const { chatEndRef, scrollContainerRef, handleScroll } = useAutoScroll(
|
||||
result ? [result.output] : [],
|
||||
result ? [result.output] : []
|
||||
);
|
||||
|
||||
// 渲染自定义的特殊可视化卡片,如果匹配成功,则替代传统的纯文本/Markdown 结果展示
|
||||
const renderSpecialCard = () => {
|
||||
if (result?.isError) return null;
|
||||
const metadata = result?.metadata;
|
||||
|
||||
// 1. 天体参数查询
|
||||
if (
|
||||
name === 'query_target' &&
|
||||
metadata &&
|
||||
typeof metadata === 'object' &&
|
||||
'target_name' in metadata
|
||||
) {
|
||||
return (
|
||||
<div className="relative space-y-2 select-none">
|
||||
<QueryTargetCard
|
||||
metadata={
|
||||
metadata as {
|
||||
target_name: string;
|
||||
ra?: string;
|
||||
dec?: string;
|
||||
parallax?: number;
|
||||
spectral_type?: string;
|
||||
v_magnitude?: number;
|
||||
otype?: string;
|
||||
oname?: string;
|
||||
aliases?: string[];
|
||||
photometry?: Record<string, number>;
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. 文献检索 / 馆藏检索
|
||||
if (
|
||||
(name === 'search_papers' || name === 'search_local_library') &&
|
||||
metadata &&
|
||||
typeof metadata === 'object' &&
|
||||
'papers' in metadata
|
||||
) {
|
||||
return (
|
||||
<PaperListCard
|
||||
metadata={
|
||||
metadata as {
|
||||
count: number;
|
||||
papers?: Array<{
|
||||
bibcode: string;
|
||||
title: string;
|
||||
first_author: string;
|
||||
year: string;
|
||||
citation_count?: number;
|
||||
pub_journal?: string;
|
||||
has_markdown?: boolean;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
library={library}
|
||||
citations={citations}
|
||||
setActiveTab={setActiveTab}
|
||||
openReader={openReader}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 任务看板 (从参数 args.todos 中取最新清单)
|
||||
if (name === 'todo_write' && args && Array.isArray(args.todos)) {
|
||||
return (
|
||||
<TodoTaskCard
|
||||
todos={
|
||||
args.todos as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
blockedBy?: string[];
|
||||
}>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 异步后台任务启动/查询
|
||||
if (
|
||||
name === 'bg_task_run' &&
|
||||
metadata &&
|
||||
typeof metadata === 'object' &&
|
||||
'task_id' in metadata
|
||||
) {
|
||||
return (
|
||||
<BgTaskProgressCard
|
||||
task={
|
||||
metadata as {
|
||||
task_id: string;
|
||||
tool_name: string;
|
||||
bibcode: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'bg_task_check') {
|
||||
if (metadata && typeof metadata === 'object') {
|
||||
if ('task_id' in metadata && 'status' in metadata) {
|
||||
return (
|
||||
<BgTaskProgressCard
|
||||
task={
|
||||
metadata as {
|
||||
task_id: string;
|
||||
tool_name: string;
|
||||
bibcode: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else if (Array.isArray((metadata as { tasks?: unknown[] }).tasks)) {
|
||||
const tasks = (
|
||||
metadata as {
|
||||
tasks: Array<{
|
||||
task_id: string;
|
||||
tool_name: string;
|
||||
bibcode: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
}>;
|
||||
}
|
||||
).tasks;
|
||||
return (
|
||||
<div className="space-y-2 select-none w-full">
|
||||
{tasks.map((task, i) => (
|
||||
<BgTaskProgressCard key={i} task={task} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const specialCard = renderSpecialCard();
|
||||
|
||||
return (
|
||||
<div className="relative space-y-2 select-none w-full">
|
||||
<div
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`}
|
||||
/>
|
||||
<div className="space-y-2 border border-slate-100 bg-white rounded-md p-3 shadow-2xs">
|
||||
<div className="space-y-2 border border-slate-100 bg-white rounded-md p-3 shadow-2xs w-full overflow-hidden">
|
||||
{/* 工具名称 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-sky-700 tracking-wider uppercase">
|
||||
@ -69,8 +236,13 @@ export function ToolCallCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果 */}
|
||||
{hasResult && (
|
||||
{/* 结果/特殊卡片 */}
|
||||
{specialCard ? (
|
||||
<div className="border-t border-slate-100 pt-2.5 mt-2.5 w-full overflow-hidden">
|
||||
{specialCard}
|
||||
</div>
|
||||
) : (
|
||||
hasResult && (
|
||||
<div className="space-y-1.5 border-t border-slate-100 pt-2.5 mt-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-emerald-700 tracking-wider uppercase">
|
||||
@ -95,9 +267,7 @@ export function ToolCallCard({
|
||||
onScroll={handleScroll}
|
||||
className="text-xs bg-slate-50 border border-slate-200 rounded-md p-2.5 text-slate-700 overflow-auto select-text leading-relaxed max-h-[60vh] prose prose-sm max-w-none prose-headings:text-slate-800 prose-code:text-sky-700 prose-pre:bg-slate-100 prose-pre:text-xs prose-img:rounded-lg"
|
||||
>
|
||||
<AgentMarkdown>
|
||||
{result!.output}
|
||||
</AgentMarkdown>
|
||||
<AgentMarkdown>{result!.output}</AgentMarkdown>
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
@ -106,6 +276,7 @@ export function ToolCallCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,12 +7,28 @@ export const safeSchema = {
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
'*': (defaultSchema.attributes?.['*'] || []).concat([
|
||||
'className', 'style', 'mathvariant', 'display',
|
||||
'className',
|
||||
'style',
|
||||
'mathvariant',
|
||||
'display',
|
||||
]),
|
||||
},
|
||||
tagNames: (defaultSchema.tagNames || []).concat([
|
||||
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup',
|
||||
'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation',
|
||||
'math',
|
||||
'mrow',
|
||||
'mi',
|
||||
'mo',
|
||||
'mn',
|
||||
'msup',
|
||||
'msub',
|
||||
'msubsup',
|
||||
'mfrac',
|
||||
'mover',
|
||||
'munder',
|
||||
'munderover',
|
||||
'mspace',
|
||||
'mtext',
|
||||
'annotation',
|
||||
]),
|
||||
};
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ export function findStreamingSubAgent(timeline: TimelineItem[]): number {
|
||||
export function routeThought(
|
||||
timeline: TimelineItem[],
|
||||
step: number,
|
||||
content: string,
|
||||
content: string
|
||||
): TimelineItem[] {
|
||||
// 子代理思考 → 路由到活跃容器
|
||||
if (isSubagentThought(content)) {
|
||||
@ -58,12 +58,14 @@ export function routeThought(
|
||||
const saIdx = findStreamingSubAgent(timeline);
|
||||
if (saIdx >= 0) {
|
||||
return updateContainerChild(timeline, saIdx, (children) => {
|
||||
const existing = children.find(c => c.type === 'thought' && c.step === step);
|
||||
const existing = children.find(
|
||||
(c) => c.type === 'thought' && c.step === step
|
||||
);
|
||||
if (existing) {
|
||||
return children.map(c =>
|
||||
return children.map((c) =>
|
||||
c.type === 'thought' && c.step === step
|
||||
? { ...c, content: subContent } as TimelineItem
|
||||
: c,
|
||||
? ({ ...c, content: subContent } as TimelineItem)
|
||||
: c
|
||||
);
|
||||
}
|
||||
return [...children, { type: 'thought', step, content: subContent }];
|
||||
@ -82,12 +84,12 @@ export function routeToolCall(
|
||||
step: number,
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
args: Record<string, unknown>
|
||||
): TimelineItem[] {
|
||||
// 创建 subagent_container
|
||||
if (isSubagentContainerCall(name)) {
|
||||
const existing = timeline.find(
|
||||
t => t.type === 'subagent_container' && t.id === id,
|
||||
(t) => t.type === 'subagent_container' && t.id === id
|
||||
);
|
||||
if (existing) return timeline;
|
||||
return [
|
||||
@ -108,12 +110,18 @@ export function routeToolCall(
|
||||
if (saIdx >= 0) {
|
||||
return updateContainerChild(timeline, saIdx, (children) => {
|
||||
const dup = children.find(
|
||||
c => c.type === 'tool_call' && 'id' in c && c.id === id,
|
||||
(c) => c.type === 'tool_call' && 'id' in c && c.id === id
|
||||
);
|
||||
if (dup) return children;
|
||||
return [
|
||||
...children,
|
||||
{ type: 'tool_call' as const, step, id, name: cleanName, arguments: args },
|
||||
{
|
||||
type: 'tool_call' as const,
|
||||
step,
|
||||
id,
|
||||
name: cleanName,
|
||||
arguments: args,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@ -132,10 +140,11 @@ export function routeToolResult(
|
||||
name: string,
|
||||
output: string,
|
||||
isError: boolean,
|
||||
metadata?: Record<string, unknown>
|
||||
): TimelineItem[] {
|
||||
// 匹配 subagent_container id → 完成容器
|
||||
const saCompleteIdx = timeline.findIndex(
|
||||
t => t.type === 'subagent_container' && t.id === toolCallId,
|
||||
(t) => t.type === 'subagent_container' && t.id === toolCallId
|
||||
);
|
||||
if (saCompleteIdx >= 0) {
|
||||
const newTimeline = [...timeline];
|
||||
@ -156,20 +165,20 @@ export function routeToolResult(
|
||||
const saIdx = findStreamingSubAgent(timeline);
|
||||
if (saIdx >= 0) {
|
||||
return updateContainerChild(timeline, saIdx, (children) =>
|
||||
children.map(c => {
|
||||
children.map((c) => {
|
||||
if (c.type === 'tool_call' && c.id === toolCallId && !c.result) {
|
||||
return { ...c, result: { output, isError } };
|
||||
return { ...c, result: { output, isError, metadata } };
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 父代理工具结果
|
||||
return timeline.map(t => {
|
||||
return timeline.map((t) => {
|
||||
if (t.type === 'tool_call' && t.id === toolCallId) {
|
||||
return { ...t, result: { output, isError } };
|
||||
return { ...t, result: { output, isError, metadata } };
|
||||
}
|
||||
return t;
|
||||
});
|
||||
@ -179,11 +188,11 @@ export function routeToolResult(
|
||||
export function routeTextDelta(
|
||||
timeline: TimelineItem[],
|
||||
content: string,
|
||||
toolCallId?: string,
|
||||
toolCallId?: string
|
||||
): { timeline: TimelineItem[]; answerDelta: string } {
|
||||
if (toolCallId) {
|
||||
return {
|
||||
timeline: timeline.map(t => {
|
||||
timeline: timeline.map((t) => {
|
||||
if (t.type === 'tool_call' && t.id === toolCallId) {
|
||||
const prevOutput = t.result?.output || '';
|
||||
return {
|
||||
@ -204,12 +213,14 @@ export function routeTextDelta(
|
||||
function upsertParentThought(
|
||||
timeline: TimelineItem[],
|
||||
step: number,
|
||||
content: string,
|
||||
content: string
|
||||
): TimelineItem[] {
|
||||
const existing = timeline.find(t => t.type === 'thought' && t.step === step);
|
||||
const existing = timeline.find(
|
||||
(t) => t.type === 'thought' && t.step === step
|
||||
);
|
||||
if (existing) {
|
||||
return timeline.map(t =>
|
||||
t.type === 'thought' && t.step === step ? { ...t, content } : t,
|
||||
return timeline.map((t) =>
|
||||
t.type === 'thought' && t.step === step ? { ...t, content } : t
|
||||
);
|
||||
}
|
||||
return [...timeline, { type: 'thought', step, content }];
|
||||
@ -220,19 +231,22 @@ function upsertParentToolCall(
|
||||
step: number,
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
args: Record<string, unknown>
|
||||
): TimelineItem[] {
|
||||
const existing = timeline.find(
|
||||
t => t.type === 'tool_call' && 'id' in t && t.id === id,
|
||||
(t) => t.type === 'tool_call' && 'id' in t && t.id === id
|
||||
);
|
||||
if (existing) return timeline;
|
||||
return [...timeline, { type: 'tool_call' as const, step, id, name, arguments: args }];
|
||||
return [
|
||||
...timeline,
|
||||
{ type: 'tool_call' as const, step, id, name, arguments: args },
|
||||
];
|
||||
}
|
||||
|
||||
function updateContainerChild(
|
||||
timeline: TimelineItem[],
|
||||
containerIdx: number,
|
||||
updateFn: (children: TimelineItem[]) => TimelineItem[],
|
||||
updateFn: (children: TimelineItem[]) => TimelineItem[]
|
||||
): TimelineItem[] {
|
||||
const newTimeline = [...timeline];
|
||||
const container = newTimeline[containerIdx] as Extract<
|
||||
|
||||
@ -4,41 +4,70 @@
|
||||
export function getToolDisplayName(name: string): string {
|
||||
switch (name) {
|
||||
// 文件系统工具
|
||||
case 'read_file': return '读取文件内容';
|
||||
case 'grep_files': return '正则搜索文件';
|
||||
case 'glob_files': return '通配符匹配文件';
|
||||
case 'run_bash': return '执行 Shell 命令';
|
||||
case 'file_write': return '写入文件';
|
||||
case 'file_edit': return '精确编辑文件';
|
||||
case 'read_file':
|
||||
return '读取文件内容';
|
||||
case 'grep_files':
|
||||
return '正则搜索文件';
|
||||
case 'glob_files':
|
||||
return '通配符匹配文件';
|
||||
case 'run_bash':
|
||||
return '执行 Shell 命令';
|
||||
case 'file_write':
|
||||
return '写入文件';
|
||||
case 'file_edit':
|
||||
return '精确编辑文件';
|
||||
// 天文科研工具
|
||||
case 'search_papers': return '检索 ADS/arXiv 文献';
|
||||
case 'get_paper_metadata': return '获取文献详细元数据';
|
||||
case 'download_paper': return '下载文献全文资源';
|
||||
case 'parse_paper': return '结构化解析文献内容';
|
||||
case 'get_paper_content': return '获取文献全文内容';
|
||||
case 'rag_search': return '检索馆藏知识库';
|
||||
case 'query_target': return '查询天体物理参数 (CDS)';
|
||||
case 'save_note': return '保存文献手札';
|
||||
case 'search_papers':
|
||||
return '检索 ADS/arXiv 文献';
|
||||
case 'get_paper_metadata':
|
||||
return '获取文献详细元数据';
|
||||
case 'download_paper':
|
||||
return '下载文献全文资源';
|
||||
case 'parse_paper':
|
||||
return '结构化解析文献内容';
|
||||
case 'get_paper_content':
|
||||
return '获取文献全文内容';
|
||||
case 'rag_search':
|
||||
return '检索馆藏知识库';
|
||||
case 'query_target':
|
||||
return '查询天体物理参数 (CDS)';
|
||||
case 'save_note':
|
||||
return '保存文献手札';
|
||||
// Agent 控制工具
|
||||
case 'todo_write': return '管理任务列表';
|
||||
case 'compress_context': return '压缩上下文窗口';
|
||||
case 'load_skill': return '加载专家技能';
|
||||
case 'subagent': return '派发子代理';
|
||||
case 'delegate_research': return '派发子代理';
|
||||
case 'ask_user': return '向用户提问';
|
||||
case 'todo_write':
|
||||
return '管理任务列表';
|
||||
case 'compress_context':
|
||||
return '压缩上下文窗口';
|
||||
case 'load_skill':
|
||||
return '加载专家技能';
|
||||
case 'subagent':
|
||||
return '派发子代理';
|
||||
case 'delegate_research':
|
||||
return '派发子代理';
|
||||
case 'ask_user':
|
||||
return '向用户提问';
|
||||
// 记忆系统
|
||||
case 'save_memory': return '保存项目记忆';
|
||||
case 'load_memory': return '读取项目记忆';
|
||||
case 'save_memory':
|
||||
return '保存项目记忆';
|
||||
case 'load_memory':
|
||||
return '读取项目记忆';
|
||||
// 图片分析
|
||||
case 'analyze_image': return '分析图像内容';
|
||||
case 'analyze_image':
|
||||
return '分析图像内容';
|
||||
// 后台任务
|
||||
case 'bg_task_run': return '启动后台任务';
|
||||
case 'bg_task_check': return '检查后台任务状态';
|
||||
case 'bg_task_run':
|
||||
return '启动后台任务';
|
||||
case 'bg_task_check':
|
||||
return '检查后台任务状态';
|
||||
// 团队协作
|
||||
case 'spawn_teammate': return '创建团队成员';
|
||||
case 'send_teammate_message': return '发送团队成员消息';
|
||||
case 'team_broadcast': return '团队广播消息';
|
||||
case 'check_team_inbox': return '检查团队收件箱';
|
||||
case 'spawn_teammate':
|
||||
return '创建团队成员';
|
||||
case 'send_teammate_message':
|
||||
return '发送团队成员消息';
|
||||
case 'team_broadcast':
|
||||
return '团队广播消息';
|
||||
case 'check_team_inbox':
|
||||
return '检查团队收件箱';
|
||||
default: {
|
||||
// 子代理工具名带 [sub] 前缀
|
||||
if (name.startsWith('[sub] ')) {
|
||||
|
||||
@ -10,7 +10,11 @@ export type TimelineItem =
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: { output: string; isError: boolean };
|
||||
result?: {
|
||||
output: string;
|
||||
isError: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
| { type: 'answer'; content: string }
|
||||
| {
|
||||
@ -25,14 +29,19 @@ export type TimelineItem =
|
||||
export interface SSEEventHandlers {
|
||||
onSession?: (sessionId: string, title?: string) => void;
|
||||
onThought?: (step: number, content: string) => void;
|
||||
onToolCall?: (step: number, id: string, name: string, args: Record<string, unknown>) => void;
|
||||
onToolCall?: (
|
||||
step: number,
|
||||
id: string,
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
) => void;
|
||||
onToolResult?: (
|
||||
step: number,
|
||||
toolCallId: string,
|
||||
name: string,
|
||||
output: string,
|
||||
isError: boolean,
|
||||
metadata?: Record<string, unknown>,
|
||||
metadata?: Record<string, unknown>
|
||||
) => void;
|
||||
onTextDelta?: (content: string, toolCallId?: string) => void;
|
||||
onUsage?: (usage: {
|
||||
|
||||
@ -5,7 +5,10 @@ import type { SSEEventHandlers, AgentSSEParams } from './types';
|
||||
|
||||
interface UseAgentSSEReturn {
|
||||
streaming: boolean;
|
||||
send: (params: AgentSSEParams, handlers: SSEEventHandlers) => Promise<string | null>;
|
||||
send: (
|
||||
params: AgentSSEParams,
|
||||
handlers: SSEEventHandlers
|
||||
) => Promise<string | null>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
@ -18,13 +21,22 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (params: AgentSSEParams, handlers: SSEEventHandlers): Promise<string | null> => {
|
||||
async (
|
||||
params: AgentSSEParams,
|
||||
handlers: SSEEventHandlers
|
||||
): Promise<string | null> => {
|
||||
setStreaming(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const body: Record<string, string | boolean | null | { data?: string; path?: string; mime_type: string }> = {
|
||||
const body: Record<
|
||||
string,
|
||||
| string
|
||||
| boolean
|
||||
| null
|
||||
| { data?: string; path?: string; mime_type: string }
|
||||
> = {
|
||||
question: params.question,
|
||||
session_id: params.sessionId,
|
||||
mode: params.mode || 'default',
|
||||
@ -88,7 +100,7 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
||||
event.step,
|
||||
event.id,
|
||||
event.name,
|
||||
event.arguments,
|
||||
event.arguments
|
||||
);
|
||||
break;
|
||||
case 'tool_result':
|
||||
@ -98,14 +110,11 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
||||
event.name,
|
||||
event.output,
|
||||
event.is_error,
|
||||
event.metadata,
|
||||
event.metadata
|
||||
);
|
||||
break;
|
||||
case 'text_delta':
|
||||
handlers.onTextDelta?.(
|
||||
event.content,
|
||||
event.tool_call_id,
|
||||
);
|
||||
handlers.onTextDelta?.(event.content, event.tool_call_id);
|
||||
break;
|
||||
case 'usage':
|
||||
handlers.onUsage?.(event);
|
||||
@ -137,7 +146,7 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
return { streaming, send, stop };
|
||||
|
||||
@ -30,6 +30,7 @@ export function useAutoScroll(deps: React.DependencyList): UseAutoScrollReturn {
|
||||
if (shouldAutoScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
|
||||
@ -43,7 +43,11 @@ export function BaseModal({
|
||||
<div className="min-w-0 flex-1">
|
||||
{typeof title === 'string' ? (
|
||||
<h3 className="text-xs font-bold text-main flex items-center gap-1.5 pr-4">
|
||||
{dotColor && <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`} />}
|
||||
{dotColor && (
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotColor}`}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{title}</span>
|
||||
</h3>
|
||||
) : (
|
||||
@ -55,8 +59,18 @@ export function BaseModal({
|
||||
className="text-slate-400 hover:text-slate-600 p-1 rounded-md hover:bg-slate-100 transition-colors shrink-0 cursor-pointer"
|
||||
title="关闭"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -34,7 +34,9 @@ export function GlobalDialog({ dialog, onClose }: GlobalDialogProps) {
|
||||
zIndex={60}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted leading-relaxed whitespace-pre-line font-medium">{dialog.message}</p>
|
||||
<p className="text-xs text-muted leading-relaxed whitespace-pre-line font-medium">
|
||||
{dialog.message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
|
||||
@ -9,11 +9,17 @@ interface PaperDetailModalProps {
|
||||
onClose: () => void;
|
||||
uploadingBibcode: string | null;
|
||||
downloadingBibcodes: Record<string, boolean>;
|
||||
handleManualUpload: (bibcode: string, type: 'pdf' | 'html', file: File) => Promise<void>;
|
||||
handleManualUpload: (
|
||||
bibcode: string,
|
||||
type: 'pdf' | 'html',
|
||||
file: File
|
||||
) => Promise<void>;
|
||||
handleMarkNoResource: (bibcode: string, clear: boolean) => Promise<void>;
|
||||
handleDownload: (bibcode: string, force?: boolean) => Promise<void>;
|
||||
openReader: (paper: StandardPaper) => void;
|
||||
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent') => void;
|
||||
setActiveTab: (
|
||||
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
|
||||
) => void;
|
||||
setSelectedPaper: (paper: StandardPaper) => void;
|
||||
loadCitations: (bibcode: string) => Promise<void>;
|
||||
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
|
||||
@ -37,7 +43,9 @@ export function PaperDetailModal({
|
||||
|
||||
const modalTitle = (
|
||||
<div className="space-y-1 pr-4">
|
||||
<span className="text-[10px] font-bold text-blueprint uppercase tracking-wider block">文献详情元数据</span>
|
||||
<span className="text-[10px] font-bold text-blueprint uppercase tracking-wider block">
|
||||
文献详情元数据
|
||||
</span>
|
||||
<h3 className="text-xs font-bold text-main leading-snug">
|
||||
{getDoctypeBadge(paper.doctype)}
|
||||
<span className="align-middle ml-1">{paper.title}</span>
|
||||
@ -53,19 +61,22 @@ export function PaperDetailModal({
|
||||
size="lg"
|
||||
zIndex={50}
|
||||
>
|
||||
|
||||
{/* 详情内容 */}
|
||||
<div className="space-y-4 h-[460px] flex flex-col overflow-y-auto pr-1 text-xs scrollbar-thin">
|
||||
{/* 作者 */}
|
||||
<div className="space-y-1 h-12 overflow-y-auto scrollbar-thin shrink-0">
|
||||
<span className="text-muted font-bold">作者列表</span>
|
||||
<p className="text-main leading-relaxed font-semibold">{paper.authors.join(', ')}</p>
|
||||
<p className="text-main leading-relaxed font-semibold">
|
||||
{paper.authors.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 期刊 & 年份 */}
|
||||
<div className="grid grid-cols-5 gap-4 border-y border-slate-150 py-2.5 shrink-0 h-14">
|
||||
<div className="space-y-0.5 flex flex-col justify-center min-w-0 col-span-4">
|
||||
<span className="text-muted font-bold block text-[10px] leading-tight">发表期刊</span>
|
||||
<span className="text-muted font-bold block text-[10px] leading-tight">
|
||||
发表期刊
|
||||
</span>
|
||||
<span
|
||||
className="text-main font-bold italic truncate block text-[11px] leading-tight"
|
||||
title={paper.pub_journal || '未标注'}
|
||||
@ -74,8 +85,12 @@ export function PaperDetailModal({
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5 flex flex-col justify-center col-span-1">
|
||||
<span className="text-muted font-bold block text-[10px] leading-tight">发表年份</span>
|
||||
<span className="text-main font-extrabold block text-[11px] leading-tight">{paper.year}</span>
|
||||
<span className="text-muted font-bold block text-[10px] leading-tight">
|
||||
发表年份
|
||||
</span>
|
||||
<span className="text-main font-extrabold block text-[11px] leading-tight">
|
||||
{paper.year}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -92,8 +107,11 @@ export function PaperDetailModal({
|
||||
<span className="text-muted font-bold block">关键词</span>
|
||||
{paper.keywords && paper.keywords.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 flex-1 content-start items-start overflow-y-auto scrollbar-thin pr-1">
|
||||
{paper.keywords.map(kw => (
|
||||
<span key={kw} className="px-2 py-0.5 rounded bg-slate-100 border border-precision text-muted font-bold text-[9px]">
|
||||
{paper.keywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
className="px-2 py-0.5 rounded bg-slate-100 border border-precision text-muted font-bold text-[9px]"
|
||||
>
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
@ -109,13 +127,22 @@ export function PaperDetailModal({
|
||||
<div className="border-t border-slate-155 pt-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[10px] font-mono mt-auto shrink-0">
|
||||
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">BIBCODE</span>
|
||||
<span className="text-main font-semibold select-all truncate block" title={paper.bibcode === paper.arxiv_id ? '暂无' : paper.bibcode}>
|
||||
{paper.bibcode === paper.arxiv_id ? '暂无' : (
|
||||
<span
|
||||
className="text-main font-semibold select-all truncate block"
|
||||
title={paper.bibcode === paper.arxiv_id ? '暂无' : paper.bibcode}
|
||||
>
|
||||
{paper.bibcode === paper.arxiv_id ? (
|
||||
'暂无'
|
||||
) : (
|
||||
<a
|
||||
href={`https://ui.adsabs.harvard.edu/abs/${paper.bibcode}/abstract`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
|
||||
onClick={() =>
|
||||
axios
|
||||
.post('/api/active_bibcode', { bibcode: paper.bibcode })
|
||||
.catch(() => {})
|
||||
}
|
||||
className="text-blueprint hover:underline"
|
||||
>
|
||||
{paper.bibcode}
|
||||
@ -125,34 +152,52 @@ export function PaperDetailModal({
|
||||
</div>
|
||||
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">DOI</span>
|
||||
<span className="text-main font-semibold select-all truncate block" title={paper.doi || '无'}>
|
||||
<span
|
||||
className="text-main font-semibold select-all truncate block"
|
||||
title={paper.doi || '无'}
|
||||
>
|
||||
{paper.doi ? (
|
||||
<a
|
||||
href={`https://doi.org/${paper.doi}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
|
||||
onClick={() =>
|
||||
axios
|
||||
.post('/api/active_bibcode', { bibcode: paper.bibcode })
|
||||
.catch(() => {})
|
||||
}
|
||||
className="text-blueprint hover:underline"
|
||||
>
|
||||
{paper.doi}
|
||||
</a>
|
||||
) : '无'}
|
||||
) : (
|
||||
'无'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||
<span className="text-muted font-bold block">ARXIV ID</span>
|
||||
<span className="text-main font-semibold select-all truncate block" title={paper.arxiv_id || '无'}>
|
||||
<span
|
||||
className="text-main font-semibold select-all truncate block"
|
||||
title={paper.arxiv_id || '无'}
|
||||
>
|
||||
{paper.arxiv_id ? (
|
||||
<a
|
||||
href={`https://arxiv.org/abs/${paper.arxiv_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => axios.post('/api/active_bibcode', { bibcode: paper.bibcode }).catch(() => {})}
|
||||
onClick={() =>
|
||||
axios
|
||||
.post('/api/active_bibcode', { bibcode: paper.bibcode })
|
||||
.catch(() => {})
|
||||
}
|
||||
className="text-blueprint hover:underline"
|
||||
>
|
||||
{paper.arxiv_id}
|
||||
</a>
|
||||
) : '无'}
|
||||
) : (
|
||||
'无'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -161,10 +206,13 @@ export function PaperDetailModal({
|
||||
<div className="border-t border-slate-150 pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted font-bold block">手动离线上传文献</span>
|
||||
<span className="text-[9px] text-amber-800 font-bold bg-amber-50 px-2 py-0.5 rounded-md border border-amber-200/60">防爬/人机验证备用</span>
|
||||
<span className="text-[9px] text-amber-800 font-bold bg-amber-50 px-2 py-0.5 rounded-md border border-amber-200/60">
|
||||
防爬/人机验证备用
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted leading-relaxed">
|
||||
若自动下载受阻,可在浏览器中打开上方链接,手动保存 PDF 或 HTML 后在此处上传覆盖。
|
||||
若自动下载受阻,可在浏览器中打开上方链接,手动保存 PDF 或 HTML
|
||||
后在此处上传覆盖。
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col items-center justify-center border border-dashed border-precision rounded-md p-2 hover:bg-slate-50 transition-colors relative cursor-pointer group min-h-[50px]">
|
||||
@ -184,7 +232,9 @@ export function PaperDetailModal({
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-bold text-blueprint group-hover:underline">
|
||||
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 PDF 文献'}
|
||||
{uploadingBibcode === paper.bibcode
|
||||
? '上传中...'
|
||||
: '上传 PDF 文献'}
|
||||
</span>
|
||||
<span className="text-[8px] text-muted">支持 .pdf 格式</span>
|
||||
</div>
|
||||
@ -206,7 +256,9 @@ export function PaperDetailModal({
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-bold text-blueprint group-hover:underline">
|
||||
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 HTML 文献'}
|
||||
{uploadingBibcode === paper.bibcode
|
||||
? '上传中...'
|
||||
: '上传 HTML 文献'}
|
||||
</span>
|
||||
<span className="text-[8px] text-muted">支持 .html 格式</span>
|
||||
</div>
|
||||
@ -214,13 +266,15 @@ export function PaperDetailModal({
|
||||
|
||||
{!paper.is_downloaded && (
|
||||
<div className="pt-1">
|
||||
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
|
||||
{paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMarkNoResource(paper.bibcode, true)}
|
||||
className="btn-console btn-console-secondary w-full py-2 rounded-md text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> 恢复自动下载状态 (允许后续重新尝试)
|
||||
<RefreshCw className="w-3 h-3" /> 恢复自动下载状态
|
||||
(允许后续重新尝试)
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@ -228,7 +282,8 @@ export function PaperDetailModal({
|
||||
onClick={() => handleMarkNoResource(paper.bibcode, false)}
|
||||
className="btn-console btn-console-secondary w-full py-2 rounded-md text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-amber-600" /> 标记为“无有效全文资源” (排查后跳过重试)
|
||||
<AlertTriangle className="w-3 h-3 text-amber-600" />{' '}
|
||||
标记为“无有效全文资源” (排查后跳过重试)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -237,12 +292,16 @@ export function PaperDetailModal({
|
||||
|
||||
{/* 自动下载失败诊断 */}
|
||||
{!paper.is_downloaded && (paper.pdf_error || paper.html_error) && (
|
||||
<div className={`rounded-md p-3 text-[10px] space-y-1 border border-slate-200 border-l-4 ${
|
||||
(paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
|
||||
<div
|
||||
className={`rounded-md p-3 text-[10px] space-y-1 border border-slate-200 border-l-4 ${
|
||||
paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource'
|
||||
? 'bg-slate-50 border-l-amber-500 text-slate-800'
|
||||
: 'bg-slate-50 border-l-rose-500 text-slate-800'
|
||||
}`}>
|
||||
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
|
||||
}`}
|
||||
>
|
||||
{paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource' ? (
|
||||
<div className="leading-relaxed">
|
||||
<div className="font-bold flex items-center gap-1.5 text-slate-900 text-[11px] mb-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||
@ -258,13 +317,17 @@ export function PaperDetailModal({
|
||||
</div>
|
||||
{paper.pdf_error && (
|
||||
<div className="leading-relaxed">
|
||||
<span className="font-semibold text-slate-600">PDF 错误: </span>
|
||||
<span className="font-semibold text-slate-600">
|
||||
PDF 错误:{' '}
|
||||
</span>
|
||||
{paper.pdf_error}
|
||||
</div>
|
||||
)}
|
||||
{paper.html_error && (
|
||||
<div className="leading-relaxed mt-0.5">
|
||||
<span className="font-semibold text-slate-600">HTML 错误: </span>
|
||||
<span className="font-semibold text-slate-600">
|
||||
HTML 错误:{' '}
|
||||
</span>
|
||||
{paper.html_error}
|
||||
</div>
|
||||
)}
|
||||
@ -300,9 +363,13 @@ export function PaperDetailModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
showConfirm('确定要强制重新下载吗?这会覆盖本地 file。', () => {
|
||||
showConfirm(
|
||||
'确定要强制重新下载吗?这会覆盖本地 file。',
|
||||
() => {
|
||||
handleDownload(paper.bibcode, true);
|
||||
}, '确认重新下载');
|
||||
},
|
||||
'确认重新下载'
|
||||
);
|
||||
}}
|
||||
disabled={downloadingBibcodes[paper.bibcode]}
|
||||
className="px-4 btn-console btn-console-secondary text-star py-2.5 rounded-md text-xs font-bold transition-all cursor-pointer disabled:opacity-50"
|
||||
|
||||
@ -7,7 +7,11 @@ interface UncachedPaperModalProps {
|
||||
loadCitations: (bibcode: string, reset: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export function UncachedPaperModal({ bibcode, onClose, loadCitations }: UncachedPaperModalProps) {
|
||||
export function UncachedPaperModal({
|
||||
bibcode,
|
||||
onClose,
|
||||
loadCitations,
|
||||
}: UncachedPaperModalProps) {
|
||||
if (!bibcode) return null;
|
||||
|
||||
return (
|
||||
@ -21,10 +25,15 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-main leading-relaxed">
|
||||
文献 <span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded-md text-blueprint font-bold select-all">{bibcode}</span> 尚未收录在本地数据库中。
|
||||
文献{' '}
|
||||
<span className="font-mono bg-slate-100 px-1.5 py-0.5 rounded-md text-blueprint font-bold select-all">
|
||||
{bibcode}
|
||||
</span>{' '}
|
||||
尚未收录在本地数据库中。
|
||||
</p>
|
||||
<p className="text-[11px] text-muted leading-relaxed">
|
||||
您可以选择在线拉取该文献元数据并入库,或是直接跳转至 NASA ADS 平台查看其原始页面。
|
||||
您可以选择在线拉取该文献元数据并入库,或是直接跳转至 NASA ADS
|
||||
平台查看其原始页面。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
@ -40,7 +49,10 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
|
||||
<button
|
||||
onClick={() => {
|
||||
axios.post('/api/active_bibcode', { bibcode }).catch(() => {});
|
||||
window.open(`https://ui.adsabs.harvard.edu/abs/${bibcode}/abstract`, '_blank');
|
||||
window.open(
|
||||
`https://ui.adsabs.harvard.edu/abs/${bibcode}/abstract`,
|
||||
'_blank'
|
||||
);
|
||||
}}
|
||||
className="flex-1 btn-console btn-console-secondary py-2 rounded-md text-[11px] font-bold text-center cursor-pointer"
|
||||
>
|
||||
|
||||
@ -1,10 +1,20 @@
|
||||
// dashboard/src/components/layout/Sidebar.tsx
|
||||
import { useState } from 'react';
|
||||
import { Search, BookOpen, GitFork, Library, RefreshCw, ChevronLeft, Sparkles, LogOut } from 'lucide-react';
|
||||
import {
|
||||
Search,
|
||||
BookOpen,
|
||||
GitFork,
|
||||
Library,
|
||||
RefreshCw,
|
||||
ChevronLeft,
|
||||
Sparkles,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
import { Logo } from '../Logo';
|
||||
|
||||
export type TabId = 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
|
||||
export type TabId =
|
||||
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: TabId;
|
||||
@ -16,7 +26,15 @@ interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, onLogout, isOpen = false, onClose }: SidebarProps) {
|
||||
export function Sidebar({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
selectedPaper,
|
||||
loadCitations,
|
||||
onLogout,
|
||||
isOpen = false,
|
||||
onClose,
|
||||
}: SidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const effectiveCollapsed = isCollapsed && !isOpen;
|
||||
|
||||
@ -36,14 +54,18 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
className={`bg-slate-50 border-r border-slate-200 flex flex-col justify-between py-6 z-40 select-none transition-all duration-355 cubic-bezier(0.4, 0, 0.2, 1) fixed inset-y-0 left-0 lg:relative lg:translate-x-0 ${
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
} ${
|
||||
isOpen ? 'w-64 px-4' : (effectiveCollapsed ? 'w-16 px-2' : 'w-64 px-4')
|
||||
isOpen ? 'w-64 px-4' : effectiveCollapsed ? 'w-16 px-2' : 'w-64 px-4'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
{/* 系统LOGO与折叠控制区 */}
|
||||
<div className={`mb-8 flex items-center transition-all duration-300 ${
|
||||
effectiveCollapsed ? 'justify-center px-0' : 'justify-between px-3'
|
||||
}`}>
|
||||
<div
|
||||
className={`mb-8 flex items-center transition-all duration-300 ${
|
||||
effectiveCollapsed
|
||||
? 'justify-center px-0'
|
||||
: 'justify-between px-3'
|
||||
}`}
|
||||
>
|
||||
{/* Logo & 标题文字 */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Logo按钮 (只在折叠状态下可点击展开) */}
|
||||
@ -56,9 +78,11 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
? 'w-11 h-11 bg-white hover:bg-slate-55 border border-slate-200 cursor-pointer shadow-xs hover:shadow-sm'
|
||||
: 'w-9 h-9 bg-transparent border border-transparent cursor-default'
|
||||
}`}
|
||||
title={effectiveCollapsed ? "展开导航" : undefined}
|
||||
title={effectiveCollapsed ? '展开导航' : undefined}
|
||||
>
|
||||
<div
|
||||
className={`transition-all duration-300 flex items-center justify-center ${effectiveCollapsed ? 'w-8 h-8' : 'w-9 h-9'}`}
|
||||
>
|
||||
<div className={`transition-all duration-300 flex items-center justify-center ${effectiveCollapsed ? 'w-8 h-8' : 'w-9 h-9'}`}>
|
||||
<Logo gradientId="sidebarStarGrad" />
|
||||
</div>
|
||||
</button>
|
||||
@ -71,8 +95,12 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
: 'opacity-100 max-w-[150px] scale-100 translate-x-0'
|
||||
}`}
|
||||
>
|
||||
<h1 className="text-sm font-bold text-slate-800 tracking-wider whitespace-nowrap">AstroResearch</h1>
|
||||
<span className="text-[11px] text-slate-500 block font-medium font-sans whitespace-nowrap">天文学科研辅助系统</span>
|
||||
<h1 className="text-sm font-bold text-slate-800 tracking-wider whitespace-nowrap">
|
||||
AstroResearch
|
||||
</h1>
|
||||
<span className="text-[11px] text-slate-500 block font-medium font-sans whitespace-nowrap">
|
||||
天文学科研辅助系统
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -115,14 +143,18 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
if (onClose) onClose(); // 移动端切页时自动收起
|
||||
}}
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border ${
|
||||
effectiveCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
|
||||
effectiveCollapsed
|
||||
? 'px-2 py-2.5 justify-center'
|
||||
: 'px-3 py-2.5'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
|
||||
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-slate-700' : 'text-slate-500'}`} />
|
||||
<Icon
|
||||
className={`w-4 h-4 shrink-0 transition-colors duration-300 ${isActive ? 'text-slate-700' : 'text-slate-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`truncate transition-all duration-300 origin-left ${
|
||||
effectiveCollapsed
|
||||
@ -147,7 +179,11 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
? 'p-0 border-transparent bg-transparent flex justify-center'
|
||||
: 'p-3.5 border-slate-200 bg-slate-100/50'
|
||||
}`}
|
||||
title={effectiveCollapsed ? `当前选定文献: ${selectedPaper.title}` : undefined}
|
||||
title={
|
||||
effectiveCollapsed
|
||||
? `当前选定文献: ${selectedPaper.title}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* 折叠下的微型图书图标 */}
|
||||
<div
|
||||
@ -168,11 +204,19 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
: 'opacity-100 max-w-[200px] max-h-40'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[9px] font-bold text-slate-500 tracking-widest block mb-1">当前选定文献</span>
|
||||
<h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">{selectedPaper.title}</h4>
|
||||
<span className="text-[9px] font-bold text-slate-500 tracking-widest block mb-1">
|
||||
当前选定文献
|
||||
</span>
|
||||
<h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">
|
||||
{selectedPaper.title}
|
||||
</h4>
|
||||
<div className="flex items-center justify-between text-[10px] font-medium text-slate-500 gap-2">
|
||||
<span className="shrink-0">发表年份: {selectedPaper.year}</span>
|
||||
<span className="truncate max-w-[90px] font-mono">{selectedPaper.bibcode}</span>
|
||||
<span className="shrink-0">
|
||||
发表年份: {selectedPaper.year}
|
||||
</span>
|
||||
<span className="truncate max-w-[90px] font-mono">
|
||||
{selectedPaper.bibcode}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -183,7 +227,7 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
? 'p-0 border-transparent bg-transparent flex justify-center'
|
||||
: 'p-3 border-slate-200 bg-slate-100/30'
|
||||
}`}
|
||||
title={effectiveCollapsed ? "未选定研究目标" : undefined}
|
||||
title={effectiveCollapsed ? '未选定研究目标' : undefined}
|
||||
>
|
||||
{/* 折叠下的微型馆藏图标 */}
|
||||
<div
|
||||
@ -204,7 +248,9 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
: 'opacity-100 max-w-[200px] max-h-12'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[10px] text-slate-400 font-medium tracking-wide">未选定研究目标</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium tracking-wide">
|
||||
未选定研究目标
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -216,7 +262,7 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-slate-500 hover:bg-rose-50/50 hover:text-rose-600 cursor-pointer group ${
|
||||
effectiveCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
|
||||
}`}
|
||||
title={effectiveCollapsed ? "退出登录" : undefined}
|
||||
title={effectiveCollapsed ? '退出登录' : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-slate-400 group-hover:text-rose-505 transition-colors" />
|
||||
<span
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// dashboard/src/components/reader/BilingualViewer.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkMath from 'remark-math';
|
||||
@ -11,8 +11,15 @@ import 'katex/dist/katex.min.css';
|
||||
import { FileText, Loader, Languages, BookOpen } from 'lucide-react';
|
||||
import type { StandardPaper, NoteRecord } from '../../types';
|
||||
import { NOTE_COLORS, safeSchema } from '../../hooks/useReaderState';
|
||||
import { highlightTargetsInMarkdown, type HighlightCandidate, type TargetInfo } from '../../utils/celestial';
|
||||
import { parseMarkdownFrontMatter, type PaperMetadata } from '../../utils/paper';
|
||||
import {
|
||||
highlightTargetsInMarkdown,
|
||||
type HighlightCandidate,
|
||||
type TargetInfo,
|
||||
} from '../../utils/celestial';
|
||||
import {
|
||||
parseMarkdownFrontMatter,
|
||||
type PaperMetadata,
|
||||
} from '../../utils/paper';
|
||||
import { splitParagraphs } from '../../hooks/useSyncScroll';
|
||||
|
||||
interface BilingualViewerProps {
|
||||
@ -46,16 +53,27 @@ interface BilingualViewerProps {
|
||||
/**
|
||||
* 极具学术控制台质感的文献元数据档案(Metadata Dossier)渲染面板
|
||||
*/
|
||||
function PaperMetadataDossier({ metadata, isChinese, id }: { metadata: PaperMetadata; isChinese?: boolean; id?: string }) {
|
||||
function PaperMetadataDossier({
|
||||
metadata,
|
||||
isChinese,
|
||||
id,
|
||||
}: {
|
||||
metadata: PaperMetadata;
|
||||
isChinese?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
if (!metadata || (!metadata.title && !metadata.author)) return null;
|
||||
|
||||
return (
|
||||
<div id={id} className="mb-6 rounded-lg p-5 bg-slate-50 border border-slate-200 relative overflow-hidden select-text">
|
||||
<div
|
||||
id={id}
|
||||
className="mb-3 sm:mb-6 rounded-lg p-3 sm:p-5 bg-slate-50 border border-slate-200 relative overflow-hidden select-text"
|
||||
>
|
||||
{/* 科技风背景线段装饰 - 极细学术边框线 */}
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-slate-100/50 pointer-events-none border-l border-b border-slate-200/60" />
|
||||
|
||||
{/* 顶部档案标徽 */}
|
||||
<div className="flex items-center gap-2 mb-3 select-none">
|
||||
<div className="flex items-center gap-2 mb-2 sm:mb-3 select-none">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[9px] font-bold bg-slate-700 text-white uppercase tracking-wider">
|
||||
{isChinese ? '文献元数据档案' : 'Paper Metadata Dossier'}
|
||||
</span>
|
||||
@ -67,23 +85,31 @@ function PaperMetadataDossier({ metadata, isChinese, id }: { metadata: PaperMeta
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<h1 className="text-sm font-bold text-slate-900 leading-snug mb-3 select-text">
|
||||
<h1 className="text-sm font-bold text-slate-900 leading-snug mb-1.5 sm:mb-3 select-text">
|
||||
{metadata.title}
|
||||
</h1>
|
||||
|
||||
{/* 作者群 */}
|
||||
{metadata.author && metadata.author.length > 0 && (
|
||||
<div className="text-xs text-slate-600 mb-4 font-medium leading-relaxed select-text">
|
||||
<span className="text-slate-400 font-semibold mr-1">{isChinese ? '作者群体:' : 'Authors:'}</span>
|
||||
<span className="italic text-slate-750">{metadata.author.join(', ')}</span>
|
||||
<div className="text-xs text-slate-600 mb-2 sm:mb-4 font-medium leading-relaxed select-text">
|
||||
<span className="text-slate-400 font-semibold mr-1">
|
||||
{isChinese ? '作者群体:' : 'Authors:'}
|
||||
</span>
|
||||
<span className="italic text-slate-750">
|
||||
{metadata.author.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部期刊与跳转 ADS 按钮 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-3 border-t border-slate-200/60 select-none">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 sm:gap-3 pt-2 sm:pt-3 border-t border-slate-200/60 select-none">
|
||||
<div className="text-[10px] text-slate-500 font-semibold">
|
||||
<span className="text-slate-400 mr-1">{isChinese ? '发表期刊/预印本:' : 'Publisher:'}</span>
|
||||
<span className="text-slate-700">{metadata.publisher || 'arXiv Preprint'}</span>
|
||||
<span className="text-slate-400 mr-1">
|
||||
{isChinese ? '发表期刊/预印本:' : 'Publisher:'}
|
||||
</span>
|
||||
<span className="text-slate-700">
|
||||
{metadata.publisher || 'arXiv Preprint'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{metadata.source && (
|
||||
@ -156,24 +182,30 @@ export function BilingualViewer({
|
||||
|
||||
const gridTemplateColumns = isMobileViewport
|
||||
? '1fr' // 手机端一律垂直单列堆叠
|
||||
: (isTabletViewport
|
||||
? (viewMode === 'bilingual' ? '1fr 1fr' : '1fr')
|
||||
: (viewMode === 'bilingual'
|
||||
? (showNotesPanel ? '1fr 1fr 380px' : '1fr 1fr')
|
||||
: (showNotesPanel ? '1fr 380px' : '1fr')));
|
||||
: isTabletViewport
|
||||
? viewMode === 'bilingual'
|
||||
? '1fr 1fr'
|
||||
: '1fr'
|
||||
: viewMode === 'bilingual'
|
||||
? showNotesPanel
|
||||
? '1fr 1fr 380px'
|
||||
: '1fr 1fr'
|
||||
: showNotesPanel
|
||||
? '1fr 380px'
|
||||
: '1fr';
|
||||
|
||||
const style = isMobileViewport
|
||||
? {
|
||||
gridTemplateColumns: '1fr',
|
||||
gridTemplateRows: viewMode === 'bilingual'
|
||||
? (showNotesPanel ? '1fr 1fr 1fr' : '1fr 1fr')
|
||||
: (showNotesPanel ? '1fr 1fr' : '1fr')
|
||||
gridTemplateRows: viewMode === 'bilingual' ? '1fr 1fr' : '1fr',
|
||||
}
|
||||
: { gridTemplateColumns };
|
||||
|
||||
// 解析英文和中文段落的 Front Matter 头部元数据
|
||||
const { metadata: engMeta, pureMarkdown: engPure } = parseMarkdownFrontMatter(englishText);
|
||||
const { metadata: chnMeta, pureMarkdown: chnPure } = parseMarkdownFrontMatter(chineseText);
|
||||
const { metadata: engMeta, pureMarkdown: engPure } =
|
||||
parseMarkdownFrontMatter(englishText);
|
||||
const { metadata: chnMeta, pureMarkdown: chnPure } =
|
||||
parseMarkdownFrontMatter(chineseText);
|
||||
|
||||
// 智能合并元数据,以防中文翻译中部分字段丢失或解析失败时,可优雅复用英文侧的数据进行降级补充
|
||||
const mergedChnMeta = (() => {
|
||||
@ -182,7 +214,10 @@ export function BilingualViewer({
|
||||
if (!chnMeta) return engMeta;
|
||||
return {
|
||||
title: chnMeta.title || engMeta.title,
|
||||
author: (chnMeta.author && chnMeta.author.length > 0) ? chnMeta.author : engMeta.author,
|
||||
author:
|
||||
chnMeta.author && chnMeta.author.length > 0
|
||||
? chnMeta.author
|
||||
: engMeta.author,
|
||||
publisher: chnMeta.publisher || engMeta.publisher,
|
||||
source: chnMeta.source || engMeta.source,
|
||||
date: chnMeta.date || engMeta.date,
|
||||
@ -190,9 +225,64 @@ export function BilingualViewer({
|
||||
};
|
||||
})();
|
||||
|
||||
// PDF Blob URL 加载机制:通过 fetch(携带 Cookie)获取 PDF 二进制数据,
|
||||
// 转为本地 blob:// URL 传给 iframe,彻底绕过 SameSite Cookie 在 iframe 子请求中不携带的浏览器安全限制。
|
||||
const [pdfBlobUrl, setPdfBlobUrl] = useState<string | null>(null);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const [pdfError, setPdfError] = useState<string | null>(null);
|
||||
const pdfBlobUrlRef = useRef<string | null>(null);
|
||||
|
||||
const loadPdfBlob = useCallback(async (bibcode: string) => {
|
||||
setPdfLoading(true);
|
||||
setPdfError(null);
|
||||
try {
|
||||
const resp = await fetch(`/api/files/PDF/${bibcode}.pdf`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`HTTP ${resp.status}`);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
// 释放旧的 blob URL
|
||||
if (pdfBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(pdfBlobUrlRef.current);
|
||||
}
|
||||
pdfBlobUrlRef.current = url;
|
||||
setPdfBlobUrl(url);
|
||||
} catch (e: unknown) {
|
||||
setPdfError(e instanceof Error ? e.message : '加载 PDF 失败');
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect -- loadPdfBlob is async, setState calls happen after awaits */
|
||||
useEffect(() => {
|
||||
if (showPdf && selectedPaper.has_pdf && selectedPaper.bibcode) {
|
||||
loadPdfBlob(selectedPaper.bibcode);
|
||||
}
|
||||
// showPdf 关闭时释放 blob URL
|
||||
if (!showPdf && pdfBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(pdfBlobUrlRef.current);
|
||||
pdfBlobUrlRef.current = null;
|
||||
setPdfBlobUrl(null);
|
||||
}
|
||||
}, [showPdf, selectedPaper.bibcode, selectedPaper.has_pdf, loadPdfBlob]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// 组件卸载时释放 blob URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pdfBlobUrlRef.current) {
|
||||
URL.revokeObjectURL(pdfBlobUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 grid gap-6 overflow-hidden w-full min-h-0"
|
||||
className="flex-1 grid gap-2 sm:gap-6 overflow-hidden w-full min-h-0"
|
||||
style={style}
|
||||
>
|
||||
{/* 英文正文视窗 (或双语对照) */}
|
||||
@ -200,11 +290,13 @@ export function BilingualViewer({
|
||||
<div
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseLeave={handleContainerMouseLeave}
|
||||
className="console-panel rounded-lg p-6 bg-white border border-slate-200 relative flex flex-col min-h-0 overflow-hidden"
|
||||
className="console-panel rounded-lg px-3 pt-3 pb-2 sm:p-6 bg-white border border-slate-200 relative flex flex-col min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none">
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-4 border-b border-slate-100 pb-1.5 sm:pb-2.5 shrink-0 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-slate-800">英文解析正文</span>
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
英文解析正文
|
||||
</span>
|
||||
{selectedPaper.has_pdf && (
|
||||
<button
|
||||
type="button"
|
||||
@ -227,16 +319,38 @@ export function BilingualViewer({
|
||||
|
||||
{showPdf ? (
|
||||
<div className="flex-1 min-h-0 w-full rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||
{pdfLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-500 space-y-3">
|
||||
<Loader className="w-8 h-8 animate-spin text-sky-600" />
|
||||
<p className="text-xs font-bold">正在加载 PDF 文献...</p>
|
||||
</div>
|
||||
) : pdfError ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-500 space-y-3">
|
||||
<p className="text-xs font-bold text-rose-600">
|
||||
PDF 加载失败: {pdfError}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadPdfBlob(selectedPaper.bibcode)}
|
||||
className="text-[10px] font-bold px-3 py-1 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 cursor-pointer"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : pdfBlobUrl ? (
|
||||
<iframe
|
||||
src={`/api/files/PDF/${selectedPaper.bibcode}.pdf#navpanes=0&pagemode=none&view=FitH`}
|
||||
src={`${pdfBlobUrl}#navpanes=0&pagemode=none&view=FitH`}
|
||||
className="w-full h-full border-none"
|
||||
title="文献 PDF"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : parsing ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
|
||||
<Loader className="w-8 h-8 animate-spin text-sky-600" />
|
||||
<p className="text-xs font-bold text-center">正在提取源文献正文排版,并转换至 Markdown 排版...</p>
|
||||
<p className="text-xs font-bold text-center">
|
||||
正在提取源文献正文排版,并转换至 Markdown 排版...
|
||||
</p>
|
||||
</div>
|
||||
) : engPure ? (
|
||||
<div
|
||||
@ -244,13 +358,23 @@ export function BilingualViewer({
|
||||
onScroll={handleEnglishScroll}
|
||||
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
|
||||
>
|
||||
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-300 prose-blockquote:text-slate-500 prose-img:max-w-full prose-img:rounded-lg">
|
||||
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-300 prose-blockquote:text-slate-500 prose-img:max-w-full prose-img:rounded-lg prose-p:my-1.5 sm:prose-p:my-3 prose-headings:my-2 sm:prose-headings:my-4">
|
||||
{/* 渲染英文元数据档案面板 */}
|
||||
{engMeta && <PaperMetadataDossier metadata={engMeta} isChinese={false} id="paragraph-block--1" />}
|
||||
{engMeta && (
|
||||
<PaperMetadataDossier
|
||||
metadata={engMeta}
|
||||
isChinese={false}
|
||||
id="paragraph-block--1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{splitParagraphs(engPure).map((para: string, idx: number) => {
|
||||
const matchedNote = notes.find(n => n.paragraph_index === idx);
|
||||
const highlightStyle = matchedNote ? NOTE_COLORS[matchedNote.highlight_color] : null;
|
||||
const matchedNote = notes.find(
|
||||
(n) => n.paragraph_index === idx
|
||||
);
|
||||
const highlightStyle = matchedNote
|
||||
? NOTE_COLORS[matchedNote.highlight_color]
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
@ -264,11 +388,17 @@ export function BilingualViewer({
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, safeSchema],
|
||||
rehypeKatex,
|
||||
]}
|
||||
components={{
|
||||
a: ({ href, children, ...props }) => {
|
||||
if (href && href.startsWith('#celestial-')) {
|
||||
const targetName = decodeURIComponent(href.slice('#celestial-'.length));
|
||||
const targetName = decodeURIComponent(
|
||||
href.slice('#celestial-'.length)
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className="celestial-target cursor-pointer font-bold text-sky-600 underline decoration-dotted decoration-2 hover:text-sky-850"
|
||||
@ -279,11 +409,15 @@ export function BilingualViewer({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} className="text-sky-600 hover:underline" {...props}>
|
||||
<a
|
||||
href={href}
|
||||
className="text-sky-600 hover:underline"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{highlightTargetsInMarkdown(para, highlightCandidates)}
|
||||
@ -296,8 +430,12 @@ export function BilingualViewer({
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
|
||||
<FileText className="w-12 h-12 mb-3 text-slate-300" />
|
||||
<p className="text-xs font-bold text-slate-500">本篇文献暂无已解析的英文源正文</p>
|
||||
<p className="text-xs text-slate-400 mt-1">请点击上方按钮提取文档结构正文</p>
|
||||
<p className="text-xs font-bold text-slate-500">
|
||||
本篇文献暂无已解析的英文源正文
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
请点击上方按钮提取文档结构正文
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -305,17 +443,19 @@ export function BilingualViewer({
|
||||
|
||||
{/* 中文翻译视窗 (或双语对照) */}
|
||||
{(viewMode === 'chinese' || viewMode === 'bilingual') && (
|
||||
<div
|
||||
className="console-panel rounded-lg p-6 bg-white border border-slate-200 relative flex flex-col overflow-hidden min-h-0"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4 border-b border-slate-100 pb-2.5 shrink-0 select-none">
|
||||
<span className="text-xs font-bold text-slate-800">中文学术对比翻译</span>
|
||||
<div className="console-panel rounded-lg px-3 pt-2 pb-3 sm:p-6 bg-white border border-slate-200 relative flex flex-col overflow-hidden min-h-0">
|
||||
<div className="hidden sm:flex items-center justify-between mb-2 sm:mb-4 border-b border-slate-100 pb-1.5 sm:pb-2.5 shrink-0 select-none">
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
中文学术对比翻译
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{translating ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
|
||||
<Loader className="w-8 h-8 animate-spin text-blueprint" />
|
||||
<p className="text-xs font-bold text-center">天文学专属词典加载中,正在通过大模型进行术语修正翻译...</p>
|
||||
<p className="text-xs font-bold text-center">
|
||||
天文学专属词典加载中,正在通过大模型进行术语修正翻译...
|
||||
</p>
|
||||
</div>
|
||||
) : chnPure ? (
|
||||
<div
|
||||
@ -323,9 +463,15 @@ export function BilingualViewer({
|
||||
onScroll={handleChineseScroll}
|
||||
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
|
||||
>
|
||||
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-350 prose-img:max-w-full prose-img:rounded-lg">
|
||||
<div className="prose prose-sm max-w-none leading-relaxed text-slate-800 prose-headings:text-slate-900 prose-strong:text-slate-900 prose-code:text-sky-700 prose-blockquote:border-slate-350 prose-img:max-w-full prose-img:rounded-lg prose-p:my-1.5 sm:prose-p:my-3 prose-headings:my-2 sm:prose-headings:my-4">
|
||||
{/* 渲染中文元数据档案面板 */}
|
||||
{mergedChnMeta && <PaperMetadataDossier metadata={mergedChnMeta} isChinese={true} id="paragraph-block--1" />}
|
||||
{mergedChnMeta && (
|
||||
<PaperMetadataDossier
|
||||
metadata={mergedChnMeta}
|
||||
isChinese={true}
|
||||
id="paragraph-block--1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{splitParagraphs(chnPure).map((para: string, idx: number) => (
|
||||
<div
|
||||
@ -335,7 +481,11 @@ export function BilingualViewer({
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
|
||||
rehypePlugins={[
|
||||
rehypeRaw,
|
||||
[rehypeSanitize, safeSchema],
|
||||
rehypeKatex,
|
||||
]}
|
||||
>
|
||||
{para}
|
||||
</ReactMarkdown>
|
||||
@ -346,8 +496,12 @@ export function BilingualViewer({
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
|
||||
<Languages className="w-12 h-12 mb-3 text-slate-300" />
|
||||
<p className="text-xs font-bold text-slate-500">本篇文献暂无已缓存的翻译结果</p>
|
||||
<p className="text-xs text-slate-400 mt-1">需点击上方“生成智能翻译”按钮启动大模型翻译</p>
|
||||
<p className="text-xs font-bold text-slate-500">
|
||||
本篇文献暂无已缓存的翻译结果
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
需点击上方“生成智能翻译”按钮启动大模型翻译
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -356,7 +510,9 @@ export function BilingualViewer({
|
||||
{children}
|
||||
|
||||
{/* 天体详情悬浮卡片 (React Portal 挂载在 body 顶层防止裁剪) */}
|
||||
{hoveredTarget && hoverCardPos && createPortal(
|
||||
{hoveredTarget &&
|
||||
hoverCardPos &&
|
||||
createPortal(
|
||||
<div
|
||||
onMouseEnter={clearHoverTimeout}
|
||||
onMouseLeave={handleMouseLeaveTarget}
|
||||
@ -370,7 +526,9 @@ export function BilingualViewer({
|
||||
className="bg-white rounded-lg p-4 border border-slate-350 shadow-sm w-72 text-[11px] space-y-2 pointer-events-auto select-text animate-in fade-in duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
||||
<span className="font-bold text-slate-900 text-[13px]">{hoveredTarget.target_name}</span>
|
||||
<span className="font-bold text-slate-900 text-[13px]">
|
||||
{hoveredTarget.target_name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 select-none">
|
||||
<a
|
||||
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
|
||||
@ -400,7 +558,8 @@ export function BilingualViewer({
|
||||
{hoveredTarget.otype}
|
||||
</span>
|
||||
)}
|
||||
{hoveredTarget.oname && hoveredTarget.oname !== hoveredTarget.target_name && (
|
||||
{hoveredTarget.oname &&
|
||||
hoveredTarget.oname !== hoveredTarget.target_name && (
|
||||
<span className="text-[10px] text-slate-400 font-medium truncate">
|
||||
aka {hoveredTarget.oname}
|
||||
</span>
|
||||
@ -408,38 +567,58 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 坐标 */}
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">RA (J2000)</span>
|
||||
<div className="font-mono font-bold text-slate-800">{hoveredTarget.ra || '未知'}</div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
RA (J2000)
|
||||
</span>
|
||||
<div className="font-mono font-bold text-slate-800">
|
||||
{hoveredTarget.ra || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">Dec (J2000)</span>
|
||||
<div className="font-mono font-bold text-slate-800">{hoveredTarget.dec || '未知'}</div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
Dec (J2000)
|
||||
</span>
|
||||
<div className="font-mono font-bold text-slate-800">
|
||||
{hoveredTarget.dec || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
{/* 光谱型 & 视星等 */}
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">光谱型</span>
|
||||
<div className="font-bold text-slate-800">{hoveredTarget.spectral_type || '未知'}</div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
光谱型
|
||||
</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
{hoveredTarget.spectral_type || '未知'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">视星等 (V)</span>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
视星等 (V)
|
||||
</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
{hoveredTarget.v_magnitude !== null && hoveredTarget.v_magnitude !== undefined
|
||||
{hoveredTarget.v_magnitude !== null &&
|
||||
hoveredTarget.v_magnitude !== undefined
|
||||
? `${hoveredTarget.v_magnitude.toFixed(2)}`
|
||||
: '未知'}
|
||||
</div>
|
||||
</div>
|
||||
{/* 视差 + 误差 + 估算距离 */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">视差 / 估算距离</span>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
视差 / 估算距离
|
||||
</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
{hoveredTarget.parallax !== null && hoveredTarget.parallax !== undefined
|
||||
{hoveredTarget.parallax !== null &&
|
||||
hoveredTarget.parallax !== undefined
|
||||
? `${hoveredTarget.parallax.toFixed(2)}${hoveredTarget.parallax_err != null ? `±${hoveredTarget.parallax_err.toFixed(4)}` : ''} mas (~${(1000.0 / hoveredTarget.parallax).toFixed(1)} pc)`
|
||||
: '未知'}
|
||||
</div>
|
||||
</div>
|
||||
{/* 自行 */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">自行</span>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
自行
|
||||
</span>
|
||||
<div className="font-bold text-slate-800 text-[10.5px]">
|
||||
{hoveredTarget.pm_ra != null && hoveredTarget.pm_de != null
|
||||
? `RA ${hoveredTarget.pm_ra.toFixed(3)}, Dec ${hoveredTarget.pm_de.toFixed(3)} mas/yr`
|
||||
@ -448,7 +627,9 @@ export function BilingualViewer({
|
||||
</div>
|
||||
{/* 视向速度 */}
|
||||
<div className="col-span-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">视向速度</span>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">
|
||||
视向速度
|
||||
</span>
|
||||
<div className="font-bold text-slate-800">
|
||||
{hoveredTarget.radial_velocity != null
|
||||
? `${hoveredTarget.radial_velocity.toFixed(1)} km/s`
|
||||
@ -457,22 +638,31 @@ export function BilingualViewer({
|
||||
</div>
|
||||
</div>
|
||||
{/* 多波段测光 */}
|
||||
{hoveredTarget.photometry && Object.keys(hoveredTarget.photometry).length > 1 && (
|
||||
{hoveredTarget.photometry &&
|
||||
Object.keys(hoveredTarget.photometry).length > 1 && (
|
||||
<div className="border-t border-slate-200/60 pt-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-1">多波段星等</span>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-1">
|
||||
多波段星等
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-x-2.5 gap-y-0.5 text-[10px] font-mono text-slate-600">
|
||||
{Object.entries(hoveredTarget.photometry).map(([band, mag]) => (
|
||||
{Object.entries(hoveredTarget.photometry).map(
|
||||
([band, mag]) => (
|
||||
<span key={band} className="whitespace-nowrap">
|
||||
<span className="text-slate-400">{band}</span>{' '}
|
||||
<span className="font-bold text-slate-700">{mag.toFixed(3)}</span>
|
||||
<span className="font-bold text-slate-700">
|
||||
{mag.toFixed(3)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && (
|
||||
<div className="border-t border-slate-200/60 pt-2">
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-0.5">常用别名</span>
|
||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-0.5">
|
||||
常用别名
|
||||
</span>
|
||||
<div className="text-[10px] text-slate-500 font-medium leading-relaxed max-h-[36px] overflow-y-auto scrollbar-thin font-mono">
|
||||
{hoveredTarget.aliases.slice(0, 8).join(', ')}
|
||||
{hoveredTarget.aliases.length > 8 && ' ...'}
|
||||
|
||||
@ -83,7 +83,10 @@ export function ReaderNotesSidebar({
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
|
||||
</span>
|
||||
<button onClick={() => setShowNotesPanel(false)} className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={() => setShowNotesPanel(false)}
|
||||
className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -120,13 +123,19 @@ export function ReaderNotesSidebar({
|
||||
{/* 天体标识符列表 */}
|
||||
<div className="px-4 py-3 border-b border-slate-200 bg-white space-y-2 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">关联天体 (CDS)</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">
|
||||
关联天体 (CDS)
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 select-none">
|
||||
{(loadingTargets || identifyingTargets) && <Loader className="w-3 h-3 animate-spin text-slate-400" />}
|
||||
{(loadingTargets || identifyingTargets) && (
|
||||
<Loader className="w-3 h-3 animate-spin text-slate-400" />
|
||||
)}
|
||||
{selectedPaper.has_markdown && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleIdentifyTargets(selectedPaper.bibcode)}
|
||||
onClick={() =>
|
||||
handleIdentifyTargets(selectedPaper.bibcode)
|
||||
}
|
||||
disabled={identifyingTargets || loadingTargets}
|
||||
className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200 hover:bg-sky-100 transition-all cursor-pointer flex items-center gap-0.5"
|
||||
title="从文献正文中自动识别天体目标并查询 CDS Sesame"
|
||||
@ -139,7 +148,9 @@ export function ReaderNotesSidebar({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{targets.length === 0 ? (
|
||||
<span className="text-[10px] text-slate-400 italic">暂无自动识别到的天体</span>
|
||||
<span className="text-[10px] text-slate-400 italic">
|
||||
暂无自动识别到的天体
|
||||
</span>
|
||||
) : (
|
||||
targets.map((t, idx) => (
|
||||
<button
|
||||
@ -147,8 +158,7 @@ export function ReaderNotesSidebar({
|
||||
type="button"
|
||||
onClick={() => handleJumpToTarget(t)}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-sky-50 hover:bg-sky-100 text-sky-700 hover:text-sky-900 border border-sky-100 hover:border-sky-200 text-[10px] font-bold cursor-pointer transition-all hover:scale-105 active:scale-95"
|
||||
title={
|
||||
[
|
||||
title={[
|
||||
`类型: ${t.otype || '?'}`,
|
||||
`RA: ${t.ra || '?'} Dec: ${t.dec || '?'}`,
|
||||
t.parallax != null
|
||||
@ -161,21 +171,27 @@ export function ReaderNotesSidebar({
|
||||
`视向速度: ${t.radial_velocity != null ? t.radial_velocity + ' km/s' : '?'}`,
|
||||
`官方名: ${t.oname || t.target_name}`,
|
||||
`别名: ${t.aliases?.join(', ') || '无'}`,
|
||||
].join('\n')
|
||||
}
|
||||
].join('\n')}
|
||||
>
|
||||
{t.target_name}
|
||||
{t.spectral_type && <span className="text-slate-400 font-medium font-mono">({t.spectral_type})</span>}
|
||||
{t.spectral_type && (
|
||||
<span className="text-slate-400 font-medium font-mono">
|
||||
({t.spectral_type})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{/* 手动关联输入框 */}
|
||||
<form onSubmit={handleAssociateTarget} className="flex gap-1.5 mt-2">
|
||||
<form
|
||||
onSubmit={handleAssociateTarget}
|
||||
className="flex gap-1.5 mt-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={manualTargetName}
|
||||
onChange={e => setManualTargetName(e.target.value)}
|
||||
onChange={(e) => setManualTargetName(e.target.value)}
|
||||
placeholder="手动关联天体(如 M 31)..."
|
||||
className="flex-1 bg-slate-50 border border-slate-200 rounded px-2.5 py-1 text-[10px] text-slate-900 placeholder-slate-400 leading-relaxed font-semibold focus:outline-none focus:bg-white focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10"
|
||||
/>
|
||||
@ -192,7 +208,9 @@ export function ReaderNotesSidebar({
|
||||
{/* 新建笔记输入区 */}
|
||||
{selectedParagraphIdx !== null && (
|
||||
<div className="px-4 py-4 border-b border-slate-200 bg-white space-y-3 shrink-0">
|
||||
<div className="text-xs font-bold text-slate-700">正在批注段落 #{selectedParagraphIdx + 1}</div>
|
||||
<div className="text-xs font-bold text-slate-700">
|
||||
正在批注段落 #{selectedParagraphIdx + 1}
|
||||
</div>
|
||||
{selectedText && (
|
||||
<div className="text-xs text-slate-600 italic line-clamp-2 bg-slate-55 px-2.5 py-1.5 rounded border border-slate-200">
|
||||
"{selectedText}"
|
||||
@ -200,7 +218,9 @@ export function ReaderNotesSidebar({
|
||||
)}
|
||||
{/* 颜色标记 */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-xs text-slate-500 font-semibold">标记色:</span>
|
||||
<span className="text-xs text-slate-500 font-semibold">
|
||||
标记色:
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
{Object.entries(NOTE_COLORS).map(([color, style]) => {
|
||||
const s = style as { bg: string; label: string };
|
||||
@ -209,7 +229,9 @@ export function ReaderNotesSidebar({
|
||||
key={color}
|
||||
onClick={() => setNewNoteColor(color)}
|
||||
className={`w-4 h-4 rounded-full border transition-transform ${
|
||||
newNoteColor === color ? 'border-slate-800 scale-120 shadow-sm' : 'border-transparent'
|
||||
newNoteColor === color
|
||||
? 'border-slate-800 scale-120 shadow-sm'
|
||||
: 'border-transparent'
|
||||
} ${s.bg.replace('border-cyan-200', '')}`}
|
||||
title={s.label}
|
||||
/>
|
||||
@ -219,7 +241,7 @@ export function ReaderNotesSidebar({
|
||||
</div>
|
||||
<textarea
|
||||
value={newNoteText}
|
||||
onChange={e => setNewNoteText(e.target.value)}
|
||||
onChange={(e) => setNewNoteText(e.target.value)}
|
||||
placeholder="记录段落心得或重点..."
|
||||
rows={3}
|
||||
className="w-full bg-white border border-slate-300 rounded-lg text-xs text-slate-900 placeholder-slate-400 px-3 py-2 resize-none focus:outline-none focus:border-sky-500 focus:ring-1 focus:ring-sky-500/10 leading-relaxed"
|
||||
@ -232,7 +254,11 @@ export function ReaderNotesSidebar({
|
||||
<PlusCircle className="w-3.5 h-3.5" /> 保存笔记
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectedParagraphIdx(null); setSelectedText(''); setNewNoteText(''); }}
|
||||
onClick={() => {
|
||||
setSelectedParagraphIdx(null);
|
||||
setSelectedText('');
|
||||
setNewNoteText('');
|
||||
}}
|
||||
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold"
|
||||
>
|
||||
取消
|
||||
@ -249,25 +275,34 @@ export function ReaderNotesSidebar({
|
||||
<div>选中文献正文的段落文字,即可在这里添加读书笔记。</div>
|
||||
</div>
|
||||
) : (
|
||||
notes.map(note => {
|
||||
const style = NOTE_COLORS[note.highlight_color] || NOTE_COLORS.cyan;
|
||||
notes.map((note) => {
|
||||
const style =
|
||||
NOTE_COLORS[note.highlight_color] || NOTE_COLORS.cyan;
|
||||
return (
|
||||
<div
|
||||
key={note.id}
|
||||
className="p-4 rounded-lg border text-xs bg-white border-slate-200 relative group overflow-hidden"
|
||||
>
|
||||
{/* 彩色左标记条 */}
|
||||
<div className={`absolute left-0 top-0 w-1 h-full ${style.bg.split(' ')[0]}`} />
|
||||
<div
|
||||
className={`absolute left-0 top-0 w-1 h-full ${style.bg.split(' ')[0]}`}
|
||||
/>
|
||||
|
||||
<div className="text-slate-500 text-[10px] font-bold mb-1">段落批注 #{note.paragraph_index + 1}</div>
|
||||
<div className="text-slate-500 text-[10px] font-bold mb-1">
|
||||
段落批注 #{note.paragraph_index + 1}
|
||||
</div>
|
||||
{note.selected_text && (
|
||||
<div className="text-slate-505 italic line-clamp-2 mb-2 border-l-2 border-slate-200 pl-2">
|
||||
"{note.selected_text}"
|
||||
</div>
|
||||
)}
|
||||
<p className="text-slate-800 leading-relaxed font-medium">{note.note_text}</p>
|
||||
<p className="text-slate-800 leading-relaxed font-medium">
|
||||
{note.note_text}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-3 border-t border-slate-100 pt-2 text-[10px] select-none">
|
||||
<span className="text-slate-400 font-semibold">{note.created_at.split('T')[0]}</span>
|
||||
<span className="text-slate-400 font-semibold">
|
||||
{note.created_at.split('T')[0]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDeleteNote(note.id)}
|
||||
className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer"
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
// dashboard/src/components/reader/ReaderToolbar.tsx
|
||||
import { useState } from 'react';
|
||||
import { FileText, Loader, Languages, RotateCw, BookOpen, Sparkles, ChevronDown, History, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
FileText,
|
||||
Loader,
|
||||
Languages,
|
||||
RotateCw,
|
||||
BookOpen,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
History,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import type { StandardPaper } from '../../types';
|
||||
|
||||
interface ReaderToolbarProps {
|
||||
@ -62,7 +72,10 @@ export function ReaderToolbar({
|
||||
return (
|
||||
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-between border-b border-slate-200 pb-3 shrink-0 gap-3">
|
||||
<div className="min-w-0 w-full xl:flex-1 pr-0 xl:pr-4">
|
||||
<h2 className="text-sm font-bold text-slate-900 line-clamp-1 leading-snug" title={selectedPaper.title}>
|
||||
<h2
|
||||
className="text-sm font-bold text-slate-900 line-clamp-1 leading-snug"
|
||||
title={selectedPaper.title}
|
||||
>
|
||||
{selectedPaper.title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500 mt-1 font-semibold">
|
||||
@ -81,13 +94,19 @@ export function ReaderToolbar({
|
||||
title="快速切换文献"
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 text-sky-600" />
|
||||
<span className="hidden sm:inline">快速切换</span><span className="sm:hidden">最近</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
|
||||
<span className="hidden sm:inline">快速切换</span>
|
||||
<span className="sm:hidden">最近</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showSwitchMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-45" onClick={() => setShowSwitchMenu(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-45"
|
||||
onClick={() => setShowSwitchMenu(false)}
|
||||
/>
|
||||
<div className="absolute left-0 xl:right-0 xl:left-auto mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
{/* 最近阅读部分 */}
|
||||
{recentlySelected.length > 0 && (
|
||||
@ -96,7 +115,7 @@ export function ReaderToolbar({
|
||||
<History className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>最近阅读</span>
|
||||
</div>
|
||||
{recentlySelected.map(paper => (
|
||||
{recentlySelected.map((paper) => (
|
||||
<button
|
||||
key={`recent-${paper.bibcode}`}
|
||||
onClick={() => {
|
||||
@ -109,8 +128,12 @@ export function ReaderToolbar({
|
||||
: 'border-transparent text-slate-700 font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block leading-tight text-left">{paper.title}</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
|
||||
<span className="truncate block leading-tight text-left">
|
||||
{paper.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -122,12 +145,14 @@ export function ReaderToolbar({
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>全部已下载馆藏</span>
|
||||
</div>
|
||||
{library.filter(p => p.is_downloaded).length === 0 ? (
|
||||
<div className="px-3 py-2 text-slate-400 italic">暂无已下载文献</div>
|
||||
{library.filter((p) => p.is_downloaded).length === 0 ? (
|
||||
<div className="px-3 py-2 text-slate-400 italic">
|
||||
暂无已下载文献
|
||||
</div>
|
||||
) : (
|
||||
library
|
||||
.filter(p => p.is_downloaded)
|
||||
.map(paper => (
|
||||
.filter((p) => p.is_downloaded)
|
||||
.map((paper) => (
|
||||
<button
|
||||
key={`lib-${paper.bibcode}`}
|
||||
onClick={() => {
|
||||
@ -140,8 +165,12 @@ export function ReaderToolbar({
|
||||
: 'border-transparent text-slate-700 font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block leading-tight text-left">{paper.title}</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
|
||||
<span className="truncate block leading-tight text-left">
|
||||
{paper.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@ -196,7 +225,9 @@ export function ReaderToolbar({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (showPdf) {
|
||||
alert("【PDF同步滚动技术限制说明】\n\nPDF 预览采用的是浏览器原生的 PDF 插件沙箱。受底层浏览器跨域安全限制和外部插件机制影响,网页脚本无法直接读取和控制 PDF 的滚动位置,因而无法实现物理同步滚动。\n\n【强烈建议】\n点击左侧面板顶部的「显示正文」按钮切换到解析正文模式。在解析正文模式下,系统将自动激活「自适应双轨滚动同步引擎(方案三)」,提供段落级的高精度无缝对齐同步滚动体验!");
|
||||
alert(
|
||||
'【PDF同步滚动技术限制说明】\n\nPDF 预览采用的是浏览器原生的 PDF 插件沙箱。受底层浏览器跨域安全限制和外部插件机制影响,网页脚本无法直接读取和控制 PDF 的滚动位置,因而无法实现物理同步滚动。\n\n【强烈建议】\n点击左侧面板顶部的「显示正文」按钮切换到解析正文模式。在解析正文模式下,系统将自动激活「自适应双轨滚动同步引擎(方案三)」,提供段落级的高精度无缝对齐同步滚动体验!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSyncScroll(!syncScroll);
|
||||
@ -208,10 +239,18 @@ export function ReaderToolbar({
|
||||
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-sm'
|
||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-55'
|
||||
}`}
|
||||
title={showPdf ? 'PDF预览模式下不支持同步滚动,请点击左侧“显示正文”切换到正文模式' : '开启或关闭中英段落双轨同步滚动'}
|
||||
title={
|
||||
showPdf
|
||||
? 'PDF预览模式下不支持同步滚动,请点击左侧“显示正文”切换到正文模式'
|
||||
: '开启或关闭中英段落双轨同步滚动'
|
||||
}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${(!showPdf && syncScroll) ? 'bg-sky-500 animate-pulse' : 'bg-slate-300'}`} />
|
||||
<span>同步滚动:{showPdf ? 'PDF暂不支持' : syncScroll ? '开启' : '关闭'}</span>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${!showPdf && syncScroll ? 'bg-sky-500 animate-pulse' : 'bg-slate-300'}`}
|
||||
/>
|
||||
<span>
|
||||
同步滚动:{showPdf ? 'PDF暂不支持' : syncScroll ? '开启' : '关闭'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -222,21 +261,33 @@ export function ReaderToolbar({
|
||||
disabled={parsing}
|
||||
className="hidden xl:flex btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
>
|
||||
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <FileText className="w-3.5 h-3.5" />}
|
||||
{parsing ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{parsing ? '正文结构解析中...' : '解析源文正文'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
|
||||
showConfirm(
|
||||
'确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。',
|
||||
() => {
|
||||
handleParse(selectedPaper.bibcode, true);
|
||||
}, '确认重新解析');
|
||||
},
|
||||
'确认重新解析'
|
||||
);
|
||||
}}
|
||||
disabled={parsing}
|
||||
className="hidden xl:flex btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
title="覆盖本地解析结果,重新从 HTML/PDF 转换为 Markdown"
|
||||
>
|
||||
{parsing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
|
||||
{parsing ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
重新解析
|
||||
</button>
|
||||
)}
|
||||
@ -248,7 +299,11 @@ export function ReaderToolbar({
|
||||
disabled={translating}
|
||||
className="hidden xl:flex btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
>
|
||||
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Languages className="w-3.5 h-3.5" />}
|
||||
{translating ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Languages className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{translating ? '天文学术语对比翻译中...' : '生成智能翻译'}
|
||||
</button>
|
||||
)}
|
||||
@ -260,7 +315,11 @@ export function ReaderToolbar({
|
||||
className="hidden xl:flex btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
title="清除翻译缓存并重新生成大模型对照翻译"
|
||||
>
|
||||
{translating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
|
||||
{translating ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
重新翻译
|
||||
</button>
|
||||
)}
|
||||
@ -273,7 +332,11 @@ export function ReaderToolbar({
|
||||
className="hidden xl:flex btn-console btn-console-primary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
title="对文献进行知识入库,以开启学术 AI 研讨"
|
||||
>
|
||||
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Sparkles className="w-3.5 h-3.5" />}
|
||||
{vectorizing ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{vectorizing ? '正在进行知识入库...' : '知识入库'}
|
||||
</button>
|
||||
)}
|
||||
@ -281,15 +344,23 @@ export function ReaderToolbar({
|
||||
{selectedPaper.has_markdown && selectedPaper.has_vector && (
|
||||
<button
|
||||
onClick={() => {
|
||||
showConfirm('确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。', () => {
|
||||
showConfirm(
|
||||
'确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。',
|
||||
() => {
|
||||
handleVectorize(selectedPaper.bibcode);
|
||||
}, '确认重新知识入库');
|
||||
},
|
||||
'确认重新知识入库'
|
||||
);
|
||||
}}
|
||||
disabled={vectorizing}
|
||||
className="hidden xl:flex btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||
title="重新为该文献解析知识点并入库"
|
||||
>
|
||||
{vectorizing ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RotateCw className="w-3.5 h-3.5" />}
|
||||
{vectorizing ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
重新知识入库
|
||||
</button>
|
||||
)}
|
||||
@ -303,22 +374,27 @@ export function ReaderToolbar({
|
||||
title="更多文献操作"
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-sky-600" />
|
||||
<span className="hidden sm:inline">文献操作</span><span className="sm:hidden">操作</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showActionsMenu ? 'rotate-180' : ''}`} />
|
||||
<span className="hidden sm:inline">文献操作</span>
|
||||
<span className="sm:hidden">操作</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 transition-transform ${showActionsMenu ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showActionsMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-45" onClick={() => setShowActionsMenu(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-45"
|
||||
onClick={() => setShowActionsMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1.5 w-max min-w-[145px] rounded-md bg-white border border-slate-200 shadow-md py-1 z-50 text-xs">
|
||||
|
||||
{/* 同步滚动 */}
|
||||
{(englishText || chineseText) && viewMode === 'bilingual' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (showPdf) {
|
||||
alert("PDF预览模式下不支持同步滚动");
|
||||
alert('PDF预览模式下不支持同步滚动');
|
||||
return;
|
||||
}
|
||||
setSyncScroll(!syncScroll);
|
||||
@ -330,7 +406,9 @@ export function ReaderToolbar({
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${(!showPdf && syncScroll) ? 'bg-sky-500 animate-pulse' : 'bg-slate-355'}`} />
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${!showPdf && syncScroll ? 'bg-sky-500 animate-pulse' : 'bg-slate-355'}`}
|
||||
/>
|
||||
<span>同步滚动:{syncScroll ? '已开启' : '已关闭'}</span>
|
||||
</button>
|
||||
)}
|
||||
@ -352,9 +430,13 @@ export function ReaderToolbar({
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
|
||||
showConfirm(
|
||||
'确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。',
|
||||
() => {
|
||||
handleParse(selectedPaper.bibcode, true);
|
||||
}, '确认重新解析');
|
||||
},
|
||||
'确认重新解析'
|
||||
);
|
||||
}}
|
||||
disabled={parsing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
@ -365,7 +447,8 @@ export function ReaderToolbar({
|
||||
)}
|
||||
|
||||
{/* 智能翻译 */}
|
||||
{selectedPaper.has_markdown && !selectedPaper.has_translation && (
|
||||
{selectedPaper.has_markdown &&
|
||||
!selectedPaper.has_translation && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleTranslate(selectedPaper.bibcode);
|
||||
@ -383,9 +466,13 @@ export function ReaderToolbar({
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
showConfirm('确定要重新进行智能翻译吗?这会覆盖本地已缓存的翻译结果。', () => {
|
||||
showConfirm(
|
||||
'确定要重新进行智能翻译吗?这会覆盖本地已缓存的翻译结果。',
|
||||
() => {
|
||||
handleTranslate(selectedPaper.bibcode, true);
|
||||
}, '确认重新翻译');
|
||||
},
|
||||
'确认重新翻译'
|
||||
);
|
||||
}}
|
||||
disabled={translating}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
@ -414,9 +501,13 @@ export function ReaderToolbar({
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowActionsMenu(false);
|
||||
showConfirm('确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。', () => {
|
||||
showConfirm(
|
||||
'确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。',
|
||||
() => {
|
||||
handleVectorize(selectedPaper.bibcode);
|
||||
}, '确认重新知识入库');
|
||||
},
|
||||
'确认重新知识入库'
|
||||
);
|
||||
}}
|
||||
disabled={vectorizing}
|
||||
className="w-full text-left px-4 py-2 text-xs transition-colors flex items-center gap-2 text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium cursor-pointer outline-none"
|
||||
@ -425,7 +516,6 @@ export function ReaderToolbar({
|
||||
<span>重新知识入库</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -437,12 +527,15 @@ export function ReaderToolbar({
|
||||
setShowNotesPanel(!showNotesPanel);
|
||||
}}
|
||||
className={`btn-console px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0 ${
|
||||
showNotesPanel ? 'bg-sky-50 text-sky-700 border-sky-200 shadow-xs' : 'btn-console-secondary'
|
||||
showNotesPanel
|
||||
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-xs'
|
||||
: 'btn-console-secondary'
|
||||
}`}
|
||||
title="开启或关闭侧边学术助手(包含阅读手札与 AI 问答)"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="hidden sm:inline">学术助手</span><span className="sm:hidden">助手</span>
|
||||
<span className="hidden sm:inline">学术助手</span>
|
||||
<span className="sm:hidden">助手</span>
|
||||
{notesCount > 0 && (
|
||||
<span className="px-1.5 py-0.2 text-[9px] rounded-full bg-sky-100 text-sky-850 font-extrabold select-none shrink-0">
|
||||
{notesCount}
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
// dashboard/src/components/sync/BatchPipelineCard.tsx
|
||||
import React from 'react';
|
||||
import { Download, AlertTriangle, Play, StopCircle, Loader, FileText, RefreshCw, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Play,
|
||||
StopCircle,
|
||||
Loader,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { CustomSelect } from '../CustomSelect';
|
||||
import type { BatchStatus } from '../../hooks/useSyncState';
|
||||
|
||||
interface BatchPipelineCardProps {
|
||||
targetPhase: 'download' | 'parse' | 'translate' | 'embed' | 'target';
|
||||
setTargetPhase: (p: 'download' | 'parse' | 'translate' | 'embed' | 'target') => void;
|
||||
setTargetPhase: (
|
||||
p: 'download' | 'parse' | 'translate' | 'embed' | 'target'
|
||||
) => void;
|
||||
batchLimitCount: number;
|
||||
setBatchLimitCount: (c: number) => void;
|
||||
sortOrder: 'default' | 'pub_year_desc' | 'created_at_desc';
|
||||
@ -55,7 +66,8 @@ export function BatchPipelineCard({
|
||||
<span>馆藏文献批量学术流水线任务</span>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-0.5">
|
||||
针对本地馆藏中的文献,批量发起正文 PDF/HTML 下载、Markdown 结构化排版解析以及中英双语对照翻译。
|
||||
针对本地馆藏中的文献,批量发起正文 PDF/HTML 下载、Markdown
|
||||
结构化排版解析以及中英双语对照翻译。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -69,11 +81,17 @@ export function BatchPipelineCard({
|
||||
{/* 条件设置 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block">目标阶段</label>
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
目标阶段
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={targetPhase}
|
||||
disabled={batchStatus.active}
|
||||
onChange={val => setTargetPhase(val as 'download' | 'parse' | 'translate' | 'embed' | 'target')}
|
||||
onChange={(val) =>
|
||||
setTargetPhase(
|
||||
val as 'download' | 'parse' | 'translate' | 'embed' | 'target'
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
options={[
|
||||
{ value: 'download', label: '下载' },
|
||||
@ -86,22 +104,32 @@ export function BatchPipelineCard({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block">批量处理上限</label>
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
批量处理上限
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={batchLimitCount}
|
||||
disabled={batchStatus.active}
|
||||
onChange={e => setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))}
|
||||
onChange={(e) =>
|
||||
setBatchLimitCount(Math.max(1, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block">处理顺序</label>
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
处理顺序
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={sortOrder}
|
||||
disabled={batchStatus.active}
|
||||
onChange={val => setSortOrder(val as 'default' | 'pub_year_desc' | 'created_at_desc')}
|
||||
onChange={(val) =>
|
||||
setSortOrder(
|
||||
val as 'default' | 'pub_year_desc' | 'created_at_desc'
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
options={[
|
||||
{ value: 'default', label: '默认(不指定)' },
|
||||
@ -114,14 +142,16 @@ export function BatchPipelineCard({
|
||||
|
||||
{/* 执行策略 */}
|
||||
<div className="space-y-2 border-t border-slate-100 pt-4">
|
||||
<label className="text-xs font-bold text-slate-700 block">执行策略</label>
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
执行策略
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 select-none">
|
||||
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skipCompleted}
|
||||
disabled={batchStatus.active}
|
||||
onChange={e => setSkipCompleted(e.target.checked)}
|
||||
onChange={(e) => setSkipCompleted(e.target.checked)}
|
||||
className="rounded text-blueprint focus:ring-blueprint/25"
|
||||
/>
|
||||
<span>跳过已完成</span>
|
||||
@ -132,7 +162,7 @@ export function BatchPipelineCard({
|
||||
type="checkbox"
|
||||
checked={skipFailed}
|
||||
disabled={batchStatus.active}
|
||||
onChange={e => setSkipFailed(e.target.checked)}
|
||||
onChange={(e) => setSkipFailed(e.target.checked)}
|
||||
className="rounded text-blueprint focus:ring-blueprint/25"
|
||||
/>
|
||||
<span>跳过当前失败</span>
|
||||
@ -143,7 +173,7 @@ export function BatchPipelineCard({
|
||||
type="checkbox"
|
||||
checked={skipPrecedingFailed}
|
||||
disabled={batchStatus.active}
|
||||
onChange={e => setSkipPrecedingFailed(e.target.checked)}
|
||||
onChange={(e) => setSkipPrecedingFailed(e.target.checked)}
|
||||
className="rounded text-blueprint focus:ring-blueprint/25"
|
||||
/>
|
||||
<span>跳过前置失败</span>
|
||||
@ -154,7 +184,7 @@ export function BatchPipelineCard({
|
||||
type="checkbox"
|
||||
checked={skipPrecedingUncompleted}
|
||||
disabled={batchStatus.active}
|
||||
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
|
||||
onChange={(e) => setSkipPrecedingUncompleted(e.target.checked)}
|
||||
className="rounded text-blueprint focus:ring-blueprint/25"
|
||||
/>
|
||||
<span>跳过前置未完成</span>
|
||||
@ -193,11 +223,21 @@ export function BatchPipelineCard({
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs font-bold text-slate-600 select-none">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-blueprint" />}
|
||||
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-700" />}
|
||||
{batchStatus.action === 'translate' && <RefreshCw className="w-3.5 h-3.5 text-slate-700 animate-spin" />}
|
||||
{batchStatus.action === 'embed' && <SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />}
|
||||
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-blueprint" />}
|
||||
{batchStatus.action === 'download' && (
|
||||
<Download className="w-3.5 h-3.5 text-blueprint" />
|
||||
)}
|
||||
{batchStatus.action === 'parse' && (
|
||||
<FileText className="w-3.5 h-3.5 text-emerald-700" />
|
||||
)}
|
||||
{batchStatus.action === 'translate' && (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-700 animate-spin" />
|
||||
)}
|
||||
{batchStatus.action === 'embed' && (
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />
|
||||
)}
|
||||
{batchStatus.action === 'target' && (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-blueprint" />
|
||||
)}
|
||||
{batchStatus.action === 'download'
|
||||
? '正文离线下载进度'
|
||||
: batchStatus.action === 'parse'
|
||||
@ -209,11 +249,20 @@ export function BatchPipelineCard({
|
||||
: '天体识别与缓存进度'}
|
||||
</span>
|
||||
<span>
|
||||
{batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total} 篇
|
||||
{((batchStatus.action === 'download' && batchStatus.download_failed > 0) ||
|
||||
(batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && (
|
||||
{batchStatus.action === 'download'
|
||||
? batchStatus.downloaded
|
||||
: batchStatus.parsed}{' '}
|
||||
/ {batchStatus.total} 篇
|
||||
{((batchStatus.action === 'download' &&
|
||||
batchStatus.download_failed > 0) ||
|
||||
(batchStatus.action !== 'download' &&
|
||||
batchStatus.parse_failed > 0)) && (
|
||||
<span className="text-red-500 ml-2 font-bold">
|
||||
(失败 {batchStatus.action === 'download' ? batchStatus.download_failed : batchStatus.parse_failed} 篇)
|
||||
(失败{' '}
|
||||
{batchStatus.action === 'download'
|
||||
? batchStatus.download_failed
|
||||
: batchStatus.parse_failed}{' '}
|
||||
篇)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@ -238,7 +287,8 @@ export function BatchPipelineCard({
|
||||
100,
|
||||
Math.round(
|
||||
((batchStatus.action === 'download'
|
||||
? batchStatus.downloaded + batchStatus.download_failed
|
||||
? batchStatus.downloaded +
|
||||
batchStatus.download_failed
|
||||
: batchStatus.parsed + batchStatus.parse_failed) /
|
||||
batchStatus.total) *
|
||||
100
|
||||
@ -254,19 +304,34 @@ export function BatchPipelineCard({
|
||||
{batchStatus.active && batchStatus.current_bibcode && (
|
||||
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
|
||||
<Loader className="w-3.5 h-3.5 text-blueprint animate-spin" />
|
||||
<span>当前正在处理: <code className="bg-slate-100 px-2 py-0.5 rounded-md font-mono font-bold text-slate-800 border border-slate-200 select-all">{batchStatus.current_bibcode}</code></span>
|
||||
<span>
|
||||
当前正在处理:{' '}
|
||||
<code className="bg-slate-100 px-2 py-0.5 rounded-md font-mono font-bold text-slate-800 border border-slate-200 select-all">
|
||||
{batchStatus.current_bibcode}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 滚动日志终端 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block select-none">实时处理日志流终端</label>
|
||||
<div ref={logsContainerRef} className="bg-slate-50 text-slate-800 font-mono text-[11px] p-4 rounded-md h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative select-all">
|
||||
<label className="text-xs font-bold text-slate-700 block select-none">
|
||||
实时处理日志流终端
|
||||
</label>
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="bg-slate-50 text-slate-800 font-mono text-[11px] p-4 rounded-md h-48 overflow-y-auto border border-slate-250 space-y-1 scrollbar-thin scrollbar-thumb-slate-300 relative select-all"
|
||||
>
|
||||
{batchStatus.logs.length === 0 ? (
|
||||
<div className="text-slate-400 italic select-none">等待数据流任务启动,暂无日志输出...</div>
|
||||
<div className="text-slate-400 italic select-none">
|
||||
等待数据流任务启动,暂无日志输出...
|
||||
</div>
|
||||
) : (
|
||||
batchStatus.logs.map((log: string, idx: number) => (
|
||||
<div key={idx} className="whitespace-pre-wrap leading-relaxed">
|
||||
<div
|
||||
key={idx}
|
||||
className="whitespace-pre-wrap leading-relaxed"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
|
||||
@ -29,7 +29,10 @@ export function BookmarkletToolCard() {
|
||||
<span>浏览器快捷直推书签导入工具</span>
|
||||
</h3>
|
||||
<p className="text-slate-500 text-xs mt-1 leading-relaxed">
|
||||
无需手动保存和拖拽上传!在您自己真实的浏览器上突破 Cloudflare/WAF 人机验证或在学校内网成功打开 PDF/HTML 页面后,点击此书签即可将文献直接推送同步到本地 AstroResearch 数据库与文献馆中。
|
||||
无需手动保存和拖拽上传!在您自己真实的浏览器上突破 Cloudflare/WAF
|
||||
人机验证或在学校内网成功打开 PDF/HTML
|
||||
页面后,点击此书签即可将文献直接推送同步到本地 AstroResearch
|
||||
数据库与文献馆中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -37,7 +40,8 @@ export function BookmarkletToolCard() {
|
||||
<div className="space-y-1.5 font-bold select-none">
|
||||
<span className="text-slate-500">第一步:安装书签到您的浏览器</span>
|
||||
<p className="text-[11px] text-slate-400 font-normal mt-1 leading-relaxed">
|
||||
将下方的按钮直接拖拽到浏览器的书签栏中;或者新建一个浏览器书签,将其 URL 设为复制的 JavaScript 代码(点击右侧按钮复制):
|
||||
将下方的按钮直接拖拽到浏览器的书签栏中;或者新建一个浏览器书签,将其
|
||||
URL 设为复制的 JavaScript 代码(点击右侧按钮复制):
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mt-2 select-none">
|
||||
@ -48,7 +52,16 @@ export function BookmarkletToolCard() {
|
||||
title="拖拽我到您的书签栏中"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<span style={{ position: 'absolute', width: '1px', height: '1px', overflow: 'hidden', opacity: 0, pointerEvents: 'none' }}>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
overflow: 'hidden',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
导入AstroResearch
|
||||
</span>
|
||||
<span>添加导入书签</span>
|
||||
@ -66,9 +79,25 @@ export function BookmarkletToolCard() {
|
||||
<div className="space-y-1.5 font-bold mt-4 select-none">
|
||||
<span className="text-slate-500">第二步:如何使用书签直推?</span>
|
||||
<ol className="list-decimal list-inside text-[11px] text-slate-500 font-normal pl-1 space-y-1.5 mt-1 leading-relaxed">
|
||||
<li>点击详情弹窗内的 <span className="font-bold text-slate-750">BIBCODE / DOI / arXiv ID 链接</span>;</li>
|
||||
<li>页面跳转至文献原始网页后,直接<span className="font-bold text-slate-750">点击该浏览器书签</span>;</li>
|
||||
<li>书签脚本会自动读取并<span className="font-bold text-slate-750">自动填充好 Bibcode</span>,您只需点击确认,文件即可秒级自动上传录入至系统!</li>
|
||||
<li>
|
||||
点击详情弹窗内的{' '}
|
||||
<span className="font-bold text-slate-750">
|
||||
BIBCODE / DOI / arXiv ID 链接
|
||||
</span>
|
||||
;
|
||||
</li>
|
||||
<li>
|
||||
页面跳转至文献原始网页后,直接
|
||||
<span className="font-bold text-slate-750">点击该浏览器书签</span>
|
||||
;
|
||||
</li>
|
||||
<li>
|
||||
书签脚本会自动读取并
|
||||
<span className="font-bold text-slate-750">
|
||||
自动填充好 Bibcode
|
||||
</span>
|
||||
,您只需点击确认,文件即可秒级自动上传录入至系统!
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
// dashboard/src/components/sync/MetadataSyncCard.tsx
|
||||
|
||||
import { SlidersHorizontal, RefreshCw, Loader, Play, Info, CheckCircle, Lightbulb } from 'lucide-react';
|
||||
import {
|
||||
SlidersHorizontal,
|
||||
RefreshCw,
|
||||
Loader,
|
||||
Play,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Lightbulb,
|
||||
} from 'lucide-react';
|
||||
import { CustomSelect } from '../CustomSelect';
|
||||
import type { HarvestStatus } from '../../hooks/useSyncState';
|
||||
|
||||
@ -19,7 +27,11 @@ interface MetadataSyncCardProps {
|
||||
rules: Array<{ field: string; op: string; val: string }>;
|
||||
handleAddRule: () => void;
|
||||
handleRemoveRule: (idx: number) => void;
|
||||
handleRuleChange: (idx: number, key: 'field' | 'op' | 'val', value: string) => void;
|
||||
handleRuleChange: (
|
||||
idx: number,
|
||||
key: 'field' | 'op' | 'val',
|
||||
value: string
|
||||
) => void;
|
||||
handleEstimate: () => Promise<void>;
|
||||
handleStartHarvest: () => Promise<void>;
|
||||
}
|
||||
@ -43,7 +55,10 @@ export function MetadataSyncCard({
|
||||
handleEstimate,
|
||||
handleStartHarvest,
|
||||
}: MetadataSyncCardProps) {
|
||||
const percent = status.total > 0 ? Math.min(100, Math.round((status.synced / status.total) * 100)) : 0;
|
||||
const percent =
|
||||
status.total > 0
|
||||
? Math.min(100, Math.round((status.synced / status.total) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -65,25 +80,31 @@ export function MetadataSyncCard({
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
disabled={status.active}
|
||||
placeholder="例如: hot subdwarf, Gaia BH1..."
|
||||
className="w-full px-4 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
/>
|
||||
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1 select-none">
|
||||
<span>高级格式:</span>
|
||||
<span><code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">author:"Althaus" AND year:2020-2023</code></span>
|
||||
<span>
|
||||
<code className="text-slate-700 bg-slate-100 px-1 py-0.2 rounded font-mono">
|
||||
author:"Althaus" AND year:2020-2023
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block">数据发布平台</label>
|
||||
<label className="text-xs font-bold text-slate-700 block">
|
||||
数据发布平台
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'ads', label: 'NASA ADS' },
|
||||
{ id: 'arxiv', label: 'arXiv 预印本' },
|
||||
].map(src => (
|
||||
].map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
@ -122,7 +143,7 @@ export function MetadataSyncCard({
|
||||
{idx > 0 ? (
|
||||
<CustomSelect
|
||||
value={rule.op}
|
||||
onChange={val => handleRuleChange(idx, 'op', val)}
|
||||
onChange={(val) => handleRuleChange(idx, 'op', val)}
|
||||
className="w-24"
|
||||
options={[
|
||||
{ value: 'AND', label: '并且 (AND)' },
|
||||
@ -131,12 +152,14 @@ export function MetadataSyncCard({
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 text-center text-xs text-slate-500 font-semibold select-none">筛选条件</div>
|
||||
<div className="w-24 text-center text-xs text-slate-500 font-semibold select-none">
|
||||
筛选条件
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CustomSelect
|
||||
value={rule.field}
|
||||
onChange={val => handleRuleChange(idx, 'field', val)}
|
||||
onChange={(val) => handleRuleChange(idx, 'field', val)}
|
||||
className="w-32"
|
||||
options={[
|
||||
{ value: 'all', label: '任意字段' },
|
||||
@ -150,7 +173,9 @@ export function MetadataSyncCard({
|
||||
<input
|
||||
type="text"
|
||||
value={rule.val}
|
||||
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleRuleChange(idx, 'val', e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
rule.field === 'year'
|
||||
? '例如: 2020-2023 或 2022'
|
||||
@ -180,13 +205,17 @@ export function MetadataSyncCard({
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
|
||||
<span>单次拉取最大上限</span>
|
||||
<span className="text-[11px] text-slate-450 font-normal">防止请求超出频率被封禁 API</span>
|
||||
<span className="text-[11px] text-slate-450 font-normal">
|
||||
防止请求超出频率被封禁 API
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={limit}
|
||||
disabled={status.active}
|
||||
onChange={e => setLimit(Math.max(1, parseInt(e.target.value) || 0))}
|
||||
onChange={(e) =>
|
||||
setLimit(Math.max(1, parseInt(e.target.value) || 0))
|
||||
}
|
||||
className="w-full px-4 py-2 rounded-md bg-slate-50 border border-slate-300 text-slate-900 focus:outline-none focus:border-blueprint transition-all text-xs font-medium"
|
||||
/>
|
||||
</div>
|
||||
@ -198,7 +227,11 @@ export function MetadataSyncCard({
|
||||
onClick={handleEstimate}
|
||||
className="flex-1 py-2 rounded-md bg-white border border-slate-300 hover:bg-slate-50 text-slate-750 text-xs font-bold flex items-center justify-center gap-2 transition-all disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{estimating ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{estimating ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
估计目录总量
|
||||
</button>
|
||||
<button
|
||||
@ -218,8 +251,14 @@ export function MetadataSyncCard({
|
||||
<div className="p-4 rounded-md bg-slate-50 border border-slate-200 flex gap-3 text-xs text-slate-800 items-center select-none animate-in fade-in duration-250">
|
||||
<Info className="w-4 h-4 shrink-0 text-slate-500" />
|
||||
<div>
|
||||
检索库中估计有约 <strong className="text-slate-900 font-bold">{estimatedCount}</strong> 篇文献记录。
|
||||
{estimatedCount > limit ? ` 限制最大同步前 ${limit} 篇元数据。` : ' 将全部进行同步。'}
|
||||
检索库中估计有约{' '}
|
||||
<strong className="text-slate-900 font-bold">
|
||||
{estimatedCount}
|
||||
</strong>{' '}
|
||||
篇文献记录。
|
||||
{estimatedCount > limit
|
||||
? ` 限制最大同步前 ${limit} 篇元数据。`
|
||||
: ' 将全部进行同步。'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -244,10 +283,21 @@ export function MetadataSyncCard({
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold leading-relaxed select-all">
|
||||
检索条件: <code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700 font-mono">{status.query}</code> • 平台: {status.source === 'all' ? '全部' : status.source === 'ads' ? 'NASA ADS' : 'arXiv'}
|
||||
检索条件:{' '}
|
||||
<code className="bg-slate-100 px-1 py-0.5 rounded text-slate-700 font-mono">
|
||||
{status.query}
|
||||
</code>{' '}
|
||||
• 平台:{' '}
|
||||
{status.source === 'all'
|
||||
? '全部'
|
||||
: status.source === 'ads'
|
||||
? 'NASA ADS'
|
||||
: 'arXiv'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-slate-800">{status.synced} / {status.total} 篇</span>
|
||||
<span className="text-xs font-bold text-slate-800">
|
||||
{status.synced} / {status.total} 篇
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-3 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
|
||||
@ -257,10 +307,14 @@ export function MetadataSyncCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status.active && (status.source === 'all' || status.source === 'arxiv') ? (
|
||||
{status.active &&
|
||||
(status.source === 'all' || status.source === 'arxiv') ? (
|
||||
<div className="p-3 rounded-md bg-slate-50 border border-slate-200 border-l-4 border-l-amber-500 text-xs text-slate-700 flex items-start gap-1.5 select-none">
|
||||
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>同步 arXiv 文献时系统会自动加设 3000 毫秒的安全限流延迟以规避服务器封锁。</span>
|
||||
<span>
|
||||
同步 arXiv 文献时系统会自动加设 3000
|
||||
毫秒的安全限流延迟以规避服务器封锁。
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,8 @@ export function useAuth() {
|
||||
|
||||
// 校验登录状态
|
||||
useEffect(() => {
|
||||
axios.get('/api/auth/check')
|
||||
axios
|
||||
.get('/api/auth/check')
|
||||
.then(() => {
|
||||
setIsAuthenticated(true);
|
||||
})
|
||||
@ -41,13 +42,19 @@ export function useAuth() {
|
||||
setLoginError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error('登录校验失败:', err);
|
||||
const axiosError = err as { response?: { data?: string | { error?: string } } };
|
||||
const axiosError = err as {
|
||||
response?: { data?: string | { error?: string } };
|
||||
};
|
||||
let errMsg = '密码错误,请重试。';
|
||||
if (axiosError.response?.data) {
|
||||
const data = axiosError.response.data;
|
||||
if (typeof data === 'string') {
|
||||
errMsg = data;
|
||||
} else if (typeof data === 'object' && data !== null && 'error' in data) {
|
||||
} else if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'error' in data
|
||||
) {
|
||||
errMsg = data.error || errMsg;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,8 @@ import axios from 'axios';
|
||||
import type { CitationNetwork } from '../types';
|
||||
|
||||
export function useCitations() {
|
||||
const [citationNetwork, setCitationNetwork] = useState<CitationNetwork | null>(null);
|
||||
const [citationNetwork, setCitationNetwork] =
|
||||
useState<CitationNetwork | null>(null);
|
||||
const [loadingCitations, setLoadingCitations] = useState(false);
|
||||
const [citationHistory, setCitationHistory] = useState<CitationNetwork[]>([]);
|
||||
const [uncachedBibcode, setUncachedBibcode] = useState<string | null>(null);
|
||||
@ -13,14 +14,14 @@ export function useCitations() {
|
||||
setLoadingCitations(true);
|
||||
try {
|
||||
const res = await axios.get<CitationNetwork>('/api/citations', {
|
||||
params: { bibcode }
|
||||
params: { bibcode },
|
||||
});
|
||||
setCitationNetwork(res.data);
|
||||
setCitationHistory(prev => {
|
||||
setCitationHistory((prev) => {
|
||||
if (reset) {
|
||||
return [res.data];
|
||||
}
|
||||
if (prev.some(net => net.bibcode === res.data.bibcode)) {
|
||||
if (prev.some((net) => net.bibcode === res.data.bibcode)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, res.data];
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// dashboard/src/hooks/useLibrary.ts
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import type { StandardPaper } from '../types';
|
||||
import type { TabId } from '../components/layout/Sidebar';
|
||||
@ -18,10 +18,38 @@ export function useLibrary({
|
||||
openReader,
|
||||
}: UseLibraryProps) {
|
||||
const [library, setLibrary] = useState<StandardPaper[]>([]);
|
||||
const [selectedPaper, setSelectedPaper] = useState<StandardPaper | null>(null);
|
||||
const [selectedPaper, setSelectedPaper] = useState<StandardPaper | null>(
|
||||
null
|
||||
);
|
||||
const [detailBibcode, setDetailBibcode] = useState<string | null>(null);
|
||||
|
||||
const [recentlySelected, setRecentlySelected] = useState<StandardPaper[]>(() => {
|
||||
// 筛选与分页状态提升
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<
|
||||
| 'all'
|
||||
| 'downloaded'
|
||||
| 'undownloaded'
|
||||
| 'download_failed'
|
||||
| 'no_resource'
|
||||
| 'parsed'
|
||||
| 'translated'
|
||||
| 'vectorized'
|
||||
>('all');
|
||||
const [filterDoctype, setFilterDoctype] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<
|
||||
'created' | 'yearDesc' | 'yearAsc' | 'citations' | 'title'
|
||||
>('created');
|
||||
const [filterAuthor, setFilterAuthor] = useState('');
|
||||
const [filterYear, setFilterYear] = useState('');
|
||||
const [filterJournal, setFilterJournal] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(12); // 每页 12 条非常适合网格响应式布局
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const initialOpenedRef = useRef(false);
|
||||
|
||||
const [recentlySelected, setRecentlySelected] = useState<StandardPaper[]>(
|
||||
() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('astro_recently_selected');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
@ -29,18 +57,24 @@ export function useLibrary({
|
||||
console.error('Failed to parse recently selected papers', e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const [downloadingBibcodes, setDownloadingBibcodes] = useState<Record<string, boolean>>({});
|
||||
const [downloadingBibcodes, setDownloadingBibcodes] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [uploadingBibcode, setUploadingBibcode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('astro_recently_selected', JSON.stringify(recentlySelected));
|
||||
localStorage.setItem(
|
||||
'astro_recently_selected',
|
||||
JSON.stringify(recentlySelected)
|
||||
);
|
||||
}, [recentlySelected]);
|
||||
|
||||
const addPaperToRecent = (paper: StandardPaper) => {
|
||||
setRecentlySelected(prev => {
|
||||
const filtered = prev.filter(p => p.bibcode !== paper.bibcode);
|
||||
setRecentlySelected((prev) => {
|
||||
const filtered = prev.filter((p) => p.bibcode !== paper.bibcode);
|
||||
return [paper, ...filtered].slice(0, 5);
|
||||
});
|
||||
};
|
||||
@ -57,60 +91,122 @@ export function useLibrary({
|
||||
};
|
||||
|
||||
const fetchLibrary = async () => {
|
||||
if (isAuthenticated !== true) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get<StandardPaper[]>('/api/library');
|
||||
setLibrary(res.data);
|
||||
|
||||
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
|
||||
if (lastReadBibcode) {
|
||||
const lastReadPaper = res.data.find(p => p.bibcode === lastReadBibcode);
|
||||
if (lastReadPaper) {
|
||||
const initialTab = localStorage.getItem('astro_active_tab') || 'search';
|
||||
openReader(lastReadPaper, initialTab !== 'reader');
|
||||
}
|
||||
const res = await axios.get<{ items: StandardPaper[]; total: number }>(
|
||||
'/api/library',
|
||||
{
|
||||
params: {
|
||||
q: searchTerm || undefined,
|
||||
status: filterStatus !== 'all' ? filterStatus : undefined,
|
||||
doctype: filterDoctype !== 'all' ? filterDoctype : undefined,
|
||||
author: filterAuthor || undefined,
|
||||
year: filterYear || undefined,
|
||||
journal: filterJournal || undefined,
|
||||
sort_by: sortBy,
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
},
|
||||
}
|
||||
);
|
||||
setLibrary(res.data.items);
|
||||
setTotalCount(res.data.total);
|
||||
} catch (e) {
|
||||
console.error('加载本地文献库失败', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化加载本地文献库
|
||||
// 初始化加载上次阅读文献 (仅执行一次)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated !== true) return;
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await axios.get<StandardPaper[]>('/api/library');
|
||||
if (!active) return;
|
||||
setLibrary(res.data);
|
||||
|
||||
if (isAuthenticated !== true || initialOpenedRef.current) return;
|
||||
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
|
||||
if (lastReadBibcode) {
|
||||
const lastReadPaper = res.data.find(p => p.bibcode === lastReadBibcode);
|
||||
if (lastReadPaper) {
|
||||
const initialTab = localStorage.getItem('astro_active_tab') || 'search';
|
||||
openReader(lastReadPaper, initialTab !== 'reader');
|
||||
axios
|
||||
.get<{ paper: StandardPaper }>('/api/paper', {
|
||||
params: { bibcode: lastReadBibcode },
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data && res.data.paper) {
|
||||
const initialTab =
|
||||
localStorage.getItem('astro_active_tab') || 'search';
|
||||
openReader(res.data.paper, initialTab !== 'reader');
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error('加载上次阅读文献失败', e));
|
||||
}
|
||||
initialOpenedRef.current = true;
|
||||
}, [isAuthenticated, openReader]);
|
||||
|
||||
// 监听筛选、排序及页码变更,触发后端加载数据
|
||||
useEffect(() => {
|
||||
if (isAuthenticated !== true) return;
|
||||
|
||||
const fetchLibrary = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get<{ items: StandardPaper[]; total: number }>(
|
||||
'/api/library',
|
||||
{
|
||||
params: {
|
||||
q: searchTerm || undefined,
|
||||
status: filterStatus !== 'all' ? filterStatus : undefined,
|
||||
doctype: filterDoctype !== 'all' ? filterDoctype : undefined,
|
||||
author: filterAuthor || undefined,
|
||||
year: filterYear || undefined,
|
||||
journal: filterJournal || undefined,
|
||||
sort_by: sortBy,
|
||||
limit: pageSize,
|
||||
offset: (currentPage - 1) * pageSize,
|
||||
},
|
||||
}
|
||||
);
|
||||
setLibrary(res.data.items);
|
||||
setTotalCount(res.data.total);
|
||||
} catch (e) {
|
||||
console.error('加载本地文献库失败', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
}, [isAuthenticated]);
|
||||
};
|
||||
|
||||
fetchLibrary();
|
||||
}, [
|
||||
isAuthenticated,
|
||||
searchTerm,
|
||||
filterStatus,
|
||||
filterDoctype,
|
||||
filterAuthor,
|
||||
filterYear,
|
||||
filterJournal,
|
||||
sortBy,
|
||||
currentPage,
|
||||
pageSize,
|
||||
]);
|
||||
|
||||
// 触发文献双格式下载
|
||||
const handleDownload = async (bibcode: string, force = false, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
|
||||
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: true }));
|
||||
const handleDownload = async (
|
||||
bibcode: string,
|
||||
force = false,
|
||||
searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>
|
||||
) => {
|
||||
setDownloadingBibcodes((prev) => ({ ...prev, [bibcode]: true }));
|
||||
try {
|
||||
const res = await axios.post<StandardPaper>('/api/download', { bibcode, force });
|
||||
const res = await axios.post<StandardPaper>('/api/download', {
|
||||
bibcode,
|
||||
force,
|
||||
});
|
||||
|
||||
if (searchResultsSetter) {
|
||||
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
|
||||
searchResultsSetter((prev) =>
|
||||
prev.map((p) => (p.bibcode === bibcode ? res.data : p))
|
||||
);
|
||||
}
|
||||
setLibrary(prev => {
|
||||
if (prev.some(p => p.bibcode === bibcode)) {
|
||||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
||||
setLibrary((prev) => {
|
||||
if (prev.some((p) => p.bibcode === bibcode)) {
|
||||
return prev.map((p) => (p.bibcode === bibcode ? res.data : p));
|
||||
} else {
|
||||
return [res.data, ...prev];
|
||||
}
|
||||
@ -122,12 +218,17 @@ export function useLibrary({
|
||||
console.error('下载文献失败', e);
|
||||
showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
|
||||
} finally {
|
||||
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: false }));
|
||||
setDownloadingBibcodes((prev) => ({ ...prev, [bibcode]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 手动上传文献文件以绕过反爬/人机验证
|
||||
const handleManualUpload = async (bibcode: string, type: 'pdf' | 'html', file: File, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
|
||||
const handleManualUpload = async (
|
||||
bibcode: string,
|
||||
type: 'pdf' | 'html',
|
||||
file: File,
|
||||
searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>
|
||||
) => {
|
||||
setUploadingBibcode(bibcode);
|
||||
const formData = new FormData();
|
||||
formData.append('bibcode', bibcode);
|
||||
@ -142,11 +243,13 @@ export function useLibrary({
|
||||
});
|
||||
|
||||
if (searchResultsSetter) {
|
||||
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
|
||||
searchResultsSetter((prev) =>
|
||||
prev.map((p) => (p.bibcode === bibcode ? res.data : p))
|
||||
);
|
||||
}
|
||||
setLibrary(prev => {
|
||||
if (prev.some(p => p.bibcode === bibcode)) {
|
||||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
||||
setLibrary((prev) => {
|
||||
if (prev.some((p) => p.bibcode === bibcode)) {
|
||||
return prev.map((p) => (p.bibcode === bibcode ? res.data : p));
|
||||
} else {
|
||||
return [res.data, ...prev];
|
||||
}
|
||||
@ -158,7 +261,8 @@ export function useLibrary({
|
||||
} catch (e: unknown) {
|
||||
console.error('手动文件上传失败', e);
|
||||
const axiosError = e as { response?: { data?: string } };
|
||||
const errMsg = axiosError.response?.data || '请确保上传的是合法且完整的文件。';
|
||||
const errMsg =
|
||||
axiosError.response?.data || '请确保上传的是合法且完整的文件。';
|
||||
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
|
||||
} finally {
|
||||
setUploadingBibcode(null);
|
||||
@ -166,17 +270,26 @@ export function useLibrary({
|
||||
};
|
||||
|
||||
// 手动标记文献为“无有效全文资源”
|
||||
const handleMarkNoResource = async (bibcode: string, clear = false, searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>) => {
|
||||
const handleMarkNoResource = async (
|
||||
bibcode: string,
|
||||
clear = false,
|
||||
searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>
|
||||
) => {
|
||||
const performMark = async () => {
|
||||
try {
|
||||
const res = await axios.post<StandardPaper>('/api/no_resource', { bibcode, clear });
|
||||
const res = await axios.post<StandardPaper>('/api/no_resource', {
|
||||
bibcode,
|
||||
clear,
|
||||
});
|
||||
|
||||
if (searchResultsSetter) {
|
||||
searchResultsSetter(prev => prev.map(p => p.bibcode === bibcode ? res.data : p));
|
||||
searchResultsSetter((prev) =>
|
||||
prev.map((p) => (p.bibcode === bibcode ? res.data : p))
|
||||
);
|
||||
}
|
||||
setLibrary(prev => {
|
||||
if (prev.some(p => p.bibcode === bibcode)) {
|
||||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
||||
setLibrary((prev) => {
|
||||
if (prev.some((p) => p.bibcode === bibcode)) {
|
||||
return prev.map((p) => (p.bibcode === bibcode ? res.data : p));
|
||||
} else {
|
||||
return [res.data, ...prev];
|
||||
}
|
||||
@ -211,7 +324,10 @@ export function useLibrary({
|
||||
|
||||
// 衍生状态计算
|
||||
const getDetailPaper = (searchResults: StandardPaper[]) => {
|
||||
return library.find(p => p.bibcode === detailBibcode) || searchResults.find(p => p.bibcode === detailBibcode);
|
||||
return (
|
||||
library.find((p) => p.bibcode === detailBibcode) ||
|
||||
searchResults.find((p) => p.bibcode === detailBibcode)
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
@ -233,5 +349,26 @@ export function useLibrary({
|
||||
handleManualUpload,
|
||||
handleMarkNoResource,
|
||||
getDetailPaper,
|
||||
// 状态和设置函数导出
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterDoctype,
|
||||
setFilterDoctype,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
filterAuthor,
|
||||
setFilterAuthor,
|
||||
filterYear,
|
||||
setFilterYear,
|
||||
filterJournal,
|
||||
setFilterJournal,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
totalCount,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,11 +8,14 @@ export function useNotes() {
|
||||
const [showNotesPanel, setShowNotesPanel] = useState(false);
|
||||
const [newNoteText, setNewNoteText] = useState('');
|
||||
const [newNoteColor, setNewNoteColor] = useState('yellow');
|
||||
const [selectedParagraphIdx, setSelectedParagraphIdx] = useState<number | null>(null);
|
||||
const [selectedParagraphIdx, setSelectedParagraphIdx] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [selectedText, setSelectedText] = useState('');
|
||||
|
||||
const handleCreateNote = async (selectedPaper: StandardPaper | null) => {
|
||||
if (!selectedPaper || selectedParagraphIdx === null || !newNoteText.trim()) return;
|
||||
if (!selectedPaper || selectedParagraphIdx === null || !newNoteText.trim())
|
||||
return;
|
||||
try {
|
||||
const res = await axios.post<NoteRecord>('/api/notes', {
|
||||
bibcode: selectedPaper.bibcode,
|
||||
@ -21,7 +24,7 @@ export function useNotes() {
|
||||
highlight_color: newNoteColor,
|
||||
selected_text: selectedText,
|
||||
});
|
||||
setNotes(prev => [...prev, res.data]);
|
||||
setNotes((prev) => [...prev, res.data]);
|
||||
setNewNoteText('');
|
||||
setSelectedParagraphIdx(null);
|
||||
setSelectedText('');
|
||||
@ -33,7 +36,7 @@ export function useNotes() {
|
||||
const handleDeleteNote = async (id: number) => {
|
||||
try {
|
||||
await axios.delete('/api/notes', { params: { id } });
|
||||
setNotes(prev => prev.filter(n => n.id !== id));
|
||||
setNotes((prev) => prev.filter((n) => n.id !== id));
|
||||
} catch (e) {
|
||||
console.error('删除笔记失败', e);
|
||||
}
|
||||
|
||||
@ -4,7 +4,11 @@ import axios from 'axios';
|
||||
import { defaultSchema } from 'rehype-sanitize';
|
||||
import type { StandardPaper } from '../types';
|
||||
|
||||
import { getHighlightCandidates, type TargetInfo, ENABLE_LEGACY_UNICODE_SPACE_COMPAT } from '../utils/celestial';
|
||||
import {
|
||||
getHighlightCandidates,
|
||||
type TargetInfo,
|
||||
ENABLE_LEGACY_UNICODE_SPACE_COMPAT,
|
||||
} from '../utils/celestial';
|
||||
import { useSyncScroll } from './useSyncScroll';
|
||||
|
||||
// LaTeX 及 HTML 过滤白名单配置
|
||||
@ -12,20 +16,67 @@ export const safeSchema = {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
'*': (defaultSchema.attributes?.['*'] || []).concat(['className', 'style', 'mathvariant', 'display', 'data-target-name']),
|
||||
'span': (defaultSchema.attributes?.['span'] || []).concat(['className', 'style', 'data-target-name']),
|
||||
'*': (defaultSchema.attributes?.['*'] || []).concat([
|
||||
'className',
|
||||
'style',
|
||||
'mathvariant',
|
||||
'display',
|
||||
'data-target-name',
|
||||
]),
|
||||
span: (defaultSchema.attributes?.['span'] || []).concat([
|
||||
'className',
|
||||
'style',
|
||||
'data-target-name',
|
||||
]),
|
||||
},
|
||||
tagNames: (defaultSchema.tagNames || []).concat([
|
||||
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', 'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation'
|
||||
'math',
|
||||
'mrow',
|
||||
'mi',
|
||||
'mo',
|
||||
'mn',
|
||||
'msup',
|
||||
'msub',
|
||||
'msubsup',
|
||||
'mfrac',
|
||||
'mover',
|
||||
'munder',
|
||||
'munderover',
|
||||
'mspace',
|
||||
'mtext',
|
||||
'annotation',
|
||||
]),
|
||||
};
|
||||
|
||||
// 读书笔记高亮配色映射表
|
||||
export const NOTE_COLORS: Record<string, { bg: string; border: string; label: string; text: string }> = {
|
||||
cyan: { bg: 'bg-cyan-50 border-cyan-200', border: 'border-cyan-300', label: '天蓝色', text: 'text-cyan-800' },
|
||||
amber: { bg: 'bg-amber-50 border-amber-200', border: 'border-amber-300', label: '星光金', text: 'text-amber-800' },
|
||||
purple: { bg: 'bg-purple-50 border-purple-200', border: 'border-purple-300', label: '淡紫色', text: 'text-purple-800' },
|
||||
green: { bg: 'bg-emerald-50 border-emerald-200', border: 'border-emerald-300', label: '荧光绿', text: 'text-emerald-800' },
|
||||
export const NOTE_COLORS: Record<
|
||||
string,
|
||||
{ bg: string; border: string; label: string; text: string }
|
||||
> = {
|
||||
cyan: {
|
||||
bg: 'bg-cyan-50 border-cyan-200',
|
||||
border: 'border-cyan-300',
|
||||
label: '天蓝色',
|
||||
text: 'text-cyan-800',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-50 border-amber-200',
|
||||
border: 'border-amber-300',
|
||||
label: '星光金',
|
||||
text: 'text-amber-800',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-50 border-purple-200',
|
||||
border: 'border-purple-300',
|
||||
label: '淡紫色',
|
||||
text: 'text-purple-800',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-emerald-50 border-emerald-200',
|
||||
border: 'border-emerald-300',
|
||||
label: '荧光绿',
|
||||
text: 'text-emerald-800',
|
||||
},
|
||||
};
|
||||
|
||||
interface UseReaderStateProps {
|
||||
@ -56,17 +107,17 @@ export function useReaderState({
|
||||
|
||||
const [sidebarTab, setSidebarTab] = useState<'notes' | 'ai'>('notes');
|
||||
const [hoveredTarget, setHoveredTarget] = useState<TargetInfo | null>(null);
|
||||
const [hoverCardPos, setHoverCardPos] = useState<{ x: number; y: number; isAbove?: boolean } | null>(null);
|
||||
const [hoverCardPos, setHoverCardPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
isAbove?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 绑定双语自适应双轨同步滚动 (方案三)
|
||||
const {
|
||||
englishRef,
|
||||
chineseRef,
|
||||
handleEnglishScroll,
|
||||
handleChineseScroll,
|
||||
} = useSyncScroll(syncScroll, viewMode, showPdf, hasMarkdown, () => {
|
||||
const { englishRef, chineseRef, handleEnglishScroll, handleChineseScroll } =
|
||||
useSyncScroll(syncScroll, viewMode, showPdf, hasMarkdown, () => {
|
||||
if (hoveredTarget) setHoveredTarget(null);
|
||||
});
|
||||
|
||||
@ -84,7 +135,7 @@ export function useReaderState({
|
||||
setLoadingTargets(true);
|
||||
try {
|
||||
const res = await axios.get<TargetInfo[]>('/api/target/list', {
|
||||
params: { bibcode: selectedPaper.bibcode }
|
||||
params: { bibcode: selectedPaper.bibcode },
|
||||
});
|
||||
if (active) setTargets(res.data);
|
||||
} catch (e) {
|
||||
@ -93,14 +144,19 @@ export function useReaderState({
|
||||
if (active) setLoadingTargets(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedPaper?.bibcode]);
|
||||
|
||||
// 自动从正文中提取天体并查询 CDS Sesame 缓存
|
||||
const handleIdentifyTargets = async (bibcode: string) => {
|
||||
setIdentifyingTargets(true);
|
||||
try {
|
||||
const res = await axios.post<{ targets: TargetInfo[] }>('/api/target/extract', { bibcode });
|
||||
const res = await axios.post<{ targets: TargetInfo[] }>(
|
||||
'/api/target/extract',
|
||||
{ bibcode }
|
||||
);
|
||||
setTargets(res.data.targets);
|
||||
} catch (e) {
|
||||
console.error('天体识别失败:', e);
|
||||
@ -116,12 +172,21 @@ export function useReaderState({
|
||||
if (!manualTargetName.trim() || associatingTarget) return;
|
||||
setAssociatingTarget(true);
|
||||
try {
|
||||
const res = await axios.post<{ status: string; target: TargetInfo }>('/api/target/associate', {
|
||||
const res = await axios.post<{ status: string; target: TargetInfo }>(
|
||||
'/api/target/associate',
|
||||
{
|
||||
bibcode: selectedPaper.bibcode,
|
||||
object_name: manualTargetName.trim()
|
||||
});
|
||||
if (!targets.some(t => t.target_name.toLowerCase() === res.data.target.target_name.toLowerCase())) {
|
||||
setTargets(prev => [...prev, res.data.target]);
|
||||
object_name: manualTargetName.trim(),
|
||||
}
|
||||
);
|
||||
if (
|
||||
!targets.some(
|
||||
(t) =>
|
||||
t.target_name.toLowerCase() ===
|
||||
res.data.target.target_name.toLowerCase()
|
||||
)
|
||||
) {
|
||||
setTargets((prev) => [...prev, res.data.target]);
|
||||
}
|
||||
setManualTargetName('');
|
||||
} catch (e) {
|
||||
@ -145,12 +210,16 @@ export function useReaderState({
|
||||
const targetName = element.getAttribute('data-target-name');
|
||||
if (targetName) {
|
||||
clearHoverTimeout();
|
||||
const matchedTarget = targets.find(t =>
|
||||
const matchedTarget = targets.find(
|
||||
(t) =>
|
||||
t.target_name.toLowerCase() === targetName.toLowerCase() ||
|
||||
t.aliases.some(a => a.toLowerCase() === targetName.toLowerCase())
|
||||
t.aliases.some((a) => a.toLowerCase() === targetName.toLowerCase())
|
||||
);
|
||||
if (matchedTarget) {
|
||||
if (!hoveredTarget || hoveredTarget.target_name !== matchedTarget.target_name) {
|
||||
if (
|
||||
!hoveredTarget ||
|
||||
hoveredTarget.target_name !== matchedTarget.target_name
|
||||
) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
// 使用 fixed 视口定位防止父级 overflow-y-auto 裁切
|
||||
@ -219,7 +288,10 @@ export function useReaderState({
|
||||
const normalizeSpace = (str: string) => {
|
||||
if (ENABLE_LEGACY_UNICODE_SPACE_COMPAT) {
|
||||
// 兼容模式:将所有特殊的 Unicode 不换行空格、窄空格、连续多空格统一替换为标准的单半角空格
|
||||
return str.replace(/[\s\u00a0\u2000-\u200a\u202f\u205f\u3000]+/g, ' ').trim().toLowerCase();
|
||||
return str
|
||||
.replace(/[\s\u00a0\u2000-\u200a\u202f\u205f\u3000]+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
// 纯净模式:仅进行标准的首尾空格清理与小写转换(无正则消耗)
|
||||
return str.trim().toLowerCase();
|
||||
@ -230,13 +302,15 @@ export function useReaderState({
|
||||
if (!normalized) return;
|
||||
cleanNames.add(normalized);
|
||||
|
||||
const spaceRegex = /^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)\s+(\d+.*)$/i;
|
||||
const spaceRegex =
|
||||
/^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)\s+(\d+.*)$/i;
|
||||
const match = normalized.match(spaceRegex);
|
||||
if (match) {
|
||||
cleanNames.add(`${match[1]}${match[2]}`);
|
||||
}
|
||||
|
||||
const noSpaceRegex = /^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)(\d+.*)$/i;
|
||||
const noSpaceRegex =
|
||||
/^(ngc|ic|m|hd|hip|gaia|wd|gd|tyc|kic|tic|psr)(\d+.*)$/i;
|
||||
const matchNoSpace = normalized.match(noSpaceRegex);
|
||||
if (matchNoSpace) {
|
||||
cleanNames.add(`${matchNoSpace[1]} ${matchNoSpace[2]}`);
|
||||
@ -249,9 +323,9 @@ export function useReaderState({
|
||||
}
|
||||
|
||||
const paragraphs = englishText.split('\n\n');
|
||||
const foundIdx = paragraphs.findIndex(para => {
|
||||
const foundIdx = paragraphs.findIndex((para) => {
|
||||
const paraClean = normalizeSpace(para);
|
||||
return Array.from(cleanNames).some(name => paraClean.includes(name));
|
||||
return Array.from(cleanNames).some((name) => paraClean.includes(name));
|
||||
});
|
||||
|
||||
if (foundIdx !== -1) {
|
||||
@ -261,16 +335,37 @@ export function useReaderState({
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// 闪烁高亮段落背景
|
||||
element.classList.add('bg-sky-50', 'ring-2', 'ring-sky-500/20', 'transition-all');
|
||||
element.classList.add(
|
||||
'bg-sky-50',
|
||||
'ring-2',
|
||||
'ring-sky-500/20',
|
||||
'transition-all'
|
||||
);
|
||||
|
||||
// 闪烁高亮段落内具体的天体标签
|
||||
const targetSpans = element.querySelectorAll(`[data-target-name]`);
|
||||
targetSpans.forEach(span => {
|
||||
const attrVal = span.getAttribute('data-target-name')?.toLowerCase();
|
||||
if (attrVal && Array.from(cleanNames).some(name => name === attrVal)) {
|
||||
span.classList.add('bg-amber-200', 'scale-105', 'px-1', 'rounded', 'transition-all');
|
||||
targetSpans.forEach((span) => {
|
||||
const attrVal = span
|
||||
.getAttribute('data-target-name')
|
||||
?.toLowerCase();
|
||||
if (
|
||||
attrVal &&
|
||||
Array.from(cleanNames).some((name) => name === attrVal)
|
||||
) {
|
||||
span.classList.add(
|
||||
'bg-amber-200',
|
||||
'scale-105',
|
||||
'px-1',
|
||||
'rounded',
|
||||
'transition-all'
|
||||
);
|
||||
setTimeout(() => {
|
||||
span.classList.remove('bg-amber-200', 'scale-105', 'px-1', 'rounded');
|
||||
span.classList.remove(
|
||||
'bg-amber-200',
|
||||
'scale-105',
|
||||
'px-1',
|
||||
'rounded'
|
||||
);
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
useAutoScroll,
|
||||
@ -76,11 +76,18 @@ export interface AgentMode {
|
||||
}
|
||||
|
||||
interface UseResearchAgentProps {
|
||||
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
|
||||
showConfirm?: (
|
||||
message: string,
|
||||
onConfirm: () => void,
|
||||
title?: string
|
||||
) => void;
|
||||
showAlert?: (message: string, title?: string) => void;
|
||||
}
|
||||
|
||||
export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentProps = {}) {
|
||||
export function useResearchAgent({
|
||||
showConfirm,
|
||||
showAlert,
|
||||
}: UseResearchAgentProps = {}) {
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||
@ -90,7 +97,12 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
const [agentMode, setAgentMode] = useState('default');
|
||||
const [agentModes, setAgentModes] = useState<AgentMode[]>([]);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [pendingImage, setPendingImage] = useState<{ data?: string; path?: string; mime_type: string; name: string } | null>(null);
|
||||
const [pendingImage, setPendingImage] = useState<{
|
||||
data?: string;
|
||||
path?: string;
|
||||
mime_type: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@ -108,10 +120,16 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
const [loadingSearch, setLoadingSearch] = useState(false);
|
||||
|
||||
// 展开折叠控制
|
||||
const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
|
||||
const [expandedThoughts, setExpandedThoughts] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
|
||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
||||
const [collapsedSubAgents, setCollapsedSubAgents] = useState<Record<string, boolean>>({});
|
||||
const [expandedResults, setExpandedResults] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [collapsedSubAgents, setCollapsedSubAgents] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
// 新功能面板状态
|
||||
const [showMetrics, setShowMetrics] = useState(false);
|
||||
@ -183,9 +201,24 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
axios.get<SessionSummary[]>('/api/chat/sessions'),
|
||||
axios.get<AgentMode[]>('/api/chat/modes').catch(() => ({
|
||||
data: [
|
||||
{ id: 'default', name: '默认', icon: 'Brain', description: '通用天体物理学研究助手,自主判断搜索、阅读或计算' },
|
||||
{ id: 'deep-research', name: '深度', icon: 'Compass', description: '多来源系统性文献调研与交叉验证,适合撰写综述' },
|
||||
{ id: 'literature-reader', name: '精读', icon: 'BookOpen', description: '专注论文精读、翻译与笔记提炼,强制只读安全模式' },
|
||||
{
|
||||
id: 'default',
|
||||
name: '默认',
|
||||
icon: 'Brain',
|
||||
description: '通用天体物理学研究助手,自主判断搜索、阅读或计算',
|
||||
},
|
||||
{
|
||||
id: 'deep-research',
|
||||
name: '深度',
|
||||
icon: 'Compass',
|
||||
description: '多来源系统性文献调研与交叉验证,适合撰写综述',
|
||||
},
|
||||
{
|
||||
id: 'literature-reader',
|
||||
name: '精读',
|
||||
icon: 'BookOpen',
|
||||
description: '专注论文精读、翻译与笔记提炼,强制只读安全模式',
|
||||
},
|
||||
] as AgentMode[],
|
||||
})),
|
||||
]);
|
||||
@ -198,17 +231,27 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
if (active) setLoadingSessions(false);
|
||||
}
|
||||
})();
|
||||
return () => { active = false; };
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载选中的会话历史消息
|
||||
const loadSessionHistory = async (sessionId: string, silent: boolean = false, onSuccess?: () => void) => {
|
||||
const loadSessionHistory = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
silent: boolean = false,
|
||||
onSuccess?: () => void
|
||||
) => {
|
||||
if (!silent) {
|
||||
setLoadingHistory(true);
|
||||
}
|
||||
setShouldAutoScroll(false);
|
||||
try {
|
||||
const res = await axios.get<{ session: SessionSummary; messages: MessageRecord[] }>(`/api/chat/sessions/${sessionId}`);
|
||||
const res = await axios.get<{
|
||||
session: SessionSummary;
|
||||
messages: MessageRecord[];
|
||||
}>(`/api/chat/sessions/${sessionId}`);
|
||||
const newMessages = res.data.messages;
|
||||
|
||||
if (res.data.session && res.data.session.mode) {
|
||||
@ -228,22 +271,28 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
});
|
||||
|
||||
// 自动迁移流式工具调用的展开/折叠状态至历史视图
|
||||
const newTurnIndex = newMessages.length > 0 ? newMessages[newMessages.length - 1].turn_index : 0;
|
||||
newMessages.forEach(msg => {
|
||||
if (msg.turn_index === newTurnIndex && msg.role === 'assistant' && msg.tool_calls) {
|
||||
msg.tool_calls.forEach(tc => {
|
||||
setExpandedArgs(prev => {
|
||||
const newTurnIndex =
|
||||
newMessages.length > 0
|
||||
? newMessages[newMessages.length - 1].turn_index
|
||||
: 0;
|
||||
newMessages.forEach((msg) => {
|
||||
if (
|
||||
msg.turn_index === newTurnIndex &&
|
||||
msg.role === 'assistant' &&
|
||||
msg.tool_calls
|
||||
) {
|
||||
msg.tool_calls.forEach((tc) => {
|
||||
setExpandedArgs((prev) => {
|
||||
if (prev[tc.id] !== undefined) return prev;
|
||||
return { ...prev, [tc.id]: false };
|
||||
});
|
||||
setExpandedResults(prev => {
|
||||
setExpandedResults((prev) => {
|
||||
if (prev[tc.id] !== undefined) return prev;
|
||||
return { ...prev, [tc.id]: false };
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('加载会话消息历史失败:', e);
|
||||
} finally {
|
||||
@ -251,7 +300,9 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[scrollToBottom, setShouldAutoScroll]
|
||||
);
|
||||
|
||||
// 会话切换监听
|
||||
useEffect(() => {
|
||||
@ -263,10 +314,14 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
loadSessionHistory(currentSessionId, true);
|
||||
}
|
||||
} else {
|
||||
queueMicrotask(() => { if (active) setMessages([]); });
|
||||
queueMicrotask(() => {
|
||||
if (active) setMessages([]);
|
||||
});
|
||||
}
|
||||
return () => { active = false; };
|
||||
}, [currentSessionId]);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [currentSessionId, loadSessionHistory]);
|
||||
|
||||
// 跨会话历史检索防抖逻辑
|
||||
useEffect(() => {
|
||||
@ -312,13 +367,16 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
};
|
||||
|
||||
// 删除会话
|
||||
const handleDeleteSession = async (sessionId: string, e: React.MouseEvent) => {
|
||||
const handleDeleteSession = async (
|
||||
sessionId: string,
|
||||
e: React.MouseEvent
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const performDelete = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/chat/sessions/${sessionId}`);
|
||||
setSessions(prev => prev.filter(s => s.session_id !== sessionId));
|
||||
setSessions((prev) => prev.filter((s) => s.session_id !== sessionId));
|
||||
if (currentSessionId === sessionId) {
|
||||
setCurrentSessionId(null);
|
||||
setMessages([]);
|
||||
@ -334,7 +392,11 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
};
|
||||
|
||||
if (showConfirm) {
|
||||
showConfirm('确认删除此研讨会话吗?删除后将无法恢复。', performDelete, '确认删除');
|
||||
showConfirm(
|
||||
'确认删除此研讨会话吗?删除后将无法恢复。',
|
||||
performDelete,
|
||||
'确认删除'
|
||||
);
|
||||
} else {
|
||||
if (window.confirm('确认删除此研讨会话吗?删除后将无法恢复。')) {
|
||||
performDelete();
|
||||
@ -371,7 +433,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
loadSessionHistory(currentSessionId, true);
|
||||
fetchSessions(); // 更新侧栏 turn_count
|
||||
} catch (e: unknown) {
|
||||
const axiosError = e as { response?: { data?: string }; message?: string };
|
||||
const axiosError = e as {
|
||||
response?: { data?: string };
|
||||
message?: string;
|
||||
};
|
||||
const errMsg = `回退失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
|
||||
if (showAlert) {
|
||||
showAlert(errMsg, '错误');
|
||||
@ -416,7 +481,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const axiosError = e as { response?: { data?: string }; message?: string };
|
||||
const axiosError = e as {
|
||||
response?: { data?: string };
|
||||
message?: string;
|
||||
};
|
||||
const errMsg = `恢复失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
|
||||
if (showAlert) {
|
||||
showAlert(errMsg, '错误');
|
||||
@ -436,7 +504,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
`/api/chat/sessions/${currentSessionId}/branch`
|
||||
);
|
||||
if (showAlert) {
|
||||
showAlert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`, '成功');
|
||||
showAlert(
|
||||
`成功分叉会话!已复制 ${res.data.copied_count} 条消息`,
|
||||
'成功'
|
||||
);
|
||||
} else {
|
||||
alert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`);
|
||||
}
|
||||
@ -445,7 +516,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
await fetchSessions();
|
||||
setCurrentSessionId(res.data.branch_session_id);
|
||||
} catch (e: unknown) {
|
||||
const axiosError = e as { response?: { data?: string }; message?: string };
|
||||
const axiosError = e as {
|
||||
response?: { data?: string };
|
||||
message?: string;
|
||||
};
|
||||
const errMsg = `分叉失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
|
||||
if (showAlert) {
|
||||
showAlert(errMsg, '错误');
|
||||
@ -492,15 +566,29 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
// 如果原消息附带了图片,通过 path 复用已有文件,避免重新上传
|
||||
if (res.data.image_path) {
|
||||
const ext = res.data.image_path.split('.').pop() || 'png';
|
||||
const mimeType = { jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', png: 'image/png' }[ext] || 'image/png';
|
||||
handleSend(questionText, { path: res.data.image_path, mime_type: mimeType, name: res.data.image_path.split('/').pop() || 'image' });
|
||||
const mimeType =
|
||||
{
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
png: 'image/png',
|
||||
}[ext] || 'image/png';
|
||||
handleSend(questionText, {
|
||||
path: res.data.image_path,
|
||||
mime_type: mimeType,
|
||||
name: res.data.image_path.split('/').pop() || 'image',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 触发自动重发
|
||||
handleSend(questionText);
|
||||
} catch (e: unknown) {
|
||||
const axiosError = e as { response?: { data?: string }; message?: string };
|
||||
const axiosError = e as {
|
||||
response?: { data?: string };
|
||||
message?: string;
|
||||
};
|
||||
const errMsg = `重试失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
|
||||
if (showAlert) {
|
||||
showAlert(errMsg, '错误');
|
||||
@ -531,7 +619,15 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
};
|
||||
|
||||
// 发送消息与流式响应处理
|
||||
const handleSend = async (questionText: string, imageOverride?: { data?: string; path?: string; mime_type: string; name: string } | null) => {
|
||||
const handleSend = async (
|
||||
questionText: string,
|
||||
imageOverride?: {
|
||||
data?: string;
|
||||
path?: string;
|
||||
mime_type: string;
|
||||
name: string;
|
||||
} | null
|
||||
) => {
|
||||
if (!questionText.trim() || streaming) return;
|
||||
|
||||
if (!currentSessionId) {
|
||||
@ -547,7 +643,9 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
const active: ActiveTurn = {
|
||||
question: questionText,
|
||||
imagePath: image
|
||||
? (image.path ? `/api/files/${image.path}` : `data:${image.mime_type};base64,${image.data}`)
|
||||
? image.path
|
||||
? `/api/files/${image.path}`
|
||||
: `data:${image.mime_type};base64,${image.data}`
|
||||
: undefined,
|
||||
timeline: [],
|
||||
finalAnswer: '',
|
||||
@ -565,11 +663,13 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
session_id: currentSessionId,
|
||||
mode: agentMode,
|
||||
thinking,
|
||||
...(image ? {
|
||||
...(image
|
||||
? {
|
||||
image: image.path
|
||||
? { path: image.path, mime_type: image.mime_type }
|
||||
: { data: image.data, mime_type: image.mime_type },
|
||||
} : {}),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@ -612,50 +712,85 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
setCurrentSessionId(event.session_id);
|
||||
fetchSessions();
|
||||
} else if (event.title) {
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.session_id === event.session_id ? { ...s, title: event.title } : s
|
||||
));
|
||||
setSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.session_id === event.session_id
|
||||
? { ...s, title: event.title }
|
||||
: s
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'thought':
|
||||
setActiveTurn(prev => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, timeline: routeThought(prev.timeline, event.step, event.content) };
|
||||
});
|
||||
break;
|
||||
case 'tool_call':
|
||||
setActiveTurn(prev => {
|
||||
setActiveTurn((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
timeline: routeToolCall(prev.timeline, event.step, event.id, event.name, event.arguments),
|
||||
timeline: routeThought(
|
||||
prev.timeline,
|
||||
event.step,
|
||||
event.content
|
||||
),
|
||||
};
|
||||
});
|
||||
break;
|
||||
case 'tool_call':
|
||||
setActiveTurn((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
timeline: routeToolCall(
|
||||
prev.timeline,
|
||||
event.step,
|
||||
event.id,
|
||||
event.name,
|
||||
event.arguments
|
||||
),
|
||||
};
|
||||
});
|
||||
break;
|
||||
case 'tool_result':
|
||||
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
|
||||
setActiveTurn(prev => {
|
||||
setExpandedResults((prev) => ({
|
||||
...prev,
|
||||
[event.tool_call_id]: true,
|
||||
}));
|
||||
setActiveTurn((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
timeline: routeToolResult(prev.timeline, event.tool_call_id, event.name, event.output, event.is_error),
|
||||
timeline: routeToolResult(
|
||||
prev.timeline,
|
||||
event.tool_call_id,
|
||||
event.name,
|
||||
event.output,
|
||||
event.is_error,
|
||||
event.metadata
|
||||
),
|
||||
};
|
||||
});
|
||||
break;
|
||||
case 'text_delta':
|
||||
if (event.tool_call_id) {
|
||||
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
|
||||
setExpandedResults((prev) => ({
|
||||
...prev,
|
||||
[event.tool_call_id]: true,
|
||||
}));
|
||||
}
|
||||
setActiveTurn(prev => {
|
||||
setActiveTurn((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (event.tool_call_id) {
|
||||
const hasToolCall = prev.timeline.some(
|
||||
t => t.type === 'tool_call' && 'id' in t && t.id === event.tool_call_id,
|
||||
(t) =>
|
||||
t.type === 'tool_call' &&
|
||||
'id' in t &&
|
||||
t.id === event.tool_call_id
|
||||
);
|
||||
if (!hasToolCall) return prev;
|
||||
}
|
||||
const { timeline, answerDelta } = routeTextDelta(
|
||||
prev.timeline, event.content, event.tool_call_id,
|
||||
prev.timeline,
|
||||
event.content,
|
||||
event.tool_call_id
|
||||
);
|
||||
return {
|
||||
...prev,
|
||||
@ -665,13 +800,13 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
});
|
||||
break;
|
||||
case 'usage':
|
||||
setActiveTurn(prev => {
|
||||
setActiveTurn((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, usage: event };
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
setActiveTurn(prev => {
|
||||
setActiveTurn((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, error: event.message };
|
||||
});
|
||||
@ -693,21 +828,28 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
} else {
|
||||
setActiveTurn(null);
|
||||
}
|
||||
|
||||
} catch (e: unknown) {
|
||||
console.error('智能体对话请求失败:', e);
|
||||
const error = e instanceof Error ? e : new Error('未知错误');
|
||||
setActiveTurn(prev => prev ? { ...prev, error: error.message || '网络连接错误,请稍后重试' } : null);
|
||||
setActiveTurn((prev) =>
|
||||
prev
|
||||
? { ...prev, error: error.message || '网络连接错误,请稍后重试' }
|
||||
: null
|
||||
);
|
||||
setStreaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const turns = groupMessagesIntoTurns(messages);
|
||||
|
||||
const toggleThought = (key: string) => setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
const toggleArgs = (tcId: string) => setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
const toggleResult = (tcId: string) => setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
const toggleSubAgent = (id: string) => setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
const toggleThought = (key: string) =>
|
||||
setExpandedThoughts((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
const toggleArgs = (tcId: string) =>
|
||||
setExpandedArgs((prev) => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
const toggleResult = (tcId: string) =>
|
||||
setExpandedResults((prev) => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
const toggleSubAgent = (id: string) =>
|
||||
setCollapsedSubAgents((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
|
||||
return {
|
||||
sessions,
|
||||
@ -775,9 +917,13 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
||||
function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
const turnsMap = new Map<number, ProcessedTurn>();
|
||||
|
||||
const nonSystemMessages = messages.filter(m => m.role !== 'system');
|
||||
const leadMessages = nonSystemMessages.filter(m => !m.metadata?.is_subagent && !m.agent_name?.startsWith('sub_'));
|
||||
const subMessages = nonSystemMessages.filter(m => m.metadata?.is_subagent || m.agent_name?.startsWith('sub_'));
|
||||
const nonSystemMessages = messages.filter((m) => m.role !== 'system');
|
||||
const leadMessages = nonSystemMessages.filter(
|
||||
(m) => !m.metadata?.is_subagent && !m.agent_name?.startsWith('sub_')
|
||||
);
|
||||
const subMessages = nonSystemMessages.filter(
|
||||
(m) => m.metadata?.is_subagent || m.agent_name?.startsWith('sub_')
|
||||
);
|
||||
|
||||
const subByAgent = new Map<string, MessageRecord[]>();
|
||||
for (const sm of subMessages) {
|
||||
@ -800,14 +946,19 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
|
||||
if (msg.token_count > 0) {
|
||||
if (!turn.usage) {
|
||||
turn.usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
||||
turn.usage = {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
};
|
||||
}
|
||||
if (msg.role === 'assistant') {
|
||||
turn.usage.completion_tokens += msg.token_count;
|
||||
} else {
|
||||
turn.usage.prompt_tokens += msg.token_count;
|
||||
}
|
||||
turn.usage.total_tokens = turn.usage.prompt_tokens + turn.usage.completion_tokens;
|
||||
turn.usage.total_tokens =
|
||||
turn.usage.prompt_tokens + turn.usage.completion_tokens;
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
@ -825,19 +976,26 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
if (hasToolCalls) {
|
||||
if (hasThought) {
|
||||
const alreadyExists = turn.timeline.some(
|
||||
t => t.type === 'thought' && t.step === stepNum
|
||||
(t) => t.type === 'thought' && t.step === stepNum
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
turn.timeline.push({ type: 'thought', step: stepNum, content: msg.thought! });
|
||||
turn.timeline.push({
|
||||
type: 'thought',
|
||||
step: stepNum,
|
||||
content: msg.thought!,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tc of msg.tool_calls!) {
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
try {
|
||||
parsedArgs = typeof tc.function.arguments === 'string'
|
||||
parsedArgs =
|
||||
typeof tc.function.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments)
|
||||
: tc.function.arguments as Record<string, unknown>;
|
||||
} catch { /* ignore */ }
|
||||
: (tc.function.arguments as Record<string, unknown>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
turn.timeline.push({
|
||||
type: 'tool_call',
|
||||
step: stepNum,
|
||||
@ -848,10 +1006,14 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
}
|
||||
} else if (hasThought && hasContent) {
|
||||
const alreadyExists = turn.timeline.some(
|
||||
t => t.type === 'thought' && t.step === stepNum
|
||||
(t) => t.type === 'thought' && t.step === stepNum
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
turn.timeline.push({ type: 'thought', step: stepNum, content: msg.thought! });
|
||||
turn.timeline.push({
|
||||
type: 'thought',
|
||||
step: stepNum,
|
||||
content: msg.thought!,
|
||||
});
|
||||
}
|
||||
turn.timeline.push({ type: 'answer', content: msg.content! });
|
||||
} else if (hasContent) {
|
||||
@ -859,12 +1021,24 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
}
|
||||
} else if (msg.role === 'tool') {
|
||||
for (const item of turn.timeline) {
|
||||
if (item.type === 'tool_call' && item.id === msg.tool_call_id && !item.result) {
|
||||
if (
|
||||
item.type === 'tool_call' &&
|
||||
item.id === msg.tool_call_id &&
|
||||
!item.result
|
||||
) {
|
||||
let isError = false;
|
||||
if (msg.content.startsWith('错误:') || msg.content.includes('fail') || msg.content.startsWith('Error:')) {
|
||||
if (
|
||||
msg.content.startsWith('错误:') ||
|
||||
msg.content.includes('fail') ||
|
||||
msg.content.startsWith('Error:')
|
||||
) {
|
||||
isError = true;
|
||||
}
|
||||
item.result = { output: msg.content, isError };
|
||||
item.result = {
|
||||
output: msg.content,
|
||||
isError,
|
||||
metadata: msg.metadata ?? undefined,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -874,7 +1048,11 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
for (const [, turn] of turnsMap) {
|
||||
for (let i = 0; i < turn.timeline.length; i++) {
|
||||
const item = turn.timeline[i];
|
||||
if (item.type !== 'tool_call' || (item.name !== 'subagent' && item.name !== 'delegate_research')) continue;
|
||||
if (
|
||||
item.type !== 'tool_call' ||
|
||||
(item.name !== 'subagent' && item.name !== 'delegate_research')
|
||||
)
|
||||
continue;
|
||||
|
||||
const delegateResult = item.result?.output || '';
|
||||
const delegateTcId = item.id;
|
||||
@ -882,7 +1060,9 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
|
||||
for (const [agentName, msgs] of subByAgent) {
|
||||
if (msgs.length === 0) continue;
|
||||
const filtered = msgs.filter(m => m.role !== 'system' && m.role !== 'user');
|
||||
const filtered = msgs.filter(
|
||||
(m) => m.role !== 'system' && m.role !== 'user'
|
||||
);
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
const children: TimelineItem[] = [];
|
||||
@ -896,19 +1076,26 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
if (hasToolCalls) {
|
||||
if (hasThought) {
|
||||
const alreadyExists = children.some(
|
||||
c => c.type === 'thought' && c.step === stepNum
|
||||
(c) => c.type === 'thought' && c.step === stepNum
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
children.push({ type: 'thought', step: stepNum, content: msg.thought! });
|
||||
children.push({
|
||||
type: 'thought',
|
||||
step: stepNum,
|
||||
content: msg.thought!,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tc of msg.tool_calls!) {
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
try {
|
||||
parsedArgs = typeof tc.function.arguments === 'string'
|
||||
parsedArgs =
|
||||
typeof tc.function.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments)
|
||||
: tc.function.arguments as Record<string, unknown>;
|
||||
} catch { /* ignore */ }
|
||||
: (tc.function.arguments as Record<string, unknown>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
children.push({
|
||||
type: 'tool_call',
|
||||
step: stepNum,
|
||||
@ -919,10 +1106,14 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
}
|
||||
} else if (hasThought && hasContent) {
|
||||
const alreadyExists = children.some(
|
||||
c => c.type === 'thought' && c.step === stepNum
|
||||
(c) => c.type === 'thought' && c.step === stepNum
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
children.push({ type: 'thought', step: stepNum, content: msg.thought! });
|
||||
children.push({
|
||||
type: 'thought',
|
||||
step: stepNum,
|
||||
content: msg.thought!,
|
||||
});
|
||||
}
|
||||
children.push({ type: 'answer', content: msg.content! });
|
||||
} else if (hasContent) {
|
||||
@ -930,12 +1121,24 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
}
|
||||
} else if (msg.role === 'tool') {
|
||||
for (const child of children) {
|
||||
if (child.type === 'tool_call' && child.id === msg.tool_call_id && !child.result) {
|
||||
if (
|
||||
child.type === 'tool_call' &&
|
||||
child.id === msg.tool_call_id &&
|
||||
!child.result
|
||||
) {
|
||||
let isError = false;
|
||||
if (msg.content.startsWith('错误:') || msg.content.includes('fail') || msg.content.startsWith('Error:')) {
|
||||
if (
|
||||
msg.content.startsWith('错误:') ||
|
||||
msg.content.includes('fail') ||
|
||||
msg.content.startsWith('Error:')
|
||||
) {
|
||||
isError = true;
|
||||
}
|
||||
child.result = { output: msg.content, isError };
|
||||
child.result = {
|
||||
output: msg.content,
|
||||
isError,
|
||||
metadata: msg.metadata ?? undefined,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -957,6 +1160,8 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||
}
|
||||
}
|
||||
|
||||
const sortedTurns = Array.from(turnsMap.values()).sort((a, b) => a.turn_index - b.turn_index);
|
||||
const sortedTurns = Array.from(turnsMap.values()).sort(
|
||||
(a, b) => a.turn_index - b.turn_index
|
||||
);
|
||||
return sortedTurns;
|
||||
}
|
||||
|
||||
@ -9,7 +9,9 @@ interface UseSearchProps {
|
||||
|
||||
export function useSearch({ showAlert }: UseSearchProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchSource, setSearchSource] = useState<'all' | 'ads' | 'arxiv'>('all');
|
||||
const [searchSource, setSearchSource] = useState<'all' | 'ads' | 'arxiv'>(
|
||||
'all'
|
||||
);
|
||||
const [searchResults, setSearchResults] = useState<StandardPaper[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [exportingList, setExportingList] = useState<string[]>([]);
|
||||
@ -18,7 +20,9 @@ export function useSearch({ showAlert }: UseSearchProps) {
|
||||
const [searchRows, setSearchRows] = useState(15);
|
||||
const [searchStart, setSearchStart] = useState(0);
|
||||
const [searchSort, setSearchSort] = useState('relevance');
|
||||
const [searchCache, setSearchCache] = useState<Record<string, StandardPaper[]>>({});
|
||||
const [searchCache, setSearchCache] = useState<
|
||||
Record<string, StandardPaper[]>
|
||||
>({});
|
||||
|
||||
const executeSearch = async (start: number, rows: number, sort: string) => {
|
||||
if (!searchQuery.trim()) return;
|
||||
@ -34,11 +38,11 @@ export function useSearch({ showAlert }: UseSearchProps) {
|
||||
setBibtexContent(null);
|
||||
try {
|
||||
const res = await axios.get<StandardPaper[]>('/api/search', {
|
||||
params: { q: searchQuery, source: searchSource, rows, start, sort }
|
||||
params: { q: searchQuery, source: searchSource, rows, start, sort },
|
||||
});
|
||||
setSearchResults(res.data);
|
||||
// 写入缓存
|
||||
setSearchCache(prev => ({ ...prev, [cacheKey]: res.data }));
|
||||
setSearchCache((prev) => ({ ...prev, [cacheKey]: res.data }));
|
||||
} catch (e) {
|
||||
console.error('检索文献失败', e);
|
||||
showAlert('检索失败,请确认后端连接及 API 密钥配置。', '检索出错');
|
||||
@ -74,7 +78,9 @@ export function useSearch({ showAlert }: UseSearchProps) {
|
||||
if (exportingList.length === 0) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await axios.post<{ bibtex: string }>('/api/export', { bibcodes: exportingList });
|
||||
const res = await axios.post<{ bibtex: string }>('/api/export', {
|
||||
bibcodes: exportingList,
|
||||
});
|
||||
setBibtexContent(res.data.bibtex);
|
||||
} catch (e) {
|
||||
console.error('导出 BibTeX 失败', e);
|
||||
@ -85,8 +91,10 @@ export function useSearch({ showAlert }: UseSearchProps) {
|
||||
};
|
||||
|
||||
const toggleExportItem = (bibcode: string) => {
|
||||
setExportingList(prev =>
|
||||
prev.includes(bibcode) ? prev.filter(b => b !== bibcode) : [...prev, bibcode]
|
||||
setExportingList((prev) =>
|
||||
prev.includes(bibcode)
|
||||
? prev.filter((b) => b !== bibcode)
|
||||
: [...prev, bibcode]
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ export function splitParagraphs(text: string): string[] {
|
||||
if (!text.trim()) return [];
|
||||
// 按 2 个以上连续换行拆分,然后过滤空段落
|
||||
const raw = text.split(/\n{2,}/);
|
||||
return raw.filter(p => p.trim().length > 0);
|
||||
return raw.filter((p) => p.trim().length > 0);
|
||||
}
|
||||
|
||||
export function useSyncScroll(
|
||||
@ -40,7 +40,8 @@ export function useSyncScroll(
|
||||
try {
|
||||
// ── PDF / 无 Markdown → 全局比例映射 ──
|
||||
if (showPdf || !hasMarkdown) {
|
||||
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
|
||||
const ratio =
|
||||
eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
|
||||
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
|
||||
return;
|
||||
}
|
||||
@ -49,7 +50,8 @@ export function useSyncScroll(
|
||||
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
|
||||
|
||||
if (engParas.length === 0 || chnParas.length === 0) {
|
||||
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
|
||||
const ratio =
|
||||
eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
|
||||
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
|
||||
return;
|
||||
}
|
||||
@ -61,7 +63,10 @@ export function useSyncScroll(
|
||||
let anchorIndex = -1;
|
||||
for (let i = 0; i < engParas.length; i++) {
|
||||
const el = engParas[i] as HTMLElement;
|
||||
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
|
||||
if (
|
||||
el.offsetTop <= threshold &&
|
||||
el.offsetTop + el.offsetHeight >= threshold
|
||||
) {
|
||||
anchorIndex = i;
|
||||
break;
|
||||
}
|
||||
@ -84,14 +89,17 @@ export function useSyncScroll(
|
||||
const anchorElement = engParas[anchorIndex] as HTMLElement;
|
||||
const offsetTop = anchorElement.offsetTop;
|
||||
const offsetHeight = anchorElement.offsetHeight || 1;
|
||||
const localRatio = Math.max(0, Math.min(1, (threshold - offsetTop) / offsetHeight));
|
||||
const localRatio = Math.max(
|
||||
0,
|
||||
Math.min(1, (threshold - offsetTop) / offsetHeight)
|
||||
);
|
||||
|
||||
// 4. 段落对齐映射(用实际段落 ID 的数值索引做映射,跳过 metadata -1)
|
||||
const engParaIds = Array.from(engParas).map(el => {
|
||||
const engParaIds = Array.from(engParas).map((el) => {
|
||||
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
});
|
||||
const chnParaIds = Array.from(chnParas).map(el => {
|
||||
const chnParaIds = Array.from(chnParas).map((el) => {
|
||||
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
});
|
||||
@ -99,14 +107,13 @@ export function useSyncScroll(
|
||||
const anchorParaId = engParaIds[anchorIndex] ?? -1;
|
||||
|
||||
// 用所有元素(含 metadata id=-1)做映射,保证连续性
|
||||
const engTextParas = engParaIds
|
||||
.map((id, idx) => ({ id, idx }));
|
||||
const chnTextParas = chnParaIds
|
||||
.map((id, idx) => ({ id, idx }));
|
||||
const engTextParas = engParaIds.map((id, idx) => ({ id, idx }));
|
||||
const chnTextParas = chnParaIds.map((id, idx) => ({ id, idx }));
|
||||
|
||||
const engTextIdx = engTextParas.findIndex(x => x.id === anchorParaId);
|
||||
const engTextIdx = engTextParas.findIndex((x) => x.id === anchorParaId);
|
||||
if (engTextIdx < 0 || chnTextParas.length === 0) {
|
||||
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
|
||||
const ratio =
|
||||
eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
|
||||
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
|
||||
return;
|
||||
}
|
||||
@ -122,14 +129,19 @@ export function useSyncScroll(
|
||||
}
|
||||
}
|
||||
|
||||
const targetPara = chnParas[chnTextParas[bestTargetIdx].idx] as HTMLElement | undefined;
|
||||
const targetPara = chnParas[chnTextParas[bestTargetIdx].idx] as
|
||||
HTMLElement | undefined;
|
||||
|
||||
if (targetPara) {
|
||||
const targetScrollTop = targetPara.offsetTop - chn.clientHeight * 0.15 + localRatio * targetPara.offsetHeight;
|
||||
const targetScrollTop =
|
||||
targetPara.offsetTop -
|
||||
chn.clientHeight * 0.15 +
|
||||
localRatio * targetPara.offsetHeight;
|
||||
const maxScroll = chn.scrollHeight - chn.clientHeight;
|
||||
chn.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||
} else {
|
||||
const ratio = eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
|
||||
const ratio =
|
||||
eng.scrollTop / (eng.scrollHeight - eng.clientHeight || 1);
|
||||
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
|
||||
}
|
||||
} catch (err) {
|
||||
@ -154,7 +166,8 @@ export function useSyncScroll(
|
||||
|
||||
try {
|
||||
if (showPdf || !hasMarkdown) {
|
||||
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
|
||||
const ratio =
|
||||
chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
|
||||
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
|
||||
return;
|
||||
}
|
||||
@ -163,7 +176,8 @@ export function useSyncScroll(
|
||||
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
|
||||
|
||||
if (engParas.length === 0 || chnParas.length === 0) {
|
||||
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
|
||||
const ratio =
|
||||
chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
|
||||
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
|
||||
return;
|
||||
}
|
||||
@ -173,7 +187,10 @@ export function useSyncScroll(
|
||||
let anchorIndex = -1;
|
||||
for (let i = 0; i < chnParas.length; i++) {
|
||||
const el = chnParas[i] as HTMLElement;
|
||||
if (el.offsetTop <= threshold && el.offsetTop + el.offsetHeight >= threshold) {
|
||||
if (
|
||||
el.offsetTop <= threshold &&
|
||||
el.offsetTop + el.offsetHeight >= threshold
|
||||
) {
|
||||
anchorIndex = i;
|
||||
break;
|
||||
}
|
||||
@ -196,14 +213,17 @@ export function useSyncScroll(
|
||||
const anchorElement = chnParas[anchorIndex] as HTMLElement;
|
||||
const offsetTop = anchorElement.offsetTop;
|
||||
const offsetHeight = anchorElement.offsetHeight || 1;
|
||||
const localRatio = Math.max(0, Math.min(1, (threshold - offsetTop) / offsetHeight));
|
||||
const localRatio = Math.max(
|
||||
0,
|
||||
Math.min(1, (threshold - offsetTop) / offsetHeight)
|
||||
);
|
||||
|
||||
// 按段落 ID 做映射
|
||||
const engParaIds = Array.from(engParas).map(el => {
|
||||
const engParaIds = Array.from(engParas).map((el) => {
|
||||
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
});
|
||||
const chnParaIds = Array.from(chnParas).map(el => {
|
||||
const chnParaIds = Array.from(chnParas).map((el) => {
|
||||
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
||||
return m ? parseInt(m[1], 10) : -1;
|
||||
});
|
||||
@ -211,14 +231,13 @@ export function useSyncScroll(
|
||||
const anchorParaId = chnParaIds[anchorIndex] ?? -1;
|
||||
|
||||
// 用所有元素(含 metadata id=-1)做映射,保证连续性
|
||||
const engTextParas = engParaIds
|
||||
.map((id, idx) => ({ id, idx }));
|
||||
const chnTextParas = chnParaIds
|
||||
.map((id, idx) => ({ id, idx }));
|
||||
const engTextParas = engParaIds.map((id, idx) => ({ id, idx }));
|
||||
const chnTextParas = chnParaIds.map((id, idx) => ({ id, idx }));
|
||||
|
||||
const chnTextIdx = chnTextParas.findIndex(x => x.id === anchorParaId);
|
||||
const chnTextIdx = chnTextParas.findIndex((x) => x.id === anchorParaId);
|
||||
if (chnTextIdx < 0 || engTextParas.length === 0) {
|
||||
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
|
||||
const ratio =
|
||||
chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
|
||||
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
|
||||
return;
|
||||
}
|
||||
@ -233,14 +252,19 @@ export function useSyncScroll(
|
||||
}
|
||||
}
|
||||
|
||||
const targetPara = engParas[engTextParas[bestTargetIdx].idx] as HTMLElement | undefined;
|
||||
const targetPara = engParas[engTextParas[bestTargetIdx].idx] as
|
||||
HTMLElement | undefined;
|
||||
|
||||
if (targetPara) {
|
||||
const targetScrollTop = targetPara.offsetTop - eng.clientHeight * 0.15 + localRatio * targetPara.offsetHeight;
|
||||
const targetScrollTop =
|
||||
targetPara.offsetTop -
|
||||
eng.clientHeight * 0.15 +
|
||||
localRatio * targetPara.offsetHeight;
|
||||
const maxScroll = eng.scrollHeight - eng.clientHeight;
|
||||
eng.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||
} else {
|
||||
const ratio = chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
|
||||
const ratio =
|
||||
chn.scrollTop / (chn.scrollHeight - chn.clientHeight || 1);
|
||||
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@ -41,13 +41,19 @@ export function useSyncState() {
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 批量下载与解析相关状态
|
||||
const [targetPhase, setTargetPhase] = useState<'download' | 'parse' | 'translate' | 'embed' | 'target'>('download');
|
||||
const [targetPhase, setTargetPhase] = useState<
|
||||
'download' | 'parse' | 'translate' | 'embed' | 'target'
|
||||
>('download');
|
||||
const [batchLimitCount, setBatchLimitCount] = useState<number>(100);
|
||||
const [sortOrder, setSortOrder] = useState<'default' | 'pub_year_desc' | 'created_at_desc'>('default');
|
||||
const [sortOrder, setSortOrder] = useState<
|
||||
'default' | 'pub_year_desc' | 'created_at_desc'
|
||||
>('default');
|
||||
const [skipCompleted, setSkipCompleted] = useState<boolean>(true);
|
||||
const [skipFailed, setSkipFailed] = useState<boolean>(false);
|
||||
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
|
||||
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
|
||||
const [skipPrecedingFailed, setSkipPrecedingFailed] =
|
||||
useState<boolean>(false);
|
||||
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
|
||||
active: false,
|
||||
@ -60,13 +66,15 @@ export function useSyncState() {
|
||||
logs: [],
|
||||
});
|
||||
const [batchError, setBatchError] = useState<string | null>(null);
|
||||
const batchPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const batchPollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null
|
||||
);
|
||||
const logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [showBuilder, setShowBuilder] = useState(false);
|
||||
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
|
||||
{ field: 'all', op: 'AND', val: '' }
|
||||
]);
|
||||
const [rules, setRules] = useState<
|
||||
Array<{ field: string; op: string; val: string }>
|
||||
>([{ field: 'all', op: 'AND', val: '' }]);
|
||||
|
||||
// 当高级表单规则变化时,自动更新同步输入框的检索式
|
||||
const updateQueryFromRules = (currentRules: typeof rules) => {
|
||||
@ -74,13 +82,16 @@ export function useSyncState() {
|
||||
currentRules.forEach((rule, idx) => {
|
||||
if (!rule.val.trim()) return;
|
||||
let valStr = rule.val.trim();
|
||||
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
|
||||
if (
|
||||
valStr.includes(' ') &&
|
||||
!valStr.startsWith('"') &&
|
||||
!valStr.startsWith('(')
|
||||
) {
|
||||
valStr = `"${valStr}"`;
|
||||
}
|
||||
|
||||
const fieldPart = rule.field !== 'all'
|
||||
? `${rule.field}:${valStr}`
|
||||
: valStr;
|
||||
const fieldPart =
|
||||
rule.field !== 'all' ? `${rule.field}:${valStr}` : valStr;
|
||||
|
||||
if (idx === 0) {
|
||||
qParts.push(fieldPart);
|
||||
@ -92,7 +103,7 @@ export function useSyncState() {
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
||||
setRules((prev) => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
||||
};
|
||||
|
||||
const handleRemoveRule = (idx: number) => {
|
||||
@ -101,8 +112,12 @@ export function useSyncState() {
|
||||
updateQueryFromRules(next);
|
||||
};
|
||||
|
||||
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
|
||||
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
|
||||
const handleRuleChange = (
|
||||
idx: number,
|
||||
key: 'field' | 'op' | 'val',
|
||||
value: string
|
||||
) => {
|
||||
const next = rules.map((r, i) => (i === idx ? { ...r, [key]: value } : r));
|
||||
setRules(next);
|
||||
updateQueryFromRules(next);
|
||||
};
|
||||
@ -110,7 +125,9 @@ export function useSyncState() {
|
||||
// 获取历史检索配置
|
||||
const fetchSyncQueries = async () => {
|
||||
try {
|
||||
const res = await axios.get<SavedSyncQuery[]>(`/api/sync/queries?t=${Date.now()}`);
|
||||
const res = await axios.get<SavedSyncQuery[]>(
|
||||
`/api/sync/queries?t=${Date.now()}`
|
||||
);
|
||||
setSyncQueries(res.data);
|
||||
} catch (e) {
|
||||
console.error('获取检索配置列表失败', e);
|
||||
@ -129,7 +146,11 @@ export function useSyncState() {
|
||||
const handleReuseQuery = (sq: SavedSyncQuery) => {
|
||||
setQuery(sq.query);
|
||||
const validSources = ['all', 'ads', 'arxiv'] as const;
|
||||
setSource(validSources.includes(sq.source as typeof validSources[number]) ? sq.source as typeof validSources[number] : 'all');
|
||||
setSource(
|
||||
validSources.includes(sq.source as (typeof validSources)[number])
|
||||
? (sq.source as (typeof validSources)[number])
|
||||
: 'all'
|
||||
);
|
||||
setLimit(sq.limit_count);
|
||||
};
|
||||
|
||||
@ -137,7 +158,7 @@ export function useSyncState() {
|
||||
setErrorMsg(null);
|
||||
|
||||
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
|
||||
setStatus(prev => ({
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
active: true,
|
||||
query: sq.query,
|
||||
@ -168,7 +189,9 @@ export function useSyncState() {
|
||||
// eslint-disable-next-line react-hooks/purity -- Date.now() is called at invocation time (setInterval/event handlers), not during render
|
||||
const ts = Date.now();
|
||||
try {
|
||||
const res = await axios.get<HarvestStatus>(`/api/sync/meta/status?t=${ts}`);
|
||||
const res = await axios.get<HarvestStatus>(
|
||||
`/api/sync/meta/status?t=${ts}`
|
||||
);
|
||||
setStatus(res.data);
|
||||
if (res.data.active) {
|
||||
startPolling();
|
||||
@ -218,6 +241,7 @@ export function useSyncState() {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 批量下载与解析相关的网络操作
|
||||
@ -225,7 +249,9 @@ export function useSyncState() {
|
||||
// eslint-disable-next-line react-hooks/purity -- Date.now() is called at invocation time (setInterval/event handlers), not during render
|
||||
const ts = Date.now();
|
||||
try {
|
||||
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${ts}`);
|
||||
const res = await axios.get<BatchStatus>(
|
||||
`/api/batch/asset/status?t=${ts}`
|
||||
);
|
||||
setBatchStatus(res.data);
|
||||
if (res.data.active) {
|
||||
startBatchPolling();
|
||||
@ -277,10 +303,15 @@ export function useSyncState() {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const startBatchPolling = () => {
|
||||
if (batchPollIntervalRef.current) return;
|
||||
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const ts = Date.now();
|
||||
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${ts}`);
|
||||
const res = await axios.get<BatchStatus>('/api/batch/asset/status');
|
||||
if (!active) return;
|
||||
setBatchStatus(res.data);
|
||||
if (res.data.active) {
|
||||
@ -299,13 +330,16 @@ export function useSyncState() {
|
||||
clearInterval(batchPollIntervalRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 日志终端自动滚动到底部
|
||||
useEffect(() => {
|
||||
const container = logsContainerRef.current;
|
||||
if (container) {
|
||||
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
|
||||
const isCloseToBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <
|
||||
80;
|
||||
if (isCloseToBottom) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
@ -323,13 +357,15 @@ export function useSyncState() {
|
||||
setEstimatedCount(null);
|
||||
try {
|
||||
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
|
||||
params: { q: query.trim(), source }
|
||||
params: { q: query.trim(), source },
|
||||
});
|
||||
setEstimatedCount(res.data.total);
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
const axiosError = e as { response?: { data?: string } };
|
||||
setErrorMsg(axiosError.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
|
||||
setErrorMsg(
|
||||
axiosError.response?.data || '估算文献总量失败,请检查 API 密钥或网络。'
|
||||
);
|
||||
} finally {
|
||||
setEstimating(false);
|
||||
}
|
||||
@ -343,7 +379,7 @@ export function useSyncState() {
|
||||
}
|
||||
setErrorMsg(null);
|
||||
|
||||
setStatus(prev => ({
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
active: true,
|
||||
query: query.trim(),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
@ -15,7 +15,11 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color-scheme: light;
|
||||
|
||||
/* 极极简白蓝学术科技风配色 - 升级为暖调纸张感学术护眼色 */
|
||||
@ -66,7 +70,9 @@ body {
|
||||
|
||||
.console-panel-active {
|
||||
border-color: var(--accent-blueprint);
|
||||
box-shadow: 0 0 0 1px var(--accent-blueprint), 0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--accent-blueprint),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* High contrast clean console button */
|
||||
@ -158,7 +164,11 @@ mark {
|
||||
|
||||
/* LaTeX-style academic publishing typography overrides */
|
||||
.prose {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
@ -201,7 +211,9 @@ mark {
|
||||
}
|
||||
|
||||
/* Remove vertical borders and striping that are generated by default */
|
||||
.prose table, .prose th, .prose td {
|
||||
.prose table,
|
||||
.prose th,
|
||||
.prose td {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
}
|
||||
@ -221,7 +233,7 @@ mark {
|
||||
|
||||
.prose blockquote p::before,
|
||||
.prose blockquote p::after {
|
||||
content: "" !important;
|
||||
content: '' !important;
|
||||
}
|
||||
|
||||
/* Formulas and Math spacing */
|
||||
@ -231,4 +243,3 @@ mark {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
|
||||
@ -1,19 +1,24 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
// 注册 PWA Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(reg => console.log('Service Worker registered with scope:', reg.scope))
|
||||
.catch(err => console.error('Service Worker registration failed:', err));
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((reg) =>
|
||||
console.log('Service Worker registered with scope:', reg.scope)
|
||||
)
|
||||
.catch((err) =>
|
||||
console.error('Service Worker registration failed:', err)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
// dashboard/src/features/citation/CitationPanel.tsx
|
||||
import { useState } from 'react';
|
||||
import { Loader, GitFork, RotateCcw, ChevronDown, History, FileText } from 'lucide-react';
|
||||
import {
|
||||
Loader,
|
||||
GitFork,
|
||||
RotateCcw,
|
||||
ChevronDown,
|
||||
History,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { CitationGalaxyCanvas } from '../components/CitationGalaxyCanvas';
|
||||
import type { StandardPaper, CitationNetwork } from '../types';
|
||||
|
||||
@ -33,7 +40,13 @@ export function CitationPanel({
|
||||
// 统一节点点击事件:已入库直接拉取,未入库弹窗选择
|
||||
const handleNodeClick = (bibcode: string) => {
|
||||
if (!citationNetwork) return;
|
||||
const inDb = bibcode === citationNetwork.bibcode || (citationNetwork.citation_counts && Object.prototype.hasOwnProperty.call(citationNetwork.citation_counts, bibcode));
|
||||
const inDb =
|
||||
bibcode === citationNetwork.bibcode ||
|
||||
(citationNetwork.citation_counts &&
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
citationNetwork.citation_counts,
|
||||
bibcode
|
||||
));
|
||||
if (inDb) {
|
||||
loadCitations(bibcode, false);
|
||||
} else {
|
||||
@ -43,28 +56,39 @@ export function CitationPanel({
|
||||
|
||||
return (
|
||||
<div className="w-full flex-1 flex flex-col space-y-4 min-h-0">
|
||||
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between border-b border-slate-200 pb-3 gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">引用星系拓扑图谱</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">通过图谱层级快速 visual 展现当前文献的“参考文献 - 被引文献”关联脉络</p>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
引用星系拓扑图谱
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
通过图谱层级快速 visual 展现当前文献的“参考文献 - 被引文献”关联脉络
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4 items-center relative z-20">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4 relative z-20">
|
||||
{selectedPaper && (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSwitchMenu(!showSwitchMenu)}
|
||||
className="btn-console btn-console-secondary px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 transition-all cursor-pointer shrink-0"
|
||||
className="btn-console btn-console-secondary px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0"
|
||||
title="快速切换星系文献"
|
||||
>
|
||||
<GitFork className="w-3.5 h-3.5 text-blueprint" />
|
||||
<span>最近文献</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
|
||||
<span className="hidden sm:inline">最近文献</span>
|
||||
<span className="sm:hidden">最近</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showSwitchMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowSwitchMenu(false)} />
|
||||
<div className="absolute right-0 mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1.5 z-55 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowSwitchMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 mt-1.5 w-72 rounded-md bg-white border border-slate-200 shadow-md py-1 z-50 text-xs max-h-96 overflow-y-auto scrollbar-thin">
|
||||
{/* 最近文献 */}
|
||||
{recentlySelected.length > 0 && (
|
||||
<div className="border-b border-slate-100 pb-1.5 mb-1.5">
|
||||
@ -72,7 +96,7 @@ export function CitationPanel({
|
||||
<History className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>最近选择</span>
|
||||
</div>
|
||||
{recentlySelected.map(paper => (
|
||||
{recentlySelected.map((paper) => (
|
||||
<button
|
||||
key={`citation-recent-${paper.bibcode}`}
|
||||
onClick={() => {
|
||||
@ -85,8 +109,12 @@ export function CitationPanel({
|
||||
: 'border-transparent text-slate-700 font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block leading-tight text-left">{paper.title}</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
|
||||
<span className="truncate block leading-tight text-left">
|
||||
{paper.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@ -98,12 +126,14 @@ export function CitationPanel({
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>全部已下载馆藏</span>
|
||||
</div>
|
||||
{library.filter(p => p.is_downloaded).length === 0 ? (
|
||||
<div className="px-3 py-2 text-slate-400 italic">暂无已下载文献</div>
|
||||
{library.filter((p) => p.is_downloaded).length === 0 ? (
|
||||
<div className="px-3 py-2 text-slate-400 italic">
|
||||
暂无已下载文献
|
||||
</div>
|
||||
) : (
|
||||
library
|
||||
.filter(p => p.is_downloaded)
|
||||
.map(paper => (
|
||||
.filter((p) => p.is_downloaded)
|
||||
.map((paper) => (
|
||||
<button
|
||||
key={`citation-lib-${paper.bibcode}`}
|
||||
onClick={() => {
|
||||
@ -116,8 +146,12 @@ export function CitationPanel({
|
||||
: 'border-transparent text-slate-700 font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate block leading-tight text-left">{paper.title}</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
|
||||
<span className="truncate block leading-tight text-left">
|
||||
{paper.title}
|
||||
</span>
|
||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@ -128,7 +162,9 @@ export function CitationPanel({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border border-slate-200 bg-white px-3 py-1.5 rounded-md shadow-sm">
|
||||
<span className="text-[11px] text-slate-500 font-bold">展示节点上限:</span>
|
||||
<span className="text-[11px] text-slate-500 font-bold">
|
||||
展示节点上限:
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
@ -138,14 +174,19 @@ export function CitationPanel({
|
||||
onChange={(e) => setNodeLimit(Number(e.target.value))}
|
||||
className="w-20 h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blueprint focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs font-bold text-blueprint w-6 text-right">{nodeLimit}</span>
|
||||
<span className="text-xs font-bold text-blueprint w-6 text-right">
|
||||
{nodeLimit}
|
||||
</span>
|
||||
</div>
|
||||
{selectedPaper && (
|
||||
<button
|
||||
onClick={() => loadCitations(selectedPaper.bibcode, true)}
|
||||
className="btn-console px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2"
|
||||
className="btn-console px-2 sm:px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1 sm:gap-2 transition-all cursor-pointer shrink-0"
|
||||
title="重置引用星系图"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" /> 重置引用星系图
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">重置引用星系图</span>
|
||||
<span className="sm:hidden">重置</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -154,7 +195,9 @@ export function CitationPanel({
|
||||
{loadingCitations ? (
|
||||
<div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
|
||||
<Loader className="w-8 h-8 animate-spin text-blueprint mb-2" />
|
||||
<p className="text-xs font-bold text-slate-600">正在从馆藏数据库中提取引用拓扑数据...</p>
|
||||
<p className="text-xs font-bold text-slate-600">
|
||||
正在从馆藏数据库中提取引用拓扑数据...
|
||||
</p>
|
||||
</div>
|
||||
) : citationNetwork ? (
|
||||
<div className="flex-1 console-panel rounded-lg border border-slate-200 overflow-hidden relative flex flex-col md:flex-row bg-white min-h-0">
|
||||
@ -170,9 +213,15 @@ export function CitationPanel({
|
||||
{/* 右侧关系详情面板 */}
|
||||
<div className="w-full md:w-80 h-64 md:h-auto border-t md:border-t-0 md:border-l border-slate-200 bg-slate-50 p-6 space-y-6 overflow-y-auto scrollbar-thin">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-blueprint tracking-wider block mb-2">● 中心焦点文献</span>
|
||||
<h4 className="text-xs font-bold text-slate-900 leading-relaxed font-sans">{citationNetwork.title}</h4>
|
||||
<p className="text-[10px] text-slate-600 font-mono mt-2 bg-slate-200/50 border border-slate-300/60 px-2 py-1.5 rounded select-all truncate">{citationNetwork.bibcode}</p>
|
||||
<span className="text-[10px] font-bold text-blueprint tracking-wider block mb-2">
|
||||
● 中心焦点文献
|
||||
</span>
|
||||
<h4 className="text-xs font-bold text-slate-900 leading-relaxed font-sans">
|
||||
{citationNetwork.title}
|
||||
</h4>
|
||||
<p className="text-[10px] text-slate-600 font-mono mt-2 bg-slate-200/50 border border-slate-300/60 px-2 py-1.5 rounded select-all truncate">
|
||||
{citationNetwork.bibcode}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
@ -181,9 +230,11 @@ export function CitationPanel({
|
||||
</span>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
|
||||
{citationNetwork.references.length === 0 ? (
|
||||
<span className="text-xs text-slate-400 italic">暂无参考文献数据</span>
|
||||
<span className="text-xs text-slate-400 italic">
|
||||
暂无参考文献数据
|
||||
</span>
|
||||
) : (
|
||||
citationNetwork.references.map(bib => (
|
||||
citationNetwork.references.map((bib) => (
|
||||
<div
|
||||
key={bib}
|
||||
onClick={() => handleNodeClick(bib)}
|
||||
@ -202,9 +253,11 @@ export function CitationPanel({
|
||||
</span>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
|
||||
{citationNetwork.citations.length === 0 ? (
|
||||
<span className="text-xs text-slate-400 italic">暂无被引文献数据</span>
|
||||
<span className="text-xs text-slate-400 italic">
|
||||
暂无被引文献数据
|
||||
</span>
|
||||
) : (
|
||||
citationNetwork.citations.map(bib => (
|
||||
citationNetwork.citations.map((bib) => (
|
||||
<div
|
||||
key={bib}
|
||||
onClick={() => handleNodeClick(bib)}
|
||||
@ -221,7 +274,9 @@ export function CitationPanel({
|
||||
) : (
|
||||
<div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
|
||||
<GitFork className="w-12 h-12 mb-3 text-slate-300" />
|
||||
<p className="text-xs font-bold text-slate-500">无法拉取当前选定目标的星系引用关系</p>
|
||||
<p className="text-xs font-bold text-slate-500">
|
||||
无法拉取当前选定目标的星系引用关系
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
// dashboard/src/features/library/LibraryPanel.tsx
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle, BookOpen, GitFork } from 'lucide-react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Library,
|
||||
RotateCw,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
GitFork,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import type { StandardPaper } from '../types';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { PaperCard } from '../components/PaperCard';
|
||||
@ -8,10 +19,54 @@ import { PaperCard } from '../components/PaperCard';
|
||||
interface LibraryPanelProps {
|
||||
library: StandardPaper[];
|
||||
fetchLibrary: () => Promise<void>;
|
||||
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
|
||||
setActiveTab: (
|
||||
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync'
|
||||
) => void;
|
||||
onShowDetail: (paper: StandardPaper) => void;
|
||||
onOpenReader: (paper: StandardPaper) => void;
|
||||
onOpenCitation: (paper: StandardPaper) => void;
|
||||
|
||||
// 提升的状态和 Setter
|
||||
searchTerm: string;
|
||||
setSearchTerm: (val: string) => void;
|
||||
filterStatus:
|
||||
| 'all'
|
||||
| 'downloaded'
|
||||
| 'undownloaded'
|
||||
| 'download_failed'
|
||||
| 'no_resource'
|
||||
| 'parsed'
|
||||
| 'translated'
|
||||
| 'vectorized';
|
||||
setFilterStatus: (
|
||||
val:
|
||||
| 'all'
|
||||
| 'downloaded'
|
||||
| 'undownloaded'
|
||||
| 'download_failed'
|
||||
| 'no_resource'
|
||||
| 'parsed'
|
||||
| 'translated'
|
||||
| 'vectorized'
|
||||
) => void;
|
||||
filterDoctype: string;
|
||||
setFilterDoctype: (val: string) => void;
|
||||
sortBy: 'created' | 'yearDesc' | 'yearAsc' | 'citations' | 'title';
|
||||
setSortBy: (
|
||||
val: 'created' | 'yearDesc' | 'yearAsc' | 'citations' | 'title'
|
||||
) => void;
|
||||
filterAuthor: string;
|
||||
setFilterAuthor: (val: string) => void;
|
||||
filterYear: string;
|
||||
setFilterYear: (val: string) => void;
|
||||
filterJournal: string;
|
||||
setFilterJournal: (val: string) => void;
|
||||
currentPage: number;
|
||||
setCurrentPage: (val: number) => void;
|
||||
pageSize: number;
|
||||
setPageSize: (val: number) => void;
|
||||
totalCount: number;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function LibraryPanel({
|
||||
@ -21,214 +76,195 @@ export function LibraryPanel({
|
||||
onShowDetail,
|
||||
onOpenReader,
|
||||
onOpenCitation,
|
||||
}: LibraryPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'downloaded' | 'undownloaded' | 'download_failed' | 'no_resource' | 'parsed' | 'translated' | 'vectorized'>('all');
|
||||
const [filterDoctype, setFilterDoctype] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<'created' | 'yearDesc' | 'yearAsc' | 'citations' | 'title'>('created');
|
||||
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterDoctype,
|
||||
setFilterDoctype,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
filterAuthor,
|
||||
setFilterAuthor,
|
||||
filterYear,
|
||||
setFilterYear,
|
||||
filterJournal,
|
||||
setFilterJournal,
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
totalCount,
|
||||
loading,
|
||||
}: LibraryPanelProps) {
|
||||
// 重新同步状态反馈
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncFeedback, setSyncFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
const [syncFeedback, setSyncFeedback] = useState<{
|
||||
type: 'success' | 'error';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// 高级元数据细化筛选状态
|
||||
// 高级元数据细化筛选面板展开状态
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [filterAuthor, setFilterAuthor] = useState('');
|
||||
const [filterYear, setFilterYear] = useState('');
|
||||
const [filterJournal, setFilterJournal] = useState('');
|
||||
|
||||
// 各种状态的文献总数量
|
||||
const countAll = library.length;
|
||||
const countDownloaded = library.filter(p => p.is_downloaded).length;
|
||||
const countUndownloaded = library.filter(p => !p.is_downloaded && !p.pdf_error && !p.html_error).length;
|
||||
const countDownloadFailed = library.filter(p => !p.is_downloaded && (p.pdf_error || p.html_error) && !(p.pdf_error === 'no_resource' && p.html_error === 'no_resource')).length;
|
||||
const countNoResource = library.filter(p => !p.is_downloaded && p.pdf_error === 'no_resource' && p.html_error === 'no_resource').length;
|
||||
const countParsed = library.filter(p => p.has_markdown).length;
|
||||
const countTranslated = library.filter(p => p.has_translation).length;
|
||||
const countVectorized = library.filter(p => p.has_vector).length;
|
||||
// 页码输入状态及处理
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const [inputPage, setInputPage] = useState(currentPage.toString());
|
||||
const prevPageRef = useRef(currentPage);
|
||||
|
||||
// 本地检索与筛选过滤
|
||||
const filteredLibrary = library.filter(paper => {
|
||||
// 1. 关键词全局检索 (标题、作者、摘要、Bibcode、arXiv ID、DOI)
|
||||
const query = searchTerm.toLowerCase().trim();
|
||||
if (query) {
|
||||
const matchTitle = paper.title.toLowerCase().includes(query);
|
||||
const matchAuthors = paper.authors.some(a => a.toLowerCase().includes(query));
|
||||
const matchAbstract = paper.abstract_text.toLowerCase().includes(query);
|
||||
const matchBibcode = paper.bibcode.toLowerCase().includes(query);
|
||||
const matchArxivId = (paper.arxiv_id || '').toLowerCase().includes(query);
|
||||
const matchDoi = (paper.doi || '').toLowerCase().includes(query);
|
||||
if (!matchTitle && !matchAuthors && !matchAbstract && !matchBibcode && !matchArxivId && !matchDoi) {
|
||||
return false;
|
||||
}
|
||||
// 同步inputPage与currentPage
|
||||
useEffect(() => {
|
||||
if (prevPageRef.current !== currentPage) {
|
||||
prevPageRef.current = currentPage;
|
||||
setInputPage(currentPage.toString());
|
||||
}
|
||||
}, [currentPage]);
|
||||
|
||||
// 2. 离线状态筛选
|
||||
if (filterStatus === 'downloaded' && !paper.is_downloaded) return false;
|
||||
if (filterStatus === 'undownloaded' && (paper.is_downloaded || paper.pdf_error || paper.html_error)) return false;
|
||||
if (filterStatus === 'download_failed') {
|
||||
if (paper.is_downloaded) return false;
|
||||
if (!paper.pdf_error && !paper.html_error) return false;
|
||||
if (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource') return false;
|
||||
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
if (val === '' || /^\d+$/.test(val)) {
|
||||
setInputPage(val);
|
||||
}
|
||||
if (filterStatus === 'no_resource') {
|
||||
if (paper.is_downloaded) return false;
|
||||
if (!(paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')) return false;
|
||||
}
|
||||
if (filterStatus === 'parsed' && !paper.has_markdown) return false;
|
||||
if (filterStatus === 'translated' && !paper.has_translation) return false;
|
||||
if (filterStatus === 'vectorized' && !paper.has_vector) return false;
|
||||
|
||||
// 3. 文献类型筛选
|
||||
if (filterDoctype !== 'all') {
|
||||
const docVal = (paper.doctype || 'article').toLowerCase();
|
||||
if (filterDoctype === 'proceedings') {
|
||||
if (docVal !== 'proceedings' && docVal !== 'inproceedings') return false;
|
||||
} else if (filterDoctype === 'thesis') {
|
||||
if (docVal !== 'phdthesis' && docVal !== 'mastersthesis') return false;
|
||||
} else if (filterDoctype === 'catalog') {
|
||||
if (docVal !== 'catalog' && docVal !== 'dataset') return false;
|
||||
} else if (filterDoctype === 'book') {
|
||||
if (docVal !== 'book' && docVal !== 'inbook') return false;
|
||||
} else if (filterDoctype === 'other') {
|
||||
const standardTypes = [
|
||||
'article', 'eprint', 'proceedings', 'inproceedings', 'proposal',
|
||||
'phdthesis', 'mastersthesis', 'abstract', 'catalog', 'dataset',
|
||||
'software', 'circular', 'book', 'inbook', 'techreport'
|
||||
];
|
||||
if (standardTypes.includes(docVal)) return false;
|
||||
} else {
|
||||
if (docVal !== filterDoctype) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 高级元数据细化筛选
|
||||
if (filterAuthor.trim()) {
|
||||
const authorQuery = filterAuthor.toLowerCase().trim();
|
||||
const matchAuthor = paper.authors.some(a => a.toLowerCase().includes(authorQuery));
|
||||
if (!matchAuthor) return false;
|
||||
}
|
||||
if (filterYear.trim()) {
|
||||
const yearQuery = filterYear.toLowerCase().trim();
|
||||
if (!paper.year.toLowerCase().includes(yearQuery)) return false;
|
||||
}
|
||||
if (filterJournal.trim()) {
|
||||
const journalQuery = filterJournal.toLowerCase().trim();
|
||||
const matchJournal = (paper.pub_journal || '').toLowerCase().includes(journalQuery);
|
||||
if (!matchJournal) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 记录每个 bibcode 在原始 library 数组中的索引,作为“导入时间倒序”的绝对依据
|
||||
const originalIndices = new Map<string, number>();
|
||||
library.forEach((paper, index) => {
|
||||
originalIndices.set(paper.bibcode, index);
|
||||
});
|
||||
|
||||
const getStatusScore = (paper: StandardPaper) => {
|
||||
if (paper.has_vector) return 5;
|
||||
if (paper.has_translation) return 4;
|
||||
if (paper.has_markdown) return 3;
|
||||
if (paper.is_downloaded) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
// 本地复合排序
|
||||
const sortedLibrary = [...filteredLibrary].sort((a, b) => {
|
||||
if (sortBy === 'created') {
|
||||
const scoreA = getStatusScore(a);
|
||||
const scoreB = getStatusScore(b);
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA; // 状态高(已翻译 > 已解析 > 已下载 > 其他)的排在前面
|
||||
const submitPageInput = () => {
|
||||
const val = inputPage.trim();
|
||||
if (!val) {
|
||||
setInputPage(currentPage.toString());
|
||||
return;
|
||||
}
|
||||
// 状态相同时,按“导入时间倒序”排序(即原始数组中的索引从小到大)
|
||||
const idxA = originalIndices.get(a.bibcode) ?? 99999;
|
||||
const idxB = originalIndices.get(b.bibcode) ?? 99999;
|
||||
return idxA - idxB;
|
||||
const pageNum = parseInt(val, 10);
|
||||
if (isNaN(pageNum) || pageNum < 1) {
|
||||
setCurrentPage(1);
|
||||
setInputPage('1');
|
||||
} else if (pageNum > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
setInputPage(totalPages.toString());
|
||||
} else {
|
||||
setCurrentPage(pageNum);
|
||||
}
|
||||
if (sortBy === 'yearDesc') {
|
||||
return (parseInt(b.year) || 0) - (parseInt(a.year) || 0);
|
||||
};
|
||||
|
||||
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitPageInput();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
if (sortBy === 'yearAsc') {
|
||||
return (parseInt(a.year) || 0) - (parseInt(b.year) || 0);
|
||||
}
|
||||
if (sortBy === 'citations') {
|
||||
return (b.citation_count || 0) - (a.citation_count || 0);
|
||||
}
|
||||
if (sortBy === 'title') {
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageInputBlur = () => {
|
||||
submitPageInput();
|
||||
};
|
||||
|
||||
const handleResync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
setSyncFeedback(null);
|
||||
try {
|
||||
await fetchLibrary();
|
||||
const count = library.length;
|
||||
setSyncFeedback({ type: 'success', message: `馆藏数据已刷新,共 ${count} 篇文献` });
|
||||
setSyncFeedback({ type: 'success', message: '馆藏数据已同步刷新' });
|
||||
} catch {
|
||||
setSyncFeedback({ type: 'error', message: '同步失败,请检查后端服务连接' });
|
||||
setSyncFeedback({
|
||||
type: 'error',
|
||||
message: '同步失败,请检查后端服务连接',
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
setTimeout(() => setSyncFeedback(null), 3000);
|
||||
}
|
||||
}, [fetchLibrary, library.length]);
|
||||
}, [fetchLibrary]);
|
||||
|
||||
// 重置所有筛选条件
|
||||
const handleResetFilters = () => {
|
||||
setSearchTerm('');
|
||||
setFilterStatus('all');
|
||||
setFilterDoctype('all');
|
||||
setFilterAuthor('');
|
||||
setFilterYear('');
|
||||
setFilterJournal('');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 辅助判断是否无任何筛选输入
|
||||
const isNoFilters =
|
||||
!searchTerm &&
|
||||
filterStatus === 'all' &&
|
||||
filterDoctype === 'all' &&
|
||||
!filterAuthor &&
|
||||
!filterYear &&
|
||||
!filterJournal;
|
||||
// 数据库完全为空的条件
|
||||
const isLibraryEmpty = totalCount === 0 && isNoFilters;
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-slate-200 pb-4">
|
||||
<div className="flex items-center justify-between mb-4 border-b border-slate-200 pb-4 select-none">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">本地文献馆藏库</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">查看和整理已同步离线保存的本地文献资源</p>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
本地文献馆藏库
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
查看和整理已同步离线保存的本地文献资源
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleResync}
|
||||
disabled={syncing}
|
||||
className="btn-console px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50 transition-all"
|
||||
className="btn-console px-4 py-2 rounded-md text-xs font-bold flex items-center gap-2 disabled:opacity-50 transition-all select-none"
|
||||
>
|
||||
<RotateCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
|
||||
<RotateCw
|
||||
className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{syncing ? '正在同步...' : '重新同步馆藏'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 同步结果反馈条 */}
|
||||
{syncFeedback && (
|
||||
<div className={`px-4 py-2.5 rounded-md text-xs font-bold flex items-center gap-2 transition-all ${
|
||||
<div
|
||||
className={`px-4 py-2.5 rounded-md text-xs font-bold flex items-center gap-2 transition-all select-none ${
|
||||
syncFeedback.type === 'success'
|
||||
? 'bg-emerald-50 border border-emerald-200/60 text-emerald-850'
|
||||
: 'bg-rose-50 border border-rose-200/60 text-rose-850'
|
||||
}`}>
|
||||
{syncFeedback.type === 'success'
|
||||
? <CheckCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
: <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
|
||||
}`}
|
||||
>
|
||||
{syncFeedback.type === 'success' ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
{syncFeedback.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索、筛选与排序工具栏 */}
|
||||
{library.length > 0 && (
|
||||
{(!isLibraryEmpty || !isNoFilters) && (
|
||||
<div className="flex flex-col gap-3.5 bg-white p-4 rounded-lg border border-slate-200 shadow-sm text-xs font-semibold text-slate-700">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* 本地检索 */}
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">馆藏内检索</label>
|
||||
<label className="block text-slate-500 font-bold">
|
||||
馆藏内检索
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="搜索文献标题、作者、摘要、Bibcode、arXiv ID 或 DOI..."
|
||||
className="w-full pl-9 pr-8 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-all p-0.5 rounded-full hover:bg-slate-100 cursor-pointer flex items-center justify-center"
|
||||
title="清空检索内容"
|
||||
>
|
||||
@ -240,30 +276,40 @@ export function LibraryPanel({
|
||||
|
||||
{/* 状态过滤 */}
|
||||
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
|
||||
<label className="block text-slate-500 font-bold">任务状态筛选</label>
|
||||
<label className="block text-slate-500 font-bold">
|
||||
任务状态筛选
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={filterStatus}
|
||||
onChange={val => setFilterStatus(val)}
|
||||
onChange={(val) => {
|
||||
setFilterStatus(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full"
|
||||
options={[
|
||||
{ value: 'all', label: `全部文献 (${countAll})` },
|
||||
{ value: 'downloaded', label: `已下载 (${countDownloaded})` },
|
||||
{ value: 'parsed', label: `已解析 (${countParsed})` },
|
||||
{ value: 'translated', label: `已翻译 (${countTranslated})` },
|
||||
{ value: 'vectorized', label: `已向量化 (${countVectorized})` },
|
||||
{ value: 'undownloaded', label: `未下载 (${countUndownloaded})` },
|
||||
{ value: 'download_failed', label: `下载失败 (${countDownloadFailed})` },
|
||||
{ value: 'no_resource', label: `无资源 (${countNoResource})` },
|
||||
{ value: 'all', label: '全部文献' },
|
||||
{ value: 'downloaded', label: '已下载' },
|
||||
{ value: 'parsed', label: '已解析' },
|
||||
{ value: 'translated', label: '已翻译' },
|
||||
{ value: 'vectorized', label: '已向量化' },
|
||||
{ value: 'undownloaded', label: '未下载' },
|
||||
{ value: 'download_failed', label: '下载失败' },
|
||||
{ value: 'no_resource', label: '无资源' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文献类型筛选 */}
|
||||
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
|
||||
<label className="block text-slate-500 font-bold">文献类型筛选</label>
|
||||
<label className="block text-slate-500 font-bold">
|
||||
文献类型筛选
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={filterDoctype}
|
||||
onChange={val => setFilterDoctype(val)}
|
||||
onChange={(val) => {
|
||||
setFilterDoctype(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full"
|
||||
options={[
|
||||
{ value: 'all', label: '全部类型' },
|
||||
@ -285,10 +331,15 @@ export function LibraryPanel({
|
||||
|
||||
{/* 排序方式 */}
|
||||
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
|
||||
<label className="block text-slate-500 font-bold">列表排序方式</label>
|
||||
<label className="block text-slate-500 font-bold">
|
||||
列表排序方式
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={sortBy}
|
||||
onChange={val => setSortBy(val)}
|
||||
onChange={(val) => {
|
||||
setSortBy(val);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full"
|
||||
options={[
|
||||
{ value: 'created', label: '默认 (导入时间)' },
|
||||
@ -322,11 +373,16 @@ export function LibraryPanel({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3.5 border-t border-slate-100 w-full transition-all">
|
||||
{/* 作者过滤 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">按特定作者过滤</label>
|
||||
<label className="block text-slate-500 font-bold">
|
||||
按特定作者过滤
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filterAuthor}
|
||||
onChange={e => setFilterAuthor(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFilterAuthor(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="如: Althaus..."
|
||||
className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
/>
|
||||
@ -334,11 +390,16 @@ export function LibraryPanel({
|
||||
|
||||
{/* 年份过滤 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">按特定发表年份过滤</label>
|
||||
<label className="block text-slate-500 font-bold">
|
||||
按特定发表年份过滤
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filterYear}
|
||||
onChange={e => setFilterYear(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFilterYear(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="如: 2023..."
|
||||
className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
/>
|
||||
@ -346,11 +407,16 @@ export function LibraryPanel({
|
||||
|
||||
{/* 期刊过滤 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-slate-500 font-bold">按特定出版期刊过滤</label>
|
||||
<label className="block text-slate-500 font-bold">
|
||||
按特定出版期刊过滤
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filterJournal}
|
||||
onChange={e => setFilterJournal(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFilterJournal(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="如: ApJ 或 MNRAS..."
|
||||
className="w-full px-3 py-2 rounded-md bg-slate-50 border border-slate-250 text-slate-900 placeholder-slate-450 focus:outline-none focus:border-blueprint focus:bg-white transition-all text-xs font-medium"
|
||||
/>
|
||||
@ -360,44 +426,71 @@ export function LibraryPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{library.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="console-panel p-24 text-center border border-slate-200 bg-white rounded-lg select-none">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-blueprint mx-auto mb-4" />
|
||||
<p className="text-xs text-slate-500 font-bold">
|
||||
正在为您读取馆藏数据库并进行筛选...
|
||||
</p>
|
||||
</div>
|
||||
) : isLibraryEmpty ? (
|
||||
<div className="console-panel p-16 rounded-lg text-center border-2 border-dashed border-slate-300">
|
||||
<Library className="w-12 h-12 text-slate-400 mx-auto mb-4" />
|
||||
<h3 className="font-bold text-slate-800 text-sm mb-2">本地文献馆藏为空</h3>
|
||||
<p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">您在检索控制台下载的天体文献会自动同步离线保存到这里,方便进行无网双语解析阅读。</p>
|
||||
<h3 className="font-bold text-slate-800 text-sm mb-2">
|
||||
本地文献馆藏为空
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">
|
||||
您在检索控制台下载的天体文献会自动同步离线保存到这里,方便进行无网双语解析阅读。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('search')}
|
||||
className="btn-console btn-console-primary px-6 py-2.5 rounded-md text-xs font-bold"
|
||||
className="btn-console btn-console-primary px-6 py-2.5 rounded-md text-xs font-bold cursor-pointer"
|
||||
>
|
||||
前往检索文献资源
|
||||
</button>
|
||||
</div>
|
||||
) : sortedLibrary.length === 0 ? (
|
||||
) : library.length === 0 ? (
|
||||
<div className="console-panel p-12 rounded-lg text-center border border-slate-200 bg-white">
|
||||
<SlidersHorizontal className="w-10 h-10 text-slate-350 mx-auto mb-3" />
|
||||
<h3 className="font-bold text-slate-700 text-xs mb-1.5">未找到符合筛选条件的文献</h3>
|
||||
<p className="text-xs text-slate-450 max-w-sm mx-auto mb-4">请尝试修改您的馆藏检索关键词或放宽离线状态及高级元数据筛选条件。</p>
|
||||
<h3 className="font-bold text-slate-700 text-xs mb-1.5">
|
||||
未找到符合筛选条件的文献
|
||||
</h3>
|
||||
<p className="text-xs text-slate-455 max-w-sm mx-auto mb-4">
|
||||
请尝试修改您的馆藏检索关键词或放宽离线状态及高级元数据筛选条件。
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setFilterStatus('all');
|
||||
setFilterDoctype('all');
|
||||
setFilterAuthor('');
|
||||
setFilterYear('');
|
||||
setFilterJournal('');
|
||||
}}
|
||||
onClick={handleResetFilters}
|
||||
className="px-4 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-655 rounded-md text-xs font-bold transition-all cursor-pointer"
|
||||
>
|
||||
重置全部检索与过滤条件
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-xs text-slate-500 font-bold px-1">
|
||||
<span>已筛选出 {sortedLibrary.length} 篇文献 / 共 {library.length} 篇</span>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center text-xs text-slate-500 font-bold px-1 select-none">
|
||||
<span>正在显示当前分页文献 / 筛选后共 {totalCount} 篇</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>每页显示</span>
|
||||
<CustomSelect
|
||||
value={pageSize.toString()}
|
||||
onChange={(val) => {
|
||||
setPageSize(parseInt(val));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-16"
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: '12', label: '12' },
|
||||
{ value: '24', label: '24' },
|
||||
{ value: '48', label: '48' },
|
||||
]}
|
||||
/>
|
||||
<span>篇</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedLibrary.map(paper => {
|
||||
{library.map((paper) => {
|
||||
return (
|
||||
<PaperCard
|
||||
key={paper.bibcode}
|
||||
@ -407,8 +500,10 @@ export function LibraryPanel({
|
||||
statusBadge={
|
||||
<div
|
||||
title={
|
||||
(!paper.is_downloaded && (paper.pdf_error || paper.html_error))
|
||||
? (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
|
||||
!paper.is_downloaded &&
|
||||
(paper.pdf_error || paper.html_error)
|
||||
? paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource'
|
||||
? '已手动标记为【无有效全文资源】,批量下载时自动跳过。'
|
||||
: `下载失败原因:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`
|
||||
: undefined
|
||||
@ -422,9 +517,10 @@ export function LibraryPanel({
|
||||
? 'bg-blue-50 text-blue-800 border-blue-200/60'
|
||||
: paper.is_downloaded
|
||||
? 'bg-slate-100 text-slate-800 border-slate-250'
|
||||
: (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
|
||||
: paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource'
|
||||
? 'bg-slate-50 text-slate-550 border-slate-200 cursor-help'
|
||||
: (paper.pdf_error || paper.html_error)
|
||||
: paper.pdf_error || paper.html_error
|
||||
? 'bg-rose-50 text-rose-850 border-rose-200/60 cursor-help'
|
||||
: 'bg-slate-50 text-slate-550 border-slate-100'
|
||||
}`}
|
||||
@ -437,9 +533,10 @@ export function LibraryPanel({
|
||||
? '已解析'
|
||||
: paper.is_downloaded
|
||||
? '已下载'
|
||||
: (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
|
||||
: paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource'
|
||||
? '无资源'
|
||||
: (paper.pdf_error || paper.html_error)
|
||||
: paper.pdf_error || paper.html_error
|
||||
? '下载失败'
|
||||
: '未下载'}
|
||||
</div>
|
||||
@ -478,13 +575,17 @@ export function LibraryPanel({
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-500' : 'bg-slate-200'}`}
|
||||
/>
|
||||
<span className="text-slate-400 text-[9px]">{paper.has_translation ? '翻译' : '未翻译'}</span>
|
||||
<span className="text-slate-400 text-[9px]">
|
||||
{paper.has_translation ? '翻译' : '未翻译'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${paper.has_vector ? 'bg-amber-500' : 'bg-slate-200'}`}
|
||||
/>
|
||||
<span className="text-slate-400 text-[9px]">{paper.has_vector ? '向量库' : '未向量化'}</span>
|
||||
<span className="text-slate-400 text-[9px]">
|
||||
{paper.has_vector ? '向量库' : '未向量化'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@ -492,6 +593,137 @@ export function LibraryPanel({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 分页控制面板 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border border-slate-200 bg-white px-5 py-4 rounded-xl shadow-xs select-none">
|
||||
{/* 移动端两键分页 */}
|
||||
<div className="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
className="relative inline-flex items-center rounded-lg border border-slate-250 bg-white px-4 py-2 text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-50 transition-all cursor-pointer"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
className="relative ml-3 inline-flex items-center rounded-lg border border-slate-250 bg-white px-4 py-2 text-xs font-bold text-slate-700 hover:bg-slate-50 disabled:opacity-50 transition-all cursor-pointer"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 桌面端完整分页 */}
|
||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 font-semibold">
|
||||
显示第{' '}
|
||||
<span className="font-bold text-slate-900">
|
||||
{(currentPage - 1) * pageSize + 1}
|
||||
</span>{' '}
|
||||
至{' '}
|
||||
<span className="font-bold text-slate-900">
|
||||
{Math.min(currentPage * pageSize, totalCount)}
|
||||
</span>{' '}
|
||||
篇文献,共{' '}
|
||||
<span className="font-bold text-slate-900">
|
||||
{totalCount}
|
||||
</span>{' '}
|
||||
篇文献
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
{/* 页码选择导航 */}
|
||||
<nav
|
||||
className="flex items-center gap-1.5"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
className={`h-8 px-3 rounded-lg flex items-center justify-center text-xs font-bold transition-all ${
|
||||
currentPage <= 1
|
||||
? 'bg-slate-50 border border-slate-100 text-slate-400 cursor-not-allowed opacity-50'
|
||||
: 'bg-white hover:bg-slate-50 border border-slate-200 text-slate-650 hover:text-slate-800 cursor-pointer shadow-3xs hover:shadow-2xs'
|
||||
}`}
|
||||
>
|
||||
<span>上一页</span>
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }).map((_, index) => {
|
||||
const pageNum = index + 1;
|
||||
if (
|
||||
pageNum === 1 ||
|
||||
pageNum === totalPages ||
|
||||
(pageNum >= currentPage - 2 &&
|
||||
pageNum <= currentPage + 2)
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`h-8 min-w-8 rounded-lg flex items-center justify-center text-xs font-bold transition-all cursor-pointer ${
|
||||
currentPage === pageNum
|
||||
? 'bg-blueprint text-white shadow-xs'
|
||||
: 'bg-white hover:bg-slate-50 border border-slate-200 text-slate-655 hover:text-slate-800 shadow-3xs'
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
} else if (
|
||||
pageNum === currentPage - 3 ||
|
||||
pageNum === currentPage + 3
|
||||
) {
|
||||
return (
|
||||
<span
|
||||
key={pageNum}
|
||||
className="h-8 min-w-8 flex items-center justify-center text-slate-400 text-xs font-bold"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<button
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
className={`h-8 px-3 rounded-lg flex items-center justify-center text-xs font-bold transition-all ${
|
||||
currentPage >= totalPages
|
||||
? 'bg-slate-50 border border-slate-100 text-slate-400 cursor-not-allowed opacity-50'
|
||||
: 'bg-white hover:bg-slate-50 border border-slate-200 text-slate-655 hover:text-slate-800 cursor-pointer shadow-3xs hover:shadow-2xs'
|
||||
}`}
|
||||
>
|
||||
<span>下一页</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* 快捷跳转输入框 */}
|
||||
<div className="flex items-center gap-1.5 border-l border-slate-200 pl-4 h-6">
|
||||
<span className="text-slate-450 text-[11px] font-semibold">
|
||||
前往第
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={inputPage}
|
||||
onChange={handlePageInputChange}
|
||||
onKeyDown={handlePageInputKeyDown}
|
||||
onBlur={handlePageInputBlur}
|
||||
className="w-10 h-7 text-center rounded-md border border-slate-250 bg-slate-55/50 text-slate-800 text-xs font-bold focus:outline-none focus:border-blueprint focus:bg-white focus:ring-1 focus:ring-blueprint transition-all shadow-3xs"
|
||||
/>
|
||||
<span className="text-slate-455 text-[11px] font-semibold">
|
||||
/ {totalPages} 页
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -70,7 +70,8 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
||||
} = props;
|
||||
|
||||
// 局部管理阅读视角模式(原文/中文/对照)
|
||||
const [viewMode, setViewMode] = useState<'bilingual' | 'english' | 'chinese'>(() => {
|
||||
const [viewMode, setViewMode] = useState<'bilingual' | 'english' | 'chinese'>(
|
||||
() => {
|
||||
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
return chineseText ? 'chinese' : 'english';
|
||||
}
|
||||
@ -78,7 +79,8 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
||||
return 'english';
|
||||
}
|
||||
return 'bilingual';
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const [userOverrode, setUserOverrode] = useState(false);
|
||||
|
||||
@ -145,7 +147,7 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full flex-1 flex flex-col space-y-4 min-h-0">
|
||||
<div className="w-full flex-1 flex flex-col space-y-2 sm:space-y-4 min-h-0">
|
||||
{/* 顶部控制栏 */}
|
||||
<ReaderToolbar
|
||||
selectedPaper={selectedPaper}
|
||||
|
||||
@ -2,13 +2,33 @@ import { useResearchAgent } from '../hooks/useResearchAgent';
|
||||
import { AgentSessionSidebar } from '../components/agent/AgentSessionSidebar';
|
||||
import { AgentMessageList } from '../components/agent/AgentMessageList';
|
||||
import { AgentInputArea } from '../components/agent/AgentInputArea';
|
||||
import type { StandardPaper } from '../types';
|
||||
import type { useCitations } from '../hooks/useCitations';
|
||||
import type { useLibrary } from '../hooks/useLibrary';
|
||||
|
||||
interface ResearchAgentPanelProps {
|
||||
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
|
||||
showConfirm?: (
|
||||
message: string,
|
||||
onConfirm: () => void,
|
||||
title?: string
|
||||
) => void;
|
||||
showAlert?: (message: string, title?: string) => void;
|
||||
openReader?: (paper: StandardPaper, skipTabSwitch?: boolean) => void;
|
||||
setActiveTab?: (
|
||||
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent'
|
||||
) => void;
|
||||
citations?: ReturnType<typeof useCitations>;
|
||||
library?: ReturnType<typeof useLibrary>;
|
||||
}
|
||||
|
||||
export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPanelProps) {
|
||||
export function ResearchAgentPanel({
|
||||
showConfirm,
|
||||
showAlert,
|
||||
openReader,
|
||||
setActiveTab,
|
||||
citations,
|
||||
library,
|
||||
}: ResearchAgentPanelProps) {
|
||||
const state = useResearchAgent({ showConfirm, showAlert });
|
||||
|
||||
return (
|
||||
@ -68,6 +88,10 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
||||
sessions={state.sessions}
|
||||
handleDeleteSession={state.handleDeleteSession}
|
||||
loadSessionHistory={state.loadSessionHistory}
|
||||
openReader={openReader}
|
||||
setActiveTab={setActiveTab}
|
||||
citations={citations}
|
||||
library={library}
|
||||
/>
|
||||
|
||||
<AgentInputArea
|
||||
|
||||
@ -1,6 +1,17 @@
|
||||
// dashboard/src/features/search/SearchPanel.tsx
|
||||
import React from 'react';
|
||||
import { Search, Loader, CheckCircle, Copy, Download, ChevronLeft, ChevronRight, SlidersHorizontal, AlertTriangle, Lightbulb } from 'lucide-react';
|
||||
import {
|
||||
Search,
|
||||
Loader,
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Download,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
SlidersHorizontal,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
} from 'lucide-react';
|
||||
import type { StandardPaper } from '../types';
|
||||
import { CustomSelect } from '../components/CustomSelect';
|
||||
import { PaperCard } from '../components/PaperCard';
|
||||
@ -29,7 +40,9 @@ interface SearchPanelProps {
|
||||
selectedPaper: StandardPaper | null;
|
||||
setSelectedPaper: (paper: StandardPaper | null) => void;
|
||||
openReader: (paper: StandardPaper) => void;
|
||||
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
|
||||
setActiveTab: (
|
||||
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync'
|
||||
) => void;
|
||||
loadCitations: (bibcode: string, reset?: boolean) => void;
|
||||
showAlert: (msg: string, title?: string) => void;
|
||||
onShowDetail: (paper: StandardPaper) => void;
|
||||
@ -64,28 +77,30 @@ export function SearchPanel({
|
||||
showAlert,
|
||||
onShowDetail,
|
||||
}: SearchPanelProps) {
|
||||
|
||||
const currentPage = Math.floor(searchStart / searchRows) + 1;
|
||||
const hasPreviousPage = searchStart > 0;
|
||||
const hasNextPage = searchResults.length >= searchRows;
|
||||
|
||||
const [showBuilder, setShowBuilder] = React.useState(false);
|
||||
const [rules, setRules] = React.useState<Array<{ field: string; op: string; val: string }>>([
|
||||
{ field: 'all', op: 'AND', val: '' }
|
||||
]);
|
||||
const [rules, setRules] = React.useState<
|
||||
Array<{ field: string; op: string; val: string }>
|
||||
>([{ field: 'all', op: 'AND', val: '' }]);
|
||||
|
||||
const updateQueryFromRules = (currentRules: typeof rules) => {
|
||||
const qParts: string[] = [];
|
||||
currentRules.forEach((rule, idx) => {
|
||||
if (!rule.val.trim()) return;
|
||||
let valStr = rule.val.trim();
|
||||
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
|
||||
if (
|
||||
valStr.includes(' ') &&
|
||||
!valStr.startsWith('"') &&
|
||||
!valStr.startsWith('(')
|
||||
) {
|
||||
valStr = `"${valStr}"`;
|
||||
}
|
||||
|
||||
const fieldPart = rule.field !== 'all'
|
||||
? `${rule.field}:${valStr}`
|
||||
: valStr;
|
||||
const fieldPart =
|
||||
rule.field !== 'all' ? `${rule.field}:${valStr}` : valStr;
|
||||
|
||||
if (idx === 0) {
|
||||
qParts.push(fieldPart);
|
||||
@ -97,7 +112,7 @@ export function SearchPanel({
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
||||
setRules((prev) => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
||||
};
|
||||
|
||||
const handleRemoveRule = (idx: number) => {
|
||||
@ -106,8 +121,12 @@ export function SearchPanel({
|
||||
updateQueryFromRules(next);
|
||||
};
|
||||
|
||||
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
|
||||
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
|
||||
const handleRuleChange = (
|
||||
idx: number,
|
||||
key: 'field' | 'op' | 'val',
|
||||
value: string
|
||||
) => {
|
||||
const next = rules.map((r, i) => (i === idx ? { ...r, [key]: value } : r));
|
||||
setRules(next);
|
||||
updateQueryFromRules(next);
|
||||
};
|
||||
@ -116,8 +135,13 @@ export function SearchPanel({
|
||||
<div className="space-y-6 w-full max-w-5xl mx-auto">
|
||||
{/* 标题 */}
|
||||
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3">
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">统一文献检索平台</h2>
|
||||
<p className="text-slate-500 text-xs">快速检索 NASA ADS 和 arXiv 预印本学术文献,支持一键下载、格式转换与星系引用分析。</p>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
统一文献检索平台
|
||||
</h2>
|
||||
<p className="text-slate-500 text-xs">
|
||||
快速检索 NASA ADS 和 arXiv
|
||||
预印本学术文献,支持一键下载、格式转换与星系引用分析。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 检索控制面板 */}
|
||||
@ -129,7 +153,7 @@ export function SearchPanel({
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="检索天文学文献 (支持关键字、作者、发表年份,如 'hot subdwarf year:2020-2023')"
|
||||
className="w-full pl-12 pr-4 py-3 rounded-md bg-slate-50 border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 focus:bg-white transition-all text-sm font-medium"
|
||||
/>
|
||||
@ -152,7 +176,11 @@ export function SearchPanel({
|
||||
disabled={searching}
|
||||
className="btn-console btn-console-primary px-6 py-3 rounded-md text-xs font-bold tracking-wider flex items-center gap-2 shrink-0"
|
||||
>
|
||||
{searching ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
{searching ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{searching ? '检索中...' : '检索文献'}
|
||||
</button>
|
||||
</div>
|
||||
@ -174,11 +202,16 @@ export function SearchPanel({
|
||||
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule, idx) => (
|
||||
<div key={idx} className="flex flex-col sm:flex-row sm:items-center gap-2 pb-3.5 sm:pb-0 border-b border-slate-200/60 sm:border-0 last:border-0 last:pb-0">
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-2 pb-3.5 sm:pb-0 border-b border-slate-200/60 sm:border-0 last:border-0 last:pb-0"
|
||||
>
|
||||
{idx > 0 ? (
|
||||
<CustomSelect
|
||||
value={rule.op}
|
||||
onChange={val => handleRuleChange(idx, 'op', String(val))}
|
||||
onChange={(val) =>
|
||||
handleRuleChange(idx, 'op', String(val))
|
||||
}
|
||||
className="w-full sm:w-24"
|
||||
options={[
|
||||
{ value: 'AND', label: '并且 (AND)' },
|
||||
@ -187,12 +220,16 @@ export function SearchPanel({
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full sm:w-24 text-left sm:text-center text-[10px] sm:text-xs text-slate-400 sm:text-slate-500 font-bold sm:font-semibold py-1 uppercase tracking-wider select-none">条件过滤</div>
|
||||
<div className="w-full sm:w-24 text-left sm:text-center text-[10px] sm:text-xs text-slate-400 sm:text-slate-500 font-bold sm:font-semibold py-1 uppercase tracking-wider select-none">
|
||||
条件过滤
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CustomSelect
|
||||
value={rule.field}
|
||||
onChange={val => handleRuleChange(idx, 'field', String(val))}
|
||||
onChange={(val) =>
|
||||
handleRuleChange(idx, 'field', String(val))
|
||||
}
|
||||
className="w-full sm:w-32"
|
||||
options={[
|
||||
{ value: 'all', label: '任意字段' },
|
||||
@ -206,7 +243,9 @@ export function SearchPanel({
|
||||
<input
|
||||
type="text"
|
||||
value={rule.val}
|
||||
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleRuleChange(idx, 'val', e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
rule.field === 'year'
|
||||
? '例如: 2020-2023 或 2022'
|
||||
@ -237,9 +276,24 @@ export function SearchPanel({
|
||||
<Lightbulb className="w-3.5 h-3.5 text-amber-500" />
|
||||
检索语法小提示:
|
||||
</span>
|
||||
<span>作者检索: <code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">author:"Althaus"</code></span>
|
||||
<span>标题检索: <code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">title:"hot subdwarf"</code></span>
|
||||
<span>年份范围: <code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">year:2020-2023</code></span>
|
||||
<span>
|
||||
作者检索:{' '}
|
||||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||||
author:"Althaus"
|
||||
</code>
|
||||
</span>
|
||||
<span>
|
||||
标题检索:{' '}
|
||||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||||
title:"hot subdwarf"
|
||||
</code>
|
||||
</span>
|
||||
<span>
|
||||
年份范围:{' '}
|
||||
<code className="bg-slate-200 px-1 py-0.5 rounded text-slate-800 font-mono text-[10px]">
|
||||
year:2020-2023
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-slate-200">
|
||||
@ -249,16 +303,27 @@ export function SearchPanel({
|
||||
{ id: 'all', label: '全部数据源' },
|
||||
{ id: 'ads', label: 'NASA ADS' },
|
||||
{ id: 'arxiv', label: 'arXiv 预印本' },
|
||||
].map(src => (
|
||||
<label key={src.id} className="flex items-center gap-2 cursor-pointer text-xs font-bold text-slate-700">
|
||||
].map((src) => (
|
||||
<label
|
||||
key={src.id}
|
||||
className="flex items-center gap-2 cursor-pointer text-xs font-bold text-slate-700"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="searchSource"
|
||||
checked={searchSource === src.id}
|
||||
onChange={() => setSearchSource(src.id as 'all' | 'ads' | 'arxiv')}
|
||||
onChange={() =>
|
||||
setSearchSource(src.id as 'all' | 'ads' | 'arxiv')
|
||||
}
|
||||
className="accent-slate-700"
|
||||
/>
|
||||
<span className={searchSource === src.id ? 'text-slate-900 font-bold' : 'text-slate-500 hover:text-slate-700'}>
|
||||
<span
|
||||
className={
|
||||
searchSource === src.id
|
||||
? 'text-slate-900 font-bold'
|
||||
: 'text-slate-500 hover:text-slate-700'
|
||||
}
|
||||
>
|
||||
{src.label}
|
||||
</span>
|
||||
</label>
|
||||
@ -268,10 +333,12 @@ export function SearchPanel({
|
||||
{/* 排序及每页条数 */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-500">排序:</span>
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
排序:
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={searchSort}
|
||||
onChange={val => handleSortChange(val)}
|
||||
onChange={(val) => handleSortChange(val)}
|
||||
options={[
|
||||
{ value: 'relevance', label: '相关度' },
|
||||
{ value: 'date_desc', label: '发表日期 (由新到旧)' },
|
||||
@ -282,10 +349,12 @@ export function SearchPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-slate-500">每页显示:</span>
|
||||
<span className="text-xs font-semibold text-slate-500">
|
||||
每页显示:
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={searchRows}
|
||||
onChange={val => handleRowsChange(Number(val))}
|
||||
onChange={(val) => handleRowsChange(Number(val))}
|
||||
options={[
|
||||
{ value: 10, label: '10 条' },
|
||||
{ value: 15, label: '15 条' },
|
||||
@ -302,7 +371,9 @@ export function SearchPanel({
|
||||
disabled={exporting}
|
||||
className="btn-console btn-console-secondary px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5"
|
||||
>
|
||||
{exporting ? <Loader className="w-3.5 h-3.5 animate-spin" /> : null}
|
||||
{exporting ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : null}
|
||||
导出已选 ({exportingList.length}) 篇 BibTeX
|
||||
</button>
|
||||
)}
|
||||
@ -338,12 +409,16 @@ export function SearchPanel({
|
||||
<div className="absolute inset-0 bg-white/60 backdrop-blur-[1px] z-10 flex items-center justify-center rounded-lg">
|
||||
<div className="flex flex-col items-center gap-3 bg-white p-6 rounded-lg shadow-sm border border-slate-200 text-center">
|
||||
<Loader className="w-8 h-8 text-slate-600 animate-spin" />
|
||||
<span className="text-xs font-bold text-slate-600">正在与学术服务器通讯,检索文献数据中...</span>
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
正在与学术服务器通讯,检索文献数据中...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`space-y-4 transition-all duration-300 ${searching ? 'opacity-45 pointer-events-none filter blur-[1px]' : ''}`}>
|
||||
{searchResults.map(paper => {
|
||||
<div
|
||||
className={`space-y-4 transition-all duration-300 ${searching ? 'opacity-45 pointer-events-none filter blur-[1px]' : ''}`}
|
||||
>
|
||||
{searchResults.map((paper) => {
|
||||
const isDownloading = downloadingBibcodes[paper.bibcode] || false;
|
||||
const isSelected = selectedPaper?.bibcode === paper.bibcode;
|
||||
return (
|
||||
@ -368,12 +443,14 @@ export function SearchPanel({
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
|
||||
{paper.pdf_error === 'no_resource' &&
|
||||
paper.html_error === 'no_resource' ? (
|
||||
<span
|
||||
title="已手动标记为【无有效全文资源】,批量下载时自动跳过"
|
||||
className="px-2 py-1 bg-slate-50 text-slate-600 border border-slate-200 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-slate-455" /> 无资源
|
||||
<AlertTriangle className="w-3 h-3 text-slate-455" />{' '}
|
||||
无资源
|
||||
</span>
|
||||
) : (
|
||||
(paper.pdf_error || paper.html_error) && (
|
||||
@ -381,7 +458,8 @@ export function SearchPanel({
|
||||
title={`自动下载失败:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`}
|
||||
className="px-2 py-1 bg-rose-50 text-rose-800 border border-rose-200/60 text-[10px] font-bold rounded-md cursor-help flex items-center gap-1"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-rose-600" /> 下载失败
|
||||
<AlertTriangle className="w-3 h-3 text-rose-600" />{' '}
|
||||
下载失败
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
@ -390,7 +468,11 @@ export function SearchPanel({
|
||||
disabled={isDownloading}
|
||||
className="btn-console btn-console-primary px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1 transition-all"
|
||||
>
|
||||
{isDownloading ? <Loader className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
|
||||
{isDownloading ? (
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
)}
|
||||
下载文献
|
||||
</button>
|
||||
</div>
|
||||
@ -435,8 +517,20 @@ export function SearchPanel({
|
||||
footerRight={
|
||||
<>
|
||||
{paper.doi && <span>DOI: {paper.doi}</span>}
|
||||
<span>Bibcode: <span className="font-semibold text-slate-700">{paper.bibcode}</span></span>
|
||||
{paper.citation_count > 0 && <span>被引次数: <span className="text-sky-750 font-bold">{paper.citation_count}</span></span>}
|
||||
<span>
|
||||
Bibcode:{' '}
|
||||
<span className="font-semibold text-slate-700">
|
||||
{paper.bibcode}
|
||||
</span>
|
||||
</span>
|
||||
{paper.citation_count > 0 && (
|
||||
<span>
|
||||
被引次数:{' '}
|
||||
<span className="text-sky-750 font-bold">
|
||||
{paper.citation_count}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@ -457,7 +551,8 @@ export function SearchPanel({
|
||||
</button>
|
||||
|
||||
<span className="text-xs font-bold text-slate-600">
|
||||
第 {currentPage} 页 (当前显示 {searchStart + 1} - {searchStart + searchResults.length} 条)
|
||||
第 {currentPage} 页 (当前显示 {searchStart + 1} -{' '}
|
||||
{searchStart + searchResults.length} 条)
|
||||
</span>
|
||||
|
||||
<button
|
||||
|
||||
@ -12,9 +12,12 @@ export function SyncPanel() {
|
||||
<div className="space-y-6 w-full max-w-3xl mx-auto">
|
||||
{/* 标题 */}
|
||||
<div className="flex flex-col gap-1.5 border-b border-slate-200 pb-3 select-none">
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">批量任务管理器</h2>
|
||||
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||
批量任务管理器
|
||||
</h2>
|
||||
<p className="text-slate-500 text-xs">
|
||||
设定检索关键词,在 NASA ADS 和 arXiv 平台大批量同步学术文献索引,或针对本地文献馆藏批量执行下载、解析、翻译等学术流水线任务。
|
||||
设定检索关键词,在 NASA ADS 和 arXiv
|
||||
平台大批量同步学术文献索引,或针对本地文献馆藏批量执行下载、解析、翻译等学术流水线任务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -88,16 +91,36 @@ export function SyncPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
|
||||
{state.syncQueries.map(sq => (
|
||||
<div key={sq.id} className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs">
|
||||
{state.syncQueries.map((sq) => (
|
||||
<div
|
||||
key={sq.id}
|
||||
className="py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 text-xs"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold text-slate-800 break-all select-all font-mono">
|
||||
{sq.query}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
|
||||
<span>数据源: <strong className="text-slate-700">{sq.source === 'all' ? '全部' : sq.source === 'ads' ? 'NASA ADS' : 'arXiv'}</strong></span>
|
||||
<span>数量限制: <strong className="text-slate-700">{sq.limit_count}</strong></span>
|
||||
<span>最近同步: <strong className="text-slate-700">{sq.last_run}</strong></span>
|
||||
<span>
|
||||
数据源:{' '}
|
||||
<strong className="text-slate-700">
|
||||
{sq.source === 'all'
|
||||
? '全部'
|
||||
: sq.source === 'ads'
|
||||
? 'NASA ADS'
|
||||
: 'arXiv'}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
数量限制:{' '}
|
||||
<strong className="text-slate-700">
|
||||
{sq.limit_count}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
最近同步:{' '}
|
||||
<strong className="text-slate-700">{sq.last_run}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0 self-end sm:self-center select-none">
|
||||
|
||||
@ -37,7 +37,9 @@ export interface HighlightCandidate {
|
||||
}
|
||||
|
||||
// 生成天体的匹配词条列表
|
||||
export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidate[] {
|
||||
export function getHighlightCandidates(
|
||||
targets: TargetInfo[]
|
||||
): HighlightCandidate[] {
|
||||
const candidates: HighlightCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@ -56,7 +58,8 @@ export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidat
|
||||
candidates.push({ name: trimmed, preferredName });
|
||||
|
||||
// 自动为带有空格的常见星表前缀添加无空格变体
|
||||
const spaceRegex = /^(NGC|IC|M|HD|HIP|Gaia|WD|GD|TYC|KIC|TIC|PSR)\s+(\d+)$/i;
|
||||
const spaceRegex =
|
||||
/^(NGC|IC|M|HD|HIP|Gaia|WD|GD|TYC|KIC|TIC|PSR)\s+(\d+)$/i;
|
||||
const match = trimmed.match(spaceRegex);
|
||||
if (match) {
|
||||
const variant = `${match[1]}${match[2]}`;
|
||||
@ -97,7 +100,10 @@ export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidat
|
||||
}
|
||||
|
||||
// 辅助函数:在 Markdown 文本中安全高亮天体名称,跳过 HTML 标签、LaTeX公式和 Markdown 链接
|
||||
export function highlightTargetsInMarkdown(text: string, candidates: HighlightCandidate[]): string {
|
||||
export function highlightTargetsInMarkdown(
|
||||
text: string,
|
||||
candidates: HighlightCandidate[]
|
||||
): string {
|
||||
if (!candidates || candidates.length === 0) return text;
|
||||
|
||||
let result = text;
|
||||
@ -106,15 +112,21 @@ export function highlightTargetsInMarkdown(text: string, candidates: HighlightCa
|
||||
|
||||
// 根据兼容开关决定是否启用宽容的 Unicode 空格正则通配
|
||||
const regexStr = ENABLE_LEGACY_UNICODE_SPACE_COMPAT
|
||||
? escaped.replace(/\s+/g, '[\\s\\u00a0\\u2000-\\u200a\\u202f\\u205f\\u3000]+')
|
||||
? escaped.replace(
|
||||
/\s+/g,
|
||||
'[\\s\\u00a0\\u2000-\\u200a\\u202f\\u205f\\u3000]+'
|
||||
)
|
||||
: escaped;
|
||||
|
||||
const regex = new RegExp(`\\b(${regexStr})\\b`, 'gi');
|
||||
|
||||
// 按 HTML 标签、LaTeX 行内/块级公式、Markdown 链接进行切片
|
||||
const parts = result.split(/(<[^>]+>|\$\$[\s\S]*?\$\$|\$[\s\S]*?\$|\[[^\]]*\]\([^)]*\))/g);
|
||||
const parts = result.split(
|
||||
/(<[^>]+>|\$\$[\s\S]*?\$\$|\$[\s\S]*?\$|\[[^\]]*\]\([^)]*\))/g
|
||||
);
|
||||
|
||||
result = parts.map((part) => {
|
||||
result = parts
|
||||
.map((part) => {
|
||||
// 若是标签、公式或 Markdown 链接,则原样返回
|
||||
if (
|
||||
part.startsWith('<') ||
|
||||
@ -124,8 +136,12 @@ export function highlightTargetsInMarkdown(text: string, candidates: HighlightCa
|
||||
return part;
|
||||
}
|
||||
// plain text 部分执行天体正则高亮替换
|
||||
return part.replace(regex, `[$1](#celestial-${encodeURIComponent(cand.preferredName)})`);
|
||||
}).join('');
|
||||
return part.replace(
|
||||
regex,
|
||||
`[$1](#celestial-${encodeURIComponent(cand.preferredName)})`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -4,31 +4,96 @@
|
||||
// 根据文献类型 (doctype) 渲染统一的 Astro 样式卡片分类徽章 (Pure View Helper)
|
||||
export const getDoctypeBadge = (doctype: string) => {
|
||||
const typeMap: Record<string, { label: string; style: string }> = {
|
||||
article: { label: '期刊文章', style: 'bg-blue-50 text-blue-700 border-blue-200' },
|
||||
eprint: { label: '预印本', style: 'bg-purple-50 text-purple-700 border-purple-200' },
|
||||
inproceedings: { label: '会议论文', style: 'bg-amber-50 text-amber-700 border-amber-200' },
|
||||
proceedings: { label: '会议集', style: 'bg-amber-50 text-amber-700 border-amber-200' },
|
||||
proposal: { label: '观测提案', style: 'bg-rose-50 text-rose-700 border-rose-200' },
|
||||
abstract: { label: '会议摘要', style: 'bg-slate-50 text-slate-700 border-slate-200' },
|
||||
catalog: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
|
||||
dataset: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
|
||||
software: { label: '软件代码', style: 'bg-teal-50 text-teal-700 border-teal-200' },
|
||||
phdthesis: { label: '博士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
|
||||
mastersthesis: { label: '硕士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
|
||||
circular: { label: '天文电报', style: 'bg-orange-50 text-orange-700 border-orange-200' },
|
||||
inbook: { label: '图书章节', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
|
||||
book: { label: '学术专著', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
|
||||
editorial: { label: '期刊社论', style: 'bg-slate-50 text-slate-700 border-slate-200' },
|
||||
erratum: { label: '勘误说明', style: 'bg-red-50 text-red-700 border-red-200' },
|
||||
misc: { label: '其他文献', style: 'bg-slate-50 text-slate-700 border-slate-200' },
|
||||
newsletter: { label: '简报新闻', style: 'bg-slate-50 text-slate-700 border-slate-200' },
|
||||
techreport: { label: '技术报告', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
|
||||
obituary: { label: '讣告', style: 'bg-slate-50 text-slate-700 border-slate-200' },
|
||||
article: {
|
||||
label: '期刊文章',
|
||||
style: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
},
|
||||
eprint: {
|
||||
label: '预印本',
|
||||
style: 'bg-purple-50 text-purple-700 border-purple-200',
|
||||
},
|
||||
inproceedings: {
|
||||
label: '会议论文',
|
||||
style: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
},
|
||||
proceedings: {
|
||||
label: '会议集',
|
||||
style: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
},
|
||||
proposal: {
|
||||
label: '观测提案',
|
||||
style: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||
},
|
||||
abstract: {
|
||||
label: '会议摘要',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
},
|
||||
catalog: {
|
||||
label: '星表数据',
|
||||
style: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||
},
|
||||
dataset: {
|
||||
label: '星表数据',
|
||||
style: 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||||
},
|
||||
software: {
|
||||
label: '软件代码',
|
||||
style: 'bg-teal-50 text-teal-700 border-teal-200',
|
||||
},
|
||||
phdthesis: {
|
||||
label: '博士论文',
|
||||
style: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
},
|
||||
mastersthesis: {
|
||||
label: '硕士论文',
|
||||
style: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
},
|
||||
circular: {
|
||||
label: '天文电报',
|
||||
style: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||
},
|
||||
inbook: {
|
||||
label: '图书章节',
|
||||
style: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
},
|
||||
book: {
|
||||
label: '学术专著',
|
||||
style: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
},
|
||||
editorial: {
|
||||
label: '期刊社论',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
},
|
||||
erratum: {
|
||||
label: '勘误说明',
|
||||
style: 'bg-red-50 text-red-700 border-red-200',
|
||||
},
|
||||
misc: {
|
||||
label: '其他文献',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
},
|
||||
newsletter: {
|
||||
label: '简报新闻',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
},
|
||||
techreport: {
|
||||
label: '技术报告',
|
||||
style: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
},
|
||||
obituary: {
|
||||
label: '讣告',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
},
|
||||
};
|
||||
const val = doctype ? doctype.toLowerCase() : 'article';
|
||||
const match = typeMap[val] || { label: '文献', style: 'bg-slate-50 text-slate-700 border-slate-200' };
|
||||
const match = typeMap[val] || {
|
||||
label: '文献',
|
||||
style: 'bg-slate-50 text-slate-700 border-slate-200',
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold border ${match.style} mr-2 align-middle`}>
|
||||
<span
|
||||
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold border ${match.style} mr-2 align-middle`}
|
||||
>
|
||||
{match.label}
|
||||
</span>
|
||||
);
|
||||
@ -94,13 +159,26 @@ export function parseMarkdownFrontMatter(markdown: string): {
|
||||
let key = rawKey;
|
||||
if (rawKey === '标题' || rawKey === 'title') {
|
||||
key = 'title';
|
||||
} else if (rawKey === '作者' || rawKey === 'author' || rawKey === 'authors') {
|
||||
} else if (
|
||||
rawKey === '作者' ||
|
||||
rawKey === 'author' ||
|
||||
rawKey === 'authors'
|
||||
) {
|
||||
key = 'author';
|
||||
} else if (rawKey === '出版社' || rawKey === '出版商' || rawKey === 'publisher') {
|
||||
} else if (
|
||||
rawKey === '出版社' ||
|
||||
rawKey === '出版商' ||
|
||||
rawKey === 'publisher'
|
||||
) {
|
||||
key = 'publisher';
|
||||
} else if (rawKey === '来源' || rawKey === 'source' || rawKey === 'url') {
|
||||
key = 'source';
|
||||
} else if (rawKey === '日期' || rawKey === '年份' || rawKey === 'date' || rawKey === 'year') {
|
||||
} else if (
|
||||
rawKey === '日期' ||
|
||||
rawKey === '年份' ||
|
||||
rawKey === 'date' ||
|
||||
rawKey === 'year'
|
||||
) {
|
||||
key = 'date';
|
||||
} else if (rawKey === '标签' || rawKey === 'tags') {
|
||||
key = 'tags';
|
||||
@ -130,13 +208,19 @@ export function parseMarkdownFrontMatter(markdown: string): {
|
||||
if (!hasQuotes) {
|
||||
// 如果没有引号,如 [Heber, Ulrich],直接去掉括号按逗号切分
|
||||
const inside = val.slice(1, -1).trim();
|
||||
metadata.author = inside.split(/[,,]/).map(a => a.trim()).filter(Boolean);
|
||||
metadata.author = inside
|
||||
.split(/[,,]/)
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
metadata.author = authors;
|
||||
}
|
||||
} else {
|
||||
// 兼容中文逗号和英文逗号的分隔
|
||||
metadata.author = val.split(/[,,]/).map(a => a.trim()).filter(Boolean);
|
||||
metadata.author = val
|
||||
.split(/[,,]/)
|
||||
.map((a) => a.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -146,7 +230,6 @@ export function parseMarkdownFrontMatter(markdown: string): {
|
||||
const hasKeys = Object.keys(metadata).length > 0;
|
||||
return {
|
||||
metadata: hasKeys ? metadata : null,
|
||||
pureMarkdown
|
||||
pureMarkdown,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -64,6 +64,8 @@ services:
|
||||
start_period: 15s
|
||||
|
||||
# ─── Mode B: Distroless 全功能单体 (Obscura/V8/BoringSSL 编译在内) ──────
|
||||
# docker compose up -d astroresearch-all
|
||||
# docker compose down astroresearch-all
|
||||
astroresearch-all:
|
||||
build:
|
||||
context: .
|
||||
|
||||
40
scratch/test_size.py
Normal file
40
scratch/test_size.py
Normal file
@ -0,0 +1,40 @@
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
conn = sqlite3.connect('./library/astro_research.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype FROM papers ORDER BY created_at DESC LIMIT 2000")
|
||||
rows = cursor.fetchall()
|
||||
columns = [c[0] for c in cursor.description]
|
||||
|
||||
data = []
|
||||
for r in rows:
|
||||
row_dict = dict(zip(columns, r))
|
||||
# Parse JSON strings
|
||||
try:
|
||||
row_dict['authors'] = json.loads(row_dict['authors']) if row_dict['authors'] else []
|
||||
except:
|
||||
row_dict['authors'] = []
|
||||
try:
|
||||
row_dict['keywords'] = json.loads(row_dict['keywords']) if row_dict['keywords'] else []
|
||||
except:
|
||||
row_dict['keywords'] = []
|
||||
|
||||
# map database fields to StandardPaper
|
||||
row_dict['pub_journal'] = row_dict.pop('pub')
|
||||
row_dict['abstract_text'] = row_dict.pop('abstract')
|
||||
row_dict['is_downloaded'] = bool(row_dict['pdf_path'] or row_dict['html_path'])
|
||||
row_dict['has_pdf'] = bool(row_dict['pdf_path'])
|
||||
row_dict['has_html'] = bool(row_dict['html_path'])
|
||||
row_dict['has_markdown'] = bool(row_dict['markdown_path'])
|
||||
row_dict['has_translation'] = bool(row_dict['translation_path'])
|
||||
row_dict['has_vector'] = False # placeholder
|
||||
row_dict['pdf_error'] = None
|
||||
row_dict['html_error'] = None
|
||||
data.append(row_dict)
|
||||
|
||||
json_str = json.dumps(data, ensure_ascii=False)
|
||||
print("Row count:", len(data))
|
||||
print("Total JSON bytes:", len(json_str.encode('utf-8')))
|
||||
print("Avg bytes per row:", len(json_str.encode('utf-8')) / len(data))
|
||||
conn.close()
|
||||
@ -43,7 +43,7 @@ impl MemoryManager {
|
||||
/// 创建并加载记忆。
|
||||
/// `library_dir` 是项目配置中的 library 目录。
|
||||
pub fn new(library_dir: PathBuf) -> Self {
|
||||
let memory_dir = library_dir.join("memory");
|
||||
let memory_dir = library_dir.join(".agent").join("memory");
|
||||
let mut manager = MemoryManager {
|
||||
memory_dir,
|
||||
entries: Vec::new(),
|
||||
@ -442,7 +442,11 @@ mod tests {
|
||||
.expect("save should succeed");
|
||||
|
||||
// 验证文件存在
|
||||
let file_path = tmp.path().join("memory").join("test-slug.md");
|
||||
let file_path = tmp
|
||||
.path()
|
||||
.join(".agent")
|
||||
.join("memory")
|
||||
.join("test-slug.md");
|
||||
assert!(file_path.exists(), "memory file should exist on disk");
|
||||
|
||||
// 验证内容正确
|
||||
@ -493,7 +497,11 @@ mod tests {
|
||||
assert_eq!(latest.unwrap().name, "V2");
|
||||
|
||||
// 验证旧版本被归档
|
||||
let archive_path = tmp.path().join("memory").join("update-slug_v1.md");
|
||||
let archive_path = tmp
|
||||
.path()
|
||||
.join(".agent")
|
||||
.join("memory")
|
||||
.join("update-slug_v1.md");
|
||||
assert!(
|
||||
archive_path.exists(),
|
||||
"old version should be archived as _v1.md"
|
||||
|
||||
@ -128,7 +128,7 @@ pub(super) async fn process_single_result(
|
||||
|
||||
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
|
||||
// 但对于已从磁盘读取内容的工具(如 read_file),跳过持久化以防止级联
|
||||
let tool_results_dir = library_dir.join("tool-results");
|
||||
let tool_results_dir = library_dir.join(".agent").join("tool-results");
|
||||
let (processed_content, _persisted_path) = if output.skip_persist {
|
||||
(output.content.clone(), None)
|
||||
} else {
|
||||
|
||||
@ -86,7 +86,7 @@ impl TrajectoryExporter {
|
||||
terminal_reason,
|
||||
};
|
||||
|
||||
let dir = library_dir.join("trajectories");
|
||||
let dir = library_dir.join(".agent").join("trajectories");
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||
@ -140,7 +140,7 @@ impl TrajectoryExporter {
|
||||
|
||||
/// 列出所有已导出的 trajectory 文件
|
||||
pub fn list_trajectories(library_dir: &Path) -> Vec<String> {
|
||||
let dir = library_dir.join("trajectories");
|
||||
let dir = library_dir.join(".agent").join("trajectories");
|
||||
match fs::read_dir(&dir) {
|
||||
Ok(entries) => entries
|
||||
.flatten()
|
||||
|
||||
@ -126,7 +126,8 @@ pub async fn chat_agent(
|
||||
let upload_dir = state
|
||||
.config
|
||||
.library_dir
|
||||
.join(".agent_images")
|
||||
.join(".agent")
|
||||
.join("images")
|
||||
.join("uploads");
|
||||
tokio::fs::create_dir_all(&upload_dir)
|
||||
.await
|
||||
|
||||
@ -237,27 +237,23 @@ pub async fn get_paper_detail(
|
||||
}
|
||||
|
||||
// ── GET /api/library ──
|
||||
#[derive(Deserialize)]
|
||||
pub struct LibraryParams {
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
#[derive(Serialize)]
|
||||
pub struct LibraryResponse {
|
||||
pub items: Vec<StandardPaper>,
|
||||
pub total: i64,
|
||||
}
|
||||
|
||||
// 获取本地图书馆文献列表
|
||||
pub async fn get_library(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<LibraryParams>,
|
||||
) -> ApiResult<Json<Vec<StandardPaper>>> {
|
||||
let list = crate::services::paper::get_library_list(
|
||||
&state.db,
|
||||
&state.config.library_dir,
|
||||
params.limit,
|
||||
params.offset,
|
||||
)
|
||||
Query(params): Query<crate::services::paper::LibraryQueryParams>,
|
||||
) -> ApiResult<Json<LibraryResponse>> {
|
||||
let (list, total) =
|
||||
crate::services::paper::get_library_list(&state.db, &state.config.library_dir, params)
|
||||
.await
|
||||
.map_err(|e| AppError::internal(format!("访问本地数据库失败: {}", e)))?;
|
||||
|
||||
Ok(Json(list))
|
||||
Ok(Json(LibraryResponse { items: list, total }))
|
||||
}
|
||||
|
||||
// ── POST /api/export ──
|
||||
|
||||
@ -494,7 +494,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::X_FRAME_OPTIONS,
|
||||
HeaderValue::from_static("DENY"),
|
||||
HeaderValue::from_static("SAMEORIGIN"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::STRICT_TRANSPORT_SECURITY,
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
// 文献元数据数据库存取。
|
||||
|
||||
use super::model::StandardPaper;
|
||||
use serde::Deserialize;
|
||||
use sqlx::{Acquire, Row, SqlitePool};
|
||||
use std::path::Path;
|
||||
use tracing::info;
|
||||
@ -316,31 +317,237 @@ pub async fn check_paper_paths_in_db(
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取本地文献库的全部文献列表
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LibraryQueryParams {
|
||||
pub q: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub doctype: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub year: Option<String>,
|
||||
pub journal: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub limit: Option<i64>,
|
||||
pub offset: Option<i64>,
|
||||
}
|
||||
|
||||
/// 获取本地文献库的文献列表(支持筛选、检索、排序和分页)
|
||||
pub async fn get_library_list(
|
||||
db: &SqlitePool,
|
||||
library_dir: &Path,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
) -> Result<Vec<StandardPaper>, sqlx::Error> {
|
||||
let rows = if let (Some(lim), Some(off)) = (limit, offset) {
|
||||
sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype, EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode) AS has_vector FROM papers ORDER BY created_at DESC LIMIT ? OFFSET ?")
|
||||
.bind(lim)
|
||||
.bind(off)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
params: LibraryQueryParams,
|
||||
) -> Result<(Vec<StandardPaper>, i64), sqlx::Error> {
|
||||
let mut where_clauses = Vec::new();
|
||||
|
||||
// 1. q 全局搜索
|
||||
if let Some(ref q) = params.q {
|
||||
if !q.trim().is_empty() {
|
||||
where_clauses.push("(title LIKE ? OR authors LIKE ? OR abstract LIKE ? OR bibcode LIKE ? OR arxiv_id LIKE ? OR doi LIKE ?)".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. status 离线状态筛选
|
||||
if let Some(ref status) = params.status {
|
||||
match status.as_str() {
|
||||
"downloaded" => {
|
||||
where_clauses.push("((pdf_path IS NOT NULL AND pdf_path NOT LIKE 'error:%') OR (html_path IS NOT NULL AND html_path NOT LIKE 'error:%'))".to_string());
|
||||
}
|
||||
"undownloaded" => {
|
||||
where_clauses.push("(pdf_path IS NULL AND html_path IS NULL)".to_string());
|
||||
}
|
||||
"download_failed" => {
|
||||
where_clauses.push("((pdf_path LIKE 'error:%' OR html_path LIKE 'error:%') AND NOT (pdf_path = 'error:no_resource' AND html_path = 'error:no_resource'))".to_string());
|
||||
}
|
||||
"no_resource" => {
|
||||
where_clauses.push(
|
||||
"(pdf_path = 'error:no_resource' AND html_path = 'error:no_resource')"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"parsed" => {
|
||||
where_clauses.push(
|
||||
"(markdown_path IS NOT NULL AND markdown_path NOT LIKE 'error:%')".to_string(),
|
||||
);
|
||||
}
|
||||
"translated" => {
|
||||
where_clauses.push(
|
||||
"(translation_path IS NOT NULL AND translation_path NOT LIKE 'error:%')"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"vectorized" => {
|
||||
where_clauses.push(
|
||||
"EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. doctype 文献类型筛选
|
||||
if let Some(ref doctype) = params.doctype {
|
||||
match doctype.as_str() {
|
||||
"all" => {}
|
||||
"proceedings" => {
|
||||
where_clauses.push("doctype IN ('proceedings', 'inproceedings')".to_string());
|
||||
}
|
||||
"thesis" => {
|
||||
where_clauses.push("doctype IN ('phdthesis', 'mastersthesis')".to_string());
|
||||
}
|
||||
"catalog" => {
|
||||
where_clauses.push("doctype IN ('catalog', 'dataset')".to_string());
|
||||
}
|
||||
"book" => {
|
||||
where_clauses.push("doctype IN ('book', 'inbook')".to_string());
|
||||
}
|
||||
"other" => {
|
||||
where_clauses.push("doctype NOT IN ('article', 'eprint', 'proceedings', 'inproceedings', 'proposal', 'phdthesis', 'mastersthesis', 'abstract', 'catalog', 'dataset', 'software', 'circular', 'book', 'inbook', 'techreport')".to_string());
|
||||
}
|
||||
_ => {
|
||||
where_clauses.push("LOWER(doctype) = ?".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. author 作者
|
||||
if let Some(ref author) = params.author {
|
||||
if !author.trim().is_empty() {
|
||||
where_clauses.push("authors LIKE ?".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 5. year 年份
|
||||
if let Some(ref year) = params.year {
|
||||
if !year.trim().is_empty() {
|
||||
where_clauses.push("year LIKE ?".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 6. journal 期刊
|
||||
if let Some(ref journal) = params.journal {
|
||||
if !journal.trim().is_empty() {
|
||||
where_clauses.push("pub LIKE ?".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let where_clause = if where_clauses.is_empty() {
|
||||
"".to_string()
|
||||
} else {
|
||||
// 无分页参数时默认限制 2000 条,防止全量加载
|
||||
sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype, EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode) AS has_vector FROM papers ORDER BY created_at DESC LIMIT 2000")
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
// --- 1. 执行计数查询以获取总匹配数 ---
|
||||
let count_sql = format!("SELECT COUNT(*) FROM papers {}", where_clause);
|
||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||
|
||||
// 顺序绑定计数查询参数
|
||||
if let Some(ref q) = params.q {
|
||||
if !q.trim().is_empty() {
|
||||
let like_q = format!("%{}%", q.trim());
|
||||
count_query = count_query
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q);
|
||||
}
|
||||
}
|
||||
if let Some(ref doctype) = params.doctype {
|
||||
match doctype.as_str() {
|
||||
"all" | "proceedings" | "thesis" | "catalog" | "book" | "other" => {}
|
||||
val => {
|
||||
count_query = count_query.bind(val.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref author) = params.author {
|
||||
if !author.trim().is_empty() {
|
||||
count_query = count_query.bind(format!("%{}%", author.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(ref year) = params.year {
|
||||
if !year.trim().is_empty() {
|
||||
count_query = count_query.bind(format!("%{}%", year.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(ref journal) = params.journal {
|
||||
if !journal.trim().is_empty() {
|
||||
count_query = count_query.bind(format!("%{}%", journal.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
let total: i64 = count_query.fetch_one(db).await?;
|
||||
|
||||
// --- 2. 执行分页数据查询 ---
|
||||
let sort_sql = match params.sort_by.as_deref() {
|
||||
Some("yearDesc") => "ORDER BY CAST(year AS INTEGER) DESC, created_at DESC",
|
||||
Some("yearAsc") => "ORDER BY CAST(year AS INTEGER) ASC, created_at DESC",
|
||||
Some("citations") => "ORDER BY citation_count DESC, created_at DESC",
|
||||
Some("title") => "ORDER BY title ASC, created_at DESC",
|
||||
_ => "ORDER BY CASE \
|
||||
WHEN EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode) THEN 5 \
|
||||
WHEN (translation_path IS NOT NULL AND translation_path NOT LIKE 'error:%') THEN 4 \
|
||||
WHEN (markdown_path IS NOT NULL AND markdown_path NOT LIKE 'error:%') THEN 3 \
|
||||
WHEN ((pdf_path IS NOT NULL AND pdf_path NOT LIKE 'error:%') OR (html_path IS NOT NULL AND html_path NOT LIKE 'error:%')) THEN 2 \
|
||||
ELSE 1 \
|
||||
END DESC, created_at DESC",
|
||||
};
|
||||
|
||||
let data_sql = format!(
|
||||
"SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype, EXISTS(SELECT 1 FROM paper_chunks_content WHERE bibcode = papers.bibcode) AS has_vector FROM papers {} {} LIMIT ? OFFSET ?",
|
||||
where_clause, sort_sql
|
||||
);
|
||||
let mut data_query = sqlx::query(&data_sql);
|
||||
|
||||
// 顺序绑定数据查询参数 (包括 limit & offset)
|
||||
if let Some(ref q) = params.q {
|
||||
if !q.trim().is_empty() {
|
||||
let like_q = format!("%{}%", q.trim());
|
||||
data_query = data_query
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q.clone())
|
||||
.bind(like_q);
|
||||
}
|
||||
}
|
||||
if let Some(ref doctype) = params.doctype {
|
||||
match doctype.as_str() {
|
||||
"all" | "proceedings" | "thesis" | "catalog" | "book" | "other" => {}
|
||||
val => {
|
||||
data_query = data_query.bind(val.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref author) = params.author {
|
||||
if !author.trim().is_empty() {
|
||||
data_query = data_query.bind(format!("%{}%", author.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(ref year) = params.year {
|
||||
if !year.trim().is_empty() {
|
||||
data_query = data_query.bind(format!("%{}%", year.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(ref journal) = params.journal {
|
||||
if !journal.trim().is_empty() {
|
||||
data_query = data_query.bind(format!("%{}%", journal.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
data_query = data_query.bind(limit).bind(offset);
|
||||
|
||||
let rows = data_query.fetch_all(db).await?;
|
||||
let mut list = Vec::new();
|
||||
for r in rows {
|
||||
list.push(parse_paper_row(&r, library_dir));
|
||||
}
|
||||
Ok(list)
|
||||
|
||||
Ok((list, total))
|
||||
}
|
||||
|
||||
/// 标记/清除文献的“无资源”状态
|
||||
|
||||
@ -10,7 +10,7 @@ pub mod reader;
|
||||
pub use db::{
|
||||
check_paper_paths_in_db, clean_identifier, get_library_list, get_paper_from_db,
|
||||
mark_no_resource_service, parse_paper_row, save_paper_to_db, save_paper_to_db_tx, CleanId,
|
||||
SQLITE_PARAM_LIMIT,
|
||||
LibraryQueryParams, SQLITE_PARAM_LIMIT,
|
||||
};
|
||||
pub use ingest::upload_paper_file_service;
|
||||
pub use model::{convert_ads_doc_to_standard, convert_arxiv_to_standard, StandardPaper};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user