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
27
CLAUDE.md
27
CLAUDE.md
@ -46,6 +46,9 @@ cargo test test_name
|
|||||||
npm run dev # HMR dev server on :5173, proxies /api to :8000
|
npm run dev # HMR dev server on :5173, proxies /api to :8000
|
||||||
npm run build # TypeScript check + Vite production build → dashboard/dist/
|
npm run build # TypeScript check + Vite production build → dashboard/dist/
|
||||||
npm run lint # ESLint
|
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
|
## Architecture Overview
|
||||||
@ -169,13 +172,14 @@ Session-level modes configure the agent's identity, tool access, step limits, th
|
|||||||
|
|
||||||
Three built-in modes (registered in `ModeRegistry::builtins()`):
|
Three built-in modes (registered in `ModeRegistry::builtins()`):
|
||||||
|
|
||||||
| Mode | ID | Tools | Max Steps | Thinking | Permission Profile |
|
| Mode | ID | Tools | Max Steps | Thinking | Permission Profile |
|
||||||
|------|-----|-------|-----------|----------|---------------------|
|
| ------------ | --------------------- | ---------------------------------- | ----------- | --------------- | ------------------ |
|
||||||
| 通用科研助手 | `default` | All (unrestricted) | 8 (default) | user-controlled | none |
|
| 通用科研助手 | `default` | All (unrestricted) | 8 (default) | user-controlled | none |
|
||||||
| 深度研究 | `deep-research` | All | 16 | forced on | `research` |
|
| 深度研究 | `deep-research` | All | 16 | forced on | `research` |
|
||||||
| 文献阅读助手 | `literature-reader` | Allowlist (read-only + literature) | 6 | forced off | `readonly` |
|
| 文献阅读助手 | `literature-reader` | Allowlist (read-only + literature) | 6 | forced off | `readonly` |
|
||||||
|
|
||||||
Key types in `modes/mod.rs`:
|
Key types in `modes/mod.rs`:
|
||||||
|
|
||||||
- **`AgentMode`** — static definition struct: id, name, description, icon, identity/principles overrides, extra sections, `ToolSet`, `ModeConfig`
|
- **`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
|
- **`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`
|
- **`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 *)`).
|
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`):
|
**Permission modes** (set via `AGENT_PERMISSION_MODE` env or mode's `permission_profile`):
|
||||||
|
|
||||||
- `default` — full rule chain evaluation
|
- `default` — full rule chain evaluation
|
||||||
- `accept_edits` — auto-allow `file_write`/`file_edit` within the working directory
|
- `accept_edits` — auto-allow `file_write`/`file_edit` within the working directory
|
||||||
- `bypass` — skip all Ask checks (Deny rules still enforced)
|
- `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.
|
**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`):
|
**Configuration** (in `.env`):
|
||||||
|
|
||||||
- `AGENT_PERMISSIONS_DENY` — comma-separated deny rules (e.g., `run_bash(rm *),run_bash(sudo *)`)
|
- `AGENT_PERMISSIONS_DENY` — comma-separated deny rules (e.g., `run_bash(rm *),run_bash(sudo *)`)
|
||||||
- `AGENT_PERMISSIONS_ALLOW` — comma-separated allow rules
|
- `AGENT_PERMISSIONS_ALLOW` — comma-separated allow rules
|
||||||
- `AGENT_PERMISSIONS_ASK` — comma-separated ask rules
|
- `AGENT_PERMISSIONS_ASK` — comma-separated ask rules
|
||||||
@ -207,11 +213,11 @@ Tool execution is gated by a priority-ordered rule chain: **Deny > Allow > Ask**
|
|||||||
|
|
||||||
The system supports three LLM tiers with cascade fallback:
|
The system supports three LLM tiers with cascade fallback:
|
||||||
|
|
||||||
| Tier | Env Prefix | Purpose | Fallback |
|
| Tier | Env Prefix | Purpose | Fallback |
|
||||||
|------|-----------|---------|----------|
|
| ------- | --------------- | ----------------------------------- | ------------------ |
|
||||||
| Primary | `LLM_` | Main agent reasoning | — |
|
| Primary | `LLM_` | Main agent reasoning | — |
|
||||||
| Medium | `LLM_MEDIUM_` | Translation, RAG | Primary LLM config |
|
| Medium | `LLM_MEDIUM_` | Translation, RAG | Primary LLM config |
|
||||||
| Fast | `LLM_FAST_` | Memory extraction, background tasks | Medium LLM config |
|
| Fast | `LLM_FAST_` | Memory extraction, background tasks | Medium LLM config |
|
||||||
|
|
||||||
Each tier has `_API_KEY`, `_API_BASE`, `_MODEL` variants. Unset tiers cascade to the next tier down.
|
Each tier has `_API_KEY`, `_API_BASE`, `_MODEL` variants. Unset tiers cascade to the next tier down.
|
||||||
|
|
||||||
@ -224,6 +230,7 @@ When `LLM_VISION_MODEL` env is set, the `analyze_image` tool (`src/agent/tools/a
|
|||||||
### Auto Memory Extraction
|
### Auto Memory Extraction
|
||||||
|
|
||||||
Controlled by env vars:
|
Controlled by env vars:
|
||||||
|
|
||||||
- `EXTRACT_MEMORY_ENABLED` (default `false`) — enables automatic memory extraction at session end and during compaction
|
- `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_THROTTLE_TURNS` (default `3`) — extract every N turns
|
||||||
- `EXTRACT_MEMORY_MAX_STEPS` (default `3`) — sub-agent max steps for extraction
|
- `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 --from=frontend-builder /app/dashboard/dist/ ./dashboard/dist/
|
||||||
COPY skills/ ./skills/
|
COPY skills/ ./skills/
|
||||||
COPY migrations/ ./migrations/
|
COPY migrations/ ./migrations/
|
||||||
|
COPY dictionary.txt ./
|
||||||
|
|
||||||
RUN mkdir -p /app/library /app/logs /app/bin
|
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 --from=frontend-builder /app/dashboard/dist/ /app/dashboard/dist/
|
||||||
COPY skills/ /app/skills/
|
COPY skills/ /app/skills/
|
||||||
COPY migrations/ /app/migrations/
|
COPY migrations/ /app/migrations/
|
||||||
|
COPY dictionary.txt /app/
|
||||||
|
|
||||||
# 二进制
|
# 二进制
|
||||||
COPY --from=backend-builder /usr/local/bin/astroresearch /app/astroresearch
|
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 reactHooks from 'eslint-plugin-react-hooks'
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
|
import prettier from 'eslint-plugin-prettier'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
@ -18,5 +19,11 @@ export default defineConfig([
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.browser,
|
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",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^10.3.0",
|
"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-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
|
"prettier": "^3.9.4",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
@ -1112,6 +1115,18 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
"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": {
|
"node_modules/eslint-plugin-react-hooks": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
"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==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"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": ">= 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": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
"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==",
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||||
|
|||||||
@ -7,6 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"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",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
@ -38,10 +41,13 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^10.3.0",
|
"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-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
|
"prettier": "^3.9.4",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.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">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
||||||
<!-- Inner Ring -->
|
<!-- Outer Orbit Ring (Light Blue) -->
|
||||||
<circle cx="24" cy="24" r="18" stroke="#bae6fd" stroke-width="1.5" />
|
<circle
|
||||||
<!-- Outer dotted Orbit ring -->
|
cx="24"
|
||||||
<circle cx="24" cy="24" r="21" stroke="#0284c7" stroke-width="1.5" stroke-dasharray="2 3" />
|
cy="24"
|
||||||
|
r="21.5"
|
||||||
<!-- Stylized Telescope lens crosshair / Stellar coordinates -->
|
stroke="#38bdf8"
|
||||||
<line x1="24" y1="2" x2="24" y2="46" stroke="#f1f5f9" stroke-width="1" />
|
stroke-width="1.2"
|
||||||
<line x1="2" y1="24" x2="46" y2="24" stroke="#f1f5f9" stroke-width="1" />
|
stroke-opacity="0.6"
|
||||||
|
stroke-dasharray="2 2"
|
||||||
<!-- 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)" />
|
|
||||||
|
<!-- Secondary Ellipse Orbit (Light Blue, tilted along top-left to bottom-right diagonal) -->
|
||||||
<!-- Orbiting planet / Electron-like ring -->
|
<ellipse
|
||||||
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" stroke-width="2" />
|
cx="24"
|
||||||
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" stroke-width="1.5" />
|
cy="24"
|
||||||
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
|
rx="21"
|
||||||
|
ry="8.5"
|
||||||
|
transform="rotate(36 24 24)"
|
||||||
|
stroke="#0284c7"
|
||||||
|
stroke-width="1.2"
|
||||||
|
stroke-opacity="0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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>
|
<defs>
|
||||||
<linearGradient id="starGrad" x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
|
<!-- Glow filter for the star -->
|
||||||
<stop offset="0%" stop-color="#0284c7" />
|
<filter id="logoGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
<stop offset="100%" stop-color="#0369a1" />
|
<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>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</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(
|
||||||
setDialog({
|
(message: string, onConfirm: () => void, title = '确认操作') => {
|
||||||
type: 'confirm',
|
setDialog({
|
||||||
title,
|
type: 'confirm',
|
||||||
message,
|
title,
|
||||||
onConfirm,
|
message,
|
||||||
});
|
onConfirm,
|
||||||
}, []);
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// 2. 局部状态定义 (多组件共用或全局网络进度状态)
|
// 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 saved = localStorage.getItem('astro_active_tab');
|
||||||
const validTabs = ['search', 'library', 'reader', 'citation', 'sync', 'agent'] as const;
|
const validTabs = [
|
||||||
return (validTabs.includes(saved as typeof validTabs[number]) ? saved : 'search') as typeof validTabs[number];
|
'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('');
|
const [englishText, setEnglishText] = useState('');
|
||||||
@ -80,45 +94,54 @@ export default function App() {
|
|||||||
// 协调:进入阅读器方法 (需要拉取正文和笔记)
|
// 协调:进入阅读器方法 (需要拉取正文和笔记)
|
||||||
const libraryRef = useRef<ReturnType<typeof useLibrary> | null>(null);
|
const libraryRef = useRef<ReturnType<typeof useLibrary> | null>(null);
|
||||||
|
|
||||||
const openReader = useCallback(async (paper: StandardPaper, skipTabSwitch = false) => {
|
const openReader = useCallback(
|
||||||
const lib = libraryRef.current;
|
async (paper: StandardPaper, skipTabSwitch = false) => {
|
||||||
if (!lib) return;
|
const lib = libraryRef.current;
|
||||||
lib.setSelectedPaper(paper);
|
if (!lib) return;
|
||||||
localStorage.setItem('last_read_bibcode', paper.bibcode);
|
lib.setSelectedPaper(paper);
|
||||||
lib.addPaperToRecent(paper);
|
localStorage.setItem('last_read_bibcode', paper.bibcode);
|
||||||
|
lib.addPaperToRecent(paper);
|
||||||
|
|
||||||
setEnglishText('');
|
setEnglishText('');
|
||||||
setChineseText('');
|
setChineseText('');
|
||||||
notes.setNotes([]);
|
notes.setNotes([]);
|
||||||
notes.setShowNotesPanel(false);
|
notes.setShowNotesPanel(false);
|
||||||
|
|
||||||
if (!skipTabSwitch) {
|
if (!skipTabSwitch) {
|
||||||
setActiveTab('reader');
|
setActiveTab('reader');
|
||||||
}
|
|
||||||
|
|
||||||
// 异步加载文献详情正文
|
|
||||||
try {
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
if (res.data.translation_content) {
|
|
||||||
setChineseText(res.data.translation_content);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载文献详情失败', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 异步加载文献已存笔记
|
// 异步加载文献详情正文
|
||||||
try {
|
try {
|
||||||
const nRes = await axios.get<NoteRecord[]>('/api/notes', { params: { bibcode: paper.bibcode } });
|
const res = await axios.get<{
|
||||||
notes.setNotes(nRes.data);
|
paper: StandardPaper;
|
||||||
} catch (e) {
|
english_content?: string;
|
||||||
console.error('加载笔记失败', e);
|
translation_content?: string;
|
||||||
}
|
}>('/api/paper', {
|
||||||
}, [notes]);
|
params: { bibcode: paper.bibcode },
|
||||||
|
});
|
||||||
|
if (res.data.english_content) {
|
||||||
|
setEnglishText(res.data.english_content);
|
||||||
|
}
|
||||||
|
if (res.data.translation_content) {
|
||||||
|
setChineseText(res.data.translation_content);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载文献详情失败', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步加载文献已存笔记
|
||||||
|
try {
|
||||||
|
const nRes = await axios.get<NoteRecord[]>('/api/notes', {
|
||||||
|
params: { bibcode: paper.bibcode },
|
||||||
|
});
|
||||||
|
notes.setNotes(nRes.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载笔记失败', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[notes]
|
||||||
|
);
|
||||||
|
|
||||||
const library = useLibrary({
|
const library = useLibrary({
|
||||||
isAuthenticated: auth.isAuthenticated,
|
isAuthenticated: auth.isAuthenticated,
|
||||||
@ -127,72 +150,113 @@ export default function App() {
|
|||||||
openReader,
|
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 });
|
const search = useSearch({ showAlert });
|
||||||
|
|
||||||
// 协调:从 RAG 问答结果跳转至目标文献段落并高亮
|
// 协调:从 RAG 问答结果跳转至目标文献段落并高亮
|
||||||
const handleJumpToSource = useCallback(async (bibcode: string, paragraphIndex: number) => {
|
const handleJumpToSource = useCallback(
|
||||||
setActiveTab('reader');
|
async (bibcode: string, paragraphIndex: number) => {
|
||||||
|
setActiveTab('reader');
|
||||||
// 如果当前选中的不是目标文献,则先加载它
|
|
||||||
if (!library.selectedPaper || library.selectedPaper.bibcode !== bibcode) {
|
// 如果当前选中的不是目标文献,则先加载它
|
||||||
setEnglishText('');
|
if (!library.selectedPaper || library.selectedPaper.bibcode !== bibcode) {
|
||||||
setChineseText('');
|
setEnglishText('');
|
||||||
notes.setNotes([]);
|
setChineseText('');
|
||||||
notes.setShowNotesPanel(false);
|
notes.setNotes([]);
|
||||||
|
notes.setShowNotesPanel(false);
|
||||||
try {
|
|
||||||
const paperRes = await axios.get<{ paper: StandardPaper, english_content?: string, translation_content?: string }>('/api/paper', {
|
try {
|
||||||
params: { bibcode }
|
const paperRes = await axios.get<{
|
||||||
});
|
paper: StandardPaper;
|
||||||
|
english_content?: string;
|
||||||
library.setSelectedPaper(paperRes.data.paper);
|
translation_content?: string;
|
||||||
if (paperRes.data.english_content) {
|
}>('/api/paper', {
|
||||||
setEnglishText(paperRes.data.english_content);
|
params: { bibcode },
|
||||||
|
});
|
||||||
|
|
||||||
|
library.setSelectedPaper(paperRes.data.paper);
|
||||||
|
if (paperRes.data.english_content) {
|
||||||
|
setEnglishText(paperRes.data.english_content);
|
||||||
|
}
|
||||||
|
if (paperRes.data.translation_content) {
|
||||||
|
setChineseText(paperRes.data.translation_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nRes = await axios.get<NoteRecord[]>('/api/notes', {
|
||||||
|
params: { bibcode },
|
||||||
|
});
|
||||||
|
notes.setNotes(nRes.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载跳转文献失败:', e);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (paperRes.data.translation_content) {
|
}
|
||||||
setChineseText(paperRes.data.translation_content);
|
|
||||||
|
// 强制开启侧边栏
|
||||||
|
notes.setShowNotesPanel(true);
|
||||||
|
|
||||||
|
// 延迟等 DOM 渲染完成进行平滑滚动与闪烁高亮
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById(
|
||||||
|
`paragraph-block-${paragraphIndex}`
|
||||||
|
);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
element.classList.add('bg-amber-100', 'transition-all');
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.remove('bg-amber-100');
|
||||||
|
}, 2500);
|
||||||
}
|
}
|
||||||
|
}, 400);
|
||||||
const nRes = await axios.get<NoteRecord[]>('/api/notes', { params: { bibcode } });
|
},
|
||||||
notes.setNotes(nRes.data);
|
[library, notes]
|
||||||
} catch (e) {
|
);
|
||||||
console.error('加载跳转文献失败:', e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 强制开启侧边栏
|
|
||||||
notes.setShowNotesPanel(true);
|
|
||||||
|
|
||||||
// 延迟等 DOM 渲染完成进行平滑滚动与闪烁高亮
|
|
||||||
setTimeout(() => {
|
|
||||||
const element = document.getElementById(`paragraph-block-${paragraphIndex}`);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
element.classList.add('bg-amber-100', 'transition-all');
|
|
||||||
setTimeout(() => {
|
|
||||||
element.classList.remove('bg-amber-100');
|
|
||||||
}, 2500);
|
|
||||||
}
|
|
||||||
}, 400);
|
|
||||||
}, [library.selectedPaper, notes]);
|
|
||||||
|
|
||||||
// 协调:触发文献解析
|
// 协调:触发文献解析
|
||||||
const handleParse = async (bibcode: string, force = false) => {
|
const handleParse = async (bibcode: string, force = false) => {
|
||||||
setParsing(true);
|
setParsing(true);
|
||||||
try {
|
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);
|
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) {
|
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) {
|
} catch (e) {
|
||||||
console.error('文献解析失败', e);
|
console.error('文献解析失败', e);
|
||||||
showAlert('文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。', '解析失败');
|
showAlert(
|
||||||
|
'文献排版解析失败,请检查是否已完成 HTML/PDF 下载,并配置了 MinerU API 节点。',
|
||||||
|
'解析失败'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setParsing(false);
|
setParsing(false);
|
||||||
}
|
}
|
||||||
@ -202,16 +266,28 @@ export default function App() {
|
|||||||
const handleTranslate = async (bibcode: string, force = false) => {
|
const handleTranslate = async (bibcode: string, force = false) => {
|
||||||
setTranslating(true);
|
setTranslating(true);
|
||||||
try {
|
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);
|
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) {
|
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) {
|
} catch (e) {
|
||||||
console.error('文献翻译失败', e);
|
console.error('文献翻译失败', e);
|
||||||
showAlert('翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。', '翻译失败');
|
showAlert(
|
||||||
|
'翻译失败,请检查 .env 中的大模型 API 密钥与端点配置。',
|
||||||
|
'翻译失败'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setTranslating(false);
|
setTranslating(false);
|
||||||
}
|
}
|
||||||
@ -221,16 +297,30 @@ export default function App() {
|
|||||||
const handleVectorize = async (bibcode: string) => {
|
const handleVectorize = async (bibcode: string) => {
|
||||||
setVectorizing(true);
|
setVectorizing(true);
|
||||||
try {
|
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) {
|
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) {
|
} catch (e) {
|
||||||
console.error('知识入库失败', e);
|
console.error('知识入库失败', e);
|
||||||
showAlert('知识入库失败,请检查 .env 中的 Embedding API 配置。', '知识入库失败');
|
showAlert(
|
||||||
|
'知识入库失败,请检查 .env 中的 Embedding API 配置。',
|
||||||
|
'知识入库失败'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setVectorizing(false);
|
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 h-screen w-screen items-center justify-center bg-[#f4f6f9]">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<Loader className="w-6 h-6 text-[#106ba3] animate-spin" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -256,24 +348,30 @@ export default function App() {
|
|||||||
<div className="flex h-screen w-screen items-center justify-center bg-[#f4f6f9] overflow-hidden relative select-none font-sans">
|
<div className="flex h-screen w-screen items-center justify-center bg-[#f4f6f9] overflow-hidden relative select-none font-sans">
|
||||||
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-sky-200/20 rounded-full blur-3xl" />
|
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-sky-200/20 rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-indigo-200/20 rounded-full blur-3xl" />
|
<div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-indigo-200/20 rounded-full blur-3xl" />
|
||||||
|
|
||||||
<div className="console-panel rounded-lg p-8 max-w-sm w-full mx-4 shadow-sm z-10 relative bg-white border border-[#d2d8e2]">
|
<div className="console-panel rounded-lg p-8 max-w-sm w-full mx-4 shadow-sm z-10 relative bg-white border border-[#d2d8e2]">
|
||||||
<div className="flex flex-col items-center text-center mb-7 select-none">
|
<div className="flex flex-col items-center text-center mb-7 select-none">
|
||||||
<div className="w-14 h-14 mb-3">
|
<div className="w-14 h-14 mb-3">
|
||||||
<Logo gradientId="loginStarGrad" />
|
<Logo gradientId="loginStarGrad" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">AstroResearch</h2>
|
<h2 className="text-sm font-bold text-[#0a2540] tracking-wider mb-1">
|
||||||
<p className="text-[10px] text-[#5c6b84] font-medium tracking-wide">天文学科研辅助系统 · 安全登录</p>
|
AstroResearch
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-[#5c6b84] font-medium tracking-wide">
|
||||||
|
天文学科研辅助系统 · 安全登录
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={auth.handleLogin} className="space-y-4">
|
<form onSubmit={auth.handleLogin} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<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">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={auth.password}
|
value={auth.password}
|
||||||
onChange={e => auth.setPassword(e.target.value)}
|
onChange={(e) => auth.setPassword(e.target.value)}
|
||||||
placeholder="请输入系统访问密码"
|
placeholder="请输入系统访问密码"
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={auth.loggingIn}
|
disabled={auth.loggingIn}
|
||||||
@ -314,11 +412,10 @@ export default function App() {
|
|||||||
// 5. 正常工作面板布局装配
|
// 5. 正常工作面板布局装配
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[100dvh] overflow-hidden text-slate-800 bg-slate-100 select-text">
|
<div className="flex h-[100dvh] overflow-hidden text-slate-800 bg-slate-100 select-text">
|
||||||
|
|
||||||
{/* 导航左侧栏 */}
|
{/* 导航左侧栏 */}
|
||||||
<Sidebar
|
<Sidebar
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
selectedPaper={library.selectedPaper}
|
selectedPaper={library.selectedPaper}
|
||||||
loadCitations={citations.loadCitations}
|
loadCitations={citations.loadCitations}
|
||||||
onLogout={auth.handleLogout}
|
onLogout={auth.handleLogout}
|
||||||
@ -343,7 +440,9 @@ export default function App() {
|
|||||||
<div className="w-6 h-6">
|
<div className="w-6 h-6">
|
||||||
<Logo gradientId="mobileHeaderStarGrad" />
|
<Logo gradientId="mobileHeaderStarGrad" />
|
||||||
</div>
|
</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>
|
||||||
</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">
|
<span className="text-[10px] font-bold px-2.5 py-1 rounded bg-slate-100 text-slate-600 font-sans tracking-wide uppercase">
|
||||||
@ -356,17 +455,27 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className={`flex-1 relative w-full flex flex-col ${
|
<div
|
||||||
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent')
|
className={`flex-1 relative w-full flex flex-col ${
|
||||||
? 'overflow-hidden p-0 sm:p-4 md:p-6 lg:p-8'
|
activeTab === 'reader' ||
|
||||||
: 'overflow-y-auto p-4 sm:p-6 md:p-8'
|
activeTab === 'citation' ||
|
||||||
}`}>
|
activeTab === 'agent'
|
||||||
<div className={`w-full flex-1 flex flex-col min-h-0 ${
|
? 'overflow-hidden p-0 sm:p-4 md:p-6 lg:p-8'
|
||||||
(activeTab === 'reader' || activeTab === 'citation' || activeTab === 'agent') ? 'max-w-none' : 'max-w-7xl mx-auto'
|
: '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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{activeTab === 'search' && (
|
{activeTab === 'search' && (
|
||||||
<SearchPanel
|
<SearchPanel
|
||||||
searchQuery={search.searchQuery}
|
searchQuery={search.searchQuery}
|
||||||
setSearchQuery={search.setSearchQuery}
|
setSearchQuery={search.setSearchQuery}
|
||||||
searchSource={search.searchSource}
|
searchSource={search.searchSource}
|
||||||
@ -386,31 +495,67 @@ export default function App() {
|
|||||||
exporting={search.exporting}
|
exporting={search.exporting}
|
||||||
bibtexContent={search.bibtexContent}
|
bibtexContent={search.bibtexContent}
|
||||||
downloadingBibcodes={library.downloadingBibcodes}
|
downloadingBibcodes={library.downloadingBibcodes}
|
||||||
handleDownload={(bibcode, force) => library.handleDownload(bibcode, force, search.setSearchResults)}
|
handleDownload={(bibcode, force) =>
|
||||||
|
library.handleDownload(
|
||||||
|
bibcode,
|
||||||
|
force,
|
||||||
|
search.setSearchResults
|
||||||
|
)
|
||||||
|
}
|
||||||
selectedPaper={library.selectedPaper}
|
selectedPaper={library.selectedPaper}
|
||||||
setSelectedPaper={library.setSelectedPaper}
|
setSelectedPaper={library.setSelectedPaper}
|
||||||
openReader={openReader}
|
openReader={openReader}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
loadCitations={citations.loadCitations}
|
loadCitations={citations.loadCitations}
|
||||||
showAlert={showAlert}
|
showAlert={showAlert}
|
||||||
onShowDetail={(paper) => library.setDetailBibcode(paper.bibcode)}
|
onShowDetail={(paper) =>
|
||||||
|
library.setDetailBibcode(paper.bibcode)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'library' && (
|
{activeTab === 'library' && (
|
||||||
<LibraryPanel
|
<LibraryPanel
|
||||||
library={library.library}
|
library={library.library}
|
||||||
fetchLibrary={library.fetchLibrary}
|
fetchLibrary={library.fetchLibrary}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
onShowDetail={(paper) => library.setDetailBibcode(paper.bibcode)}
|
onShowDetail={(paper) =>
|
||||||
|
library.setDetailBibcode(paper.bibcode)
|
||||||
|
}
|
||||||
onOpenReader={openReader}
|
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' && (
|
{activeTab === 'reader' &&
|
||||||
library.selectedPaper ? (
|
(library.selectedPaper ? (
|
||||||
<ReaderPanel
|
<ReaderPanel
|
||||||
selectedPaper={library.selectedPaper}
|
selectedPaper={library.selectedPaper}
|
||||||
library={library.library}
|
library={library.library}
|
||||||
recentlySelected={library.recentlySelected}
|
recentlySelected={library.recentlySelected}
|
||||||
@ -435,7 +580,9 @@ export default function App() {
|
|||||||
setNewNoteColor={notes.setNewNoteColor}
|
setNewNoteColor={notes.setNewNoteColor}
|
||||||
newNoteText={notes.newNoteText}
|
newNoteText={notes.newNoteText}
|
||||||
setNewNoteText={notes.setNewNoteText}
|
setNewNoteText={notes.setNewNoteText}
|
||||||
handleCreateNote={() => notes.handleCreateNote(library.selectedPaper)}
|
handleCreateNote={() =>
|
||||||
|
notes.handleCreateNote(library.selectedPaper)
|
||||||
|
}
|
||||||
handleDeleteNote={notes.handleDeleteNote}
|
handleDeleteNote={notes.handleDeleteNote}
|
||||||
showConfirm={showConfirm}
|
showConfirm={showConfirm}
|
||||||
onJumpToSource={handleJumpToSource}
|
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">
|
<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" />
|
<BookOpen className="w-8 h-8" />
|
||||||
</div>
|
</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 className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
|
||||||
双语对比阅读功能需要基于已下载的馆藏文献。请前往"馆藏管理"选择或上传一篇文献,然后开启沉浸式双语精读。
|
双语对比阅读功能需要基于已下载的馆藏文献。请前往"馆藏管理"选择或上传一篇文献,然后开启沉浸式双语精读。
|
||||||
</p>
|
</p>
|
||||||
@ -456,16 +605,21 @@ export default function App() {
|
|||||||
选择文献
|
选择文献
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'citation' && (
|
{activeTab === 'citation' &&
|
||||||
library.selectedPaper ? (
|
(library.selectedPaper ? (
|
||||||
<CitationPanel
|
<CitationPanel
|
||||||
selectedPaper={library.selectedPaper}
|
selectedPaper={library.selectedPaper}
|
||||||
library={library.library}
|
library={library.library}
|
||||||
recentlySelected={library.recentlySelected}
|
recentlySelected={library.recentlySelected}
|
||||||
onSwitchPaper={(paper) => library.openCitation(paper, setActiveTab, citations.loadCitations)}
|
onSwitchPaper={(paper) =>
|
||||||
|
library.openCitation(
|
||||||
|
paper,
|
||||||
|
setActiveTab,
|
||||||
|
citations.loadCitations
|
||||||
|
)
|
||||||
|
}
|
||||||
loadingCitations={citations.loadingCitations}
|
loadingCitations={citations.loadingCitations}
|
||||||
citationNetwork={citations.citationNetwork}
|
citationNetwork={citations.citationNetwork}
|
||||||
citationHistory={citations.citationHistory}
|
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">
|
<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" />
|
<GitFork className="w-8 h-8" />
|
||||||
</div>
|
</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 className="text-xs text-slate-500 max-w-sm text-center leading-relaxed mb-6">
|
||||||
引用星系拓扑图谱用于展示单篇文献的"引用 - 被引"星系脉络。请前往"馆藏管理"选择一篇文献并生成其引用图谱。
|
引用星系拓扑图谱用于展示单篇文献的"引用 -
|
||||||
|
被引"星系脉络。请前往"馆藏管理"选择一篇文献并生成其引用图谱。
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('library')}
|
onClick={() => setActiveTab('library')}
|
||||||
@ -488,17 +645,18 @@ export default function App() {
|
|||||||
选择文献
|
选择文献
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'sync' && (
|
{activeTab === 'sync' && <SyncPanel />}
|
||||||
<SyncPanel />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'agent' && (
|
{activeTab === 'agent' && (
|
||||||
<ResearchAgentPanel
|
<ResearchAgentPanel
|
||||||
showConfirm={showConfirm}
|
showConfirm={showConfirm}
|
||||||
showAlert={showAlert}
|
showAlert={showAlert}
|
||||||
|
openReader={openReader}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
citations={citations}
|
||||||
|
library={library}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@ -507,10 +665,7 @@ export default function App() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* 全局统一 Alert / Confirm 弹窗 */}
|
{/* 全局统一 Alert / Confirm 弹窗 */}
|
||||||
<GlobalDialog
|
<GlobalDialog dialog={dialog} onClose={() => setDialog(null)} />
|
||||||
dialog={dialog}
|
|
||||||
onClose={() => setDialog(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 未入库文献操作引导弹窗 */}
|
{/* 未入库文献操作引导弹窗 */}
|
||||||
<UncachedPaperModal
|
<UncachedPaperModal
|
||||||
@ -525,9 +680,20 @@ export default function App() {
|
|||||||
onClose={() => library.setDetailBibcode(null)}
|
onClose={() => library.setDetailBibcode(null)}
|
||||||
uploadingBibcode={library.uploadingBibcode}
|
uploadingBibcode={library.uploadingBibcode}
|
||||||
downloadingBibcodes={library.downloadingBibcodes}
|
downloadingBibcodes={library.downloadingBibcodes}
|
||||||
handleManualUpload={(bibcode, type, file) => library.handleManualUpload(bibcode, type, file, search.setSearchResults)}
|
handleManualUpload={(bibcode, type, file) =>
|
||||||
handleMarkNoResource={(bibcode, clear) => library.handleMarkNoResource(bibcode, clear, search.setSearchResults)}
|
library.handleManualUpload(
|
||||||
handleDownload={(bibcode, force) => library.handleDownload(bibcode, force, search.setSearchResults)}
|
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}
|
openReader={openReader}
|
||||||
setActiveTab={setActiveTab}
|
setActiveTab={setActiveTab}
|
||||||
setSelectedPaper={library.setSelectedPaper}
|
setSelectedPaper={library.setSelectedPaper}
|
||||||
|
|||||||
@ -2,15 +2,29 @@
|
|||||||
// 文献 AI 问答助手 — 使用共享 AgentMarkdown / useAgentSSE / 子代理路由 / 渲染组件
|
// 文献 AI 问答助手 — 使用共享 AgentMarkdown / useAgentSSE / 子代理路由 / 渲染组件
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Send, Loader, X, BookOpen, AlertCircle, Compass,
|
Send,
|
||||||
Brain, Square, Paperclip,
|
Loader,
|
||||||
|
X,
|
||||||
|
BookOpen,
|
||||||
|
AlertCircle,
|
||||||
|
Compass,
|
||||||
|
Brain,
|
||||||
|
Square,
|
||||||
|
Paperclip,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
AgentMarkdown, useAgentSSE, useAutoScroll,
|
AgentMarkdown,
|
||||||
ThoughtCard, ToolCallCard, SubAgentContainer,
|
useAgentSSE,
|
||||||
|
useAutoScroll,
|
||||||
|
ThoughtCard,
|
||||||
|
ToolCallCard,
|
||||||
|
SubAgentContainer,
|
||||||
PARENT_COLORS,
|
PARENT_COLORS,
|
||||||
routeThought, routeToolCall, routeToolResult, routeTextDelta,
|
routeThought,
|
||||||
|
routeToolCall,
|
||||||
|
routeToolResult,
|
||||||
|
routeTextDelta,
|
||||||
} from './agent';
|
} from './agent';
|
||||||
import type { SSEEventHandlers, TimelineItem } from './agent';
|
import type { SSEEventHandlers, TimelineItem } from './agent';
|
||||||
|
|
||||||
@ -51,13 +65,22 @@ export function AIAssistantPanel({
|
|||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 折叠状态(共享 ThoughtCard / ToolCallCard / SubAgentContainer 的展开控制)
|
// 折叠状态(共享 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 [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
|
||||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
const [expandedResults, setExpandedResults] = useState<
|
||||||
const [collapsedSubAgents, setCollapsedSubAgents] = useState<Record<string, boolean>>({});
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const [collapsedSubAgents, setCollapsedSubAgents] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
const [pendingImage, setPendingImage] = useState<{
|
const [pendingImage, setPendingImage] = useState<{
|
||||||
data?: string; path?: string; mime_type: string; name: string;
|
data?: string;
|
||||||
|
path?: string;
|
||||||
|
mime_type: string;
|
||||||
|
name: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@ -67,13 +90,13 @@ export function AIAssistantPanel({
|
|||||||
|
|
||||||
// ── 折叠控制 ──
|
// ── 折叠控制 ──
|
||||||
const toggleThought = (key: string) =>
|
const toggleThought = (key: string) =>
|
||||||
setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] }));
|
setExpandedThoughts((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
const toggleArgs = (tcId: string) =>
|
const toggleArgs = (tcId: string) =>
|
||||||
setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
setExpandedArgs((prev) => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||||
const toggleResult = (tcId: string) =>
|
const toggleResult = (tcId: string) =>
|
||||||
setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
setExpandedResults((prev) => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||||
const toggleSubAgent = (id: string) =>
|
const toggleSubAgent = (id: string) =>
|
||||||
setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] }));
|
setCollapsedSubAgents((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
|
||||||
// ── 图片处理 ──
|
// ── 图片处理 ──
|
||||||
const attachImage = (file: File) => {
|
const attachImage = (file: File) => {
|
||||||
@ -122,7 +145,11 @@ export function AIAssistantPanel({
|
|||||||
const handleStop = async () => {
|
const handleStop = async () => {
|
||||||
sseStop();
|
sseStop();
|
||||||
if (sessionId) {
|
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,
|
text: questionText,
|
||||||
timeline: [],
|
timeline: [],
|
||||||
imageUrl: figure
|
imageUrl: figure
|
||||||
? (figure.path
|
? figure.path
|
||||||
? `/api/files/${figure.path}`
|
? `/api/files/${figure.path}`
|
||||||
: `data:${figure.mime_type};base64,${figure.data}`)
|
: `data:${figure.mime_type};base64,${figure.data}`
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
setInput('');
|
setInput('');
|
||||||
setShouldAutoScroll(true);
|
setShouldAutoScroll(true);
|
||||||
|
|
||||||
// AI 消息占位符(用 TimelineItem[] 替代旧 AgentStep[])
|
// AI 消息占位符(用 TimelineItem[] 替代旧 AgentStep[])
|
||||||
const aiMsg: Message = { sender: 'ai', text: '', timeline: [], sources: [] };
|
const aiMsg: Message = {
|
||||||
setMessages(prev => [...prev, aiMsg]);
|
sender: 'ai',
|
||||||
|
text: '',
|
||||||
|
timeline: [],
|
||||||
|
sources: [],
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, aiMsg]);
|
||||||
|
|
||||||
// ── SSE 事件处理器(使用共享子代理路由)──
|
// ── SSE 事件处理器(使用共享子代理路由)──
|
||||||
const handlers: SSEEventHandlers = {
|
const handlers: SSEEventHandlers = {
|
||||||
@ -157,47 +189,79 @@ export function AIAssistantPanel({
|
|||||||
if (!sessionId) setSessionId(sid);
|
if (!sessionId) setSessionId(sid);
|
||||||
},
|
},
|
||||||
onThought: (step, content) => {
|
onThought: (step, content) => {
|
||||||
setMessages(prev => updateAiTimeline(prev, (timeline) =>
|
setMessages((prev) =>
|
||||||
routeThought(timeline, step, content),
|
updateAiTimeline(prev, (timeline) =>
|
||||||
));
|
routeThought(timeline, step, content)
|
||||||
|
)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onToolCall: (step, _id, name, args) => {
|
onToolCall: (step, _id, name, args) => {
|
||||||
setMessages(prev => updateAiTimeline(prev, (timeline) =>
|
setMessages((prev) =>
|
||||||
routeToolCall(timeline, step, _id, name, args),
|
updateAiTimeline(prev, (timeline) =>
|
||||||
));
|
routeToolCall(timeline, step, _id, name, args)
|
||||||
|
)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onToolResult: (_step, tcId, name, _output, isError, metadata) => {
|
onToolResult: (_step, tcId, name, _output, isError, metadata) => {
|
||||||
setExpandedResults(prev => ({ ...prev, [tcId]: true }));
|
setExpandedResults((prev) => ({ ...prev, [tcId]: true }));
|
||||||
setMessages(prev => {
|
setMessages((prev) => {
|
||||||
if (prev.length === 0) return prev;
|
if (prev.length === 0) return prev;
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last.sender !== 'ai') return prev;
|
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 来源收集
|
// RAG 来源收集
|
||||||
const sources = [...(last.sources || [])];
|
const sources = [...(last.sources || [])];
|
||||||
if (name === 'rag_search' && metadata?.sources && Array.isArray(metadata.sources)) {
|
if (
|
||||||
for (const s of metadata.sources as Array<{ bibcode: string; paragraph_index: number; preview?: string; distance?: number }>) {
|
name === 'rag_search' &&
|
||||||
if (!sources.some(
|
metadata?.sources &&
|
||||||
c => c.bibcode === s.bibcode && c.paragraph_index === s.paragraph_index,
|
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({
|
sources.push({
|
||||||
bibcode: s.bibcode, paragraph_index: s.paragraph_index,
|
bibcode: s.bibcode,
|
||||||
content: s.preview || '', distance: s.distance ?? 0,
|
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) => {
|
onTextDelta: (content, toolCallId) => {
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
setExpandedResults(prev => ({ ...prev, [toolCallId]: true }));
|
setExpandedResults((prev) => ({ ...prev, [toolCallId]: true }));
|
||||||
}
|
}
|
||||||
setMessages(prev => {
|
setMessages((prev) => {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last?.sender !== 'ai') return prev;
|
if (last?.sender !== 'ai') return prev;
|
||||||
const { timeline, answerDelta } = routeTextDelta(last.timeline, content, toolCallId);
|
const { timeline, answerDelta } = routeTextDelta(
|
||||||
|
last.timeline,
|
||||||
|
content,
|
||||||
|
toolCallId
|
||||||
|
);
|
||||||
return [
|
return [
|
||||||
...prev.slice(0, -1),
|
...prev.slice(0, -1),
|
||||||
{ ...last, timeline, text: last.text + answerDelta },
|
{ ...last, timeline, text: last.text + answerDelta },
|
||||||
@ -211,7 +275,8 @@ export function AIAssistantPanel({
|
|||||||
|
|
||||||
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
|
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
|
||||||
const requestQuery = questionText.includes(bibcode)
|
const requestQuery = questionText.includes(bibcode)
|
||||||
? questionText : `${contextPrefix}${questionText}`;
|
? questionText
|
||||||
|
: `${contextPrefix}${questionText}`;
|
||||||
|
|
||||||
let imagePayload: { data: string; mime_type: string } | undefined;
|
let imagePayload: { data: string; mime_type: string } | undefined;
|
||||||
if (figure?.data) {
|
if (figure?.data) {
|
||||||
@ -227,12 +292,16 @@ export function AIAssistantPanel({
|
|||||||
? { data: imagePayload.data, mime_type: imagePayload.mime_type }
|
? { data: imagePayload.data, mime_type: imagePayload.mime_type }
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
handlers,
|
handlers
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 时间线条目渲染(委托到共享组件)──
|
// ── 时间线条目渲染(委托到共享组件)──
|
||||||
const renderTimelineItem = (item: TimelineItem, idx: number, isStreaming: boolean) => {
|
const renderTimelineItem = (
|
||||||
|
item: TimelineItem,
|
||||||
|
idx: number,
|
||||||
|
isStreaming: boolean
|
||||||
|
) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'subagent_container':
|
case 'subagent_container':
|
||||||
return (
|
return (
|
||||||
@ -305,7 +374,8 @@ export function AIAssistantPanel({
|
|||||||
<Compass className="w-10 h-10 mx-auto text-sky-500 opacity-60" />
|
<Compass className="w-10 h-10 mx-auto text-sky-500 opacity-60" />
|
||||||
<h3 className="text-xs font-bold text-slate-800">文献阅读助手</h3>
|
<h3 className="text-xs font-bold text-slate-800">文献阅读助手</h3>
|
||||||
<p className="text-[11px] text-slate-500 leading-relaxed font-semibold">
|
<p className="text-[11px] text-slate-500 leading-relaxed font-semibold">
|
||||||
专注文献精读与理解的 AI 助手。支持语义检索、逐段解读、公式分析,严格只读模式保障文献安全。
|
专注文献精读与理解的 AI
|
||||||
|
助手。支持语义检索、逐段解读、公式分析,严格只读模式保障文献安全。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -363,7 +433,7 @@ export function AIAssistantPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="pl-4 border-l border-slate-200 space-y-2">
|
<div className="pl-4 border-l border-slate-200 space-y-2">
|
||||||
{msg.timeline.map((item, tIdx) =>
|
{msg.timeline.map((item, tIdx) =>
|
||||||
renderTimelineItem(item, tIdx, !!streaming),
|
renderTimelineItem(item, tIdx, !!streaming)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -376,7 +446,9 @@ export function AIAssistantPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
!streaming && (
|
!streaming && (
|
||||||
<span className="text-slate-400 italic">正在构建最终结论...</span>
|
<span className="text-slate-400 italic">
|
||||||
|
正在构建最终结论...
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -393,13 +465,17 @@ export function AIAssistantPanel({
|
|||||||
{msg.sources.map((src, sIdx) => (
|
{msg.sources.map((src, sIdx) => (
|
||||||
<button
|
<button
|
||||||
key={sIdx}
|
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"
|
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}
|
title={src.content}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 truncate mr-2">
|
<div className="flex items-center gap-1.5 truncate mr-2">
|
||||||
<BookOpen className="w-3 h-3 text-sky-600 shrink-0" />
|
<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">
|
<span className="text-slate-400 text-[9px] font-bold">
|
||||||
§{src.paragraph_index + 1}
|
§{src.paragraph_index + 1}
|
||||||
</span>
|
</span>
|
||||||
@ -416,12 +492,16 @@ export function AIAssistantPanel({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{streaming && messages.length > 0 && !messages[messages.length - 1].text && (
|
{streaming &&
|
||||||
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
messages.length > 0 &&
|
||||||
<Loader className="w-4 h-4 animate-spin text-sky-600" />
|
!messages[messages.length - 1].text && (
|
||||||
<span className="text-[10px] font-bold">文献检索及推理结论构建中...</span>
|
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
||||||
</div>
|
<Loader className="w-4 h-4 animate-spin text-sky-600" />
|
||||||
)}
|
<span className="text-[10px] font-bold">
|
||||||
|
文献检索及推理结论构建中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-xs w-[95%] font-semibold">
|
<div className="flex items-start gap-2 bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-xs w-[95%] font-semibold">
|
||||||
@ -456,7 +536,9 @@ export function AIAssistantPanel({
|
|||||||
}}
|
}}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
disabled={streaming}
|
disabled={streaming}
|
||||||
placeholder={pendingImage ? '针对选中图表提问...' : '向 AI 馆藏助手提问...'}
|
placeholder={
|
||||||
|
pendingImage ? '针对选中图表提问...' : '向 AI 馆藏助手提问...'
|
||||||
|
}
|
||||||
rows={2}
|
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"
|
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
|
// 更新最后一条 AI 消息的 timeline
|
||||||
function updateAiTimeline(
|
function updateAiTimeline(
|
||||||
prev: Message[],
|
prev: Message[],
|
||||||
fn: (timeline: TimelineItem[]) => TimelineItem[],
|
fn: (timeline: TimelineItem[]) => TimelineItem[]
|
||||||
): Message[] {
|
): Message[] {
|
||||||
if (prev.length === 0) return prev;
|
if (prev.length === 0) return prev;
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last.sender !== 'ai') return prev;
|
if (last.sender !== 'ai') return prev;
|
||||||
return [...prev.slice(0, -1), { ...last, timeline: fn(last.timeline) }];
|
return [...prev.slice(0, -1), { ...last, timeline: fn(last.timeline) }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,12 @@ interface Link {
|
|||||||
target: string;
|
target: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNodeClick }: CanvasProps) {
|
export function CitationGalaxyCanvas({
|
||||||
|
networks,
|
||||||
|
activeNetwork,
|
||||||
|
nodeLimit,
|
||||||
|
onNodeClick,
|
||||||
|
}: CanvasProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -53,8 +58,8 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
allIds.add(net.bibcode);
|
allIds.add(net.bibcode);
|
||||||
// 根据被引用数量决定中心节点大小 (起步半径 16,按被引量上限 32 比例缩放)
|
// 根据被引用数量决定中心节点大小 (起步半径 16,按被引量上限 32 比例缩放)
|
||||||
const citeCount = net.citation_count || 0;
|
const citeCount = net.citation_count || 0;
|
||||||
const radius = isActive
|
const radius = isActive
|
||||||
? Math.min(32, Math.max(18, 16 + citeCount / 50))
|
? Math.min(32, Math.max(18, 16 + citeCount / 50))
|
||||||
: Math.min(24, Math.max(12, 11 + citeCount / 100));
|
: Math.min(24, Math.max(12, 11 + citeCount / 100));
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: net.bibcode,
|
id: net.bibcode,
|
||||||
@ -74,10 +79,16 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
if (nodes.length >= MAX_NODES) return;
|
if (nodes.length >= MAX_NODES) return;
|
||||||
if (!allIds.has(ref)) {
|
if (!allIds.has(ref)) {
|
||||||
allIds.add(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 dist = 120 + Math.random() * 30;
|
||||||
const centerNode = nodes.find(n => n.id === net.bibcode);
|
const centerNode = nodes.find((n) => n.id === net.bibcode);
|
||||||
const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, ref) : false;
|
const inDb = activeNetwork.citation_counts
|
||||||
|
? Object.prototype.hasOwnProperty.call(
|
||||||
|
activeNetwork.citation_counts,
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
: false;
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: ref,
|
id: ref,
|
||||||
label: ref,
|
label: ref,
|
||||||
@ -100,10 +111,16 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
if (nodes.length >= MAX_NODES) return;
|
if (nodes.length >= MAX_NODES) return;
|
||||||
if (!allIds.has(cit)) {
|
if (!allIds.has(cit)) {
|
||||||
allIds.add(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 dist = 140 + Math.random() * 40;
|
||||||
const centerNode = nodes.find(n => n.id === net.bibcode);
|
const centerNode = nodes.find((n) => n.id === net.bibcode);
|
||||||
const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, cit) : false;
|
const inDb = activeNetwork.citation_counts
|
||||||
|
? Object.prototype.hasOwnProperty.call(
|
||||||
|
activeNetwork.citation_counts,
|
||||||
|
cit
|
||||||
|
)
|
||||||
|
: false;
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: cit,
|
id: cit,
|
||||||
label: cit,
|
label: cit,
|
||||||
@ -125,17 +142,20 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
|
|
||||||
// 计算外围节点在当前渲染网络中的度数(连线数),动态微调其半径大小,凸显网络枢纽节点
|
// 计算外围节点在当前渲染网络中的度数(连线数),动态微调其半径大小,凸显网络枢纽节点
|
||||||
const degrees: Record<string, number> = {};
|
const degrees: Record<string, number> = {};
|
||||||
links.forEach(l => {
|
links.forEach((l) => {
|
||||||
degrees[l.source] = (degrees[l.source] || 0) + 1;
|
degrees[l.source] = (degrees[l.source] || 0) + 1;
|
||||||
degrees[l.target] = (degrees[l.target] || 0) + 1;
|
degrees[l.target] = (degrees[l.target] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
nodes.forEach(n => {
|
nodes.forEach((n) => {
|
||||||
if (n.type !== 'center') {
|
if (n.type !== 'center') {
|
||||||
const deg = degrees[n.id] || 0;
|
const deg = degrees[n.id] || 0;
|
||||||
const citeCount = activeNetwork.citation_counts?.[n.id] || 0;
|
const citeCount = activeNetwork.citation_counts?.[n.id] || 0;
|
||||||
// 融合数据库中该文献的被引数量 (起步+citeCount/40) 与当前渲染网格连线度数,动态决定半径 (最大限制 18)
|
// 融合数据库中该文献的被引数量 (起步+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 fx = (dx / dist) * force;
|
||||||
const fy = (dy / 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].vx -= fx;
|
||||||
nodes[i].vy -= fy;
|
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].vx += fx;
|
||||||
nodes[j].vy += fy;
|
nodes[j].vy += fy;
|
||||||
}
|
}
|
||||||
@ -175,9 +201,9 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
links.forEach(link => {
|
links.forEach((link) => {
|
||||||
const sourceNode = nodes.find(n => n.id === link.source);
|
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||||
const targetNode = nodes.find(n => n.id === link.target);
|
const targetNode = nodes.find((n) => n.id === link.target);
|
||||||
if (sourceNode && targetNode) {
|
if (sourceNode && targetNode) {
|
||||||
const dx = targetNode.x - sourceNode.x;
|
const dx = targetNode.x - sourceNode.x;
|
||||||
const dy = targetNode.y - sourceNode.y;
|
const dy = targetNode.y - sourceNode.y;
|
||||||
@ -186,18 +212,24 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
const fx = (dx / dist) * force;
|
const fx = (dx / dist) * force;
|
||||||
const fy = (dy / 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.vx += fx;
|
||||||
sourceNode.vy += fy;
|
sourceNode.vy += fy;
|
||||||
}
|
}
|
||||||
if (targetNode.type !== 'center' || targetNode.id !== activeNetwork.bibcode) {
|
if (
|
||||||
|
targetNode.type !== 'center' ||
|
||||||
|
targetNode.id !== activeNetwork.bibcode
|
||||||
|
) {
|
||||||
targetNode.vx -= fx;
|
targetNode.vx -= fx;
|
||||||
targetNode.vy -= fy;
|
targetNode.vy -= fy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
nodes.forEach(node => {
|
nodes.forEach((node) => {
|
||||||
if (node.id !== activeNetwork.bibcode) {
|
if (node.id !== activeNetwork.bibcode) {
|
||||||
node.x += node.vx;
|
node.x += node.vx;
|
||||||
node.y += node.vy;
|
node.y += node.vy;
|
||||||
@ -222,12 +254,12 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
ctx.translate(-cx, -cy);
|
ctx.translate(-cx, -cy);
|
||||||
|
|
||||||
// 绘制背景宇宙引力线 & 刻度圈 - 极其素雅的学术引力线
|
// 绘制背景宇宙引力线 & 刻度圈 - 极其素雅的学术引力线
|
||||||
const centerNode = nodes.find(n => n.id === activeNetwork.bibcode);
|
const centerNode = nodes.find((n) => n.id === activeNetwork.bibcode);
|
||||||
if (centerNode) {
|
if (centerNode) {
|
||||||
ctx.strokeStyle = 'rgba(148, 163, 184, 0.08)';
|
ctx.strokeStyle = 'rgba(148, 163, 184, 0.08)';
|
||||||
ctx.lineWidth = 0.75;
|
ctx.lineWidth = 0.75;
|
||||||
ctx.setLineDash([3, 5]);
|
ctx.setLineDash([3, 5]);
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(centerNode.x, centerNode.y, 130, 0, Math.PI * 2);
|
ctx.arc(centerNode.x, centerNode.y, 130, 0, Math.PI * 2);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
@ -247,9 +279,9 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
|
|
||||||
// 绘制连线 - 精细线宽
|
// 绘制连线 - 精细线宽
|
||||||
ctx.lineWidth = 0.75;
|
ctx.lineWidth = 0.75;
|
||||||
links.forEach(link => {
|
links.forEach((link) => {
|
||||||
const sourceNode = nodes.find(n => n.id === link.source);
|
const sourceNode = nodes.find((n) => n.id === link.source);
|
||||||
const targetNode = nodes.find(n => n.id === link.target);
|
const targetNode = nodes.find((n) => n.id === link.target);
|
||||||
if (sourceNode && targetNode) {
|
if (sourceNode && targetNode) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(sourceNode.x, sourceNode.y);
|
ctx.moveTo(sourceNode.x, sourceNode.y);
|
||||||
@ -261,14 +293,20 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
|
|
||||||
// 绘制节点
|
// 绘制节点
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
nodes.forEach(node => {
|
nodes.forEach((node) => {
|
||||||
const isHovered = hoveredNode?.id === node.id;
|
const isHovered = hoveredNode?.id === node.id;
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = node.inDb ? 1.0 : 0.35; // 未入库文献透明度降为 0.35
|
ctx.globalAlpha = node.inDb ? 1.0 : 0.35; // 未入库文献透明度降为 0.35
|
||||||
|
|
||||||
ctx.beginPath();
|
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.fillStyle = node.color + (isHovered ? '20' : '0a');
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
@ -278,15 +316,25 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(node.x, node.y, node.radius + (isHovered ? 3 : 1.5), 0, Math.PI * 2);
|
ctx.arc(
|
||||||
ctx.strokeStyle = node.color + '30';
|
node.x,
|
||||||
|
node.y,
|
||||||
|
node.radius + (isHovered ? 3 : 1.5),
|
||||||
|
0,
|
||||||
|
Math.PI * 2
|
||||||
|
);
|
||||||
|
ctx.strokeStyle = node.color + '30';
|
||||||
ctx.lineWidth = 0.75;
|
ctx.lineWidth = 0.75;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
ctx.fillStyle = isHovered ? '#0369a1' : '#475569';
|
ctx.fillStyle = isHovered ? '#0369a1' : '#475569';
|
||||||
ctx.font = isHovered ? 'bold 9.5px sans-serif' : '9px sans-serif';
|
ctx.font = isHovered ? 'bold 9.5px sans-serif' : '9px sans-serif';
|
||||||
ctx.textAlign = 'center';
|
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();
|
ctx.restore();
|
||||||
});
|
});
|
||||||
@ -314,7 +362,8 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
const dx = node.x - gx;
|
const dx = node.x - gx;
|
||||||
const dy = node.y - gy;
|
const dy = node.y - gy;
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
if (dist < node.radius + 12) { // 移动端稍微加大触碰敏感区
|
if (dist < node.radius + 12) {
|
||||||
|
// 移动端稍微加大触碰敏感区
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -359,7 +408,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
hoveredNode = found;
|
hoveredNode = found;
|
||||||
|
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
canvas.style.cursor = 'grabbing';
|
canvas.style.cursor = 'grabbing';
|
||||||
} else {
|
} else {
|
||||||
@ -409,9 +458,12 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
hasDragged = false;
|
hasDragged = false;
|
||||||
dragStartX = e.touches[0].clientX - offsetX;
|
dragStartX = e.touches[0].clientX - offsetX;
|
||||||
dragStartY = e.touches[0].clientY - offsetY;
|
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) {
|
} else if (e.touches.length === 2) {
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
@ -435,7 +487,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||||
|
|
||||||
// 双指开合缩放
|
// 双指开合缩放
|
||||||
const newScale = initialScale * (dist / initialTouchDistance);
|
const newScale = initialScale * (dist / initialTouchDistance);
|
||||||
scale = Math.max(0.15, Math.min(5.0, newScale));
|
scale = Math.max(0.15, Math.min(5.0, newScale));
|
||||||
@ -459,7 +511,7 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
canvas.addEventListener('mouseleave', handleMouseLeave);
|
canvas.addEventListener('mouseleave', handleMouseLeave);
|
||||||
canvas.addEventListener('click', handleCanvasClick);
|
canvas.addEventListener('click', handleCanvasClick);
|
||||||
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
|
||||||
// 注册触控事件
|
// 注册触控事件
|
||||||
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
|
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||||
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
|
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
@ -473,13 +525,13 @@ export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, onNod
|
|||||||
canvas.removeEventListener('mouseleave', handleMouseLeave);
|
canvas.removeEventListener('mouseleave', handleMouseLeave);
|
||||||
canvas.removeEventListener('click', handleCanvasClick);
|
canvas.removeEventListener('click', handleCanvasClick);
|
||||||
canvas.removeEventListener('wheel', handleWheel);
|
canvas.removeEventListener('wheel', handleWheel);
|
||||||
|
|
||||||
// 注销触控事件
|
// 注销触控事件
|
||||||
canvas.removeEventListener('touchstart', handleTouchStart);
|
canvas.removeEventListener('touchstart', handleTouchStart);
|
||||||
canvas.removeEventListener('touchmove', handleTouchMove);
|
canvas.removeEventListener('touchmove', handleTouchMove);
|
||||||
canvas.removeEventListener('touchend', handleTouchEnd);
|
canvas.removeEventListener('touchend', handleTouchEnd);
|
||||||
};
|
};
|
||||||
}, [networks, activeNetwork, onNodeClick]);
|
}, [networks, activeNetwork, nodeLimit, onNodeClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@ -12,6 +12,7 @@ interface CustomSelectProps<T extends string | number = string | number> {
|
|||||||
options: SelectOption<T>[];
|
options: SelectOption<T>[];
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomSelect<T extends string | number = string | number>({
|
export function CustomSelect<T extends string | number = string | number>({
|
||||||
@ -20,6 +21,7 @@ export function CustomSelect<T extends string | number = string | number>({
|
|||||||
options,
|
options,
|
||||||
className = '',
|
className = '',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
size = 'md',
|
||||||
}: CustomSelectProps<T>) {
|
}: CustomSelectProps<T>) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@ -27,7 +29,10 @@ export function CustomSelect<T extends string | number = string | number>({
|
|||||||
// Close when clicking outside
|
// Close when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
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);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -39,7 +44,8 @@ export function CustomSelect<T extends string | number = string | number>({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedOption = options.find(opt => opt.value === value) || options[0];
|
const selectedOption =
|
||||||
|
options.find((opt) => opt.value === value) || options[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`relative inline-block ${className}`}>
|
<div ref={containerRef} className={`relative inline-block ${className}`}>
|
||||||
@ -47,7 +53,9 @@ export function CustomSelect<T extends string | number = string | number>({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
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' : ''
|
isOpen ? 'border-blueprint ring-1 ring-blueprint bg-white' : ''
|
||||||
} ${disabled ? 'bg-slate-50 text-slate-450 cursor-not-allowed border-slate-200' : ''}`}
|
} ${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>
|
</button>
|
||||||
|
|
||||||
{isOpen && !disabled && (
|
{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">
|
<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 => {
|
{options.map((option) => {
|
||||||
const isSelected = option.value === value;
|
const isSelected = option.value === value;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -71,7 +79,9 @@ export function CustomSelect<T extends string | number = string | number>({
|
|||||||
onChange(option.value);
|
onChange(option.value);
|
||||||
setIsOpen(false);
|
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
|
isSelected
|
||||||
? 'bg-blueprint/5 text-blueprint font-bold'
|
? 'bg-blueprint/5 text-blueprint font-bold'
|
||||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 font-medium'
|
: '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">
|
<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" />
|
<AlertTriangle className="w-6 h-6" />
|
||||||
</div>
|
</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">
|
<p className="text-xs text-slate-500 text-center max-w-sm mb-4">
|
||||||
{this.state.error?.message || '发生了未知错误'}
|
{this.state.error?.message || '发生了未知错误'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -3,19 +3,100 @@ interface LogoProps {
|
|||||||
gradientId?: string;
|
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 (
|
return (
|
||||||
<svg width="100%" height="100%" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
<svg
|
||||||
<circle cx="24" cy="24" r="18" stroke="#bae6fd" strokeWidth="1.5" />
|
width="100%"
|
||||||
<circle cx="24" cy="24" r="21" stroke="#0284c7" strokeWidth="1.5" strokeDasharray="2 3" />
|
height="100%"
|
||||||
<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})`} />
|
viewBox="0 0 48 48"
|
||||||
<ellipse cx="24" cy="24" rx="20" ry="7" transform="rotate(-28 24 24)" stroke="#0284c7" strokeWidth="2" />
|
fill="none"
|
||||||
<circle cx="38" cy="16" r="4.5" fill="#0284c7" stroke="#ffffff" strokeWidth="1.5" />
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<circle cx="10" cy="32" r="2.5" fill="#38bdf8" />
|
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>
|
<defs>
|
||||||
<linearGradient id={gradientId} x1="15" y1="9" x2="33" y2="33" gradientUnits="userSpaceOnUse">
|
{/* Glow filter for the star */}
|
||||||
<stop offset="0%" stopColor="#0284c7" />
|
<filter id="logoGlow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
<stop offset="100%" stopColor="#0369a1" />
|
<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>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@ -42,7 +42,8 @@ export function PaperCard({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-500 font-semibold mt-1.5 uppercase">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -67,11 +68,15 @@ export function PaperCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`console-panel p-6 rounded-lg border transition-all relative ${
|
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 */}
|
{/* 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 flex-col md:flex-row md:justify-between md:items-start gap-4 mb-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -83,7 +88,17 @@ export function PaperCard({
|
|||||||
<span className="align-middle">{paper.title}</span>
|
<span className="align-middle">{paper.title}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-500 font-medium mt-2 leading-snug">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -99,11 +114,7 @@ export function PaperCard({
|
|||||||
{/* Footer Area */}
|
{/* Footer Area */}
|
||||||
{(footerLeft || footerRight) && (
|
{(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">
|
<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 && (
|
{footerLeft && <div className="flex gap-2">{footerLeft}</div>}
|
||||||
<div className="flex gap-2">
|
|
||||||
{footerLeft}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{footerRight && (
|
{footerRight && (
|
||||||
<div className="text-xs text-slate-450 flex flex-wrap gap-x-4 gap-y-1 font-mono">
|
<div className="text-xs text-slate-450 flex flex-wrap gap-x-4 gap-y-1 font-mono">
|
||||||
{footerRight}
|
{footerRight}
|
||||||
@ -114,4 +125,3 @@ export function PaperCard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Brain, Compass, BookOpen, Send, Square, Paperclip, X
|
Brain,
|
||||||
|
Compass,
|
||||||
|
BookOpen,
|
||||||
|
Send,
|
||||||
|
Square,
|
||||||
|
Paperclip,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { AgentMode } from '../../hooks/useResearchAgent';
|
import type { AgentMode } from '../../hooks/useResearchAgent';
|
||||||
|
|
||||||
@ -13,8 +19,20 @@ interface AgentInputAreaProps {
|
|||||||
agentMode: string;
|
agentMode: string;
|
||||||
setAgentMode: (val: string) => void;
|
setAgentMode: (val: string) => void;
|
||||||
agentModes: AgentMode[];
|
agentModes: AgentMode[];
|
||||||
pendingImage: { data?: string; path?: string; mime_type: string; name: string } | null;
|
pendingImage: {
|
||||||
setPendingImage: (val: { data?: string; path?: string; mime_type: string; name: string } | null) => void;
|
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;
|
onSend: (text: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
@ -25,10 +43,12 @@ interface AgentInputAreaProps {
|
|||||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
Brain,
|
Brain,
|
||||||
Compass,
|
Compass,
|
||||||
BookOpen
|
BookOpen,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getIconComponent(iconName: string): React.ComponentType<{ className?: string }> {
|
function getIconComponent(
|
||||||
|
iconName: string
|
||||||
|
): React.ComponentType<{ className?: string }> {
|
||||||
return ICON_MAP[iconName] || Brain;
|
return ICON_MAP[iconName] || Brain;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +83,11 @@ export function AgentInputArea({
|
|||||||
{pendingImage && (
|
{pendingImage && (
|
||||||
<div className="relative inline-block mb-1 group select-none self-start">
|
<div className="relative inline-block mb-1 group select-none self-start">
|
||||||
<img
|
<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"
|
alt="Preview"
|
||||||
className="w-12 h-12 object-cover rounded-lg border border-slate-200 shadow-sm"
|
className="w-12 h-12 object-cover rounded-lg border border-slate-200 shadow-sm"
|
||||||
/>
|
/>
|
||||||
@ -119,7 +143,7 @@ export function AgentInputArea({
|
|||||||
>
|
>
|
||||||
<Paperclip className="w-3.5 h-3.5" />
|
<Paperclip className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Agent 模式选择器 */}
|
{/* Agent 模式选择器 */}
|
||||||
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
|
<div className="flex gap-1 bg-slate-100 p-0.5 rounded-lg border border-slate-200">
|
||||||
{agentModes.map((m) => {
|
{agentModes.map((m) => {
|
||||||
@ -138,7 +162,9 @@ export function AgentInputArea({
|
|||||||
: 'bg-transparent border-transparent text-slate-400 hover:text-blueprint'
|
: 'bg-transparent border-transparent text-slate-400 hover:text-blueprint'
|
||||||
} disabled:opacity-60`}
|
} 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>
|
<span className="hidden sm:inline">{m.name}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@ -153,14 +179,20 @@ export function AgentInputArea({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setThinking(!thinking)}
|
onClick={() => setThinking(!thinking)}
|
||||||
disabled={streaming}
|
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 ${
|
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
|
thinking
|
||||||
? 'bg-blueprint/5 border-blueprint/30 text-blueprint shadow-3xs'
|
? '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'
|
: 'bg-white border-slate-200 text-slate-400 hover:text-blueprint hover:border-blueprint/20'
|
||||||
} disabled:opacity-60`}
|
} 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>
|
<span className="hidden sm:inline">思考</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@ -168,7 +200,7 @@ export function AgentInputArea({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onStop}
|
onClick={onStop}
|
||||||
|
|||||||
@ -1,7 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
PanelLeftOpen, BarChart3, ScrollText, Trash2, Loader, Compass, Rewind,
|
PanelLeftOpen,
|
||||||
CheckCircle2, AlertTriangle, RotateCcw, RefreshCw, GitBranch
|
BarChart3,
|
||||||
|
ScrollText,
|
||||||
|
Trash2,
|
||||||
|
Loader,
|
||||||
|
Compass,
|
||||||
|
Rewind,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
RotateCcw,
|
||||||
|
RefreshCw,
|
||||||
|
GitBranch,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AgentMarkdown } from './AgentMarkdown';
|
import { AgentMarkdown } from './AgentMarkdown';
|
||||||
import { ThoughtCard } from './ThoughtCard';
|
import { ThoughtCard } from './ThoughtCard';
|
||||||
@ -12,17 +22,33 @@ import { PARENT_COLORS } from './constants';
|
|||||||
import type { ColorScheme } from './constants';
|
import type { ColorScheme } from './constants';
|
||||||
import type { TimelineItem } from './types';
|
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 { AskUserQuestionCard } from './AskUserQuestionCard';
|
||||||
import { PermissionRequestCard } from './PermissionRequestCard';
|
import { PermissionRequestCard } from './PermissionRequestCard';
|
||||||
import { AgentMetricsPanel } from './AgentMetricsPanel';
|
import { AgentMetricsPanel } from './AgentMetricsPanel';
|
||||||
import { AuditLogViewer } from './AuditLogViewer';
|
import { AuditLogViewer } from './AuditLogViewer';
|
||||||
|
|
||||||
const QUICK_PROMPTS = [
|
const QUICK_PROMPTS = [
|
||||||
"检索 2023 年以后关于热亚矮星 (Subdwarf) 双星演化的研究成果",
|
'检索 2023 年以后关于热亚矮星 (Subdwarf) 双星演化的研究成果',
|
||||||
"读取文献 2024ApJ...960..123A 并总结其核心观测结论",
|
'读取文献 2024ApJ...960..123A 并总结其核心观测结论',
|
||||||
"在馆藏中语义搜索关于白矮星非径向振动的讨论",
|
'在馆藏中语义搜索关于白矮星非径向振动的讨论',
|
||||||
"查询天体 GD 358 的物理参数"
|
'查询天体 GD 358 的物理参数',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SYSTEM_INTERNAL_TOOLS = [
|
||||||
|
'compress_context',
|
||||||
|
'load_skill',
|
||||||
|
'save_memory',
|
||||||
|
'load_memory',
|
||||||
|
'bg_task_check',
|
||||||
|
'search_history',
|
||||||
];
|
];
|
||||||
|
|
||||||
interface AgentMessageListProps {
|
interface AgentMessageListProps {
|
||||||
@ -58,8 +84,21 @@ interface AgentMessageListProps {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
setSidebarCollapsed: (val: boolean) => void;
|
setSidebarCollapsed: (val: boolean) => void;
|
||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
handleDeleteSession: (sessionId: string, e: React.MouseEvent) => Promise<void>;
|
handleDeleteSession: (
|
||||||
loadSessionHistory: (sessionId: string, silent: boolean, onSuccess?: () => void) => Promise<void>;
|
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({
|
export function AgentMessageList({
|
||||||
@ -97,13 +136,16 @@ export function AgentMessageList({
|
|||||||
sessions,
|
sessions,
|
||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
loadSessionHistory,
|
loadSessionHistory,
|
||||||
|
openReader,
|
||||||
|
setActiveTab,
|
||||||
|
citations,
|
||||||
|
library,
|
||||||
}: AgentMessageListProps) {
|
}: AgentMessageListProps) {
|
||||||
|
|
||||||
const renderTimelineItem = (
|
const renderTimelineItem = (
|
||||||
item: TimelineItem,
|
item: TimelineItem,
|
||||||
idx: number,
|
idx: number,
|
||||||
isStreaming: boolean,
|
isStreaming: boolean,
|
||||||
colors: ColorScheme = PARENT_COLORS,
|
colors: ColorScheme = PARENT_COLORS
|
||||||
) => {
|
) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'subagent_container':
|
case 'subagent_container':
|
||||||
@ -139,6 +181,13 @@ export function AgentMessageList({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'tool_call': {
|
case 'tool_call': {
|
||||||
|
// 如果是系统级内部工具,并且执行没有出错,则直接在前端隐藏,避免聊天时间线过于嘈杂
|
||||||
|
if (
|
||||||
|
SYSTEM_INTERNAL_TOOLS.includes(item.name) &&
|
||||||
|
!item.result?.isError
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const tcId = item.id;
|
const tcId = item.id;
|
||||||
return (
|
return (
|
||||||
<ToolCallCard
|
<ToolCallCard
|
||||||
@ -153,6 +202,10 @@ export function AgentMessageList({
|
|||||||
isResultExpanded={expandedResults[tcId] === true}
|
isResultExpanded={expandedResults[tcId] === true}
|
||||||
onToggleArgs={() => toggleArgs(tcId)}
|
onToggleArgs={() => toggleArgs(tcId)}
|
||||||
onToggleResult={() => toggleResult(tcId)}
|
onToggleResult={() => toggleResult(tcId)}
|
||||||
|
openReader={openReader}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
citations={citations}
|
||||||
|
library={library}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -186,7 +239,8 @@ export function AgentMessageList({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-bold text-slate-800 tracking-wide line-clamp-1">
|
<h3 className="text-xs font-bold text-slate-800 tracking-wide line-clamp-1">
|
||||||
{currentSessionId
|
{currentSessionId
|
||||||
? (sessions.find(s => s.session_id === currentSessionId)?.title || '未命名会话')
|
? sessions.find((s) => s.session_id === currentSessionId)
|
||||||
|
?.title || '未命名会话'
|
||||||
: '探索性科研研讨'}
|
: '探索性科研研讨'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
|
<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">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* 指标面板按钮 */}
|
{/* 指标面板按钮 */}
|
||||||
<button
|
<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 ${
|
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||||
showMetrics
|
showMetrics
|
||||||
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
||||||
@ -210,7 +267,10 @@ export function AgentMessageList({
|
|||||||
{/* 审计日志按钮 */}
|
{/* 审计日志按钮 */}
|
||||||
{currentSessionId && (
|
{currentSessionId && (
|
||||||
<button
|
<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 ${
|
className={`p-1.5 rounded-lg border text-xs font-bold transition-all cursor-pointer flex items-center gap-1 ${
|
||||||
showAuditLog
|
showAuditLog
|
||||||
? 'bg-amber-50 border-amber-200 text-amber-700'
|
? 'bg-amber-50 border-amber-200 text-amber-700'
|
||||||
@ -224,10 +284,10 @@ export function AgentMessageList({
|
|||||||
{currentSessionId && (
|
{currentSessionId && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDeleteSession(currentSessionId, e)}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
<span>删除会话</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -255,22 +315,28 @@ export function AgentMessageList({
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className={`flex-1 overflow-y-auto p-5 space-y-6 bg-slate-50/50 scrollbar-thin transition-all duration-300 ${
|
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 ? (
|
{turns.length === 0 && !activeTurn ? (
|
||||||
<div className="py-12 space-y-8 max-w-xl mx-auto">
|
<div className="py-12 space-y-8 max-w-xl mx-auto">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<Compass className="w-12 h-12 mx-auto text-sky-500 opacity-60" />
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 快速提示词 */}
|
{/* 快速提示词 */}
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{QUICK_PROMPTS.map((q, idx) => (
|
{QUICK_PROMPTS.map((q, idx) => (
|
||||||
<button
|
<button
|
||||||
@ -278,8 +344,12 @@ export function AgentMessageList({
|
|||||||
onClick={() => handleSend(q)}
|
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"
|
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="group-hover:text-blueprint font-semibold">
|
||||||
<span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider">点击开始</span>
|
{q}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400 uppercase font-bold tracking-wider">
|
||||||
|
点击开始
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -292,7 +362,9 @@ export function AgentMessageList({
|
|||||||
<div key={turn.turn_index} className="space-y-4">
|
<div key={turn.turn_index} className="space-y-4">
|
||||||
{/* 用户提问 */}
|
{/* 用户提问 */}
|
||||||
<div className="flex flex-col items-end space-y-1 group relative">
|
<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%]">
|
<div className="flex items-center gap-2 max-w-[85%]">
|
||||||
{turn.questionMessageId && (
|
{turn.questionMessageId && (
|
||||||
<button
|
<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">
|
<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 && (
|
{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>{turn.question}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -315,7 +391,9 @@ export function AgentMessageList({
|
|||||||
{/* 时间线 — 思考/工具/答案 */}
|
{/* 时间线 — 思考/工具/答案 */}
|
||||||
{turn.timeline.length > 0 && (
|
{turn.timeline.length > 0 && (
|
||||||
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -333,10 +411,16 @@ export function AgentMessageList({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 当前提问 */}
|
{/* 当前提问 */}
|
||||||
<div className="flex flex-col items-end space-y-1">
|
<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">
|
<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 && (
|
{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>{activeTurn.question}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -345,7 +429,10 @@ export function AgentMessageList({
|
|||||||
{/* SSE Stream Timeline */}
|
{/* SSE Stream Timeline */}
|
||||||
{activeTurn.timeline.length > 0 && (
|
{activeTurn.timeline.length > 0 && (
|
||||||
<div className="pl-4 border-l border-slate-200 space-y-3 my-2 relative">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -366,7 +453,9 @@ export function AgentMessageList({
|
|||||||
{streaming && !activeTurn.finalAnswer && !activeTurn.error && (
|
{streaming && !activeTurn.finalAnswer && !activeTurn.error && (
|
||||||
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
||||||
<Loader className="w-4 h-4 animate-spin text-blueprint" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -376,7 +465,9 @@ export function AgentMessageList({
|
|||||||
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold">查询发生错误</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -431,11 +522,15 @@ export function AgentMessageList({
|
|||||||
loadSessionHistory(currentSessionId, true);
|
loadSessionHistory(currentSessionId, true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onQuestionCountChange={(count: number) => setHasPendingQuestion(count > 0)}
|
onQuestionCountChange={(count: number) =>
|
||||||
|
setHasPendingQuestion(count > 0)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<PermissionRequestCard
|
<PermissionRequestCard
|
||||||
sessionId={currentSessionId}
|
sessionId={currentSessionId}
|
||||||
onRequestCountChange={(count: number) => setHasPendingPermission(count > 0)}
|
onRequestCountChange={(count: number) =>
|
||||||
|
setHasPendingPermission(count > 0)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
// dashboard/src/features/agent/AgentMetricsPanel.tsx
|
// dashboard/src/features/agent/AgentMetricsPanel.tsx
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
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';
|
import type { AgentMetricsResponse } from '../../types';
|
||||||
|
|
||||||
interface AgentMetricsPanelProps {
|
interface AgentMetricsPanelProps {
|
||||||
@ -83,7 +91,10 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
|||||||
setMetrics(res.data);
|
setMetrics(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取智能体指标失败:', e);
|
console.error('获取智能体指标失败:', e);
|
||||||
showAlert?.('获取智能体运行指标失败,请确认后端服务状态。', '指标加载出错');
|
showAlert?.(
|
||||||
|
'获取智能体运行指标失败,请确认后端服务状态。',
|
||||||
|
'指标加载出错'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -98,13 +109,18 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
|||||||
if (active) setMetrics(res.data);
|
if (active) setMetrics(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取智能体指标失败:', e);
|
console.error('获取智能体指标失败:', e);
|
||||||
showAlert?.('获取智能体运行指标失败,请确认后端服务状态。', '指标加载出错');
|
showAlert?.(
|
||||||
|
'获取智能体运行指标失败,请确认后端服务状态。',
|
||||||
|
'指标加载出错'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setLoading(false);
|
if (active) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { active = false; };
|
return () => {
|
||||||
}, []);
|
active = false;
|
||||||
|
};
|
||||||
|
}, [showAlert]);
|
||||||
|
|
||||||
// 提取工具调用排行(取前10)
|
// 提取工具调用排行(取前10)
|
||||||
const toolBreakdown = metrics?.tool_call_breakdown
|
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"
|
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="刷新指标"
|
title="刷新指标"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
|
className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -183,11 +201,16 @@ export function AgentMetricsPanel({ showAlert }: AgentMetricsPanelProps) {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{toolBreakdown.map(([name, count]) => {
|
{toolBreakdown.map(([name, count]) => {
|
||||||
const barWidth = Math.max((count / maxToolCalls) * 100, 2);
|
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;
|
const label = TOOL_LABELS[name] || name;
|
||||||
return (
|
return (
|
||||||
<div key={name} className="flex items-center gap-2.5">
|
<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}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 h-5 bg-slate-100 rounded-full overflow-hidden border border-slate-200">
|
<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>
|
</span>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<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
|
<span
|
||||||
key={category}
|
key={category}
|
||||||
className="px-2.5 py-1 rounded-lg text-[10px] font-bold border bg-slate-50 text-slate-600 border-slate-200"
|
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 (
|
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">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
<span className="opacity-60">{icon}</span>
|
<span className="opacity-60">{icon}</span>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">{label}</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider opacity-70">
|
||||||
</div>
|
{label}
|
||||||
<div className="text-lg font-extrabold tracking-tight">
|
</span>
|
||||||
{value}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-lg font-extrabold tracking-tight">{value}</div>
|
||||||
</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[]> = {
|
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'],
|
'RAG/天体': ['rag_search', 'query_target', 'save_note'],
|
||||||
'Agent控制': ['todo_write', 'compress_context', 'load_skill', 'subagent', 'delegate_research', 'ask_user'],
|
Agent控制: [
|
||||||
'记忆系统': ['save_memory', 'load_memory'],
|
'todo_write',
|
||||||
'后台任务': ['bg_task_run', 'bg_task_check'],
|
'compress_context',
|
||||||
'团队协作': ['spawn_teammate', 'send_teammate_message', 'team_broadcast', 'check_team_inbox'],
|
'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> = {};
|
const result: Record<string, number> = {};
|
||||||
|
|||||||
@ -1,8 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
Clock, Plus, PanelLeftClose, Search, X, MessageSquare, BookOpen, Trash2, Loader
|
Clock,
|
||||||
|
Plus,
|
||||||
|
PanelLeftClose,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
MessageSquare,
|
||||||
|
BookOpen,
|
||||||
|
Trash2,
|
||||||
|
Loader,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { SessionSummary, SearchResult } from '../../hooks/useResearchAgent';
|
import type {
|
||||||
|
SessionSummary,
|
||||||
|
SearchResult,
|
||||||
|
} from '../../hooks/useResearchAgent';
|
||||||
|
|
||||||
interface AgentSessionSidebarProps {
|
interface AgentSessionSidebarProps {
|
||||||
sessions: SessionSummary[];
|
sessions: SessionSummary[];
|
||||||
@ -37,7 +48,6 @@ export function AgentSessionSidebar({
|
|||||||
onNewSession,
|
onNewSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
}: AgentSessionSidebarProps) {
|
}: AgentSessionSidebarProps) {
|
||||||
|
|
||||||
const handleSelectSession = (id: string | null) => {
|
const handleSelectSession = (id: string | null) => {
|
||||||
setCurrentSessionId(id);
|
setCurrentSessionId(id);
|
||||||
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
|
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||||
@ -64,11 +74,11 @@ export function AgentSessionSidebar({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<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
|
collapsed
|
||||||
? '-translate-x-full lg:translate-x-0 lg:w-0 lg:overflow-hidden lg:opacity-0 lg:border-r-0'
|
? '-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">
|
<div className="flex flex-col min-h-0 flex-1">
|
||||||
@ -101,7 +111,7 @@ export function AgentSessionSidebar({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="搜索历史会话或消息内容..."
|
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"
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={searchScopeOnlyCurrent}
|
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"
|
className="rounded text-blueprint border-slate-300 focus:ring-blueprint w-3 h-3 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span>仅搜索当前会话</span>
|
<span>仅搜索当前会话</span>
|
||||||
@ -145,7 +155,9 @@ export function AgentSessionSidebar({
|
|||||||
searchResults.map((result, idx) => {
|
searchResults.map((result, idx) => {
|
||||||
const isActive = result.session_id === currentSessionId;
|
const isActive = result.session_id === currentSessionId;
|
||||||
const isMessage = result.result_type.startsWith('message/');
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -177,7 +189,7 @@ export function AgentSessionSidebar({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xs leading-snug line-clamp-1 block text-left font-bold text-slate-800">
|
<span className="text-xs leading-snug line-clamp-1 block text-left font-bold text-slate-800">
|
||||||
{result.title || '无标题会话'}
|
{result.title || '无标题会话'}
|
||||||
</span>
|
</span>
|
||||||
@ -190,49 +202,48 @@ 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" />
|
||||||
|
<span>加载历史会话中...</span>
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-400 text-[11px] italic">
|
||||||
|
暂无历史会话记录
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 正常的会话列表渲染
|
sessions.map((session) => {
|
||||||
loadingSessions ? (
|
const isActive = session.session_id === currentSessionId;
|
||||||
<div className="flex items-center justify-center p-8 text-slate-400 text-xs gap-2">
|
return (
|
||||||
<Loader className="w-3.5 h-3.5 animate-spin text-blueprint" />
|
<button
|
||||||
<span>加载历史会话中...</span>
|
key={session.session_id}
|
||||||
</div>
|
onClick={() => handleSelectSession(session.session_id)}
|
||||||
) : sessions.length === 0 ? (
|
className={`w-full text-left p-3 rounded-md border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
|
||||||
<div className="text-center py-12 text-slate-400 text-[11px] italic">
|
isActive
|
||||||
暂无历史会话记录
|
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
|
||||||
</div>
|
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-655'
|
||||||
) : (
|
}`}
|
||||||
sessions.map(session => {
|
>
|
||||||
const isActive = session.session_id === currentSessionId;
|
<div className="flex-1 min-w-0 flex flex-col gap-1 text-left">
|
||||||
return (
|
<span className="text-xs leading-snug line-clamp-2 block text-left">
|
||||||
|
{session.title || '无标题会话'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400 font-semibold block text-left">
|
||||||
|
{session.turn_count} 轮交互 •{' '}
|
||||||
|
{session.updated_at.split(' ')[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
key={session.session_id}
|
onClick={(e) => onDeleteSession(session.session_id, e)}
|
||||||
onClick={() => handleSelectSession(session.session_id)}
|
className="text-slate-400 hover:text-red-655 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-white border border-transparent hover:border-slate-200 transition-all cursor-pointer shrink-0"
|
||||||
className={`w-full text-left p-3 rounded-md border transition-all duration-200 group flex items-start justify-between gap-2 cursor-pointer ${
|
title="删除此会话"
|
||||||
isActive
|
|
||||||
? 'bg-blueprint/5 border-blueprint text-blueprint font-bold shadow-2xs'
|
|
||||||
: 'border-transparent bg-transparent hover:bg-slate-100 text-slate-655'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-1 text-left">
|
<Trash2 className="w-3 h-3" />
|
||||||
<span className="text-xs leading-snug line-clamp-2 block text-left">
|
|
||||||
{session.title || '无标题会话'}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] text-slate-400 font-semibold block text-left">
|
|
||||||
{session.turn_count} 轮交互 • {session.updated_at.split(' ')[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={(e) => onDeleteSession(session.session_id, e)}
|
|
||||||
className="text-slate-400 hover:text-red-655 p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-white border border-transparent hover:border-slate-200 transition-all cursor-pointer shrink-0"
|
|
||||||
title="删除此会话"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
</button>
|
||||||
})
|
);
|
||||||
)
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,7 +10,11 @@ interface AnswerCardProps {
|
|||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnswerCard({ content, colorScheme, isStreaming }: AnswerCardProps) {
|
export function AnswerCard({
|
||||||
|
content,
|
||||||
|
colorScheme,
|
||||||
|
isStreaming,
|
||||||
|
}: AnswerCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
<div
|
<div
|
||||||
@ -24,9 +28,7 @@ export function AnswerCard({ content, colorScheme, isStreaming }: AnswerCardProp
|
|||||||
<span>结论</span>
|
<span>结论</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">
|
<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>
|
<AgentMarkdown>{content}</AgentMarkdown>
|
||||||
{content}
|
|
||||||
</AgentMarkdown>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,8 +9,13 @@ interface AskUserQuestionCardProps {
|
|||||||
onQuestionCountChange?: (count: number) => void;
|
onQuestionCountChange?: (count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUserQuestionCardProps) {
|
export function AskUserQuestionCard({
|
||||||
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]);
|
onAnswered,
|
||||||
|
onQuestionCountChange,
|
||||||
|
}: AskUserQuestionCardProps) {
|
||||||
|
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||||
const [freeText, setFreeText] = useState<Record<string, string>>({});
|
const [freeText, setFreeText] = useState<Record<string, string>>({});
|
||||||
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
|
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 : [];
|
const data = Array.isArray(res.data) ? res.data : [];
|
||||||
setPendingQuestions(data);
|
setPendingQuestions(data);
|
||||||
// 自动展开新问题
|
// 自动展开新问题
|
||||||
setExpanded(prev => {
|
setExpanded((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
for (const q of data) {
|
for (const q of data) {
|
||||||
if (q && q.question_id && !(q.question_id in next)) {
|
if (q && q.question_id && !(q.question_id in next)) {
|
||||||
@ -56,14 +61,18 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
onQuestionCountChange?.(pendingQuestions.length);
|
onQuestionCountChange?.(pendingQuestions.length);
|
||||||
}, [pendingQuestions.length, onQuestionCountChange]);
|
}, [pendingQuestions.length, onQuestionCountChange]);
|
||||||
|
|
||||||
const toggleOption = (questionId: string, optionLabel: string, multiSelect: boolean) => {
|
const toggleOption = (
|
||||||
setAnswers(prev => {
|
questionId: string,
|
||||||
|
optionLabel: string,
|
||||||
|
multiSelect: boolean
|
||||||
|
) => {
|
||||||
|
setAnswers((prev) => {
|
||||||
const current = prev[questionId] || [];
|
const current = prev[questionId] || [];
|
||||||
if (multiSelect) {
|
if (multiSelect) {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
[questionId]: current.includes(optionLabel)
|
[questionId]: current.includes(optionLabel)
|
||||||
? current.filter(o => o !== optionLabel)
|
? current.filter((o) => o !== optionLabel)
|
||||||
: [...current, optionLabel],
|
: [...current, optionLabel],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@ -73,8 +82,8 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (questionId: string) => {
|
const handleSubmit = async (questionId: string) => {
|
||||||
setSubmitting(prev => ({ ...prev, [questionId]: true }));
|
setSubmitting((prev) => ({ ...prev, [questionId]: true }));
|
||||||
setError(prev => ({ ...prev, [questionId]: null }));
|
setError((prev) => ({ ...prev, [questionId]: null }));
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/chat/answer', {
|
await axios.post('/api/chat/answer', {
|
||||||
question_id: questionId,
|
question_id: questionId,
|
||||||
@ -82,19 +91,21 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
free_text: freeText[questionId] || null,
|
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 };
|
const next = { ...prev };
|
||||||
delete next[questionId];
|
delete next[questionId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setFreeText(prev => {
|
setFreeText((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[questionId];
|
delete next[questionId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setError(prev => {
|
setError((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[questionId];
|
delete next[questionId];
|
||||||
return next;
|
return next;
|
||||||
@ -103,27 +114,30 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('提交答案失败:', e);
|
console.error('提交答案失败:', e);
|
||||||
const axiosError = e as { response?: { status?: number } };
|
const axiosError = e as { response?: { status?: number } };
|
||||||
const msg = axiosError.response?.status === 410
|
const msg =
|
||||||
? '该问题已超时或已被回答'
|
axiosError.response?.status === 410
|
||||||
: axiosError.response?.status === 404
|
? '该问题已超时或已被回答'
|
||||||
? '未找到该问题'
|
: axiosError.response?.status === 404
|
||||||
: '提交失败,请稍后重试';
|
? '未找到该问题'
|
||||||
setError(prev => ({ ...prev, [questionId]: msg }));
|
: '提交失败,请稍后重试';
|
||||||
|
setError((prev) => ({ ...prev, [questionId]: msg }));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(prev => ({ ...prev, [questionId]: false }));
|
setSubmitting((prev) => ({ ...prev, [questionId]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dismissQuestion = (questionId: string) => {
|
const dismissQuestion = (questionId: string) => {
|
||||||
setPendingQuestions(prev => prev.filter(q => q.question_id !== questionId));
|
setPendingQuestions((prev) =>
|
||||||
setExpanded(prev => ({ ...prev, [questionId]: false }));
|
prev.filter((q) => q.question_id !== questionId)
|
||||||
|
);
|
||||||
|
setExpanded((prev) => ({ ...prev, [questionId]: false }));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (pendingQuestions.length === 0) return null;
|
if (pendingQuestions.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{pendingQuestions.map(q => {
|
{pendingQuestions.map((q) => {
|
||||||
if (!q || !q.question_id) return null;
|
if (!q || !q.question_id) return null;
|
||||||
|
|
||||||
const isExpanded = expanded[q.question_id] !== false;
|
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">
|
<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 || '提问'}
|
{q.header || '提问'}
|
||||||
</span>
|
</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 || ''}
|
{q.question || ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -152,7 +169,12 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
{options.length > 0 && (
|
{options.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="text-[10px] font-bold text-blueprint hover:underline cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
{isExpanded ? '收起选项' : '展开选项'}
|
{isExpanded ? '收起选项' : '展开选项'}
|
||||||
@ -172,14 +194,24 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
</span>
|
</span>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{options.map((option, idx) => {
|
{options.map((option, idx) => {
|
||||||
const label = typeof option?.label === 'string' ? option.label : String(option);
|
const label =
|
||||||
const desc = typeof option?.description === 'string' ? option.description : '';
|
typeof option?.label === 'string'
|
||||||
const selected = (answers[q.question_id] || []).includes(label);
|
? option.label
|
||||||
|
: String(option);
|
||||||
|
const desc =
|
||||||
|
typeof option?.description === 'string'
|
||||||
|
? option.description
|
||||||
|
: '';
|
||||||
|
const selected = (
|
||||||
|
answers[q.question_id] || []
|
||||||
|
).includes(label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={idx}
|
key={idx}
|
||||||
onClick={() => toggleOption(q.question_id, label, multiSelect)}
|
onClick={() =>
|
||||||
|
toggleOption(q.question_id, label, multiSelect)
|
||||||
|
}
|
||||||
disabled={isSubmitting}
|
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 ${
|
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
|
selected
|
||||||
@ -189,13 +221,19 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
>
|
>
|
||||||
{/* 选择指示器 */}
|
{/* 选择指示器 */}
|
||||||
{multiSelect ? (
|
{multiSelect ? (
|
||||||
selected
|
selected ? (
|
||||||
? <CheckSquare className="w-3.5 h-3.5 text-blueprint shrink-0" />
|
<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" />
|
) : (
|
||||||
|
<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 ${
|
<div
|
||||||
selected ? 'border-blueprint bg-blueprint' : 'border-slate-300'
|
className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
|
||||||
}`}>
|
selected
|
||||||
|
? 'border-blueprint bg-blueprint'
|
||||||
|
: 'border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<div className="w-1 h-1 rounded-full bg-white" />
|
<div className="w-1 h-1 rounded-full bg-white" />
|
||||||
@ -203,12 +241,17 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 将 label 与 desc 合并在同一行呈现以压缩高度 */}
|
{/* 将 label 与 desc 合并在同一行呈现以压缩高度 */}
|
||||||
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 truncate">
|
<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 && (
|
{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}
|
— {desc}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -224,7 +267,12 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<textarea
|
<textarea
|
||||||
value={freeText[q.question_id] || ''}
|
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}
|
disabled={isSubmitting}
|
||||||
placeholder="补充说明(可选)..."
|
placeholder="补充说明(可选)..."
|
||||||
rows={1}
|
rows={1}
|
||||||
@ -274,4 +322,3 @@ export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUs
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,16 @@
|
|||||||
// dashboard/src/features/agent/AuditLogViewer.tsx
|
// dashboard/src/features/agent/AuditLogViewer.tsx
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
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';
|
import type { AuditLogEntry } from '../../types';
|
||||||
|
|
||||||
interface AuditLogViewerProps {
|
interface AuditLogViewerProps {
|
||||||
@ -48,7 +57,9 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
|||||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [expandedPreview, setExpandedPreview] = useState<Record<number, boolean>>({});
|
const [expandedPreview, setExpandedPreview] = useState<
|
||||||
|
Record<number, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
@ -57,7 +68,9 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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);
|
if (active) setEntries(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载审计日志失败:', e);
|
console.error('加载审计日志失败:', e);
|
||||||
@ -66,11 +79,13 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
|||||||
if (active) setLoading(false);
|
if (active) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { active = false; };
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const okCount = entries.filter(e => e.status === 'OK').length;
|
const okCount = entries.filter((e) => e.status === 'OK').length;
|
||||||
const failCount = entries.filter(e => e.status === 'FAIL').length;
|
const failCount = entries.filter((e) => e.status === 'FAIL').length;
|
||||||
const totalElapsed = entries.reduce((sum, e) => sum + e.elapsed_ms, 0);
|
const totalElapsed = entries.reduce((sum, e) => sum + e.elapsed_ms, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -97,15 +112,21 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
|||||||
{entries.length > 0 && (
|
{entries.length > 0 && (
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<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="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 className="text-[9px] font-bold text-emerald-500">成功</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-rose-50/40 border border-rose-200 px-3 py-2 text-center">
|
<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 className="text-[9px] font-bold text-rose-500">失败</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-slate-50 border border-slate-200 px-3 py-2 text-center">
|
<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 className="text-[9px] font-bold text-slate-500">总耗时</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -137,17 +158,28 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
|||||||
<table className="w-full text-[10px]">
|
<table className="w-full text-[10px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 text-left">
|
<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 w-10">
|
||||||
<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>
|
||||||
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider w-16 text-right">耗时</th>
|
<th className="pb-2 pr-2 font-extrabold text-slate-400 uppercase tracking-wider">
|
||||||
<th className="pb-2 font-extrabold text-slate-400 uppercase tracking-wider">输出预览</th>
|
工具
|
||||||
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map(entry => {
|
{entries.map((entry) => {
|
||||||
const isExpanded = expandedPreview[entry.id] || false;
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@ -179,7 +211,9 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
|||||||
FAIL
|
FAIL
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-slate-400 text-[9px]">{entry.status}</span>
|
<span className="text-slate-400 text-[9px]">
|
||||||
|
{entry.status}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-2 text-right font-mono text-slate-500 align-top">
|
<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}
|
{entry.output_preview}
|
||||||
</pre>
|
</pre>
|
||||||
<button
|
<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"
|
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" />
|
<ChevronUp className="w-2.5 h-2.5" />
|
||||||
@ -208,16 +247,28 @@ export function AuditLogViewer({ sessionId, onClose }: AuditLogViewerProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<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]"
|
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" />
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-slate-400 italic text-[9px]">—</span>
|
<span className="text-slate-400 italic text-[9px]">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 [pending, setPending] = useState<PendingPermissionRequest[]>([]);
|
||||||
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
|
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
|
||||||
const [responses, setResponses] = useState<Record<string, 'allow' | 'deny' | null>>({});
|
const [responses, setResponses] = useState<
|
||||||
const [expandedParams, setExpandedParams] = useState<Record<string, boolean>>({});
|
Record<string, 'allow' | 'deny' | null>
|
||||||
|
>({});
|
||||||
|
const [expandedParams, setExpandedParams] = useState<Record<string, boolean>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
// 轮询待处理权限请求
|
// 轮询待处理权限请求
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -25,7 +32,7 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
|||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<PendingPermissionRequest[]>(
|
const res = await axios.get<PendingPermissionRequest[]>(
|
||||||
`/api/chat/sessions/${sessionId}/permissions`,
|
`/api/chat/sessions/${sessionId}/permissions`
|
||||||
);
|
);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const data = Array.isArray(res.data) ? res.data : [];
|
const data = Array.isArray(res.data) ? res.data : [];
|
||||||
@ -47,41 +54,42 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
|||||||
const respond = async (
|
const respond = async (
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
allowed: boolean,
|
allowed: boolean,
|
||||||
allowAlways: boolean,
|
allowAlways: boolean
|
||||||
) => {
|
) => {
|
||||||
setSubmitting(prev => ({ ...prev, [toolCallId]: true }));
|
setSubmitting((prev) => ({ ...prev, [toolCallId]: true }));
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
await axios.post(`/api/chat/sessions/${sessionId}/permissions/respond`, {
|
||||||
`/api/chat/sessions/${sessionId}/permissions/respond`,
|
tool_call_id: toolCallId,
|
||||||
{ tool_call_id: toolCallId, allowed, allow_always: allowAlways },
|
allowed,
|
||||||
);
|
allow_always: allowAlways,
|
||||||
setResponses(prev => ({
|
});
|
||||||
|
setResponses((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[toolCallId]: allowed ? 'allow' : 'deny',
|
[toolCallId]: allowed ? 'allow' : 'deny',
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('权限响应发送失败:', err);
|
console.error('权限响应发送失败:', err);
|
||||||
} finally {
|
} 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(() => {
|
useEffect(() => {
|
||||||
onRequestCountChange?.(activeRequests.length);
|
onRequestCountChange?.(activeRequests.length);
|
||||||
}, [activeRequests.length, onRequestCountChange]);
|
}, [activeRequests.length, onRequestCountChange]);
|
||||||
|
|
||||||
const toggleParams = (id: string) => {
|
const toggleParams = (id: string) => {
|
||||||
setExpandedParams(prev => ({ ...prev, [id]: !prev[id] }));
|
setExpandedParams((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (activeRequests.length === 0) return null;
|
if (activeRequests.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{activeRequests.map(req => {
|
{activeRequests.map((req) => {
|
||||||
const hasArgs = Object.keys(req.arguments || {}).length > 0;
|
const hasArgs = Object.keys(req.arguments || {}).length > 0;
|
||||||
const showArgs = expandedParams[req.permission_id] || false;
|
const showArgs = expandedParams[req.permission_id] || false;
|
||||||
|
|
||||||
@ -94,12 +102,17 @@ 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="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">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Shield className="h-3.5 w-3.5 text-amber-500 shrink-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>
|
<span className="font-bold text-slate-800 tracking-wide text-[11px] shrink-0">
|
||||||
<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>
|
||||||
|
<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}
|
{req.tool_name}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasArgs && (
|
{hasArgs && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -112,7 +125,9 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
|||||||
</div>
|
</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 && (
|
{hasArgs && showArgs && (
|
||||||
@ -157,5 +172,3 @@ export function PermissionRequestCard({ sessionId, onRequestCountChange }: Permi
|
|||||||
</div>
|
</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) {
|
}: SubAgentContainerProps) {
|
||||||
const isComplete = status === 'complete';
|
const isComplete = status === 'complete';
|
||||||
const stepCount = children.length;
|
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 (
|
return (
|
||||||
<div className="relative space-y-2 select-none">
|
<div className="relative space-y-2 select-none">
|
||||||
@ -69,7 +69,9 @@ export function SubAgentContainer({
|
|||||||
{stepCount} 步骤 · {toolCount} 工具
|
{stepCount} 步骤 · {toolCount} 工具
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<span className="text-[10px] font-bold text-violet-600 hover:text-violet-800 shrink-0">
|
<span className="text-[10px] font-bold text-violet-600 hover:text-violet-800 shrink-0">
|
||||||
{isCollapsed ? '展开' : '收起'}
|
{isCollapsed ? '展开' : '收起'}
|
||||||
@ -112,7 +114,7 @@ export function SubAgentContainer({
|
|||||||
expandedResults,
|
expandedResults,
|
||||||
onToggleThought,
|
onToggleThought,
|
||||||
onToggleArgs,
|
onToggleArgs,
|
||||||
onToggleResult,
|
onToggleResult
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -133,7 +135,7 @@ function renderNestedTimelineItem(
|
|||||||
expandedResults: Record<string, boolean>,
|
expandedResults: Record<string, boolean>,
|
||||||
onToggleThought: (key: string) => void,
|
onToggleThought: (key: string) => void,
|
||||||
onToggleArgs: (tcId: string) => void,
|
onToggleArgs: (tcId: string) => void,
|
||||||
onToggleResult: (tcId: string) => void,
|
onToggleResult: (tcId: string) => void
|
||||||
) {
|
) {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'thought': {
|
case 'thought': {
|
||||||
|
|||||||
@ -21,7 +21,9 @@ export function ThoughtCard({
|
|||||||
onToggle,
|
onToggle,
|
||||||
}: ThoughtCardProps) {
|
}: ThoughtCardProps) {
|
||||||
const preview = content.length > 120 ? content.slice(0, 120) + '…' : content;
|
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 推导包裹色(用于容器背景)
|
// 从 colorScheme 推导包裹色(用于容器背景)
|
||||||
const isSub = colorScheme.thought.idle.includes('violet');
|
const isSub = colorScheme.thought.idle.includes('violet');
|
||||||
@ -30,7 +32,9 @@ export function ThoughtCard({
|
|||||||
: 'bg-purple-50/40 border border-purple-100/50';
|
: 'bg-purple-50/40 border border-purple-100/50';
|
||||||
const textClass = isSub ? 'text-violet-600' : 'text-purple-600';
|
const textClass = isSub ? 'text-violet-600' : 'text-purple-600';
|
||||||
const previewClass = isSub ? 'text-violet-400' : 'text-purple-400';
|
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';
|
const mutedClass = isSub ? 'text-violet-400' : 'text-purple-400';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -61,7 +65,9 @@ export function ThoughtCard({
|
|||||||
{content}
|
{content}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className={`text-[11px] italic font-medium leading-relaxed ${previewClass}`}>
|
<p
|
||||||
|
className={`text-[11px] italic font-medium leading-relaxed ${previewClass}`}
|
||||||
|
>
|
||||||
{preview}
|
{preview}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -5,18 +5,37 @@ import type { ColorScheme } from './constants';
|
|||||||
import { getToolDisplayName } from './toolDisplayNames';
|
import { getToolDisplayName } from './toolDisplayNames';
|
||||||
import { AgentMarkdown } from './AgentMarkdown';
|
import { AgentMarkdown } from './AgentMarkdown';
|
||||||
import { useAutoScroll } from './useAutoScroll';
|
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 {
|
interface ToolCallCardProps {
|
||||||
step: number;
|
step: number;
|
||||||
name: string;
|
name: string;
|
||||||
arguments: Record<string, unknown>;
|
arguments: Record<string, unknown>;
|
||||||
result?: { output: string; isError: boolean };
|
result?: {
|
||||||
|
output: string;
|
||||||
|
isError: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
colorScheme: ColorScheme;
|
colorScheme: ColorScheme;
|
||||||
isArgsExpanded: boolean;
|
isArgsExpanded: boolean;
|
||||||
isResultExpanded: boolean;
|
isResultExpanded: boolean;
|
||||||
onToggleArgs: () => void;
|
onToggleArgs: () => void;
|
||||||
onToggleResult: () => 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({
|
export function ToolCallCard({
|
||||||
@ -30,19 +49,167 @@ export function ToolCallCard({
|
|||||||
isResultExpanded,
|
isResultExpanded,
|
||||||
onToggleArgs,
|
onToggleArgs,
|
||||||
onToggleResult,
|
onToggleResult,
|
||||||
|
openReader,
|
||||||
|
setActiveTab,
|
||||||
|
citations,
|
||||||
|
library,
|
||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const hasResult = !!result;
|
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(
|
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 (
|
||||||
|
<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 (
|
return (
|
||||||
<div className="relative space-y-2 select-none">
|
<div className="relative space-y-2 select-none w-full">
|
||||||
<div
|
<div
|
||||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`}
|
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 justify-between">
|
||||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-sky-700 tracking-wider uppercase">
|
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-sky-700 tracking-wider uppercase">
|
||||||
@ -69,43 +236,47 @@ export function ToolCallCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 结果 */}
|
{/* 结果/特殊卡片 */}
|
||||||
{hasResult && (
|
{specialCard ? (
|
||||||
<div className="space-y-1.5 border-t border-slate-100 pt-2.5 mt-2.5">
|
<div className="border-t border-slate-100 pt-2.5 mt-2.5 w-full overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
{specialCard}
|
||||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-emerald-700 tracking-wider uppercase">
|
|
||||||
<Eye className="w-3.5 h-3.5" />
|
|
||||||
<span>观测与反馈</span>
|
|
||||||
{result!.isError && (
|
|
||||||
<span className="px-1.5 py-0.2 rounded bg-rose-50 text-rose-700 border border-rose-100 text-[9px]">
|
|
||||||
错误返回
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onToggleResult}
|
|
||||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 hover:underline cursor-pointer flex items-center gap-0.5"
|
|
||||||
>
|
|
||||||
{isResultExpanded ? '收起结果' : '展开结果'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{isResultExpanded ? (
|
|
||||||
<div
|
|
||||||
ref={scrollContainerRef}
|
|
||||||
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>
|
|
||||||
<div ref={chatEndRef} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-[10px] text-slate-400 italic font-medium">
|
|
||||||
结果已截断 (共 {result!.output.length} 字符)。点击右侧展开。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</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">
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
<span>观测与反馈</span>
|
||||||
|
{result!.isError && (
|
||||||
|
<span className="px-1.5 py-0.2 rounded bg-rose-50 text-rose-700 border border-rose-100 text-[9px]">
|
||||||
|
错误返回
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggleResult}
|
||||||
|
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 hover:underline cursor-pointer flex items-center gap-0.5"
|
||||||
|
>
|
||||||
|
{isResultExpanded ? '收起结果' : '展开结果'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isResultExpanded ? (
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
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>
|
||||||
|
<div ref={chatEndRef} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[10px] text-slate-400 italic font-medium">
|
||||||
|
结果已截断 (共 {result!.output.length} 字符)。点击右侧展开。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,12 +7,28 @@ export const safeSchema = {
|
|||||||
attributes: {
|
attributes: {
|
||||||
...defaultSchema.attributes,
|
...defaultSchema.attributes,
|
||||||
'*': (defaultSchema.attributes?.['*'] || []).concat([
|
'*': (defaultSchema.attributes?.['*'] || []).concat([
|
||||||
'className', 'style', 'mathvariant', 'display',
|
'className',
|
||||||
|
'style',
|
||||||
|
'mathvariant',
|
||||||
|
'display',
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
tagNames: (defaultSchema.tagNames || []).concat([
|
tagNames: (defaultSchema.tagNames || []).concat([
|
||||||
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup',
|
'math',
|
||||||
'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation',
|
'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(
|
export function routeThought(
|
||||||
timeline: TimelineItem[],
|
timeline: TimelineItem[],
|
||||||
step: number,
|
step: number,
|
||||||
content: string,
|
content: string
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
// 子代理思考 → 路由到活跃容器
|
// 子代理思考 → 路由到活跃容器
|
||||||
if (isSubagentThought(content)) {
|
if (isSubagentThought(content)) {
|
||||||
@ -58,12 +58,14 @@ export function routeThought(
|
|||||||
const saIdx = findStreamingSubAgent(timeline);
|
const saIdx = findStreamingSubAgent(timeline);
|
||||||
if (saIdx >= 0) {
|
if (saIdx >= 0) {
|
||||||
return updateContainerChild(timeline, saIdx, (children) => {
|
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) {
|
if (existing) {
|
||||||
return children.map(c =>
|
return children.map((c) =>
|
||||||
c.type === 'thought' && c.step === step
|
c.type === 'thought' && c.step === step
|
||||||
? { ...c, content: subContent } as TimelineItem
|
? ({ ...c, content: subContent } as TimelineItem)
|
||||||
: c,
|
: c
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return [...children, { type: 'thought', step, content: subContent }];
|
return [...children, { type: 'thought', step, content: subContent }];
|
||||||
@ -82,12 +84,12 @@ export function routeToolCall(
|
|||||||
step: number,
|
step: number,
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
// 创建 subagent_container
|
// 创建 subagent_container
|
||||||
if (isSubagentContainerCall(name)) {
|
if (isSubagentContainerCall(name)) {
|
||||||
const existing = timeline.find(
|
const existing = timeline.find(
|
||||||
t => t.type === 'subagent_container' && t.id === id,
|
(t) => t.type === 'subagent_container' && t.id === id
|
||||||
);
|
);
|
||||||
if (existing) return timeline;
|
if (existing) return timeline;
|
||||||
return [
|
return [
|
||||||
@ -108,12 +110,18 @@ export function routeToolCall(
|
|||||||
if (saIdx >= 0) {
|
if (saIdx >= 0) {
|
||||||
return updateContainerChild(timeline, saIdx, (children) => {
|
return updateContainerChild(timeline, saIdx, (children) => {
|
||||||
const dup = children.find(
|
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;
|
if (dup) return children;
|
||||||
return [
|
return [
|
||||||
...children,
|
...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,
|
name: string,
|
||||||
output: string,
|
output: string,
|
||||||
isError: boolean,
|
isError: boolean,
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
// 匹配 subagent_container id → 完成容器
|
// 匹配 subagent_container id → 完成容器
|
||||||
const saCompleteIdx = timeline.findIndex(
|
const saCompleteIdx = timeline.findIndex(
|
||||||
t => t.type === 'subagent_container' && t.id === toolCallId,
|
(t) => t.type === 'subagent_container' && t.id === toolCallId
|
||||||
);
|
);
|
||||||
if (saCompleteIdx >= 0) {
|
if (saCompleteIdx >= 0) {
|
||||||
const newTimeline = [...timeline];
|
const newTimeline = [...timeline];
|
||||||
@ -156,20 +165,20 @@ export function routeToolResult(
|
|||||||
const saIdx = findStreamingSubAgent(timeline);
|
const saIdx = findStreamingSubAgent(timeline);
|
||||||
if (saIdx >= 0) {
|
if (saIdx >= 0) {
|
||||||
return updateContainerChild(timeline, saIdx, (children) =>
|
return updateContainerChild(timeline, saIdx, (children) =>
|
||||||
children.map(c => {
|
children.map((c) => {
|
||||||
if (c.type === 'tool_call' && c.id === toolCallId && !c.result) {
|
if (c.type === 'tool_call' && c.id === toolCallId && !c.result) {
|
||||||
return { ...c, result: { output, isError } };
|
return { ...c, result: { output, isError, metadata } };
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 父代理工具结果
|
// 父代理工具结果
|
||||||
return timeline.map(t => {
|
return timeline.map((t) => {
|
||||||
if (t.type === 'tool_call' && t.id === toolCallId) {
|
if (t.type === 'tool_call' && t.id === toolCallId) {
|
||||||
return { ...t, result: { output, isError } };
|
return { ...t, result: { output, isError, metadata } };
|
||||||
}
|
}
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
@ -179,11 +188,11 @@ export function routeToolResult(
|
|||||||
export function routeTextDelta(
|
export function routeTextDelta(
|
||||||
timeline: TimelineItem[],
|
timeline: TimelineItem[],
|
||||||
content: string,
|
content: string,
|
||||||
toolCallId?: string,
|
toolCallId?: string
|
||||||
): { timeline: TimelineItem[]; answerDelta: string } {
|
): { timeline: TimelineItem[]; answerDelta: string } {
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
return {
|
return {
|
||||||
timeline: timeline.map(t => {
|
timeline: timeline.map((t) => {
|
||||||
if (t.type === 'tool_call' && t.id === toolCallId) {
|
if (t.type === 'tool_call' && t.id === toolCallId) {
|
||||||
const prevOutput = t.result?.output || '';
|
const prevOutput = t.result?.output || '';
|
||||||
return {
|
return {
|
||||||
@ -204,12 +213,14 @@ export function routeTextDelta(
|
|||||||
function upsertParentThought(
|
function upsertParentThought(
|
||||||
timeline: TimelineItem[],
|
timeline: TimelineItem[],
|
||||||
step: number,
|
step: number,
|
||||||
content: string,
|
content: string
|
||||||
): TimelineItem[] {
|
): 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) {
|
if (existing) {
|
||||||
return timeline.map(t =>
|
return timeline.map((t) =>
|
||||||
t.type === 'thought' && t.step === step ? { ...t, content } : t,
|
t.type === 'thought' && t.step === step ? { ...t, content } : t
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return [...timeline, { type: 'thought', step, content }];
|
return [...timeline, { type: 'thought', step, content }];
|
||||||
@ -220,19 +231,22 @@ function upsertParentToolCall(
|
|||||||
step: number,
|
step: number,
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
const existing = timeline.find(
|
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;
|
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(
|
function updateContainerChild(
|
||||||
timeline: TimelineItem[],
|
timeline: TimelineItem[],
|
||||||
containerIdx: number,
|
containerIdx: number,
|
||||||
updateFn: (children: TimelineItem[]) => TimelineItem[],
|
updateFn: (children: TimelineItem[]) => TimelineItem[]
|
||||||
): TimelineItem[] {
|
): TimelineItem[] {
|
||||||
const newTimeline = [...timeline];
|
const newTimeline = [...timeline];
|
||||||
const container = newTimeline[containerIdx] as Extract<
|
const container = newTimeline[containerIdx] as Extract<
|
||||||
|
|||||||
@ -4,41 +4,70 @@
|
|||||||
export function getToolDisplayName(name: string): string {
|
export function getToolDisplayName(name: string): string {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
// 文件系统工具
|
// 文件系统工具
|
||||||
case 'read_file': return '读取文件内容';
|
case 'read_file':
|
||||||
case 'grep_files': return '正则搜索文件';
|
return '读取文件内容';
|
||||||
case 'glob_files': return '通配符匹配文件';
|
case 'grep_files':
|
||||||
case 'run_bash': return '执行 Shell 命令';
|
return '正则搜索文件';
|
||||||
case 'file_write': return '写入文件';
|
case 'glob_files':
|
||||||
case 'file_edit': return '精确编辑文件';
|
return '通配符匹配文件';
|
||||||
|
case 'run_bash':
|
||||||
|
return '执行 Shell 命令';
|
||||||
|
case 'file_write':
|
||||||
|
return '写入文件';
|
||||||
|
case 'file_edit':
|
||||||
|
return '精确编辑文件';
|
||||||
// 天文科研工具
|
// 天文科研工具
|
||||||
case 'search_papers': return '检索 ADS/arXiv 文献';
|
case 'search_papers':
|
||||||
case 'get_paper_metadata': return '获取文献详细元数据';
|
return '检索 ADS/arXiv 文献';
|
||||||
case 'download_paper': return '下载文献全文资源';
|
case 'get_paper_metadata':
|
||||||
case 'parse_paper': return '结构化解析文献内容';
|
return '获取文献详细元数据';
|
||||||
case 'get_paper_content': return '获取文献全文内容';
|
case 'download_paper':
|
||||||
case 'rag_search': return '检索馆藏知识库';
|
return '下载文献全文资源';
|
||||||
case 'query_target': return '查询天体物理参数 (CDS)';
|
case 'parse_paper':
|
||||||
case 'save_note': return '保存文献手札';
|
return '结构化解析文献内容';
|
||||||
|
case 'get_paper_content':
|
||||||
|
return '获取文献全文内容';
|
||||||
|
case 'rag_search':
|
||||||
|
return '检索馆藏知识库';
|
||||||
|
case 'query_target':
|
||||||
|
return '查询天体物理参数 (CDS)';
|
||||||
|
case 'save_note':
|
||||||
|
return '保存文献手札';
|
||||||
// Agent 控制工具
|
// Agent 控制工具
|
||||||
case 'todo_write': return '管理任务列表';
|
case 'todo_write':
|
||||||
case 'compress_context': return '压缩上下文窗口';
|
return '管理任务列表';
|
||||||
case 'load_skill': return '加载专家技能';
|
case 'compress_context':
|
||||||
case 'subagent': return '派发子代理';
|
return '压缩上下文窗口';
|
||||||
case 'delegate_research': return '派发子代理';
|
case 'load_skill':
|
||||||
case 'ask_user': return '向用户提问';
|
return '加载专家技能';
|
||||||
|
case 'subagent':
|
||||||
|
return '派发子代理';
|
||||||
|
case 'delegate_research':
|
||||||
|
return '派发子代理';
|
||||||
|
case 'ask_user':
|
||||||
|
return '向用户提问';
|
||||||
// 记忆系统
|
// 记忆系统
|
||||||
case 'save_memory': return '保存项目记忆';
|
case 'save_memory':
|
||||||
case 'load_memory': return '读取项目记忆';
|
return '保存项目记忆';
|
||||||
|
case 'load_memory':
|
||||||
|
return '读取项目记忆';
|
||||||
// 图片分析
|
// 图片分析
|
||||||
case 'analyze_image': return '分析图像内容';
|
case 'analyze_image':
|
||||||
|
return '分析图像内容';
|
||||||
// 后台任务
|
// 后台任务
|
||||||
case 'bg_task_run': return '启动后台任务';
|
case 'bg_task_run':
|
||||||
case 'bg_task_check': return '检查后台任务状态';
|
return '启动后台任务';
|
||||||
|
case 'bg_task_check':
|
||||||
|
return '检查后台任务状态';
|
||||||
// 团队协作
|
// 团队协作
|
||||||
case 'spawn_teammate': return '创建团队成员';
|
case 'spawn_teammate':
|
||||||
case 'send_teammate_message': return '发送团队成员消息';
|
return '创建团队成员';
|
||||||
case 'team_broadcast': return '团队广播消息';
|
case 'send_teammate_message':
|
||||||
case 'check_team_inbox': return '检查团队收件箱';
|
return '发送团队成员消息';
|
||||||
|
case 'team_broadcast':
|
||||||
|
return '团队广播消息';
|
||||||
|
case 'check_team_inbox':
|
||||||
|
return '检查团队收件箱';
|
||||||
default: {
|
default: {
|
||||||
// 子代理工具名带 [sub] 前缀
|
// 子代理工具名带 [sub] 前缀
|
||||||
if (name.startsWith('[sub] ')) {
|
if (name.startsWith('[sub] ')) {
|
||||||
|
|||||||
@ -10,7 +10,11 @@ export type TimelineItem =
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
arguments: Record<string, unknown>;
|
arguments: Record<string, unknown>;
|
||||||
result?: { output: string; isError: boolean };
|
result?: {
|
||||||
|
output: string;
|
||||||
|
isError: boolean;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
| { type: 'answer'; content: string }
|
| { type: 'answer'; content: string }
|
||||||
| {
|
| {
|
||||||
@ -25,14 +29,19 @@ export type TimelineItem =
|
|||||||
export interface SSEEventHandlers {
|
export interface SSEEventHandlers {
|
||||||
onSession?: (sessionId: string, title?: string) => void;
|
onSession?: (sessionId: string, title?: string) => void;
|
||||||
onThought?: (step: number, content: 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?: (
|
onToolResult?: (
|
||||||
step: number,
|
step: number,
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
name: string,
|
name: string,
|
||||||
output: string,
|
output: string,
|
||||||
isError: boolean,
|
isError: boolean,
|
||||||
metadata?: Record<string, unknown>,
|
metadata?: Record<string, unknown>
|
||||||
) => void;
|
) => void;
|
||||||
onTextDelta?: (content: string, toolCallId?: string) => void;
|
onTextDelta?: (content: string, toolCallId?: string) => void;
|
||||||
onUsage?: (usage: {
|
onUsage?: (usage: {
|
||||||
|
|||||||
@ -5,7 +5,10 @@ import type { SSEEventHandlers, AgentSSEParams } from './types';
|
|||||||
|
|
||||||
interface UseAgentSSEReturn {
|
interface UseAgentSSEReturn {
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
send: (params: AgentSSEParams, handlers: SSEEventHandlers) => Promise<string | null>;
|
send: (
|
||||||
|
params: AgentSSEParams,
|
||||||
|
handlers: SSEEventHandlers
|
||||||
|
) => Promise<string | null>;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,13 +21,22 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (params: AgentSSEParams, handlers: SSEEventHandlers): Promise<string | null> => {
|
async (
|
||||||
|
params: AgentSSEParams,
|
||||||
|
handlers: SSEEventHandlers
|
||||||
|
): Promise<string | null> => {
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
|
||||||
try {
|
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,
|
question: params.question,
|
||||||
session_id: params.sessionId,
|
session_id: params.sessionId,
|
||||||
mode: params.mode || 'default',
|
mode: params.mode || 'default',
|
||||||
@ -88,7 +100,7 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
|||||||
event.step,
|
event.step,
|
||||||
event.id,
|
event.id,
|
||||||
event.name,
|
event.name,
|
||||||
event.arguments,
|
event.arguments
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'tool_result':
|
case 'tool_result':
|
||||||
@ -98,14 +110,11 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
|||||||
event.name,
|
event.name,
|
||||||
event.output,
|
event.output,
|
||||||
event.is_error,
|
event.is_error,
|
||||||
event.metadata,
|
event.metadata
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'text_delta':
|
case 'text_delta':
|
||||||
handlers.onTextDelta?.(
|
handlers.onTextDelta?.(event.content, event.tool_call_id);
|
||||||
event.content,
|
|
||||||
event.tool_call_id,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case 'usage':
|
case 'usage':
|
||||||
handlers.onUsage?.(event);
|
handlers.onUsage?.(event);
|
||||||
@ -137,7 +146,7 @@ export function useAgentSSE(): UseAgentSSEReturn {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { streaming, send, stop };
|
return { streaming, send, stop };
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export function useAutoScroll(deps: React.DependencyList): UseAutoScrollReturn {
|
|||||||
if (shouldAutoScroll) {
|
if (shouldAutoScroll) {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, deps);
|
}, deps);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
|
|||||||
@ -43,7 +43,11 @@ export function BaseModal({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{typeof title === 'string' ? (
|
{typeof title === 'string' ? (
|
||||||
<h3 className="text-xs font-bold text-main flex items-center gap-1.5 pr-4">
|
<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>
|
<span className="truncate">{title}</span>
|
||||||
</h3>
|
</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"
|
className="text-slate-400 hover:text-slate-600 p-1 rounded-md hover:bg-slate-100 transition-colors shrink-0 cursor-pointer"
|
||||||
title="关闭"
|
title="关闭"
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -34,7 +34,9 @@ export function GlobalDialog({ dialog, onClose }: GlobalDialogProps) {
|
|||||||
zIndex={60}
|
zIndex={60}
|
||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<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>
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -9,11 +9,17 @@ interface PaperDetailModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
uploadingBibcode: string | null;
|
uploadingBibcode: string | null;
|
||||||
downloadingBibcodes: Record<string, boolean>;
|
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>;
|
handleMarkNoResource: (bibcode: string, clear: boolean) => Promise<void>;
|
||||||
handleDownload: (bibcode: string, force?: boolean) => Promise<void>;
|
handleDownload: (bibcode: string, force?: boolean) => Promise<void>;
|
||||||
openReader: (paper: StandardPaper) => 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;
|
setSelectedPaper: (paper: StandardPaper) => void;
|
||||||
loadCitations: (bibcode: string) => Promise<void>;
|
loadCitations: (bibcode: string) => Promise<void>;
|
||||||
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
|
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
|
||||||
@ -37,7 +43,9 @@ export function PaperDetailModal({
|
|||||||
|
|
||||||
const modalTitle = (
|
const modalTitle = (
|
||||||
<div className="space-y-1 pr-4">
|
<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">
|
<h3 className="text-xs font-bold text-main leading-snug">
|
||||||
{getDoctypeBadge(paper.doctype)}
|
{getDoctypeBadge(paper.doctype)}
|
||||||
<span className="align-middle ml-1">{paper.title}</span>
|
<span className="align-middle ml-1">{paper.title}</span>
|
||||||
@ -53,298 +61,357 @@ export function PaperDetailModal({
|
|||||||
size="lg"
|
size="lg"
|
||||||
zIndex={50}
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 详情内容 */}
|
{/* 期刊 & 年份 */}
|
||||||
<div className="space-y-4 h-[460px] flex flex-col overflow-y-auto pr-1 text-xs scrollbar-thin">
|
<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">
|
||||||
<div className="space-y-1 h-12 overflow-y-auto scrollbar-thin shrink-0">
|
<span className="text-muted font-bold block text-[10px] leading-tight">
|
||||||
<span className="text-muted font-bold">作者列表</span>
|
发表期刊
|
||||||
<p className="text-main leading-relaxed font-semibold">{paper.authors.join(', ')}</p>
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-main font-bold italic truncate block text-[11px] leading-tight"
|
||||||
|
title={paper.pub_journal || '未标注'}
|
||||||
|
>
|
||||||
|
{paper.pub_journal || '未标注'}
|
||||||
|
</span>
|
||||||
</div>
|
</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">
|
||||||
<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>
|
||||||
<span className="text-muted font-bold block text-[10px] leading-tight">发表期刊</span>
|
<span className="text-main font-extrabold block text-[11px] leading-tight">
|
||||||
<span
|
{paper.year}
|
||||||
className="text-main font-bold italic truncate block text-[11px] leading-tight"
|
</span>
|
||||||
title={paper.pub_journal || '未标注'}
|
|
||||||
>
|
|
||||||
{paper.pub_journal || '未标注'}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 摘要 */}
|
{/* 摘要 */}
|
||||||
<div className="space-y-1.5 flex flex-col h-40 shrink-0">
|
<div className="space-y-1.5 flex flex-col h-40 shrink-0">
|
||||||
<span className="text-muted font-bold block">摘要</span>
|
<span className="text-muted font-bold block">摘要</span>
|
||||||
<p className="text-main leading-relaxed font-normal bg-slate-50 p-3 flex-1 rounded-md border border-precision text-justify overflow-y-auto scrollbar-thin select-text">
|
<p className="text-main leading-relaxed font-normal bg-slate-50 p-3 flex-1 rounded-md border border-precision text-justify overflow-y-auto scrollbar-thin select-text">
|
||||||
{paper.abstract_text || '该文献暂无摘要数据。'}
|
{paper.abstract_text || '该文献暂无摘要数据。'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 关键词 */}
|
{/* 关键词 */}
|
||||||
<div className="space-y-1.5 flex flex-col h-16 shrink-0">
|
<div className="space-y-1.5 flex flex-col h-16 shrink-0">
|
||||||
<span className="text-muted font-bold block">关键词</span>
|
<span className="text-muted font-bold block">关键词</span>
|
||||||
{paper.keywords && paper.keywords.length > 0 ? (
|
{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">
|
<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 => (
|
{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]">
|
<span
|
||||||
{kw}
|
key={kw}
|
||||||
</span>
|
className="px-2 py-0.5 rounded bg-slate-100 border border-precision text-muted font-bold text-[9px]"
|
||||||
))}
|
>
|
||||||
</div>
|
{kw}
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center flex-1 bg-slate-50 border border-precision rounded-md text-[10px] text-muted font-bold select-none">
|
|
||||||
暂无关键词
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标识符 */}
|
|
||||||
<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 ? '暂无' : (
|
|
||||||
<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(() => {})}
|
|
||||||
className="text-blueprint hover:underline"
|
|
||||||
>
|
|
||||||
{paper.bibcode}
|
|
||||||
</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">DOI</span>
|
|
||||||
<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(() => {})}
|
|
||||||
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 || '无'}>
|
|
||||||
{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(() => {})}
|
|
||||||
className="text-blueprint hover:underline"
|
|
||||||
>
|
|
||||||
{paper.arxiv_id}
|
|
||||||
</a>
|
|
||||||
) : '无'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 手动上传文件(应对防爬阻断) */}
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-muted leading-relaxed">
|
|
||||||
若自动下载受阻,可在浏览器中打开上方链接,手动保存 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]">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="application/pdf"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) handleManualUpload(paper.bibcode, 'pdf', file);
|
|
||||||
}}
|
|
||||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
|
||||||
disabled={uploadingBibcode === paper.bibcode}
|
|
||||||
/>
|
|
||||||
{paper.has_pdf && (
|
|
||||||
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 bg-emerald-50 border border-emerald-200/60 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
|
|
||||||
已下载
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] font-bold text-blueprint group-hover:underline">
|
|
||||||
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 PDF 文献'}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[8px] text-muted">支持 .pdf 格式</span>
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
<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]">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="text/html,.html"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) handleManualUpload(paper.bibcode, 'html', file);
|
|
||||||
}}
|
|
||||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
|
||||||
disabled={uploadingBibcode === paper.bibcode}
|
|
||||||
/>
|
|
||||||
{paper.has_html && (
|
|
||||||
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 bg-emerald-50 border border-emerald-200/60 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
|
|
||||||
已下载
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] font-bold text-blueprint group-hover:underline">
|
|
||||||
{uploadingBibcode === paper.bibcode ? '上传中...' : '上传 HTML 文献'}
|
|
||||||
</span>
|
|
||||||
<span className="text-[8px] text-muted">支持 .html 格式</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center flex-1 bg-slate-50 border border-precision rounded-md text-[10px] text-muted font-bold select-none">
|
||||||
|
暂无关键词
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!paper.is_downloaded && (
|
{/* 标识符 */}
|
||||||
<div className="pt-1">
|
<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">
|
||||||
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
|
<div className="bg-slate-50 px-2.5 py-1.5 rounded-md border border-precision">
|
||||||
<button
|
<span className="text-muted font-bold block">BIBCODE</span>
|
||||||
type="button"
|
<span
|
||||||
onClick={() => handleMarkNoResource(paper.bibcode, true)}
|
className="text-main font-semibold select-all truncate block"
|
||||||
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"
|
title={paper.bibcode === paper.arxiv_id ? '暂无' : paper.bibcode}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3 h-3" /> 恢复自动下载状态 (允许后续重新尝试)
|
{paper.bibcode === paper.arxiv_id ? (
|
||||||
</button>
|
'暂无'
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
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" /> 标记为“无有效全文资源” (排查后跳过重试)
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 自动下载失败诊断 */}
|
|
||||||
{!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')
|
|
||||||
? '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' ? (
|
|
||||||
<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" />
|
|
||||||
已手动标记为【无有效全文资源】
|
|
||||||
</div>
|
|
||||||
该文献已从物理文献下载重试队列中排除,后续批量下载时系统将自动跳过此文献。
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<a
|
||||||
<div className="font-bold flex items-center gap-1.5 text-slate-900 text-[11px]">
|
href={`https://ui.adsabs.harvard.edu/abs/${paper.bibcode}/abstract`}
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-600" />
|
target="_blank"
|
||||||
自动下载失败诊断信息:
|
rel="noreferrer"
|
||||||
</div>
|
onClick={() =>
|
||||||
{paper.pdf_error && (
|
axios
|
||||||
<div className="leading-relaxed">
|
.post('/api/active_bibcode', { bibcode: paper.bibcode })
|
||||||
<span className="font-semibold text-slate-600">PDF 错误: </span>
|
.catch(() => {})
|
||||||
{paper.pdf_error}
|
}
|
||||||
</div>
|
className="text-blueprint hover:underline"
|
||||||
)}
|
>
|
||||||
{paper.html_error && (
|
{paper.bibcode}
|
||||||
<div className="leading-relaxed mt-0.5">
|
</a>
|
||||||
<span className="font-semibold text-slate-600">HTML 错误: </span>
|
)}
|
||||||
{paper.html_error}
|
</span>
|
||||||
</div>
|
</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 || '无'}
|
||||||
|
>
|
||||||
|
{paper.doi ? (
|
||||||
|
<a
|
||||||
|
href={`https://doi.org/${paper.doi}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
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 || '无'}
|
||||||
|
>
|
||||||
|
{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(() => {})
|
||||||
|
}
|
||||||
|
className="text-blueprint hover:underline"
|
||||||
|
>
|
||||||
|
{paper.arxiv_id}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
'无'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 手动上传文件(应对防爬阻断) */}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted leading-relaxed">
|
||||||
|
若自动下载受阻,可在浏览器中打开上方链接,手动保存 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]">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleManualUpload(paper.bibcode, 'pdf', file);
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||||
|
disabled={uploadingBibcode === paper.bibcode}
|
||||||
|
/>
|
||||||
|
{paper.has_pdf && (
|
||||||
|
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 bg-emerald-50 border border-emerald-200/60 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
|
||||||
|
已下载
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-bold text-blueprint group-hover:underline">
|
||||||
|
{uploadingBibcode === paper.bibcode
|
||||||
|
? '上传中...'
|
||||||
|
: '上传 PDF 文献'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] text-muted">支持 .pdf 格式</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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]">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="text/html,.html"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleManualUpload(paper.bibcode, 'html', file);
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||||
|
disabled={uploadingBibcode === paper.bibcode}
|
||||||
|
/>
|
||||||
|
{paper.has_html && (
|
||||||
|
<span className="absolute top-1 right-1 text-[8px] font-bold text-emerald-800 bg-emerald-50 border border-emerald-200/60 px-1 py-0.2 rounded-md select-none pointer-events-none z-10">
|
||||||
|
已下载
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-bold text-blueprint group-hover:underline">
|
||||||
|
{uploadingBibcode === paper.bibcode
|
||||||
|
? '上传中...'
|
||||||
|
: '上传 HTML 文献'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] text-muted">支持 .html 格式</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!paper.is_downloaded && (
|
||||||
|
<div className="pt-1">
|
||||||
|
{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" /> 恢复自动下载状态
|
||||||
|
(允许后续重新尝试)
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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" />{' '}
|
||||||
|
标记为“无有效全文资源” (排查后跳过重试)
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部操作:整合所有动作(阅读、图谱、下载) */}
|
{/* 自动下载失败诊断 */}
|
||||||
<div className="flex flex-wrap gap-2 pt-3 border-t border-slate-150">
|
{!paper.is_downloaded && (paper.pdf_error || paper.html_error) && (
|
||||||
{paper.is_downloaded ? (
|
<div
|
||||||
<>
|
className={`rounded-md p-3 text-[10px] space-y-1 border border-slate-200 border-l-4 ${
|
||||||
<button
|
paper.pdf_error === 'no_resource' &&
|
||||||
onClick={() => {
|
paper.html_error === 'no_resource'
|
||||||
onClose();
|
? 'bg-slate-50 border-l-amber-500 text-slate-800'
|
||||||
openReader(paper);
|
: 'bg-slate-50 border-l-rose-500 text-slate-800'
|
||||||
}}
|
}`}
|
||||||
className="flex-1 btn-console btn-console-primary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
|
>
|
||||||
>
|
{paper.pdf_error === 'no_resource' &&
|
||||||
打开阅读器
|
paper.html_error === 'no_resource' ? (
|
||||||
</button>
|
<div className="leading-relaxed">
|
||||||
<button
|
<div className="font-bold flex items-center gap-1.5 text-slate-900 text-[11px] mb-1">
|
||||||
onClick={() => {
|
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||||||
onClose();
|
已手动标记为【无有效全文资源】
|
||||||
setSelectedPaper(paper);
|
</div>
|
||||||
setActiveTab('citation');
|
该文献已从物理文献下载重试队列中排除,后续批量下载时系统将自动跳过此文献。
|
||||||
loadCitations(paper.bibcode);
|
</div>
|
||||||
}}
|
) : (
|
||||||
className="flex-1 btn-console btn-console-secondary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
|
<>
|
||||||
>
|
<div className="font-bold flex items-center gap-1.5 text-slate-900 text-[11px]">
|
||||||
查看引用图谱
|
<span className="w-1.5 h-1.5 rounded-full bg-rose-600" />
|
||||||
</button>
|
自动下载失败诊断信息:
|
||||||
<button
|
</div>
|
||||||
onClick={() => {
|
{paper.pdf_error && (
|
||||||
showConfirm('确定要强制重新下载吗?这会覆盖本地 file。', () => {
|
<div className="leading-relaxed">
|
||||||
handleDownload(paper.bibcode, true);
|
<span className="font-semibold text-slate-600">
|
||||||
}, '确认重新下载');
|
PDF 错误:{' '}
|
||||||
}}
|
</span>
|
||||||
disabled={downloadingBibcodes[paper.bibcode]}
|
{paper.pdf_error}
|
||||||
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"
|
</div>
|
||||||
>
|
|
||||||
{downloadingBibcodes[paper.bibcode] ? '重下中...' : '重新下载'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
handleDownload(paper.bibcode);
|
|
||||||
}}
|
|
||||||
disabled={downloadingBibcodes[paper.bibcode]}
|
|
||||||
className="flex-1 btn-console btn-console-primary py-2.5 rounded-md text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{downloadingBibcodes[paper.bibcode] ? (
|
|
||||||
<>
|
|
||||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
<span>正在下载同步...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
<span>下载文献到本地馆藏</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
{paper.html_error && (
|
||||||
<button
|
<div className="leading-relaxed mt-0.5">
|
||||||
onClick={() => {
|
<span className="font-semibold text-slate-600">
|
||||||
onClose();
|
HTML 错误:{' '}
|
||||||
setSelectedPaper(paper);
|
</span>
|
||||||
setActiveTab('citation');
|
{paper.html_error}
|
||||||
loadCitations(paper.bibcode);
|
</div>
|
||||||
}}
|
)}
|
||||||
className="px-6 btn-console btn-console-secondary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
|
</>
|
||||||
>
|
)}
|
||||||
引用图谱
|
</div>
|
||||||
</button>
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* 底部操作:整合所有动作(阅读、图谱、下载) */}
|
||||||
|
<div className="flex flex-wrap gap-2 pt-3 border-t border-slate-150">
|
||||||
|
{paper.is_downloaded ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
openReader(paper);
|
||||||
|
}}
|
||||||
|
className="flex-1 btn-console btn-console-primary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
|
||||||
|
>
|
||||||
|
打开阅读器
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
setSelectedPaper(paper);
|
||||||
|
setActiveTab('citation');
|
||||||
|
loadCitations(paper.bibcode);
|
||||||
|
}}
|
||||||
|
className="flex-1 btn-console btn-console-secondary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
|
||||||
|
>
|
||||||
|
查看引用图谱
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{downloadingBibcodes[paper.bibcode] ? '重下中...' : '重新下载'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleDownload(paper.bibcode);
|
||||||
|
}}
|
||||||
|
disabled={downloadingBibcodes[paper.bibcode]}
|
||||||
|
className="flex-1 btn-console btn-console-primary py-2.5 rounded-md text-xs font-bold flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{downloadingBibcodes[paper.bibcode] ? (
|
||||||
|
<>
|
||||||
|
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
<span>正在下载同步...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>下载文献到本地馆藏</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose();
|
||||||
|
setSelectedPaper(paper);
|
||||||
|
setActiveTab('citation');
|
||||||
|
loadCitations(paper.bibcode);
|
||||||
|
}}
|
||||||
|
className="px-6 btn-console btn-console-secondary py-2.5 rounded-md text-xs font-bold text-center cursor-pointer"
|
||||||
|
>
|
||||||
|
引用图谱
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,11 @@ interface UncachedPaperModalProps {
|
|||||||
loadCitations: (bibcode: string, reset: boolean) => Promise<void>;
|
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;
|
if (!bibcode) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -21,10 +25,15 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
|
|||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-main leading-relaxed">
|
<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>
|
||||||
<p className="text-[11px] text-muted leading-relaxed">
|
<p className="text-[11px] text-muted leading-relaxed">
|
||||||
您可以选择在线拉取该文献元数据并入库,或是直接跳转至 NASA ADS 平台查看其原始页面。
|
您可以选择在线拉取该文献元数据并入库,或是直接跳转至 NASA ADS
|
||||||
|
平台查看其原始页面。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
@ -40,7 +49,10 @@ export function UncachedPaperModal({ bibcode, onClose, loadCitations }: Uncached
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
axios.post('/api/active_bibcode', { bibcode }).catch(() => {});
|
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"
|
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
|
// dashboard/src/components/layout/Sidebar.tsx
|
||||||
import { useState } from 'react';
|
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 type { StandardPaper } from '../../types';
|
||||||
import { Logo } from '../Logo';
|
import { Logo } from '../Logo';
|
||||||
|
|
||||||
export type TabId = 'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
|
export type TabId =
|
||||||
|
'search' | 'library' | 'reader' | 'citation' | 'sync' | 'agent';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
activeTab: TabId;
|
activeTab: TabId;
|
||||||
@ -16,7 +26,15 @@ interface SidebarProps {
|
|||||||
onClose?: () => void;
|
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 [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const effectiveCollapsed = isCollapsed && !isOpen;
|
const effectiveCollapsed = isCollapsed && !isOpen;
|
||||||
|
|
||||||
@ -31,19 +49,23 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
|||||||
aria-label="关闭导航"
|
aria-label="关闭导航"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
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 ${
|
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 ? '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>
|
<div>
|
||||||
{/* 系统LOGO与折叠控制区 */}
|
{/* 系统LOGO与折叠控制区 */}
|
||||||
<div className={`mb-8 flex items-center transition-all duration-300 ${
|
<div
|
||||||
effectiveCollapsed ? 'justify-center px-0' : 'justify-between px-3'
|
className={`mb-8 flex items-center transition-all duration-300 ${
|
||||||
}`}>
|
effectiveCollapsed
|
||||||
|
? 'justify-center px-0'
|
||||||
|
: 'justify-between px-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{/* Logo & 标题文字 */}
|
{/* Logo & 标题文字 */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{/* Logo按钮 (只在折叠状态下可点击展开) */}
|
{/* Logo按钮 (只在折叠状态下可点击展开) */}
|
||||||
@ -52,27 +74,33 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
|||||||
disabled={!effectiveCollapsed}
|
disabled={!effectiveCollapsed}
|
||||||
onClick={() => setIsCollapsed(false)}
|
onClick={() => setIsCollapsed(false)}
|
||||||
className={`flex items-center justify-center shrink-0 rounded-lg transition-all duration-300 ${
|
className={`flex items-center justify-center shrink-0 rounded-lg transition-all duration-300 ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'w-11 h-11 bg-white hover:bg-slate-55 border border-slate-200 cursor-pointer shadow-xs hover:shadow-sm'
|
? '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'
|
: '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" />
|
<Logo gradientId="sidebarStarGrad" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 系统说明文字 */}
|
{/* 系统说明文字 */}
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col transition-all duration-300 origin-left ${
|
className={`flex flex-col transition-all duration-300 origin-left ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'opacity-0 max-w-0 scale-95 -translate-x-2 pointer-events-none select-none overflow-hidden h-0'
|
? 'opacity-0 max-w-0 scale-95 -translate-x-2 pointer-events-none select-none overflow-hidden h-0'
|
||||||
: 'opacity-100 max-w-[150px] scale-100 translate-x-0'
|
: '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>
|
<h1 className="text-sm font-bold text-slate-800 tracking-wider whitespace-nowrap">
|
||||||
<span className="text-[11px] text-slate-500 block font-medium font-sans whitespace-nowrap">天文学科研辅助系统</span>
|
AstroResearch
|
||||||
|
</h1>
|
||||||
|
<span className="text-[11px] text-slate-500 block font-medium font-sans whitespace-nowrap">
|
||||||
|
天文学科研辅助系统
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -81,8 +109,8 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCollapsed(true)}
|
onClick={() => setIsCollapsed(true)}
|
||||||
className={`p-1.5 rounded-full bg-slate-100 hover:bg-slate-200 border border-slate-200 text-slate-505 hover:text-slate-800 transition-all duration-300 cursor-pointer shadow-2xs shrink-0 items-center justify-center hover:scale-105 active:scale-95 hidden lg:flex ${
|
className={`p-1.5 rounded-full bg-slate-100 hover:bg-slate-200 border border-slate-200 text-slate-505 hover:text-slate-800 transition-all duration-300 cursor-pointer shadow-2xs shrink-0 items-center justify-center hover:scale-105 active:scale-95 hidden lg:flex ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'opacity-0 scale-75 pointer-events-none w-0 h-0 p-0 border-0 overflow-hidden'
|
? 'opacity-0 scale-75 pointer-events-none w-0 h-0 p-0 border-0 overflow-hidden'
|
||||||
: 'opacity-100 scale-100'
|
: 'opacity-100 scale-100'
|
||||||
}`}
|
}`}
|
||||||
title="收起导航"
|
title="收起导航"
|
||||||
@ -115,18 +143,22 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
|||||||
if (onClose) onClose(); // 移动端切页时自动收起
|
if (onClose) onClose(); // 移动端切页时自动收起
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border ${
|
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
|
isActive
|
||||||
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
|
? 'bg-slate-100 border-slate-200 text-slate-850 shadow-xs'
|
||||||
: 'border-transparent text-slate-650 hover:bg-slate-100 hover:text-slate-800'
|
: '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
|
||||||
<span
|
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 ${
|
className={`truncate transition-all duration-300 origin-left ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
|
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
|
||||||
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
|
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -141,19 +173,23 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
|||||||
{/* 底部当前选定文献提示 (平滑动画版本) 与 退出登录 */}
|
{/* 底部当前选定文献提示 (平滑动画版本) 与 退出登录 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{selectedPaper ? (
|
{selectedPaper ? (
|
||||||
<div
|
<div
|
||||||
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'p-0 border-transparent bg-transparent flex justify-center'
|
? 'p-0 border-transparent bg-transparent flex justify-center'
|
||||||
: 'p-3.5 border-slate-200 bg-slate-100/50'
|
: 'p-3.5 border-slate-200 bg-slate-100/50'
|
||||||
}`}
|
}`}
|
||||||
title={effectiveCollapsed ? `当前选定文献: ${selectedPaper.title}` : undefined}
|
title={
|
||||||
|
effectiveCollapsed
|
||||||
|
? `当前选定文献: ${selectedPaper.title}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* 折叠下的微型图书图标 */}
|
{/* 折叠下的微型图书图标 */}
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-55 text-slate-650 shrink-0 shadow-xs ${
|
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-55 text-slate-650 shrink-0 shadow-xs ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'w-9 h-9 opacity-100 scale-100'
|
? 'w-9 h-9 opacity-100 scale-100'
|
||||||
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
|
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -161,35 +197,43 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 展开下的完整详情 */}
|
{/* 展开下的完整详情 */}
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 origin-left ${
|
className={`transition-all duration-300 origin-left ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'opacity-0 max-w-0 max-h-0 pointer-events-none select-none overflow-hidden'
|
? 'opacity-0 max-w-0 max-h-0 pointer-events-none select-none overflow-hidden'
|
||||||
: 'opacity-100 max-w-[200px] max-h-40'
|
: 'opacity-100 max-w-[200px] max-h-40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[9px] font-bold text-slate-500 tracking-widest block mb-1">当前选定文献</span>
|
<span className="text-[9px] font-bold text-slate-500 tracking-widest block mb-1">
|
||||||
<h4 className="text-xs text-slate-800 font-bold line-clamp-2 mb-2 leading-relaxed">{selectedPaper.title}</h4>
|
当前选定文献
|
||||||
|
</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">
|
<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="shrink-0">
|
||||||
<span className="truncate max-w-[90px] font-mono">{selectedPaper.bibcode}</span>
|
发表年份: {selectedPaper.year}
|
||||||
|
</span>
|
||||||
|
<span className="truncate max-w-[90px] font-mono">
|
||||||
|
{selectedPaper.bibcode}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
className={`relative overflow-hidden transition-all duration-300 border rounded-lg ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'p-0 border-transparent bg-transparent flex justify-center'
|
? 'p-0 border-transparent bg-transparent flex justify-center'
|
||||||
: 'p-3 border-slate-200 bg-slate-100/30'
|
: 'p-3 border-slate-200 bg-slate-100/30'
|
||||||
}`}
|
}`}
|
||||||
title={effectiveCollapsed ? "未选定研究目标" : undefined}
|
title={effectiveCollapsed ? '未选定研究目标' : undefined}
|
||||||
>
|
>
|
||||||
{/* 折叠下的微型馆藏图标 */}
|
{/* 折叠下的微型馆藏图标 */}
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100/30 text-slate-400 shrink-0 ${
|
className={`transition-all duration-300 flex items-center justify-center rounded-lg border border-slate-200 bg-slate-100/30 text-slate-400 shrink-0 ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'w-9 h-9 opacity-100 scale-100'
|
? 'w-9 h-9 opacity-100 scale-100'
|
||||||
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
|
: 'w-0 h-0 opacity-0 scale-75 overflow-hidden'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -197,14 +241,16 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 展开下的提示文字 */}
|
{/* 展开下的提示文字 */}
|
||||||
<div
|
<div
|
||||||
className={`text-center transition-all duration-300 origin-left ${
|
className={`text-center transition-all duration-300 origin-left ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'opacity-0 max-w-0 max-h-0 pointer-events-none select-none overflow-hidden'
|
? 'opacity-0 max-w-0 max-h-0 pointer-events-none select-none overflow-hidden'
|
||||||
: 'opacity-100 max-w-[200px] max-h-12'
|
: '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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -216,13 +262,13 @@ 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 ${
|
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'
|
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" />
|
<LogOut className="w-4 h-4 shrink-0 text-slate-400 group-hover:text-rose-505 transition-colors" />
|
||||||
<span
|
<span
|
||||||
className={`truncate transition-all duration-300 origin-left ${
|
className={`truncate transition-all duration-300 origin-left ${
|
||||||
effectiveCollapsed
|
effectiveCollapsed
|
||||||
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
|
? 'opacity-0 max-w-0 pointer-events-none select-none overflow-hidden scale-90 -translate-x-2'
|
||||||
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
|
: 'opacity-100 max-w-[150px] scale-100 translate-x-0 ml-3'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// dashboard/src/components/reader/BilingualViewer.tsx
|
// dashboard/src/components/reader/BilingualViewer.tsx
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkMath from 'remark-math';
|
import remarkMath from 'remark-math';
|
||||||
@ -11,8 +11,15 @@ import 'katex/dist/katex.min.css';
|
|||||||
import { FileText, Loader, Languages, BookOpen } from 'lucide-react';
|
import { FileText, Loader, Languages, BookOpen } from 'lucide-react';
|
||||||
import type { StandardPaper, NoteRecord } from '../../types';
|
import type { StandardPaper, NoteRecord } from '../../types';
|
||||||
import { NOTE_COLORS, safeSchema } from '../../hooks/useReaderState';
|
import { NOTE_COLORS, safeSchema } from '../../hooks/useReaderState';
|
||||||
import { highlightTargetsInMarkdown, type HighlightCandidate, type TargetInfo } from '../../utils/celestial';
|
import {
|
||||||
import { parseMarkdownFrontMatter, type PaperMetadata } from '../../utils/paper';
|
highlightTargetsInMarkdown,
|
||||||
|
type HighlightCandidate,
|
||||||
|
type TargetInfo,
|
||||||
|
} from '../../utils/celestial';
|
||||||
|
import {
|
||||||
|
parseMarkdownFrontMatter,
|
||||||
|
type PaperMetadata,
|
||||||
|
} from '../../utils/paper';
|
||||||
import { splitParagraphs } from '../../hooks/useSyncScroll';
|
import { splitParagraphs } from '../../hooks/useSyncScroll';
|
||||||
|
|
||||||
interface BilingualViewerProps {
|
interface BilingualViewerProps {
|
||||||
@ -46,16 +53,27 @@ interface BilingualViewerProps {
|
|||||||
/**
|
/**
|
||||||
* 极具学术控制台质感的文献元数据档案(Metadata Dossier)渲染面板
|
* 极具学术控制台质感的文献元数据档案(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;
|
if (!metadata || (!metadata.title && !metadata.author)) return null;
|
||||||
|
|
||||||
return (
|
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="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">
|
<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'}
|
{isChinese ? '文献元数据档案' : 'Paper Metadata Dossier'}
|
||||||
</span>
|
</span>
|
||||||
@ -67,25 +85,33 @@ function PaperMetadataDossier({ metadata, isChinese, id }: { metadata: PaperMeta
|
|||||||
</div>
|
</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}
|
{metadata.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* 作者群 */}
|
{/* 作者群 */}
|
||||||
{metadata.author && metadata.author.length > 0 && (
|
{metadata.author && metadata.author.length > 0 && (
|
||||||
<div className="text-xs text-slate-600 mb-4 font-medium leading-relaxed select-text">
|
<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="text-slate-400 font-semibold mr-1">
|
||||||
<span className="italic text-slate-750">{metadata.author.join(', ')}</span>
|
{isChinese ? '作者群体:' : 'Authors:'}
|
||||||
|
</span>
|
||||||
|
<span className="italic text-slate-750">
|
||||||
|
{metadata.author.join(', ')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 底部期刊与跳转 ADS 按钮 */}
|
{/* 底部期刊与跳转 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">
|
<div className="text-[10px] text-slate-500 font-semibold">
|
||||||
<span className="text-slate-400 mr-1">{isChinese ? '发表期刊/预印本:' : 'Publisher:'}</span>
|
<span className="text-slate-400 mr-1">
|
||||||
<span className="text-slate-700">{metadata.publisher || 'arXiv Preprint'}</span>
|
{isChinese ? '发表期刊/预印本:' : 'Publisher:'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-700">
|
||||||
|
{metadata.publisher || 'arXiv Preprint'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{metadata.source && (
|
{metadata.source && (
|
||||||
<a
|
<a
|
||||||
href={metadata.source}
|
href={metadata.source}
|
||||||
@ -156,24 +182,30 @@ export function BilingualViewer({
|
|||||||
|
|
||||||
const gridTemplateColumns = isMobileViewport
|
const gridTemplateColumns = isMobileViewport
|
||||||
? '1fr' // 手机端一律垂直单列堆叠
|
? '1fr' // 手机端一律垂直单列堆叠
|
||||||
: (isTabletViewport
|
: isTabletViewport
|
||||||
? (viewMode === 'bilingual' ? '1fr 1fr' : '1fr')
|
? viewMode === 'bilingual'
|
||||||
: (viewMode === 'bilingual'
|
? '1fr 1fr'
|
||||||
? (showNotesPanel ? '1fr 1fr 380px' : '1fr 1fr')
|
: '1fr'
|
||||||
: (showNotesPanel ? '1fr 380px' : '1fr')));
|
: viewMode === 'bilingual'
|
||||||
|
? showNotesPanel
|
||||||
|
? '1fr 1fr 380px'
|
||||||
|
: '1fr 1fr'
|
||||||
|
: showNotesPanel
|
||||||
|
? '1fr 380px'
|
||||||
|
: '1fr';
|
||||||
|
|
||||||
const style = isMobileViewport
|
const style = isMobileViewport
|
||||||
? {
|
? {
|
||||||
gridTemplateColumns: '1fr',
|
gridTemplateColumns: '1fr',
|
||||||
gridTemplateRows: viewMode === 'bilingual'
|
gridTemplateRows: viewMode === 'bilingual' ? '1fr 1fr' : '1fr',
|
||||||
? (showNotesPanel ? '1fr 1fr 1fr' : '1fr 1fr')
|
|
||||||
: (showNotesPanel ? '1fr 1fr' : '1fr')
|
|
||||||
}
|
}
|
||||||
: { gridTemplateColumns };
|
: { gridTemplateColumns };
|
||||||
|
|
||||||
// 解析英文和中文段落的 Front Matter 头部元数据
|
// 解析英文和中文段落的 Front Matter 头部元数据
|
||||||
const { metadata: engMeta, pureMarkdown: engPure } = parseMarkdownFrontMatter(englishText);
|
const { metadata: engMeta, pureMarkdown: engPure } =
|
||||||
const { metadata: chnMeta, pureMarkdown: chnPure } = parseMarkdownFrontMatter(chineseText);
|
parseMarkdownFrontMatter(englishText);
|
||||||
|
const { metadata: chnMeta, pureMarkdown: chnPure } =
|
||||||
|
parseMarkdownFrontMatter(chineseText);
|
||||||
|
|
||||||
// 智能合并元数据,以防中文翻译中部分字段丢失或解析失败时,可优雅复用英文侧的数据进行降级补充
|
// 智能合并元数据,以防中文翻译中部分字段丢失或解析失败时,可优雅复用英文侧的数据进行降级补充
|
||||||
const mergedChnMeta = (() => {
|
const mergedChnMeta = (() => {
|
||||||
@ -182,7 +214,10 @@ export function BilingualViewer({
|
|||||||
if (!chnMeta) return engMeta;
|
if (!chnMeta) return engMeta;
|
||||||
return {
|
return {
|
||||||
title: chnMeta.title || engMeta.title,
|
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,
|
publisher: chnMeta.publisher || engMeta.publisher,
|
||||||
source: chnMeta.source || engMeta.source,
|
source: chnMeta.source || engMeta.source,
|
||||||
date: chnMeta.date || engMeta.date,
|
date: chnMeta.date || engMeta.date,
|
||||||
@ -190,21 +225,78 @@ 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 (
|
return (
|
||||||
<div
|
<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}
|
style={style}
|
||||||
>
|
>
|
||||||
{/* 英文正文视窗 (或双语对照) */}
|
{/* 英文正文视窗 (或双语对照) */}
|
||||||
{(viewMode === 'english' || viewMode === 'bilingual') && (
|
{(viewMode === 'english' || viewMode === 'bilingual') && (
|
||||||
<div
|
<div
|
||||||
onMouseOver={handleMouseOver}
|
onMouseOver={handleMouseOver}
|
||||||
onMouseLeave={handleContainerMouseLeave}
|
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">
|
<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 && (
|
{selectedPaper.has_pdf && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -224,33 +316,65 @@ export function BilingualViewer({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPdf ? (
|
{showPdf ? (
|
||||||
<div className="flex-1 min-h-0 w-full rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
<div className="flex-1 min-h-0 w-full rounded-lg overflow-hidden border border-slate-200 bg-slate-50">
|
||||||
<iframe
|
{pdfLoading ? (
|
||||||
src={`/api/files/PDF/${selectedPaper.bibcode}.pdf#navpanes=0&pagemode=none&view=FitH`}
|
<div className="flex flex-col items-center justify-center h-full text-slate-500 space-y-3">
|
||||||
className="w-full h-full border-none"
|
<Loader className="w-8 h-8 animate-spin text-sky-600" />
|
||||||
title="文献 PDF"
|
<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={`${pdfBlobUrl}#navpanes=0&pagemode=none&view=FitH`}
|
||||||
|
className="w-full h-full border-none"
|
||||||
|
title="文献 PDF"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : parsing ? (
|
) : parsing ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
|
<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" />
|
<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>
|
</div>
|
||||||
) : engPure ? (
|
) : engPure ? (
|
||||||
<div
|
<div
|
||||||
ref={englishRef}
|
ref={englishRef}
|
||||||
onScroll={handleEnglishScroll}
|
onScroll={handleEnglishScroll}
|
||||||
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
|
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) => {
|
{splitParagraphs(engPure).map((para: string, idx: number) => {
|
||||||
const matchedNote = notes.find(n => n.paragraph_index === idx);
|
const matchedNote = notes.find(
|
||||||
const highlightStyle = matchedNote ? NOTE_COLORS[matchedNote.highlight_color] : null;
|
(n) => n.paragraph_index === idx
|
||||||
|
);
|
||||||
|
const highlightStyle = matchedNote
|
||||||
|
? NOTE_COLORS[matchedNote.highlight_color]
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
@ -264,11 +388,17 @@ export function BilingualViewer({
|
|||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkMath, remarkGfm]}
|
remarkPlugins={[remarkMath, remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
|
rehypePlugins={[
|
||||||
|
rehypeRaw,
|
||||||
|
[rehypeSanitize, safeSchema],
|
||||||
|
rehypeKatex,
|
||||||
|
]}
|
||||||
components={{
|
components={{
|
||||||
a: ({ href, children, ...props }) => {
|
a: ({ href, children, ...props }) => {
|
||||||
if (href && href.startsWith('#celestial-')) {
|
if (href && href.startsWith('#celestial-')) {
|
||||||
const targetName = decodeURIComponent(href.slice('#celestial-'.length));
|
const targetName = decodeURIComponent(
|
||||||
|
href.slice('#celestial-'.length)
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="celestial-target cursor-pointer font-bold text-sky-600 underline decoration-dotted decoration-2 hover:text-sky-850"
|
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 (
|
return (
|
||||||
<a href={href} className="text-sky-600 hover:underline" {...props}>
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-sky-600 hover:underline"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{highlightTargetsInMarkdown(para, highlightCandidates)}
|
{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">
|
<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" />
|
<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 font-bold text-slate-500">
|
||||||
<p className="text-xs text-slate-400 mt-1">请点击上方按钮提取文档结构正文</p>
|
本篇文献暂无已解析的英文源正文
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
请点击上方按钮提取文档结构正文
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -305,28 +443,36 @@ export function BilingualViewer({
|
|||||||
|
|
||||||
{/* 中文翻译视窗 (或双语对照) */}
|
{/* 中文翻译视窗 (或双语对照) */}
|
||||||
{(viewMode === 'chinese' || viewMode === 'bilingual') && (
|
{(viewMode === 'chinese' || viewMode === 'bilingual') && (
|
||||||
<div
|
<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">
|
||||||
className="console-panel rounded-lg 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">
|
||||||
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{translating ? (
|
{translating ? (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-500 space-y-3">
|
<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" />
|
<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>
|
</div>
|
||||||
) : chnPure ? (
|
) : chnPure ? (
|
||||||
<div
|
<div
|
||||||
ref={chineseRef}
|
ref={chineseRef}
|
||||||
onScroll={handleChineseScroll}
|
onScroll={handleChineseScroll}
|
||||||
className="flex-1 overflow-y-auto pr-1 scrollbar-thin"
|
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) => (
|
{splitParagraphs(chnPure).map((para: string, idx: number) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
@ -335,7 +481,11 @@ export function BilingualViewer({
|
|||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkMath, remarkGfm]}
|
remarkPlugins={[remarkMath, remarkGfm]}
|
||||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
|
rehypePlugins={[
|
||||||
|
rehypeRaw,
|
||||||
|
[rehypeSanitize, safeSchema],
|
||||||
|
rehypeKatex,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
{para}
|
{para}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
@ -346,8 +496,12 @@ export function BilingualViewer({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 select-none">
|
<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" />
|
<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 font-bold text-slate-500">
|
||||||
<p className="text-xs text-slate-400 mt-1">需点击上方“生成智能翻译”按钮启动大模型翻译</p>
|
本篇文献暂无已缓存的翻译结果
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
需点击上方“生成智能翻译”按钮启动大模型翻译
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -356,132 +510,168 @@ export function BilingualViewer({
|
|||||||
{children}
|
{children}
|
||||||
|
|
||||||
{/* 天体详情悬浮卡片 (React Portal 挂载在 body 顶层防止裁剪) */}
|
{/* 天体详情悬浮卡片 (React Portal 挂载在 body 顶层防止裁剪) */}
|
||||||
{hoveredTarget && hoverCardPos && createPortal(
|
{hoveredTarget &&
|
||||||
<div
|
hoverCardPos &&
|
||||||
onMouseEnter={clearHoverTimeout}
|
createPortal(
|
||||||
onMouseLeave={handleMouseLeaveTarget}
|
<div
|
||||||
style={{
|
onMouseEnter={clearHoverTimeout}
|
||||||
position: 'fixed',
|
onMouseLeave={handleMouseLeaveTarget}
|
||||||
left: `${hoverCardPos.x}px`,
|
style={{
|
||||||
top: `${hoverCardPos.y}px`,
|
position: 'fixed',
|
||||||
transform: hoverCardPos.isAbove ? 'translateY(-100%)' : 'none',
|
left: `${hoverCardPos.x}px`,
|
||||||
zIndex: 9999,
|
top: `${hoverCardPos.y}px`,
|
||||||
}}
|
transform: hoverCardPos.isAbove ? 'translateY(-100%)' : 'none',
|
||||||
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"
|
zIndex: 9999,
|
||||||
>
|
}}
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
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"
|
||||||
<span className="font-bold text-slate-900 text-[13px]">{hoveredTarget.target_name}</span>
|
>
|
||||||
<div className="flex items-center gap-1.5 select-none">
|
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
||||||
<a
|
<span className="font-bold text-slate-900 text-[13px]">
|
||||||
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
|
{hoveredTarget.target_name}
|
||||||
target="_blank"
|
</span>
|
||||||
rel="noopener noreferrer"
|
<div className="flex items-center gap-1.5 select-none">
|
||||||
className="text-[9px] font-extrabold bg-slate-100 hover:bg-slate-200 text-slate-700 hover:text-slate-900 px-1.5 py-0.5 rounded border border-slate-200 transition-all cursor-pointer"
|
<a
|
||||||
title="在 SIMBAD 中查询该天体详情"
|
href={`https://simbad.cds.unistra.fr/simbad/sim-id?Ident=${encodeURIComponent(hoveredTarget.target_name)}`}
|
||||||
>
|
target="_blank"
|
||||||
SIMBAD
|
rel="noopener noreferrer"
|
||||||
</a>
|
className="text-[9px] font-extrabold bg-slate-100 hover:bg-slate-200 text-slate-700 hover:text-slate-900 px-1.5 py-0.5 rounded border border-slate-200 transition-all cursor-pointer"
|
||||||
<a
|
title="在 SIMBAD 中查询该天体详情"
|
||||||
href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`}
|
>
|
||||||
target="_blank"
|
SIMBAD
|
||||||
rel="noopener noreferrer"
|
</a>
|
||||||
className="text-[9px] font-extrabold bg-slate-100 hover:bg-slate-200 text-slate-700 hover:text-slate-900 px-1.5 py-0.5 rounded border border-slate-200 transition-all cursor-pointer"
|
<a
|
||||||
title="在 VizieR 中查询相关文献和表数据"
|
href={`https://vizier.cds.unistra.fr/viz-bin/VizieR?-c=${encodeURIComponent(hoveredTarget.target_name)}`}
|
||||||
>
|
target="_blank"
|
||||||
VizieR
|
rel="noopener noreferrer"
|
||||||
</a>
|
className="text-[9px] font-extrabold bg-slate-100 hover:bg-slate-200 text-slate-700 hover:text-slate-900 px-1.5 py-0.5 rounded border border-slate-200 transition-all cursor-pointer"
|
||||||
</div>
|
title="在 VizieR 中查询相关文献和表数据"
|
||||||
</div>
|
>
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-slate-600">
|
VizieR
|
||||||
{/* 天体类型 & 官方名称 */}
|
</a>
|
||||||
<div className="col-span-2 flex items-center gap-2">
|
|
||||||
{hoveredTarget.otype && (
|
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-50 text-amber-850 border border-amber-200/70 text-[9px] font-bold font-mono">
|
|
||||||
{hoveredTarget.otype}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{hoveredTarget.oname && hoveredTarget.oname !== hoveredTarget.target_name && (
|
|
||||||
<span className="text-[10px] text-slate-400 font-medium truncate">
|
|
||||||
aka {hoveredTarget.oname}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<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.toFixed(2)}`
|
|
||||||
: '未知'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 视差 + 误差 + 估算距离 */}
|
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5 text-slate-600">
|
||||||
<div className="col-span-2">
|
{/* 天体类型 & 官方名称 */}
|
||||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block">视差 / 估算距离</span>
|
<div className="col-span-2 flex items-center gap-2">
|
||||||
<div className="font-bold text-slate-800">
|
{hoveredTarget.otype && (
|
||||||
{hoveredTarget.parallax !== null && hoveredTarget.parallax !== undefined
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-amber-50 text-amber-850 border border-amber-200/70 text-[9px] font-bold font-mono">
|
||||||
? `${hoveredTarget.parallax.toFixed(2)}${hoveredTarget.parallax_err != null ? `±${hoveredTarget.parallax_err.toFixed(4)}` : ''} mas (~${(1000.0 / hoveredTarget.parallax).toFixed(1)} pc)`
|
{hoveredTarget.otype}
|
||||||
: '未知'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 自行 */}
|
|
||||||
<div className="col-span-2">
|
|
||||||
<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`
|
|
||||||
: '未知'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 视向速度 */}
|
|
||||||
<div className="col-span-2">
|
|
||||||
<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`
|
|
||||||
: '未知'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 多波段测光 */}
|
|
||||||
{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>
|
|
||||||
<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]) => (
|
|
||||||
<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>
|
</span>
|
||||||
))}
|
)}
|
||||||
|
{hoveredTarget.oname &&
|
||||||
|
hoveredTarget.oname !== hoveredTarget.target_name && (
|
||||||
|
<span className="text-[10px] text-slate-400 font-medium truncate">
|
||||||
|
aka {hoveredTarget.oname}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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.toFixed(2)}`
|
||||||
|
: '未知'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 视差 + 误差 + 估算距离 */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<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.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>
|
||||||
|
<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`
|
||||||
|
: '未知'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 视向速度 */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<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`
|
||||||
|
: '未知'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* 多波段测光 */}
|
||||||
{hoveredTarget.aliases && hoveredTarget.aliases.length > 0 && (
|
{hoveredTarget.photometry &&
|
||||||
<div className="border-t border-slate-200/60 pt-2">
|
Object.keys(hoveredTarget.photometry).length > 1 && (
|
||||||
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-0.5">常用别名</span>
|
<div className="border-t border-slate-200/60 pt-2">
|
||||||
<div className="text-[10px] text-slate-500 font-medium leading-relaxed max-h-[36px] overflow-y-auto scrollbar-thin font-mono">
|
<span className="text-slate-500 font-bold tracking-wide text-[9px] uppercase block mb-1">
|
||||||
{hoveredTarget.aliases.slice(0, 8).join(', ')}
|
多波段星等
|
||||||
{hoveredTarget.aliases.length > 8 && ' ...'}
|
</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]) => (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
<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 && ' ...'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>,
|
||||||
</div>,
|
document.body
|
||||||
document.body
|
)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,78 +77,88 @@ export function ReaderNotesSidebar({
|
|||||||
className="fixed inset-0 bg-slate-900/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
|
className="fixed inset-0 bg-slate-900/30 backdrop-blur-xs z-35 lg:hidden cursor-pointer w-full h-full border-none outline-none"
|
||||||
aria-label="关闭侧栏"
|
aria-label="关闭侧栏"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="console-panel rounded-lg border border-slate-200 bg-slate-50 flex flex-col overflow-hidden shadow-sm h-full fixed top-0 right-0 h-full w-[88vw] sm:w-[380px] z-40 lg:relative lg:top-auto lg:right-auto lg:h-full lg:w-auto lg:z-10">
|
<div className="console-panel rounded-lg border border-slate-200 bg-slate-50 flex flex-col overflow-hidden shadow-sm h-full fixed top-0 right-0 h-full w-[88vw] sm:w-[380px] z-40 lg:relative lg:top-auto lg:right-auto lg:h-full lg:w-auto lg:z-10">
|
||||||
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
|
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
|
||||||
<span className="text-xs font-bold text-slate-800">
|
<span className="text-xs font-bold text-slate-800">
|
||||||
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
|
{sidebarTab === 'notes' ? '观测记录手札' : '文献 AI 问答'}
|
||||||
</span>
|
</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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标签栏选择器 */}
|
|
||||||
<div className="flex border-b border-slate-200 bg-white select-none shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSidebarTab('notes')}
|
|
||||||
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
|
|
||||||
sidebarTab === 'notes'
|
|
||||||
? 'border-blueprint text-blueprint'
|
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
观测手札 ({notes.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSidebarTab('ai')}
|
|
||||||
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
|
|
||||||
sidebarTab === 'ai'
|
|
||||||
? 'border-blueprint text-blueprint'
|
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
文献 AI 问答
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 根据 Tab 渲染内容 */}
|
{/* 标签栏选择器 */}
|
||||||
{sidebarTab === 'notes' ? (
|
<div className="flex border-b border-slate-200 bg-white select-none shrink-0">
|
||||||
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
<button
|
||||||
{/* 天体标识符列表 */}
|
type="button"
|
||||||
<div className="px-4 py-3 border-b border-slate-200 bg-white space-y-2 shrink-0">
|
onClick={() => setSidebarTab('notes')}
|
||||||
<div className="flex items-center justify-between">
|
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
|
||||||
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">关联天体 (CDS)</span>
|
sidebarTab === 'notes'
|
||||||
<div className="flex items-center gap-1.5 select-none">
|
? 'border-blueprint text-blueprint'
|
||||||
{(loadingTargets || identifyingTargets) && <Loader className="w-3 h-3 animate-spin text-slate-400" />}
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||||
{selectedPaper.has_markdown && (
|
}`}
|
||||||
<button
|
>
|
||||||
type="button"
|
观测手札 ({notes.length})
|
||||||
onClick={() => handleIdentifyTargets(selectedPaper.bibcode)}
|
</button>
|
||||||
disabled={identifyingTargets || loadingTargets}
|
<button
|
||||||
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"
|
type="button"
|
||||||
title="从文献正文中自动识别天体目标并查询 CDS Sesame"
|
onClick={() => setSidebarTab('ai')}
|
||||||
>
|
className={`flex-1 py-2 text-center text-xs font-bold transition-all border-b-2 cursor-pointer ${
|
||||||
<Sparkles className="w-2.5 h-2.5" />
|
sidebarTab === 'ai'
|
||||||
{targets.length > 0 ? '重新识别' : '自动识别'}
|
? 'border-blueprint text-blueprint'
|
||||||
</button>
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||||
)}
|
}`}
|
||||||
|
>
|
||||||
|
文献 AI 问答
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 根据 Tab 渲染内容 */}
|
||||||
|
{sidebarTab === 'notes' ? (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
||||||
|
{/* 天体标识符列表 */}
|
||||||
|
<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>
|
||||||
|
<div className="flex items-center gap-1.5 select-none">
|
||||||
|
{(loadingTargets || identifyingTargets) && (
|
||||||
|
<Loader className="w-3 h-3 animate-spin text-slate-400" />
|
||||||
|
)}
|
||||||
|
{selectedPaper.has_markdown && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-2.5 h-2.5" />
|
||||||
|
{targets.length > 0 ? '重新识别' : '自动识别'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto pr-1 scrollbar-thin">
|
||||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto pr-1 scrollbar-thin">
|
{targets.length === 0 ? (
|
||||||
{targets.length === 0 ? (
|
<span className="text-[10px] text-slate-400 italic">
|
||||||
<span className="text-[10px] text-slate-400 italic">暂无自动识别到的天体</span>
|
暂无自动识别到的天体
|
||||||
) : (
|
</span>
|
||||||
targets.map((t, idx) => (
|
) : (
|
||||||
<button
|
targets.map((t, idx) => (
|
||||||
key={idx}
|
<button
|
||||||
type="button"
|
key={idx}
|
||||||
onClick={() => handleJumpToTarget(t)}
|
type="button"
|
||||||
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"
|
onClick={() => handleJumpToTarget(t)}
|
||||||
title={
|
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={[
|
||||||
`类型: ${t.otype || '?'}`,
|
`类型: ${t.otype || '?'}`,
|
||||||
`RA: ${t.ra || '?'} Dec: ${t.dec || '?'}`,
|
`RA: ${t.ra || '?'} Dec: ${t.dec || '?'}`,
|
||||||
t.parallax != null
|
t.parallax != null
|
||||||
@ -161,135 +171,160 @@ export function ReaderNotesSidebar({
|
|||||||
`视向速度: ${t.radial_velocity != null ? t.radial_velocity + ' km/s' : '?'}`,
|
`视向速度: ${t.radial_velocity != null ? t.radial_velocity + ' km/s' : '?'}`,
|
||||||
`官方名: ${t.oname || t.target_name}`,
|
`官方名: ${t.oname || t.target_name}`,
|
||||||
`别名: ${t.aliases?.join(', ') || '无'}`,
|
`别名: ${t.aliases?.join(', ') || '无'}`,
|
||||||
].join('\n')
|
].join('\n')}
|
||||||
}
|
>
|
||||||
>
|
{t.target_name}
|
||||||
{t.target_name}
|
{t.spectral_type && (
|
||||||
{t.spectral_type && <span className="text-slate-400 font-medium font-mono">({t.spectral_type})</span>}
|
<span className="text-slate-400 font-medium font-mono">
|
||||||
</button>
|
({t.spectral_type})
|
||||||
))
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
{/* 手动关联输入框 */}
|
))
|
||||||
<form onSubmit={handleAssociateTarget} className="flex gap-1.5 mt-2">
|
)}
|
||||||
<input
|
</div>
|
||||||
type="text"
|
{/* 手动关联输入框 */}
|
||||||
value={manualTargetName}
|
<form
|
||||||
onChange={e => setManualTargetName(e.target.value)}
|
onSubmit={handleAssociateTarget}
|
||||||
placeholder="手动关联天体(如 M 31)..."
|
className="flex gap-1.5 mt-2"
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!manualTargetName.trim() || associatingTarget}
|
|
||||||
className="btn-console btn-console-primary px-2.5 py-1 rounded text-[10px] font-bold disabled:opacity-50 cursor-pointer"
|
|
||||||
>
|
>
|
||||||
{associatingTarget ? '...' : '关联'}
|
<input
|
||||||
</button>
|
type="text"
|
||||||
</form>
|
value={manualTargetName}
|
||||||
</div>
|
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"
|
||||||
{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>
|
|
||||||
{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}"
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 颜色标记 */}
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<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 };
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
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'
|
|
||||||
} ${s.bg.replace('border-cyan-200', '')}`}
|
|
||||||
title={s.label}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={newNoteText}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCreateNote}
|
type="submit"
|
||||||
className="btn-console btn-console-primary flex-1 px-3 py-1.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1"
|
disabled={!manualTargetName.trim() || associatingTarget}
|
||||||
|
className="btn-console btn-console-primary px-2.5 py-1 rounded text-[10px] font-bold disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-3.5 h-3.5" /> 保存笔记
|
{associatingTarget ? '...' : '关联'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
</form>
|
||||||
onClick={() => { setSelectedParagraphIdx(null); setSelectedText(''); setNewNoteText(''); }}
|
|
||||||
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 笔记列表 */}
|
{/* 新建笔记输入区 */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0 scrollbar-thin">
|
{selectedParagraphIdx !== null && (
|
||||||
{notes.length === 0 ? (
|
<div className="px-4 py-4 border-b border-slate-200 bg-white space-y-3 shrink-0">
|
||||||
<div className="text-center text-slate-400 text-xs py-12 space-y-2 select-none">
|
<div className="text-xs font-bold text-slate-700">
|
||||||
<Pencil className="w-8 h-8 mx-auto opacity-30 text-slate-500" />
|
正在批注段落 #{selectedParagraphIdx + 1}
|
||||||
<div>选中文献正文的段落文字,即可在这里添加读书笔记。</div>
|
</div>
|
||||||
</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">
|
||||||
notes.map(note => {
|
"{selectedText}"
|
||||||
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="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>
|
|
||||||
<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>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteNote(note.id)}
|
|
||||||
className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})
|
{/* 颜色标记 */}
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<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 };
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
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'
|
||||||
|
} ${s.bg.replace('border-cyan-200', '')}`}
|
||||||
|
title={s.label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={newNoteText}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCreateNote}
|
||||||
|
className="btn-console btn-console-primary flex-1 px-3 py-1.5 rounded-lg text-xs font-bold flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-3.5 h-3.5" /> 保存笔记
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedParagraphIdx(null);
|
||||||
|
setSelectedText('');
|
||||||
|
setNewNoteText('');
|
||||||
|
}}
|
||||||
|
className="btn-console btn-console-secondary px-3 py-1.5 rounded-lg text-xs font-bold"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 笔记列表 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0 scrollbar-thin">
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<div className="text-center text-slate-400 text-xs py-12 space-y-2 select-none">
|
||||||
|
<Pencil className="w-8 h-8 mx-auto opacity-30 text-slate-500" />
|
||||||
|
<div>选中文献正文的段落文字,即可在这里添加读书笔记。</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
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="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>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteNote(note.id)}
|
||||||
|
className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="flex-1 overflow-hidden h-full">
|
||||||
<div className="flex-1 overflow-hidden h-full">
|
<AIAssistantPanel
|
||||||
<AIAssistantPanel
|
bibcode={selectedPaper.bibcode}
|
||||||
bibcode={selectedPaper.bibcode}
|
onJumpToSource={onJumpToSource}
|
||||||
onJumpToSource={onJumpToSource}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,16 @@
|
|||||||
// dashboard/src/components/reader/ReaderToolbar.tsx
|
// dashboard/src/components/reader/ReaderToolbar.tsx
|
||||||
import { useState } from 'react';
|
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';
|
import type { StandardPaper } from '../../types';
|
||||||
|
|
||||||
interface ReaderToolbarProps {
|
interface ReaderToolbarProps {
|
||||||
@ -20,7 +30,7 @@ interface ReaderToolbarProps {
|
|||||||
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
|
showConfirm: (message: string, onConfirm: () => void, title?: string) => void;
|
||||||
englishText: string;
|
englishText: string;
|
||||||
chineseText: string;
|
chineseText: string;
|
||||||
|
|
||||||
// 来自 useReaderState 的状态
|
// 来自 useReaderState 的状态
|
||||||
showSwitchMenu: boolean;
|
showSwitchMenu: boolean;
|
||||||
setShowSwitchMenu: (show: boolean) => void;
|
setShowSwitchMenu: (show: boolean) => void;
|
||||||
@ -48,7 +58,7 @@ export function ReaderToolbar({
|
|||||||
showConfirm,
|
showConfirm,
|
||||||
englishText,
|
englishText,
|
||||||
chineseText,
|
chineseText,
|
||||||
|
|
||||||
showSwitchMenu,
|
showSwitchMenu,
|
||||||
setShowSwitchMenu,
|
setShowSwitchMenu,
|
||||||
viewMode,
|
viewMode,
|
||||||
@ -62,7 +72,10 @@ export function ReaderToolbar({
|
|||||||
return (
|
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="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">
|
<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}
|
{selectedPaper.title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-500 mt-1 font-semibold">
|
<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="快速切换文献"
|
title="快速切换文献"
|
||||||
>
|
>
|
||||||
<BookOpen className="w-3.5 h-3.5 text-sky-600" />
|
<BookOpen className="w-3.5 h-3.5 text-sky-600" />
|
||||||
<span className="hidden sm:inline">快速切换</span><span className="sm:hidden">最近</span>
|
<span className="hidden sm:inline">快速切换</span>
|
||||||
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
|
<span className="sm:hidden">最近</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showSwitchMenu && (
|
{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">
|
<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 && (
|
{recentlySelected.length > 0 && (
|
||||||
@ -96,7 +115,7 @@ export function ReaderToolbar({
|
|||||||
<History className="w-3.5 h-3.5 text-slate-400" />
|
<History className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<span>最近阅读</span>
|
<span>最近阅读</span>
|
||||||
</div>
|
</div>
|
||||||
{recentlySelected.map(paper => (
|
{recentlySelected.map((paper) => (
|
||||||
<button
|
<button
|
||||||
key={`recent-${paper.bibcode}`}
|
key={`recent-${paper.bibcode}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -104,30 +123,36 @@ export function ReaderToolbar({
|
|||||||
setShowSwitchMenu(false);
|
setShowSwitchMenu(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||||
paper.bibcode === selectedPaper.bibcode
|
paper.bibcode === selectedPaper.bibcode
|
||||||
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
||||||
: 'border-transparent text-slate-700 font-medium'
|
: 'border-transparent text-slate-700 font-medium'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate block leading-tight text-left">{paper.title}</span>
|
<span className="truncate block leading-tight text-left">
|
||||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
|
{paper.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||||
|
{paper.bibcode}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 全部馆藏已下载文献 */}
|
{/* 全部馆藏已下载文献 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
|
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
|
||||||
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<span>全部已下载馆藏</span>
|
<span>全部已下载馆藏</span>
|
||||||
</div>
|
</div>
|
||||||
{library.filter(p => p.is_downloaded).length === 0 ? (
|
{library.filter((p) => p.is_downloaded).length === 0 ? (
|
||||||
<div className="px-3 py-2 text-slate-400 italic">暂无已下载文献</div>
|
<div className="px-3 py-2 text-slate-400 italic">
|
||||||
|
暂无已下载文献
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
library
|
library
|
||||||
.filter(p => p.is_downloaded)
|
.filter((p) => p.is_downloaded)
|
||||||
.map(paper => (
|
.map((paper) => (
|
||||||
<button
|
<button
|
||||||
key={`lib-${paper.bibcode}`}
|
key={`lib-${paper.bibcode}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -135,13 +160,17 @@ export function ReaderToolbar({
|
|||||||
setShowSwitchMenu(false);
|
setShowSwitchMenu(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||||
paper.bibcode === selectedPaper.bibcode
|
paper.bibcode === selectedPaper.bibcode
|
||||||
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
||||||
: 'border-transparent text-slate-700 font-medium'
|
: 'border-transparent text-slate-700 font-medium'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate block leading-tight text-left">{paper.title}</span>
|
<span className="truncate block leading-tight text-left">
|
||||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
|
{paper.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||||
|
{paper.bibcode}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -196,7 +225,9 @@ export function ReaderToolbar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showPdf) {
|
if (showPdf) {
|
||||||
alert("【PDF同步滚动技术限制说明】\n\nPDF 预览采用的是浏览器原生的 PDF 插件沙箱。受底层浏览器跨域安全限制和外部插件机制影响,网页脚本无法直接读取和控制 PDF 的滚动位置,因而无法实现物理同步滚动。\n\n【强烈建议】\n点击左侧面板顶部的「显示正文」按钮切换到解析正文模式。在解析正文模式下,系统将自动激活「自适应双轨滚动同步引擎(方案三)」,提供段落级的高精度无缝对齐同步滚动体验!");
|
alert(
|
||||||
|
'【PDF同步滚动技术限制说明】\n\nPDF 预览采用的是浏览器原生的 PDF 插件沙箱。受底层浏览器跨域安全限制和外部插件机制影响,网页脚本无法直接读取和控制 PDF 的滚动位置,因而无法实现物理同步滚动。\n\n【强烈建议】\n点击左侧面板顶部的「显示正文」按钮切换到解析正文模式。在解析正文模式下,系统将自动激活「自适应双轨滚动同步引擎(方案三)」,提供段落级的高精度无缝对齐同步滚动体验!'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSyncScroll(!syncScroll);
|
setSyncScroll(!syncScroll);
|
||||||
@ -205,13 +236,21 @@ export function ReaderToolbar({
|
|||||||
showPdf
|
showPdf
|
||||||
? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed opacity-70'
|
? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed opacity-70'
|
||||||
: syncScroll
|
: syncScroll
|
||||||
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-sm'
|
? 'bg-sky-50 text-sky-700 border-sky-200 shadow-sm'
|
||||||
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-55'
|
: '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
|
||||||
<span>同步滚动:{showPdf ? 'PDF暂不支持' : syncScroll ? '开启' : '关闭'}</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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -222,21 +261,33 @@ export function ReaderToolbar({
|
|||||||
disabled={parsing}
|
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"
|
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 ? '正文结构解析中...' : '解析源文正文'}
|
{parsing ? '正文结构解析中...' : '解析源文正文'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
|
showConfirm(
|
||||||
handleParse(selectedPaper.bibcode, true);
|
'确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。',
|
||||||
}, '确认重新解析');
|
() => {
|
||||||
|
handleParse(selectedPaper.bibcode, true);
|
||||||
|
},
|
||||||
|
'确认重新解析'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={parsing}
|
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"
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -248,7 +299,11 @@ export function ReaderToolbar({
|
|||||||
disabled={translating}
|
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"
|
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 ? '天文学术语对比翻译中...' : '生成智能翻译'}
|
{translating ? '天文学术语对比翻译中...' : '生成智能翻译'}
|
||||||
</button>
|
</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"
|
className="hidden xl:flex btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||||
title="清除翻译缓存并重新生成大模型对照翻译"
|
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>
|
</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"
|
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 研讨"
|
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 ? '正在进行知识入库...' : '知识入库'}
|
{vectorizing ? '正在进行知识入库...' : '知识入库'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -281,19 +344,27 @@ export function ReaderToolbar({
|
|||||||
{selectedPaper.has_markdown && selectedPaper.has_vector && (
|
{selectedPaper.has_markdown && selectedPaper.has_vector && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showConfirm('确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。', () => {
|
showConfirm(
|
||||||
handleVectorize(selectedPaper.bibcode);
|
'确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。',
|
||||||
}, '确认重新知识入库');
|
() => {
|
||||||
|
handleVectorize(selectedPaper.bibcode);
|
||||||
|
},
|
||||||
|
'确认重新知识入库'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={vectorizing}
|
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"
|
className="hidden xl:flex btn-console btn-console-secondary px-4 py-2 rounded-lg text-xs font-bold items-center gap-2"
|
||||||
title="重新为该文献解析知识点并入库"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 移动端/平板端 折叠后的“文献操作”菜单 */}
|
{/* 移动端/平板端 折叠后的“文献操作”菜单 */}
|
||||||
<div className="relative xl:hidden shrink-0">
|
<div className="relative xl:hidden shrink-0">
|
||||||
<button
|
<button
|
||||||
@ -303,22 +374,27 @@ export function ReaderToolbar({
|
|||||||
title="更多文献操作"
|
title="更多文献操作"
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="w-3.5 h-3.5 text-sky-600" />
|
<SlidersHorizontal className="w-3.5 h-3.5 text-sky-600" />
|
||||||
<span className="hidden sm:inline">文献操作</span><span className="sm:hidden">操作</span>
|
<span className="hidden sm:inline">文献操作</span>
|
||||||
<ChevronDown className={`w-3 h-3 transition-transform ${showActionsMenu ? 'rotate-180' : ''}`} />
|
<span className="sm:hidden">操作</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3 h-3 transition-transform ${showActionsMenu ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showActionsMenu && (
|
{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">
|
<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' && (
|
{(englishText || chineseText) && viewMode === 'bilingual' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showPdf) {
|
if (showPdf) {
|
||||||
alert("PDF预览模式下不支持同步滚动");
|
alert('PDF预览模式下不支持同步滚动');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSyncScroll(!syncScroll);
|
setSyncScroll(!syncScroll);
|
||||||
@ -330,7 +406,9 @@ export function ReaderToolbar({
|
|||||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
: '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>
|
<span>同步滚动:{syncScroll ? '已开启' : '已关闭'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -352,9 +430,13 @@ export function ReaderToolbar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowActionsMenu(false);
|
setShowActionsMenu(false);
|
||||||
showConfirm('确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。', () => {
|
showConfirm(
|
||||||
handleParse(selectedPaper.bibcode, true);
|
'确定要重新解析正文吗?这会覆盖本地已解析的 Markdown。',
|
||||||
}, '确认重新解析');
|
() => {
|
||||||
|
handleParse(selectedPaper.bibcode, true);
|
||||||
|
},
|
||||||
|
'确认重新解析'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={parsing}
|
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"
|
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,27 +447,32 @@ export function ReaderToolbar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 智能翻译 */}
|
{/* 智能翻译 */}
|
||||||
{selectedPaper.has_markdown && !selectedPaper.has_translation && (
|
{selectedPaper.has_markdown &&
|
||||||
<button
|
!selectedPaper.has_translation && (
|
||||||
onClick={() => {
|
<button
|
||||||
handleTranslate(selectedPaper.bibcode);
|
onClick={() => {
|
||||||
setShowActionsMenu(false);
|
handleTranslate(selectedPaper.bibcode);
|
||||||
}}
|
setShowActionsMenu(false);
|
||||||
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"
|
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"
|
||||||
<Languages className="w-4 h-4 text-slate-400" />
|
>
|
||||||
<span>{translating ? '翻译中...' : '生成智能翻译'}</span>
|
<Languages className="w-4 h-4 text-slate-400" />
|
||||||
</button>
|
<span>{translating ? '翻译中...' : '生成智能翻译'}</span>
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedPaper.has_translation && (
|
{selectedPaper.has_translation && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowActionsMenu(false);
|
setShowActionsMenu(false);
|
||||||
showConfirm('确定要重新进行智能翻译吗?这会覆盖本地已缓存的翻译结果。', () => {
|
showConfirm(
|
||||||
handleTranslate(selectedPaper.bibcode, true);
|
'确定要重新进行智能翻译吗?这会覆盖本地已缓存的翻译结果。',
|
||||||
}, '确认重新翻译');
|
() => {
|
||||||
|
handleTranslate(selectedPaper.bibcode, true);
|
||||||
|
},
|
||||||
|
'确认重新翻译'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={translating}
|
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"
|
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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowActionsMenu(false);
|
setShowActionsMenu(false);
|
||||||
showConfirm('确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。', () => {
|
showConfirm(
|
||||||
handleVectorize(selectedPaper.bibcode);
|
'确定要重新进行知识入库吗?这会清除先前该文献已有的知识点记录并重新写入。',
|
||||||
}, '确认重新知识入库');
|
() => {
|
||||||
|
handleVectorize(selectedPaper.bibcode);
|
||||||
|
},
|
||||||
|
'确认重新知识入库'
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={vectorizing}
|
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"
|
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,24 +516,26 @@ export function ReaderToolbar({
|
|||||||
<span>重新知识入库</span>
|
<span>重新知识入库</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4. 学术助手按钮 */}
|
{/* 4. 学术助手按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowNotesPanel(!showNotesPanel);
|
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 ${
|
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 问答)"
|
title="开启或关闭侧边学术助手(包含阅读手札与 AI 问答)"
|
||||||
>
|
>
|
||||||
<Sparkles className="w-3.5 h-3.5 text-amber-500" />
|
<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 && (
|
{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">
|
<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}
|
{notesCount}
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
// dashboard/src/components/sync/BatchPipelineCard.tsx
|
// dashboard/src/components/sync/BatchPipelineCard.tsx
|
||||||
import React from 'react';
|
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 { CustomSelect } from '../CustomSelect';
|
||||||
import type { BatchStatus } from '../../hooks/useSyncState';
|
import type { BatchStatus } from '../../hooks/useSyncState';
|
||||||
|
|
||||||
interface BatchPipelineCardProps {
|
interface BatchPipelineCardProps {
|
||||||
targetPhase: 'download' | 'parse' | 'translate' | 'embed' | 'target';
|
targetPhase: 'download' | 'parse' | 'translate' | 'embed' | 'target';
|
||||||
setTargetPhase: (p: 'download' | 'parse' | 'translate' | 'embed' | 'target') => void;
|
setTargetPhase: (
|
||||||
|
p: 'download' | 'parse' | 'translate' | 'embed' | 'target'
|
||||||
|
) => void;
|
||||||
batchLimitCount: number;
|
batchLimitCount: number;
|
||||||
setBatchLimitCount: (c: number) => void;
|
setBatchLimitCount: (c: number) => void;
|
||||||
sortOrder: 'default' | 'pub_year_desc' | 'created_at_desc';
|
sortOrder: 'default' | 'pub_year_desc' | 'created_at_desc';
|
||||||
@ -55,7 +66,8 @@ export function BatchPipelineCard({
|
|||||||
<span>馆藏文献批量学术流水线任务</span>
|
<span>馆藏文献批量学术流水线任务</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-slate-500 text-xs mt-0.5">
|
<p className="text-slate-500 text-xs mt-0.5">
|
||||||
针对本地馆藏中的文献,批量发起正文 PDF/HTML 下载、Markdown 结构化排版解析以及中英双语对照翻译。
|
针对本地馆藏中的文献,批量发起正文 PDF/HTML 下载、Markdown
|
||||||
|
结构化排版解析以及中英双语对照翻译。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -69,11 +81,17 @@ export function BatchPipelineCard({
|
|||||||
{/* 条件设置 */}
|
{/* 条件设置 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="space-y-2">
|
<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
|
<CustomSelect
|
||||||
value={targetPhase}
|
value={targetPhase}
|
||||||
disabled={batchStatus.active}
|
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"
|
className="w-full"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'download', label: '下载' },
|
{ value: 'download', label: '下载' },
|
||||||
@ -86,22 +104,32 @@ export function BatchPipelineCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={batchLimitCount}
|
value={batchLimitCount}
|
||||||
disabled={batchStatus.active}
|
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"
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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
|
<CustomSelect
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
disabled={batchStatus.active}
|
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"
|
className="w-full"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'default', label: '默认(不指定)' },
|
{ value: 'default', label: '默认(不指定)' },
|
||||||
@ -114,14 +142,16 @@ export function BatchPipelineCard({
|
|||||||
|
|
||||||
{/* 执行策略 */}
|
{/* 执行策略 */}
|
||||||
<div className="space-y-2 border-t border-slate-100 pt-4">
|
<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">
|
<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">
|
<label className="flex items-center gap-2 text-xs font-medium text-slate-700 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={skipCompleted}
|
checked={skipCompleted}
|
||||||
disabled={batchStatus.active}
|
disabled={batchStatus.active}
|
||||||
onChange={e => setSkipCompleted(e.target.checked)}
|
onChange={(e) => setSkipCompleted(e.target.checked)}
|
||||||
className="rounded text-blueprint focus:ring-blueprint/25"
|
className="rounded text-blueprint focus:ring-blueprint/25"
|
||||||
/>
|
/>
|
||||||
<span>跳过已完成</span>
|
<span>跳过已完成</span>
|
||||||
@ -132,7 +162,7 @@ export function BatchPipelineCard({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={skipFailed}
|
checked={skipFailed}
|
||||||
disabled={batchStatus.active}
|
disabled={batchStatus.active}
|
||||||
onChange={e => setSkipFailed(e.target.checked)}
|
onChange={(e) => setSkipFailed(e.target.checked)}
|
||||||
className="rounded text-blueprint focus:ring-blueprint/25"
|
className="rounded text-blueprint focus:ring-blueprint/25"
|
||||||
/>
|
/>
|
||||||
<span>跳过当前失败</span>
|
<span>跳过当前失败</span>
|
||||||
@ -143,7 +173,7 @@ export function BatchPipelineCard({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={skipPrecedingFailed}
|
checked={skipPrecedingFailed}
|
||||||
disabled={batchStatus.active}
|
disabled={batchStatus.active}
|
||||||
onChange={e => setSkipPrecedingFailed(e.target.checked)}
|
onChange={(e) => setSkipPrecedingFailed(e.target.checked)}
|
||||||
className="rounded text-blueprint focus:ring-blueprint/25"
|
className="rounded text-blueprint focus:ring-blueprint/25"
|
||||||
/>
|
/>
|
||||||
<span>跳过前置失败</span>
|
<span>跳过前置失败</span>
|
||||||
@ -154,7 +184,7 @@ export function BatchPipelineCard({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={skipPrecedingUncompleted}
|
checked={skipPrecedingUncompleted}
|
||||||
disabled={batchStatus.active}
|
disabled={batchStatus.active}
|
||||||
onChange={e => setSkipPrecedingUncompleted(e.target.checked)}
|
onChange={(e) => setSkipPrecedingUncompleted(e.target.checked)}
|
||||||
className="rounded text-blueprint focus:ring-blueprint/25"
|
className="rounded text-blueprint focus:ring-blueprint/25"
|
||||||
/>
|
/>
|
||||||
<span>跳过前置未完成</span>
|
<span>跳过前置未完成</span>
|
||||||
@ -193,27 +223,46 @@ export function BatchPipelineCard({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex justify-between text-xs font-bold text-slate-600 select-none">
|
<div className="flex justify-between text-xs font-bold text-slate-600 select-none">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
{batchStatus.action === 'download' && <Download className="w-3.5 h-3.5 text-blueprint" />}
|
{batchStatus.action === 'download' && (
|
||||||
{batchStatus.action === 'parse' && <FileText className="w-3.5 h-3.5 text-emerald-700" />}
|
<Download className="w-3.5 h-3.5 text-blueprint" />
|
||||||
{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 === 'parse' && (
|
||||||
{batchStatus.action === 'target' && <RefreshCw className="w-3.5 h-3.5 text-blueprint" />}
|
<FileText className="w-3.5 h-3.5 text-emerald-700" />
|
||||||
{batchStatus.action === 'download'
|
)}
|
||||||
? '正文离线下载进度'
|
{batchStatus.action === 'translate' && (
|
||||||
: batchStatus.action === 'parse'
|
<RefreshCw className="w-3.5 h-3.5 text-slate-700 animate-spin" />
|
||||||
? '结构化排版解析进度'
|
)}
|
||||||
: batchStatus.action === 'translate'
|
{batchStatus.action === 'embed' && (
|
||||||
? '中英双语对照翻译进度'
|
<SlidersHorizontal className="w-3.5 h-3.5 text-amber-600" />
|
||||||
: batchStatus.action === 'embed'
|
)}
|
||||||
? '段落向量分块入库进度'
|
{batchStatus.action === 'target' && (
|
||||||
: '天体识别与缓存进度'}
|
<RefreshCw className="w-3.5 h-3.5 text-blueprint" />
|
||||||
|
)}
|
||||||
|
{batchStatus.action === 'download'
|
||||||
|
? '正文离线下载进度'
|
||||||
|
: batchStatus.action === 'parse'
|
||||||
|
? '结构化排版解析进度'
|
||||||
|
: batchStatus.action === 'translate'
|
||||||
|
? '中英双语对照翻译进度'
|
||||||
|
: batchStatus.action === 'embed'
|
||||||
|
? '段落向量分块入库进度'
|
||||||
|
: '天体识别与缓存进度'}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{batchStatus.action === 'download' ? batchStatus.downloaded : batchStatus.parsed} / {batchStatus.total} 篇
|
{batchStatus.action === 'download'
|
||||||
{((batchStatus.action === 'download' && batchStatus.download_failed > 0) ||
|
? batchStatus.downloaded
|
||||||
(batchStatus.action !== 'download' && batchStatus.parse_failed > 0)) && (
|
: 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">
|
<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>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@ -221,15 +270,15 @@ export function BatchPipelineCard({
|
|||||||
<div className="w-full h-2 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
|
<div className="w-full h-2 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
|
||||||
<div
|
<div
|
||||||
className={`h-full transition-all duration-300 ${
|
className={`h-full transition-all duration-300 ${
|
||||||
batchStatus.action === 'download'
|
batchStatus.action === 'download'
|
||||||
? 'bg-blueprint'
|
? 'bg-blueprint'
|
||||||
: batchStatus.action === 'parse'
|
: batchStatus.action === 'parse'
|
||||||
? 'bg-emerald-600'
|
? 'bg-emerald-600'
|
||||||
: batchStatus.action === 'translate'
|
: batchStatus.action === 'translate'
|
||||||
? 'bg-slate-700'
|
? 'bg-slate-700'
|
||||||
: batchStatus.action === 'embed'
|
: batchStatus.action === 'embed'
|
||||||
? 'bg-amber-600'
|
? 'bg-amber-600'
|
||||||
: 'bg-blueprint'
|
: 'bg-blueprint'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
width: `${
|
width: `${
|
||||||
@ -238,7 +287,8 @@ export function BatchPipelineCard({
|
|||||||
100,
|
100,
|
||||||
Math.round(
|
Math.round(
|
||||||
((batchStatus.action === 'download'
|
((batchStatus.action === 'download'
|
||||||
? batchStatus.downloaded + batchStatus.download_failed
|
? batchStatus.downloaded +
|
||||||
|
batchStatus.download_failed
|
||||||
: batchStatus.parsed + batchStatus.parse_failed) /
|
: batchStatus.parsed + batchStatus.parse_failed) /
|
||||||
batchStatus.total) *
|
batchStatus.total) *
|
||||||
100
|
100
|
||||||
@ -254,19 +304,34 @@ export function BatchPipelineCard({
|
|||||||
{batchStatus.active && batchStatus.current_bibcode && (
|
{batchStatus.active && batchStatus.current_bibcode && (
|
||||||
<div className="text-xs font-bold text-slate-600 flex items-center gap-2">
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 滚动日志终端 */}
|
{/* 滚动日志终端 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-700 block select-none">实时处理日志流终端</label>
|
<label className="text-xs font-bold text-slate-700 block select-none">
|
||||||
<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>
|
||||||
|
<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 ? (
|
{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) => (
|
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}
|
{log}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -29,7 +29,10 @@ export function BookmarkletToolCard() {
|
|||||||
<span>浏览器快捷直推书签导入工具</span>
|
<span>浏览器快捷直推书签导入工具</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-slate-500 text-xs mt-1 leading-relaxed">
|
<p className="text-slate-500 text-xs mt-1 leading-relaxed">
|
||||||
无需手动保存和拖拽上传!在您自己真实的浏览器上突破 Cloudflare/WAF 人机验证或在学校内网成功打开 PDF/HTML 页面后,点击此书签即可将文献直接推送同步到本地 AstroResearch 数据库与文献馆中。
|
无需手动保存和拖拽上传!在您自己真实的浏览器上突破 Cloudflare/WAF
|
||||||
|
人机验证或在学校内网成功打开 PDF/HTML
|
||||||
|
页面后,点击此书签即可将文献直接推送同步到本地 AstroResearch
|
||||||
|
数据库与文献馆中。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -37,9 +40,10 @@ export function BookmarkletToolCard() {
|
|||||||
<div className="space-y-1.5 font-bold select-none">
|
<div className="space-y-1.5 font-bold select-none">
|
||||||
<span className="text-slate-500">第一步:安装书签到您的浏览器</span>
|
<span className="text-slate-500">第一步:安装书签到您的浏览器</span>
|
||||||
<p className="text-[11px] text-slate-400 font-normal mt-1 leading-relaxed">
|
<p className="text-[11px] text-slate-400 font-normal mt-1 leading-relaxed">
|
||||||
将下方的按钮直接拖拽到浏览器的书签栏中;或者新建一个浏览器书签,将其 URL 设为复制的 JavaScript 代码(点击右侧按钮复制):
|
将下方的按钮直接拖拽到浏览器的书签栏中;或者新建一个浏览器书签,将其
|
||||||
|
URL 设为复制的 JavaScript 代码(点击右侧按钮复制):
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3 mt-2 select-none">
|
<div className="flex flex-wrap items-center gap-3 mt-2 select-none">
|
||||||
{/* 可直接拖拽的链接按钮 */}
|
{/* 可直接拖拽的链接按钮 */}
|
||||||
<a
|
<a
|
||||||
@ -48,7 +52,16 @@ export function BookmarkletToolCard() {
|
|||||||
title="拖拽我到您的书签栏中"
|
title="拖拽我到您的书签栏中"
|
||||||
onClick={(e) => e.preventDefault()}
|
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
|
导入AstroResearch
|
||||||
</span>
|
</span>
|
||||||
<span>添加导入书签</span>
|
<span>添加导入书签</span>
|
||||||
@ -66,9 +79,25 @@ export function BookmarkletToolCard() {
|
|||||||
<div className="space-y-1.5 font-bold mt-4 select-none">
|
<div className="space-y-1.5 font-bold mt-4 select-none">
|
||||||
<span className="text-slate-500">第二步:如何使用书签直推?</span>
|
<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">
|
<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>
|
||||||
<li>页面跳转至文献原始网页后,直接<span className="font-bold text-slate-750">点击该浏览器书签</span>;</li>
|
点击详情弹窗内的{' '}
|
||||||
<li>书签脚本会自动读取并<span className="font-bold text-slate-750">自动填充好 Bibcode</span>,您只需点击确认,文件即可秒级自动上传录入至系统!</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>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,14 @@
|
|||||||
// dashboard/src/components/sync/MetadataSyncCard.tsx
|
// 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 { CustomSelect } from '../CustomSelect';
|
||||||
import type { HarvestStatus } from '../../hooks/useSyncState';
|
import type { HarvestStatus } from '../../hooks/useSyncState';
|
||||||
|
|
||||||
@ -19,7 +27,11 @@ interface MetadataSyncCardProps {
|
|||||||
rules: Array<{ field: string; op: string; val: string }>;
|
rules: Array<{ field: string; op: string; val: string }>;
|
||||||
handleAddRule: () => void;
|
handleAddRule: () => void;
|
||||||
handleRemoveRule: (idx: number) => 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>;
|
handleEstimate: () => Promise<void>;
|
||||||
handleStartHarvest: () => Promise<void>;
|
handleStartHarvest: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@ -43,7 +55,10 @@ export function MetadataSyncCard({
|
|||||||
handleEstimate,
|
handleEstimate,
|
||||||
handleStartHarvest,
|
handleStartHarvest,
|
||||||
}: MetadataSyncCardProps) {
|
}: 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -65,25 +80,31 @@ export function MetadataSyncCard({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
disabled={status.active}
|
disabled={status.active}
|
||||||
placeholder="例如: hot subdwarf, Gaia BH1..."
|
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"
|
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">
|
<div className="text-[11px] text-slate-450 flex flex-wrap gap-x-2.5 px-0.5 mt-1 select-none">
|
||||||
<span>高级格式:</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="flex gap-2">
|
||||||
{[
|
{[
|
||||||
{ id: 'all', label: '全部' },
|
{ id: 'all', label: '全部' },
|
||||||
{ id: 'ads', label: 'NASA ADS' },
|
{ id: 'ads', label: 'NASA ADS' },
|
||||||
{ id: 'arxiv', label: 'arXiv 预印本' },
|
{ id: 'arxiv', label: 'arXiv 预印本' },
|
||||||
].map(src => (
|
].map((src) => (
|
||||||
<button
|
<button
|
||||||
key={src.id}
|
key={src.id}
|
||||||
type="button"
|
type="button"
|
||||||
@ -122,7 +143,7 @@ export function MetadataSyncCard({
|
|||||||
{idx > 0 ? (
|
{idx > 0 ? (
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={rule.op}
|
value={rule.op}
|
||||||
onChange={val => handleRuleChange(idx, 'op', val)}
|
onChange={(val) => handleRuleChange(idx, 'op', val)}
|
||||||
className="w-24"
|
className="w-24"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'AND', label: '并且 (AND)' },
|
{ 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
|
<CustomSelect
|
||||||
value={rule.field}
|
value={rule.field}
|
||||||
onChange={val => handleRuleChange(idx, 'field', val)}
|
onChange={(val) => handleRuleChange(idx, 'field', val)}
|
||||||
className="w-32"
|
className="w-32"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: '任意字段' },
|
{ value: 'all', label: '任意字段' },
|
||||||
@ -150,12 +173,14 @@ export function MetadataSyncCard({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={rule.val}
|
value={rule.val}
|
||||||
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
|
onChange={(e) =>
|
||||||
|
handleRuleChange(idx, 'val', e.target.value)
|
||||||
|
}
|
||||||
placeholder={
|
placeholder={
|
||||||
rule.field === 'year'
|
rule.field === 'year'
|
||||||
? '例如: 2020-2023 或 2022'
|
? '例如: 2020-2023 或 2022'
|
||||||
: rule.field === 'author'
|
: rule.field === 'author'
|
||||||
? '例如: Althaus'
|
? '例如: Althaus'
|
||||||
: '请输入检索词...'
|
: '请输入检索词...'
|
||||||
}
|
}
|
||||||
className="flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-450 focus:outline-none focus:border-blueprint text-xs font-medium"
|
className="flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-450 focus:outline-none focus:border-blueprint text-xs font-medium"
|
||||||
@ -180,13 +205,17 @@ export function MetadataSyncCard({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
|
<label className="text-xs font-bold text-slate-700 block flex items-center justify-between">
|
||||||
<span>单次拉取最大上限</span>
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={limit}
|
value={limit}
|
||||||
disabled={status.active}
|
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"
|
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>
|
</div>
|
||||||
@ -198,7 +227,11 @@ export function MetadataSyncCard({
|
|||||||
onClick={handleEstimate}
|
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"
|
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>
|
||||||
<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">
|
<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" />
|
<Info className="w-4 h-4 shrink-0 text-slate-500" />
|
||||||
<div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -244,10 +283,21 @@ export function MetadataSyncCard({
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-slate-500 text-[11px] mt-1.5 font-semibold leading-relaxed select-all">
|
<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>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="w-full h-3 rounded-md bg-slate-100 overflow-hidden border border-slate-200 select-none">
|
<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>
|
</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">
|
<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" />
|
<Lightbulb className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
<span>同步 arXiv 文献时系统会自动加设 3000 毫秒的安全限流延迟以规避服务器封锁。</span>
|
<span>
|
||||||
|
同步 arXiv 文献时系统会自动加设 3000
|
||||||
|
毫秒的安全限流延迟以规避服务器封锁。
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,7 +15,8 @@ export function useAuth() {
|
|||||||
|
|
||||||
// 校验登录状态
|
// 校验登录状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios.get('/api/auth/check')
|
axios
|
||||||
|
.get('/api/auth/check')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
})
|
})
|
||||||
@ -41,13 +42,19 @@ export function useAuth() {
|
|||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('登录校验失败:', err);
|
console.error('登录校验失败:', err);
|
||||||
const axiosError = err as { response?: { data?: string | { error?: string } } };
|
const axiosError = err as {
|
||||||
|
response?: { data?: string | { error?: string } };
|
||||||
|
};
|
||||||
let errMsg = '密码错误,请重试。';
|
let errMsg = '密码错误,请重试。';
|
||||||
if (axiosError.response?.data) {
|
if (axiosError.response?.data) {
|
||||||
const data = axiosError.response.data;
|
const data = axiosError.response.data;
|
||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
errMsg = data;
|
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;
|
errMsg = data.error || errMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,8 @@ import axios from 'axios';
|
|||||||
import type { CitationNetwork } from '../types';
|
import type { CitationNetwork } from '../types';
|
||||||
|
|
||||||
export function useCitations() {
|
export function useCitations() {
|
||||||
const [citationNetwork, setCitationNetwork] = useState<CitationNetwork | null>(null);
|
const [citationNetwork, setCitationNetwork] =
|
||||||
|
useState<CitationNetwork | null>(null);
|
||||||
const [loadingCitations, setLoadingCitations] = useState(false);
|
const [loadingCitations, setLoadingCitations] = useState(false);
|
||||||
const [citationHistory, setCitationHistory] = useState<CitationNetwork[]>([]);
|
const [citationHistory, setCitationHistory] = useState<CitationNetwork[]>([]);
|
||||||
const [uncachedBibcode, setUncachedBibcode] = useState<string | null>(null);
|
const [uncachedBibcode, setUncachedBibcode] = useState<string | null>(null);
|
||||||
@ -13,14 +14,14 @@ export function useCitations() {
|
|||||||
setLoadingCitations(true);
|
setLoadingCitations(true);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<CitationNetwork>('/api/citations', {
|
const res = await axios.get<CitationNetwork>('/api/citations', {
|
||||||
params: { bibcode }
|
params: { bibcode },
|
||||||
});
|
});
|
||||||
setCitationNetwork(res.data);
|
setCitationNetwork(res.data);
|
||||||
setCitationHistory(prev => {
|
setCitationHistory((prev) => {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
return [res.data];
|
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;
|
||||||
}
|
}
|
||||||
return [...prev, res.data];
|
return [...prev, res.data];
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
// dashboard/src/hooks/useLibrary.ts
|
// dashboard/src/hooks/useLibrary.ts
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { StandardPaper } from '../types';
|
import type { StandardPaper } from '../types';
|
||||||
import type { TabId } from '../components/layout/Sidebar';
|
import type { TabId } from '../components/layout/Sidebar';
|
||||||
@ -18,29 +18,63 @@ export function useLibrary({
|
|||||||
openReader,
|
openReader,
|
||||||
}: UseLibraryProps) {
|
}: UseLibraryProps) {
|
||||||
const [library, setLibrary] = useState<StandardPaper[]>([]);
|
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 [detailBibcode, setDetailBibcode] = useState<string | null>(null);
|
||||||
|
|
||||||
const [recentlySelected, setRecentlySelected] = useState<StandardPaper[]>(() => {
|
// 筛选与分页状态提升
|
||||||
try {
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const saved = localStorage.getItem('astro_recently_selected');
|
const [filterStatus, setFilterStatus] = useState<
|
||||||
return saved ? JSON.parse(saved) : [];
|
| 'all'
|
||||||
} catch (e) {
|
| 'downloaded'
|
||||||
console.error('Failed to parse recently selected papers', e);
|
| 'undownloaded'
|
||||||
return [];
|
| '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 [downloadingBibcodes, setDownloadingBibcodes] = useState<Record<string, boolean>>({});
|
const [recentlySelected, setRecentlySelected] = useState<StandardPaper[]>(
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('astro_recently_selected');
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse recently selected papers', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [downloadingBibcodes, setDownloadingBibcodes] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
const [uploadingBibcode, setUploadingBibcode] = useState<string | null>(null);
|
const [uploadingBibcode, setUploadingBibcode] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('astro_recently_selected', JSON.stringify(recentlySelected));
|
localStorage.setItem(
|
||||||
|
'astro_recently_selected',
|
||||||
|
JSON.stringify(recentlySelected)
|
||||||
|
);
|
||||||
}, [recentlySelected]);
|
}, [recentlySelected]);
|
||||||
|
|
||||||
const addPaperToRecent = (paper: StandardPaper) => {
|
const addPaperToRecent = (paper: StandardPaper) => {
|
||||||
setRecentlySelected(prev => {
|
setRecentlySelected((prev) => {
|
||||||
const filtered = prev.filter(p => p.bibcode !== paper.bibcode);
|
const filtered = prev.filter((p) => p.bibcode !== paper.bibcode);
|
||||||
return [paper, ...filtered].slice(0, 5);
|
return [paper, ...filtered].slice(0, 5);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -57,60 +91,122 @@ export function useLibrary({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchLibrary = async () => {
|
const fetchLibrary = async () => {
|
||||||
|
if (isAuthenticated !== true) return;
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<StandardPaper[]>('/api/library');
|
const res = await axios.get<{ items: StandardPaper[]; total: number }>(
|
||||||
setLibrary(res.data);
|
'/api/library',
|
||||||
|
{
|
||||||
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
|
params: {
|
||||||
if (lastReadBibcode) {
|
q: searchTerm || undefined,
|
||||||
const lastReadPaper = res.data.find(p => p.bibcode === lastReadBibcode);
|
status: filterStatus !== 'all' ? filterStatus : undefined,
|
||||||
if (lastReadPaper) {
|
doctype: filterDoctype !== 'all' ? filterDoctype : undefined,
|
||||||
const initialTab = localStorage.getItem('astro_active_tab') || 'search';
|
author: filterAuthor || undefined,
|
||||||
openReader(lastReadPaper, initialTab !== 'reader');
|
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) {
|
} catch (e) {
|
||||||
console.error('加载本地文献库失败', e);
|
console.error('加载本地文献库失败', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 初始化加载本地文献库
|
// 初始化加载上次阅读文献 (仅执行一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated !== true || initialOpenedRef.current) return;
|
||||||
|
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
|
||||||
|
if (lastReadBibcode) {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated !== true) return;
|
if (isAuthenticated !== true) return;
|
||||||
let active = true;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await axios.get<StandardPaper[]>('/api/library');
|
|
||||||
if (!active) return;
|
|
||||||
setLibrary(res.data);
|
|
||||||
|
|
||||||
const lastReadBibcode = localStorage.getItem('last_read_bibcode');
|
const fetchLibrary = async () => {
|
||||||
if (lastReadBibcode) {
|
setLoading(true);
|
||||||
const lastReadPaper = res.data.find(p => p.bibcode === lastReadBibcode);
|
try {
|
||||||
if (lastReadPaper) {
|
const res = await axios.get<{ items: StandardPaper[]; total: number }>(
|
||||||
const initialTab = localStorage.getItem('astro_active_tab') || 'search';
|
'/api/library',
|
||||||
openReader(lastReadPaper, initialTab !== 'reader');
|
{
|
||||||
|
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) {
|
} catch (e) {
|
||||||
console.error('加载本地文献库失败', 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[]>>) => {
|
const handleDownload = async (
|
||||||
setDownloadingBibcodes(prev => ({ ...prev, [bibcode]: true }));
|
bibcode: string,
|
||||||
|
force = false,
|
||||||
|
searchResultsSetter?: React.Dispatch<React.SetStateAction<StandardPaper[]>>
|
||||||
|
) => {
|
||||||
|
setDownloadingBibcodes((prev) => ({ ...prev, [bibcode]: true }));
|
||||||
try {
|
try {
|
||||||
const res = await axios.post<StandardPaper>('/api/download', { bibcode, force });
|
const res = await axios.post<StandardPaper>('/api/download', {
|
||||||
|
bibcode,
|
||||||
|
force,
|
||||||
|
});
|
||||||
|
|
||||||
if (searchResultsSetter) {
|
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 => {
|
setLibrary((prev) => {
|
||||||
if (prev.some(p => p.bibcode === bibcode)) {
|
if (prev.some((p) => p.bibcode === bibcode)) {
|
||||||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
return prev.map((p) => (p.bibcode === bibcode ? res.data : p));
|
||||||
} else {
|
} else {
|
||||||
return [res.data, ...prev];
|
return [res.data, ...prev];
|
||||||
}
|
}
|
||||||
@ -122,12 +218,17 @@ export function useLibrary({
|
|||||||
console.error('下载文献失败', e);
|
console.error('下载文献失败', e);
|
||||||
showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
|
showAlert('文献下载失败,请检查 ADS 网络限制与网络代理!', '下载失败');
|
||||||
} finally {
|
} 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);
|
setUploadingBibcode(bibcode);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('bibcode', bibcode);
|
formData.append('bibcode', bibcode);
|
||||||
@ -140,13 +241,15 @@ export function useLibrary({
|
|||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (searchResultsSetter) {
|
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 => {
|
setLibrary((prev) => {
|
||||||
if (prev.some(p => p.bibcode === bibcode)) {
|
if (prev.some((p) => p.bibcode === bibcode)) {
|
||||||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
return prev.map((p) => (p.bibcode === bibcode ? res.data : p));
|
||||||
} else {
|
} else {
|
||||||
return [res.data, ...prev];
|
return [res.data, ...prev];
|
||||||
}
|
}
|
||||||
@ -158,7 +261,8 @@ export function useLibrary({
|
|||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('手动文件上传失败', e);
|
console.error('手动文件上传失败', e);
|
||||||
const axiosError = e as { response?: { data?: string } };
|
const axiosError = e as { response?: { data?: string } };
|
||||||
const errMsg = axiosError.response?.data || '请确保上传的是合法且完整的文件。';
|
const errMsg =
|
||||||
|
axiosError.response?.data || '请确保上传的是合法且完整的文件。';
|
||||||
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
|
showAlert(`文件上传失败: ${errMsg}`, '上传出错');
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingBibcode(null);
|
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 () => {
|
const performMark = async () => {
|
||||||
try {
|
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) {
|
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 => {
|
setLibrary((prev) => {
|
||||||
if (prev.some(p => p.bibcode === bibcode)) {
|
if (prev.some((p) => p.bibcode === bibcode)) {
|
||||||
return prev.map(p => p.bibcode === bibcode ? res.data : p);
|
return prev.map((p) => (p.bibcode === bibcode ? res.data : p));
|
||||||
} else {
|
} else {
|
||||||
return [res.data, ...prev];
|
return [res.data, ...prev];
|
||||||
}
|
}
|
||||||
@ -185,8 +298,8 @@ export function useLibrary({
|
|||||||
setSelectedPaper(res.data);
|
setSelectedPaper(res.data);
|
||||||
}
|
}
|
||||||
showAlert(
|
showAlert(
|
||||||
clear
|
clear
|
||||||
? '已成功清除“无全文资源”标记,该文献已被重新允许重载!'
|
? '已成功清除“无全文资源”标记,该文献已被重新允许重载!'
|
||||||
: '已成功将文献标记为“无全文资源”,后续批量任务将自动跳过!',
|
: '已成功将文献标记为“无全文资源”,后续批量任务将自动跳过!',
|
||||||
'标记更新'
|
'标记更新'
|
||||||
);
|
);
|
||||||
@ -211,7 +324,10 @@ export function useLibrary({
|
|||||||
|
|
||||||
// 衍生状态计算
|
// 衍生状态计算
|
||||||
const getDetailPaper = (searchResults: StandardPaper[]) => {
|
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 {
|
return {
|
||||||
@ -233,5 +349,26 @@ export function useLibrary({
|
|||||||
handleManualUpload,
|
handleManualUpload,
|
||||||
handleMarkNoResource,
|
handleMarkNoResource,
|
||||||
getDetailPaper,
|
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 [showNotesPanel, setShowNotesPanel] = useState(false);
|
||||||
const [newNoteText, setNewNoteText] = useState('');
|
const [newNoteText, setNewNoteText] = useState('');
|
||||||
const [newNoteColor, setNewNoteColor] = useState('yellow');
|
const [newNoteColor, setNewNoteColor] = useState('yellow');
|
||||||
const [selectedParagraphIdx, setSelectedParagraphIdx] = useState<number | null>(null);
|
const [selectedParagraphIdx, setSelectedParagraphIdx] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
const [selectedText, setSelectedText] = useState('');
|
const [selectedText, setSelectedText] = useState('');
|
||||||
|
|
||||||
const handleCreateNote = async (selectedPaper: StandardPaper | null) => {
|
const handleCreateNote = async (selectedPaper: StandardPaper | null) => {
|
||||||
if (!selectedPaper || selectedParagraphIdx === null || !newNoteText.trim()) return;
|
if (!selectedPaper || selectedParagraphIdx === null || !newNoteText.trim())
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
const res = await axios.post<NoteRecord>('/api/notes', {
|
const res = await axios.post<NoteRecord>('/api/notes', {
|
||||||
bibcode: selectedPaper.bibcode,
|
bibcode: selectedPaper.bibcode,
|
||||||
@ -21,7 +24,7 @@ export function useNotes() {
|
|||||||
highlight_color: newNoteColor,
|
highlight_color: newNoteColor,
|
||||||
selected_text: selectedText,
|
selected_text: selectedText,
|
||||||
});
|
});
|
||||||
setNotes(prev => [...prev, res.data]);
|
setNotes((prev) => [...prev, res.data]);
|
||||||
setNewNoteText('');
|
setNewNoteText('');
|
||||||
setSelectedParagraphIdx(null);
|
setSelectedParagraphIdx(null);
|
||||||
setSelectedText('');
|
setSelectedText('');
|
||||||
@ -33,7 +36,7 @@ export function useNotes() {
|
|||||||
const handleDeleteNote = async (id: number) => {
|
const handleDeleteNote = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await axios.delete('/api/notes', { params: { id } });
|
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) {
|
} catch (e) {
|
||||||
console.error('删除笔记失败', e);
|
console.error('删除笔记失败', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,11 @@ import axios from 'axios';
|
|||||||
import { defaultSchema } from 'rehype-sanitize';
|
import { defaultSchema } from 'rehype-sanitize';
|
||||||
import type { StandardPaper } from '../types';
|
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';
|
import { useSyncScroll } from './useSyncScroll';
|
||||||
|
|
||||||
// LaTeX 及 HTML 过滤白名单配置
|
// LaTeX 及 HTML 过滤白名单配置
|
||||||
@ -12,20 +16,67 @@ export const safeSchema = {
|
|||||||
...defaultSchema,
|
...defaultSchema,
|
||||||
attributes: {
|
attributes: {
|
||||||
...defaultSchema.attributes,
|
...defaultSchema.attributes,
|
||||||
'*': (defaultSchema.attributes?.['*'] || []).concat(['className', 'style', 'mathvariant', 'display', 'data-target-name']),
|
'*': (defaultSchema.attributes?.['*'] || []).concat([
|
||||||
'span': (defaultSchema.attributes?.['span'] || []).concat(['className', 'style', 'data-target-name']),
|
'className',
|
||||||
|
'style',
|
||||||
|
'mathvariant',
|
||||||
|
'display',
|
||||||
|
'data-target-name',
|
||||||
|
]),
|
||||||
|
span: (defaultSchema.attributes?.['span'] || []).concat([
|
||||||
|
'className',
|
||||||
|
'style',
|
||||||
|
'data-target-name',
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
tagNames: (defaultSchema.tagNames || []).concat([
|
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 }> = {
|
export const NOTE_COLORS: Record<
|
||||||
cyan: { bg: 'bg-cyan-50 border-cyan-200', border: 'border-cyan-300', label: '天蓝色', text: 'text-cyan-800' },
|
string,
|
||||||
amber: { bg: 'bg-amber-50 border-amber-200', border: 'border-amber-300', label: '星光金', text: 'text-amber-800' },
|
{ bg: string; border: string; label: string; text: string }
|
||||||
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' },
|
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 {
|
interface UseReaderStateProps {
|
||||||
@ -56,19 +107,19 @@ export function useReaderState({
|
|||||||
|
|
||||||
const [sidebarTab, setSidebarTab] = useState<'notes' | 'ai'>('notes');
|
const [sidebarTab, setSidebarTab] = useState<'notes' | 'ai'>('notes');
|
||||||
const [hoveredTarget, setHoveredTarget] = useState<TargetInfo | null>(null);
|
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 hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// 绑定双语自适应双轨同步滚动 (方案三)
|
// 绑定双语自适应双轨同步滚动 (方案三)
|
||||||
const {
|
const { englishRef, chineseRef, handleEnglishScroll, handleChineseScroll } =
|
||||||
englishRef,
|
useSyncScroll(syncScroll, viewMode, showPdf, hasMarkdown, () => {
|
||||||
chineseRef,
|
if (hoveredTarget) setHoveredTarget(null);
|
||||||
handleEnglishScroll,
|
});
|
||||||
handleChineseScroll,
|
|
||||||
} = useSyncScroll(syncScroll, viewMode, showPdf, hasMarkdown, () => {
|
|
||||||
if (hoveredTarget) setHoveredTarget(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 预先计算并缓存天体高亮匹配项
|
// 预先计算并缓存天体高亮匹配项
|
||||||
const highlightCandidates = useMemo(() => {
|
const highlightCandidates = useMemo(() => {
|
||||||
@ -84,7 +135,7 @@ export function useReaderState({
|
|||||||
setLoadingTargets(true);
|
setLoadingTargets(true);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<TargetInfo[]>('/api/target/list', {
|
const res = await axios.get<TargetInfo[]>('/api/target/list', {
|
||||||
params: { bibcode: selectedPaper.bibcode }
|
params: { bibcode: selectedPaper.bibcode },
|
||||||
});
|
});
|
||||||
if (active) setTargets(res.data);
|
if (active) setTargets(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -93,14 +144,19 @@ export function useReaderState({
|
|||||||
if (active) setLoadingTargets(false);
|
if (active) setLoadingTargets(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { active = false; };
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
}, [selectedPaper?.bibcode]);
|
}, [selectedPaper?.bibcode]);
|
||||||
|
|
||||||
// 自动从正文中提取天体并查询 CDS Sesame 缓存
|
// 自动从正文中提取天体并查询 CDS Sesame 缓存
|
||||||
const handleIdentifyTargets = async (bibcode: string) => {
|
const handleIdentifyTargets = async (bibcode: string) => {
|
||||||
setIdentifyingTargets(true);
|
setIdentifyingTargets(true);
|
||||||
try {
|
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);
|
setTargets(res.data.targets);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('天体识别失败:', e);
|
console.error('天体识别失败:', e);
|
||||||
@ -116,12 +172,21 @@ export function useReaderState({
|
|||||||
if (!manualTargetName.trim() || associatingTarget) return;
|
if (!manualTargetName.trim() || associatingTarget) return;
|
||||||
setAssociatingTarget(true);
|
setAssociatingTarget(true);
|
||||||
try {
|
try {
|
||||||
const res = await axios.post<{ status: string; target: TargetInfo }>('/api/target/associate', {
|
const res = await axios.post<{ status: string; target: TargetInfo }>(
|
||||||
bibcode: selectedPaper.bibcode,
|
'/api/target/associate',
|
||||||
object_name: manualTargetName.trim()
|
{
|
||||||
});
|
bibcode: selectedPaper.bibcode,
|
||||||
if (!targets.some(t => t.target_name.toLowerCase() === res.data.target.target_name.toLowerCase())) {
|
object_name: manualTargetName.trim(),
|
||||||
setTargets(prev => [...prev, res.data.target]);
|
}
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!targets.some(
|
||||||
|
(t) =>
|
||||||
|
t.target_name.toLowerCase() ===
|
||||||
|
res.data.target.target_name.toLowerCase()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setTargets((prev) => [...prev, res.data.target]);
|
||||||
}
|
}
|
||||||
setManualTargetName('');
|
setManualTargetName('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -145,31 +210,35 @@ export function useReaderState({
|
|||||||
const targetName = element.getAttribute('data-target-name');
|
const targetName = element.getAttribute('data-target-name');
|
||||||
if (targetName) {
|
if (targetName) {
|
||||||
clearHoverTimeout();
|
clearHoverTimeout();
|
||||||
const matchedTarget = targets.find(t =>
|
const matchedTarget = targets.find(
|
||||||
t.target_name.toLowerCase() === targetName.toLowerCase() ||
|
(t) =>
|
||||||
t.aliases.some(a => a.toLowerCase() === targetName.toLowerCase())
|
t.target_name.toLowerCase() === targetName.toLowerCase() ||
|
||||||
|
t.aliases.some((a) => a.toLowerCase() === targetName.toLowerCase())
|
||||||
);
|
);
|
||||||
if (matchedTarget) {
|
if (matchedTarget) {
|
||||||
if (!hoveredTarget || hoveredTarget.target_name !== matchedTarget.target_name) {
|
if (
|
||||||
|
!hoveredTarget ||
|
||||||
|
hoveredTarget.target_name !== matchedTarget.target_name
|
||||||
|
) {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
// 使用 fixed 视口定位防止父级 overflow-y-auto 裁切
|
// 使用 fixed 视口定位防止父级 overflow-y-auto 裁切
|
||||||
let x = rect.left;
|
let x = rect.left;
|
||||||
if (x + 288 > window.innerWidth) {
|
if (x + 288 > window.innerWidth) {
|
||||||
x = window.innerWidth - 288 - 16;
|
x = window.innerWidth - 288 - 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 像素级高内聚自适应翻转定位 (transform: translateY(-100%) 方案)
|
// 像素级高内聚自适应翻转定位 (transform: translateY(-100%) 方案)
|
||||||
const estimatedHeight = 400; // 考虑参数与别名全满时的最大安全预估高度
|
const estimatedHeight = 400; // 考虑参数与别名全满时的最大安全预估高度
|
||||||
let isAbove = false;
|
let isAbove = false;
|
||||||
let y = rect.bottom + 2;
|
let y = rect.bottom + 2;
|
||||||
|
|
||||||
if (y + estimatedHeight > window.innerHeight) {
|
if (y + estimatedHeight > window.innerHeight) {
|
||||||
// 当天体文字靠下时,向上翻转定位在文字上方,并通过 isAbove 标记应用 CSS 平移
|
// 当天体文字靠下时,向上翻转定位在文字上方,并通过 isAbove 标记应用 CSS 平移
|
||||||
isAbove = true;
|
isAbove = true;
|
||||||
y = rect.top - 2;
|
y = rect.top - 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
setHoverCardPos({ x, y, isAbove });
|
setHoverCardPos({ x, y, isAbove });
|
||||||
setHoveredTarget(matchedTarget);
|
setHoveredTarget(matchedTarget);
|
||||||
}
|
}
|
||||||
@ -205,7 +274,7 @@ export function useReaderState({
|
|||||||
// 点击侧边栏天体时,跳转并高亮正文中对应的天体
|
// 点击侧边栏天体时,跳转并高亮正文中对应的天体
|
||||||
const handleJumpToTarget = (target: TargetInfo) => {
|
const handleJumpToTarget = (target: TargetInfo) => {
|
||||||
if (!englishText) return;
|
if (!englishText) return;
|
||||||
|
|
||||||
// 如果当前处于仅中文视图,自动切换为对照视图(移动端切换为英文视图)
|
// 如果当前处于仅中文视图,自动切换为对照视图(移动端切换为英文视图)
|
||||||
if (viewMode === 'chinese') {
|
if (viewMode === 'chinese') {
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
@ -214,12 +283,15 @@ export function useReaderState({
|
|||||||
|
|
||||||
// 生成所有可能匹配的名称变体(包含空格与无空格变体、别名)
|
// 生成所有可能匹配的名称变体(包含空格与无空格变体、别名)
|
||||||
const cleanNames = new Set<string>();
|
const cleanNames = new Set<string>();
|
||||||
|
|
||||||
// 历史存量数据 Unicode 异型空格兼容清洗函数 (根据 ENABLE_LEGACY_UNICODE_SPACE_COMPAT 开关自适应切换)
|
// 历史存量数据 Unicode 异型空格兼容清洗函数 (根据 ENABLE_LEGACY_UNICODE_SPACE_COMPAT 开关自适应切换)
|
||||||
const normalizeSpace = (str: string) => {
|
const normalizeSpace = (str: string) => {
|
||||||
if (ENABLE_LEGACY_UNICODE_SPACE_COMPAT) {
|
if (ENABLE_LEGACY_UNICODE_SPACE_COMPAT) {
|
||||||
// 兼容模式:将所有特殊的 Unicode 不换行空格、窄空格、连续多空格统一替换为标准的单半角空格
|
// 兼容模式:将所有特殊的 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();
|
return str.trim().toLowerCase();
|
||||||
@ -229,14 +301,16 @@ export function useReaderState({
|
|||||||
const normalized = normalizeSpace(name);
|
const normalized = normalizeSpace(name);
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
cleanNames.add(normalized);
|
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);
|
const match = normalized.match(spaceRegex);
|
||||||
if (match) {
|
if (match) {
|
||||||
cleanNames.add(`${match[1]}${match[2]}`);
|
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);
|
const matchNoSpace = normalized.match(noSpaceRegex);
|
||||||
if (matchNoSpace) {
|
if (matchNoSpace) {
|
||||||
cleanNames.add(`${matchNoSpace[1]} ${matchNoSpace[2]}`);
|
cleanNames.add(`${matchNoSpace[1]} ${matchNoSpace[2]}`);
|
||||||
@ -249,9 +323,9 @@ export function useReaderState({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paragraphs = englishText.split('\n\n');
|
const paragraphs = englishText.split('\n\n');
|
||||||
const foundIdx = paragraphs.findIndex(para => {
|
const foundIdx = paragraphs.findIndex((para) => {
|
||||||
const paraClean = normalizeSpace(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) {
|
if (foundIdx !== -1) {
|
||||||
@ -259,18 +333,39 @@ export function useReaderState({
|
|||||||
const element = document.getElementById(`paragraph-block-${foundIdx}`);
|
const element = document.getElementById(`paragraph-block-${foundIdx}`);
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
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]`);
|
const targetSpans = element.querySelectorAll(`[data-target-name]`);
|
||||||
targetSpans.forEach(span => {
|
targetSpans.forEach((span) => {
|
||||||
const attrVal = span.getAttribute('data-target-name')?.toLowerCase();
|
const attrVal = span
|
||||||
if (attrVal && Array.from(cleanNames).some(name => name === attrVal)) {
|
.getAttribute('data-target-name')
|
||||||
span.classList.add('bg-amber-200', 'scale-105', 'px-1', 'rounded', 'transition-all');
|
?.toLowerCase();
|
||||||
|
if (
|
||||||
|
attrVal &&
|
||||||
|
Array.from(cleanNames).some((name) => name === attrVal)
|
||||||
|
) {
|
||||||
|
span.classList.add(
|
||||||
|
'bg-amber-200',
|
||||||
|
'scale-105',
|
||||||
|
'px-1',
|
||||||
|
'rounded',
|
||||||
|
'transition-all'
|
||||||
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
span.classList.remove('bg-amber-200', 'scale-105', 'px-1', 'rounded');
|
span.classList.remove(
|
||||||
|
'bg-amber-200',
|
||||||
|
'scale-105',
|
||||||
|
'px-1',
|
||||||
|
'rounded'
|
||||||
|
);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {
|
import {
|
||||||
useAutoScroll,
|
useAutoScroll,
|
||||||
@ -76,11 +76,18 @@ export interface AgentMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UseResearchAgentProps {
|
interface UseResearchAgentProps {
|
||||||
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
|
showConfirm?: (
|
||||||
|
message: string,
|
||||||
|
onConfirm: () => void,
|
||||||
|
title?: string
|
||||||
|
) => void;
|
||||||
showAlert?: (message: string, 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 [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||||
@ -90,7 +97,12 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
const [agentMode, setAgentMode] = useState('default');
|
const [agentMode, setAgentMode] = useState('default');
|
||||||
const [agentModes, setAgentModes] = useState<AgentMode[]>([]);
|
const [agentModes, setAgentModes] = useState<AgentMode[]>([]);
|
||||||
const [thinking, setThinking] = useState(false);
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
@ -108,10 +120,16 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
const [loadingSearch, setLoadingSearch] = useState(false);
|
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 [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
|
||||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
const [expandedResults, setExpandedResults] = useState<
|
||||||
const [collapsedSubAgents, setCollapsedSubAgents] = useState<Record<string, boolean>>({});
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const [collapsedSubAgents, setCollapsedSubAgents] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 新功能面板状态
|
// 新功能面板状态
|
||||||
const [showMetrics, setShowMetrics] = useState(false);
|
const [showMetrics, setShowMetrics] = useState(false);
|
||||||
@ -183,9 +201,24 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
axios.get<SessionSummary[]>('/api/chat/sessions'),
|
axios.get<SessionSummary[]>('/api/chat/sessions'),
|
||||||
axios.get<AgentMode[]>('/api/chat/modes').catch(() => ({
|
axios.get<AgentMode[]>('/api/chat/modes').catch(() => ({
|
||||||
data: [
|
data: [
|
||||||
{ id: 'default', name: '默认', icon: 'Brain', description: '通用天体物理学研究助手,自主判断搜索、阅读或计算' },
|
{
|
||||||
{ id: 'deep-research', name: '深度', icon: 'Compass', description: '多来源系统性文献调研与交叉验证,适合撰写综述' },
|
id: 'default',
|
||||||
{ id: 'literature-reader', name: '精读', icon: 'BookOpen', description: '专注论文精读、翻译与笔记提炼,强制只读安全模式' },
|
name: '默认',
|
||||||
|
icon: 'Brain',
|
||||||
|
description: '通用天体物理学研究助手,自主判断搜索、阅读或计算',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deep-research',
|
||||||
|
name: '深度',
|
||||||
|
icon: 'Compass',
|
||||||
|
description: '多来源系统性文献调研与交叉验证,适合撰写综述',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'literature-reader',
|
||||||
|
name: '精读',
|
||||||
|
icon: 'BookOpen',
|
||||||
|
description: '专注论文精读、翻译与笔记提炼,强制只读安全模式',
|
||||||
|
},
|
||||||
] as AgentMode[],
|
] as AgentMode[],
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
@ -198,60 +231,78 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
if (active) setLoadingSessions(false);
|
if (active) setLoadingSessions(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { active = false; };
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 加载选中的会话历史消息
|
// 加载选中的会话历史消息
|
||||||
const loadSessionHistory = async (sessionId: string, silent: boolean = false, onSuccess?: () => void) => {
|
const loadSessionHistory = useCallback(
|
||||||
if (!silent) {
|
async (
|
||||||
setLoadingHistory(true);
|
sessionId: string,
|
||||||
}
|
silent: boolean = false,
|
||||||
setShouldAutoScroll(false);
|
onSuccess?: () => void
|
||||||
try {
|
) => {
|
||||||
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) {
|
|
||||||
setAgentMode(res.data.session.mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onSuccess) {
|
|
||||||
onSuccess();
|
|
||||||
}
|
|
||||||
setMessages(newMessages);
|
|
||||||
// 加载历史后瞬时跳到底部(无动画),并在渲染稳定后恢复自动滚动状态
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
scrollToBottom('auto');
|
|
||||||
setTimeout(() => {
|
|
||||||
setShouldAutoScroll(true);
|
|
||||||
}, 50);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 自动迁移流式工具调用的展开/折叠状态至历史视图
|
|
||||||
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 => {
|
|
||||||
if (prev[tc.id] !== undefined) return prev;
|
|
||||||
return { ...prev, [tc.id]: false };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载会话消息历史失败:', e);
|
|
||||||
} finally {
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoadingHistory(false);
|
setLoadingHistory(true);
|
||||||
}
|
}
|
||||||
}
|
setShouldAutoScroll(false);
|
||||||
};
|
try {
|
||||||
|
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) {
|
||||||
|
setAgentMode(res.data.session.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
setMessages(newMessages);
|
||||||
|
// 加载历史后瞬时跳到底部(无动画),并在渲染稳定后恢复自动滚动状态
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToBottom('auto');
|
||||||
|
setTimeout(() => {
|
||||||
|
setShouldAutoScroll(true);
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动迁移流式工具调用的展开/折叠状态至历史视图
|
||||||
|
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) => {
|
||||||
|
if (prev[tc.id] !== undefined) return prev;
|
||||||
|
return { ...prev, [tc.id]: false };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载会话消息历史失败:', e);
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
setLoadingHistory(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollToBottom, setShouldAutoScroll]
|
||||||
|
);
|
||||||
|
|
||||||
// 会话切换监听
|
// 会话切换监听
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -263,10 +314,14 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
loadSessionHistory(currentSessionId, true);
|
loadSessionHistory(currentSessionId, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
queueMicrotask(() => { if (active) setMessages([]); });
|
queueMicrotask(() => {
|
||||||
|
if (active) setMessages([]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return () => { active = false; };
|
return () => {
|
||||||
}, [currentSessionId]);
|
active = false;
|
||||||
|
};
|
||||||
|
}, [currentSessionId, loadSessionHistory]);
|
||||||
|
|
||||||
// 跨会话历史检索防抖逻辑
|
// 跨会话历史检索防抖逻辑
|
||||||
useEffect(() => {
|
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();
|
e.stopPropagation();
|
||||||
|
|
||||||
const performDelete = async () => {
|
const performDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/chat/sessions/${sessionId}`);
|
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) {
|
if (currentSessionId === sessionId) {
|
||||||
setCurrentSessionId(null);
|
setCurrentSessionId(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@ -334,7 +392,11 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (showConfirm) {
|
if (showConfirm) {
|
||||||
showConfirm('确认删除此研讨会话吗?删除后将无法恢复。', performDelete, '确认删除');
|
showConfirm(
|
||||||
|
'确认删除此研讨会话吗?删除后将无法恢复。',
|
||||||
|
performDelete,
|
||||||
|
'确认删除'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
if (window.confirm('确认删除此研讨会话吗?删除后将无法恢复。')) {
|
if (window.confirm('确认删除此研讨会话吗?删除后将无法恢复。')) {
|
||||||
performDelete();
|
performDelete();
|
||||||
@ -360,7 +422,7 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
`/api/chat/sessions/${currentSessionId}/rewind`,
|
`/api/chat/sessions/${currentSessionId}/rewind`,
|
||||||
body
|
body
|
||||||
);
|
);
|
||||||
|
|
||||||
if (showAlert) {
|
if (showAlert) {
|
||||||
showAlert(`已回退 ${res.data.rewound_count} 条消息`, '成功');
|
showAlert(`已回退 ${res.data.rewound_count} 条消息`, '成功');
|
||||||
} else {
|
} else {
|
||||||
@ -371,7 +433,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
loadSessionHistory(currentSessionId, true);
|
loadSessionHistory(currentSessionId, true);
|
||||||
fetchSessions(); // 更新侧栏 turn_count
|
fetchSessions(); // 更新侧栏 turn_count
|
||||||
} catch (e: unknown) {
|
} 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 || '未知错误'}`;
|
const errMsg = `回退失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
|
||||||
if (showAlert) {
|
if (showAlert) {
|
||||||
showAlert(errMsg, '错误');
|
showAlert(errMsg, '错误');
|
||||||
@ -416,7 +481,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} 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 || '未知错误'}`;
|
const errMsg = `恢复失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
|
||||||
if (showAlert) {
|
if (showAlert) {
|
||||||
showAlert(errMsg, '错误');
|
showAlert(errMsg, '错误');
|
||||||
@ -436,7 +504,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
`/api/chat/sessions/${currentSessionId}/branch`
|
`/api/chat/sessions/${currentSessionId}/branch`
|
||||||
);
|
);
|
||||||
if (showAlert) {
|
if (showAlert) {
|
||||||
showAlert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`, '成功');
|
showAlert(
|
||||||
|
`成功分叉会话!已复制 ${res.data.copied_count} 条消息`,
|
||||||
|
'成功'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
alert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`);
|
alert(`成功分叉会话!已复制 ${res.data.copied_count} 条消息`);
|
||||||
}
|
}
|
||||||
@ -445,7 +516,10 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
await fetchSessions();
|
await fetchSessions();
|
||||||
setCurrentSessionId(res.data.branch_session_id);
|
setCurrentSessionId(res.data.branch_session_id);
|
||||||
} catch (e: unknown) {
|
} 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 || '未知错误'}`;
|
const errMsg = `分叉失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
|
||||||
if (showAlert) {
|
if (showAlert) {
|
||||||
showAlert(errMsg, '错误');
|
showAlert(errMsg, '错误');
|
||||||
@ -474,7 +548,7 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
const res = await axios.post<RetryResult>(
|
const res = await axios.post<RetryResult>(
|
||||||
`/api/chat/sessions/${currentSessionId}/retry`
|
`/api/chat/sessions/${currentSessionId}/retry`
|
||||||
);
|
);
|
||||||
|
|
||||||
const questionText = res.data.retried_message;
|
const questionText = res.data.retried_message;
|
||||||
if (!questionText) {
|
if (!questionText) {
|
||||||
if (showAlert) {
|
if (showAlert) {
|
||||||
@ -492,15 +566,29 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
// 如果原消息附带了图片,通过 path 复用已有文件,避免重新上传
|
// 如果原消息附带了图片,通过 path 复用已有文件,避免重新上传
|
||||||
if (res.data.image_path) {
|
if (res.data.image_path) {
|
||||||
const ext = res.data.image_path.split('.').pop() || 'png';
|
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';
|
const mimeType =
|
||||||
handleSend(questionText, { path: res.data.image_path, mime_type: mimeType, name: res.data.image_path.split('/').pop() || 'image' });
|
{
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 触发自动重发
|
// 触发自动重发
|
||||||
handleSend(questionText);
|
handleSend(questionText);
|
||||||
} catch (e: unknown) {
|
} 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 || '未知错误'}`;
|
const errMsg = `重试失败: ${axiosError.response?.data || axiosError.message || '未知错误'}`;
|
||||||
if (showAlert) {
|
if (showAlert) {
|
||||||
showAlert(errMsg, '错误');
|
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 (!questionText.trim() || streaming) return;
|
||||||
|
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
@ -547,7 +643,9 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
const active: ActiveTurn = {
|
const active: ActiveTurn = {
|
||||||
question: questionText,
|
question: questionText,
|
||||||
imagePath: image
|
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,
|
: undefined,
|
||||||
timeline: [],
|
timeline: [],
|
||||||
finalAnswer: '',
|
finalAnswer: '',
|
||||||
@ -565,11 +663,13 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
session_id: currentSessionId,
|
session_id: currentSessionId,
|
||||||
mode: agentMode,
|
mode: agentMode,
|
||||||
thinking,
|
thinking,
|
||||||
...(image ? {
|
...(image
|
||||||
image: image.path
|
? {
|
||||||
? { path: image.path, mime_type: image.mime_type }
|
image: image.path
|
||||||
: { data: image.data, mime_type: image.mime_type },
|
? { path: image.path, mime_type: image.mime_type }
|
||||||
} : {}),
|
: { data: image.data, mime_type: image.mime_type },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -604,7 +704,7 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(dataStr);
|
const event = JSON.parse(dataStr);
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'session':
|
case 'session':
|
||||||
if (!activeSessionId) {
|
if (!activeSessionId) {
|
||||||
@ -612,50 +712,85 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
setCurrentSessionId(event.session_id);
|
setCurrentSessionId(event.session_id);
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
} else if (event.title) {
|
} else if (event.title) {
|
||||||
setSessions(prev => prev.map(s =>
|
setSessions((prev) =>
|
||||||
s.session_id === event.session_id ? { ...s, title: event.title } : s
|
prev.map((s) =>
|
||||||
));
|
s.session_id === event.session_id
|
||||||
|
? { ...s, title: event.title }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'thought':
|
case 'thought':
|
||||||
setActiveTurn(prev => {
|
setActiveTurn((prev) => {
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, timeline: routeThought(prev.timeline, event.step, event.content) };
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'tool_call':
|
|
||||||
setActiveTurn(prev => {
|
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...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;
|
break;
|
||||||
case 'tool_result':
|
case 'tool_result':
|
||||||
setExpandedResults(prev => ({ ...prev, [event.tool_call_id]: true }));
|
setExpandedResults((prev) => ({
|
||||||
setActiveTurn(prev => {
|
...prev,
|
||||||
|
[event.tool_call_id]: true,
|
||||||
|
}));
|
||||||
|
setActiveTurn((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...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;
|
break;
|
||||||
case 'text_delta':
|
case 'text_delta':
|
||||||
if (event.tool_call_id) {
|
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 (!prev) return prev;
|
||||||
if (event.tool_call_id) {
|
if (event.tool_call_id) {
|
||||||
const hasToolCall = prev.timeline.some(
|
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;
|
if (!hasToolCall) return prev;
|
||||||
}
|
}
|
||||||
const { timeline, answerDelta } = routeTextDelta(
|
const { timeline, answerDelta } = routeTextDelta(
|
||||||
prev.timeline, event.content, event.tool_call_id,
|
prev.timeline,
|
||||||
|
event.content,
|
||||||
|
event.tool_call_id
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@ -665,13 +800,13 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'usage':
|
case 'usage':
|
||||||
setActiveTurn(prev => {
|
setActiveTurn((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return { ...prev, usage: event };
|
return { ...prev, usage: event };
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
setActiveTurn(prev => {
|
setActiveTurn((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return { ...prev, error: event.message };
|
return { ...prev, error: event.message };
|
||||||
});
|
});
|
||||||
@ -693,21 +828,28 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
} else {
|
} else {
|
||||||
setActiveTurn(null);
|
setActiveTurn(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('智能体对话请求失败:', e);
|
console.error('智能体对话请求失败:', e);
|
||||||
const error = e instanceof Error ? e : new Error('未知错误');
|
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);
|
setStreaming(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const turns = groupMessagesIntoTurns(messages);
|
const turns = groupMessagesIntoTurns(messages);
|
||||||
|
|
||||||
const toggleThought = (key: string) => setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] }));
|
const toggleThought = (key: string) =>
|
||||||
const toggleArgs = (tcId: string) => setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
setExpandedThoughts((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
const toggleResult = (tcId: string) => setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
const toggleArgs = (tcId: string) =>
|
||||||
const toggleSubAgent = (id: string) => setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] }));
|
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 {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
@ -775,9 +917,13 @@ export function useResearchAgent({ showConfirm, showAlert }: UseResearchAgentPro
|
|||||||
function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
||||||
const turnsMap = new Map<number, ProcessedTurn>();
|
const turnsMap = new Map<number, ProcessedTurn>();
|
||||||
|
|
||||||
const nonSystemMessages = messages.filter(m => m.role !== 'system');
|
const nonSystemMessages = messages.filter((m) => m.role !== 'system');
|
||||||
const leadMessages = nonSystemMessages.filter(m => !m.metadata?.is_subagent && !m.agent_name?.startsWith('sub_'));
|
const leadMessages = nonSystemMessages.filter(
|
||||||
const subMessages = nonSystemMessages.filter(m => m.metadata?.is_subagent || m.agent_name?.startsWith('sub_'));
|
(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[]>();
|
const subByAgent = new Map<string, MessageRecord[]>();
|
||||||
for (const sm of subMessages) {
|
for (const sm of subMessages) {
|
||||||
@ -800,14 +946,19 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
|
|
||||||
if (msg.token_count > 0) {
|
if (msg.token_count > 0) {
|
||||||
if (!turn.usage) {
|
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') {
|
if (msg.role === 'assistant') {
|
||||||
turn.usage.completion_tokens += msg.token_count;
|
turn.usage.completion_tokens += msg.token_count;
|
||||||
} else {
|
} else {
|
||||||
turn.usage.prompt_tokens += msg.token_count;
|
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') {
|
if (msg.role === 'user') {
|
||||||
@ -825,19 +976,26 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
if (hasToolCalls) {
|
if (hasToolCalls) {
|
||||||
if (hasThought) {
|
if (hasThought) {
|
||||||
const alreadyExists = turn.timeline.some(
|
const alreadyExists = turn.timeline.some(
|
||||||
t => t.type === 'thought' && t.step === stepNum
|
(t) => t.type === 'thought' && t.step === stepNum
|
||||||
);
|
);
|
||||||
if (!alreadyExists) {
|
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!) {
|
for (const tc of msg.tool_calls!) {
|
||||||
let parsedArgs: Record<string, unknown> = {};
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
parsedArgs = typeof tc.function.arguments === 'string'
|
parsedArgs =
|
||||||
? JSON.parse(tc.function.arguments)
|
typeof tc.function.arguments === 'string'
|
||||||
: tc.function.arguments as Record<string, unknown>;
|
? JSON.parse(tc.function.arguments)
|
||||||
} catch { /* ignore */ }
|
: (tc.function.arguments as Record<string, unknown>);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
turn.timeline.push({
|
turn.timeline.push({
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
step: stepNum,
|
step: stepNum,
|
||||||
@ -848,10 +1006,14 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
}
|
}
|
||||||
} else if (hasThought && hasContent) {
|
} else if (hasThought && hasContent) {
|
||||||
const alreadyExists = turn.timeline.some(
|
const alreadyExists = turn.timeline.some(
|
||||||
t => t.type === 'thought' && t.step === stepNum
|
(t) => t.type === 'thought' && t.step === stepNum
|
||||||
);
|
);
|
||||||
if (!alreadyExists) {
|
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! });
|
turn.timeline.push({ type: 'answer', content: msg.content! });
|
||||||
} else if (hasContent) {
|
} else if (hasContent) {
|
||||||
@ -859,12 +1021,24 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
}
|
}
|
||||||
} else if (msg.role === 'tool') {
|
} else if (msg.role === 'tool') {
|
||||||
for (const item of turn.timeline) {
|
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;
|
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;
|
isError = true;
|
||||||
}
|
}
|
||||||
item.result = { output: msg.content, isError };
|
item.result = {
|
||||||
|
output: msg.content,
|
||||||
|
isError,
|
||||||
|
metadata: msg.metadata ?? undefined,
|
||||||
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -874,7 +1048,11 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
for (const [, turn] of turnsMap) {
|
for (const [, turn] of turnsMap) {
|
||||||
for (let i = 0; i < turn.timeline.length; i++) {
|
for (let i = 0; i < turn.timeline.length; i++) {
|
||||||
const item = turn.timeline[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 delegateResult = item.result?.output || '';
|
||||||
const delegateTcId = item.id;
|
const delegateTcId = item.id;
|
||||||
@ -882,7 +1060,9 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
|
|
||||||
for (const [agentName, msgs] of subByAgent) {
|
for (const [agentName, msgs] of subByAgent) {
|
||||||
if (msgs.length === 0) continue;
|
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;
|
if (filtered.length === 0) continue;
|
||||||
|
|
||||||
const children: TimelineItem[] = [];
|
const children: TimelineItem[] = [];
|
||||||
@ -896,19 +1076,26 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
if (hasToolCalls) {
|
if (hasToolCalls) {
|
||||||
if (hasThought) {
|
if (hasThought) {
|
||||||
const alreadyExists = children.some(
|
const alreadyExists = children.some(
|
||||||
c => c.type === 'thought' && c.step === stepNum
|
(c) => c.type === 'thought' && c.step === stepNum
|
||||||
);
|
);
|
||||||
if (!alreadyExists) {
|
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!) {
|
for (const tc of msg.tool_calls!) {
|
||||||
let parsedArgs: Record<string, unknown> = {};
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
parsedArgs = typeof tc.function.arguments === 'string'
|
parsedArgs =
|
||||||
? JSON.parse(tc.function.arguments)
|
typeof tc.function.arguments === 'string'
|
||||||
: tc.function.arguments as Record<string, unknown>;
|
? JSON.parse(tc.function.arguments)
|
||||||
} catch { /* ignore */ }
|
: (tc.function.arguments as Record<string, unknown>);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
children.push({
|
children.push({
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
step: stepNum,
|
step: stepNum,
|
||||||
@ -919,10 +1106,14 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
}
|
}
|
||||||
} else if (hasThought && hasContent) {
|
} else if (hasThought && hasContent) {
|
||||||
const alreadyExists = children.some(
|
const alreadyExists = children.some(
|
||||||
c => c.type === 'thought' && c.step === stepNum
|
(c) => c.type === 'thought' && c.step === stepNum
|
||||||
);
|
);
|
||||||
if (!alreadyExists) {
|
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! });
|
children.push({ type: 'answer', content: msg.content! });
|
||||||
} else if (hasContent) {
|
} else if (hasContent) {
|
||||||
@ -930,12 +1121,24 @@ function groupMessagesIntoTurns(messages: MessageRecord[]): ProcessedTurn[] {
|
|||||||
}
|
}
|
||||||
} else if (msg.role === 'tool') {
|
} else if (msg.role === 'tool') {
|
||||||
for (const child of children) {
|
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;
|
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;
|
isError = true;
|
||||||
}
|
}
|
||||||
child.result = { output: msg.content, isError };
|
child.result = {
|
||||||
|
output: msg.content,
|
||||||
|
isError,
|
||||||
|
metadata: msg.metadata ?? undefined,
|
||||||
|
};
|
||||||
break;
|
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;
|
return sortedTurns;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,9 @@ interface UseSearchProps {
|
|||||||
|
|
||||||
export function useSearch({ showAlert }: UseSearchProps) {
|
export function useSearch({ showAlert }: UseSearchProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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 [searchResults, setSearchResults] = useState<StandardPaper[]>([]);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [exportingList, setExportingList] = useState<string[]>([]);
|
const [exportingList, setExportingList] = useState<string[]>([]);
|
||||||
@ -18,11 +20,13 @@ export function useSearch({ showAlert }: UseSearchProps) {
|
|||||||
const [searchRows, setSearchRows] = useState(15);
|
const [searchRows, setSearchRows] = useState(15);
|
||||||
const [searchStart, setSearchStart] = useState(0);
|
const [searchStart, setSearchStart] = useState(0);
|
||||||
const [searchSort, setSearchSort] = useState('relevance');
|
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) => {
|
const executeSearch = async (start: number, rows: number, sort: string) => {
|
||||||
if (!searchQuery.trim()) return;
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
// 构造缓存 Key
|
// 构造缓存 Key
|
||||||
const cacheKey = `${searchQuery.trim()}_${searchSource}_${start}_${rows}_${sort}`;
|
const cacheKey = `${searchQuery.trim()}_${searchSource}_${start}_${rows}_${sort}`;
|
||||||
if (searchCache[cacheKey]) {
|
if (searchCache[cacheKey]) {
|
||||||
@ -34,11 +38,11 @@ export function useSearch({ showAlert }: UseSearchProps) {
|
|||||||
setBibtexContent(null);
|
setBibtexContent(null);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<StandardPaper[]>('/api/search', {
|
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);
|
setSearchResults(res.data);
|
||||||
// 写入缓存
|
// 写入缓存
|
||||||
setSearchCache(prev => ({ ...prev, [cacheKey]: res.data }));
|
setSearchCache((prev) => ({ ...prev, [cacheKey]: res.data }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('检索文献失败', e);
|
console.error('检索文献失败', e);
|
||||||
showAlert('检索失败,请确认后端连接及 API 密钥配置。', '检索出错');
|
showAlert('检索失败,请确认后端连接及 API 密钥配置。', '检索出错');
|
||||||
@ -74,7 +78,9 @@ export function useSearch({ showAlert }: UseSearchProps) {
|
|||||||
if (exportingList.length === 0) return;
|
if (exportingList.length === 0) return;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
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);
|
setBibtexContent(res.data.bibtex);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('导出 BibTeX 失败', e);
|
console.error('导出 BibTeX 失败', e);
|
||||||
@ -85,8 +91,10 @@ export function useSearch({ showAlert }: UseSearchProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleExportItem = (bibcode: string) => {
|
const toggleExportItem = (bibcode: string) => {
|
||||||
setExportingList(prev =>
|
setExportingList((prev) =>
|
||||||
prev.includes(bibcode) ? prev.filter(b => b !== bibcode) : [...prev, bibcode]
|
prev.includes(bibcode)
|
||||||
|
? prev.filter((b) => b !== bibcode)
|
||||||
|
: [...prev, bibcode]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export function splitParagraphs(text: string): string[] {
|
|||||||
if (!text.trim()) return [];
|
if (!text.trim()) return [];
|
||||||
// 按 2 个以上连续换行拆分,然后过滤空段落
|
// 按 2 个以上连续换行拆分,然后过滤空段落
|
||||||
const raw = text.split(/\n{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(
|
export function useSyncScroll(
|
||||||
@ -40,7 +40,8 @@ export function useSyncScroll(
|
|||||||
try {
|
try {
|
||||||
// ── PDF / 无 Markdown → 全局比例映射 ──
|
// ── PDF / 无 Markdown → 全局比例映射 ──
|
||||||
if (showPdf || !hasMarkdown) {
|
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);
|
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -49,7 +50,8 @@ export function useSyncScroll(
|
|||||||
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
|
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
|
||||||
|
|
||||||
if (engParas.length === 0 || chnParas.length === 0) {
|
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);
|
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -61,7 +63,10 @@ export function useSyncScroll(
|
|||||||
let anchorIndex = -1;
|
let anchorIndex = -1;
|
||||||
for (let i = 0; i < engParas.length; i++) {
|
for (let i = 0; i < engParas.length; i++) {
|
||||||
const el = engParas[i] as HTMLElement;
|
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;
|
anchorIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -84,14 +89,17 @@ export function useSyncScroll(
|
|||||||
const anchorElement = engParas[anchorIndex] as HTMLElement;
|
const anchorElement = engParas[anchorIndex] as HTMLElement;
|
||||||
const offsetTop = anchorElement.offsetTop;
|
const offsetTop = anchorElement.offsetTop;
|
||||||
const offsetHeight = anchorElement.offsetHeight || 1;
|
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)
|
// 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-(.+)/);
|
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
||||||
return m ? parseInt(m[1], 10) : -1;
|
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-(.+)/);
|
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
||||||
return m ? parseInt(m[1], 10) : -1;
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
});
|
});
|
||||||
@ -99,14 +107,13 @@ export function useSyncScroll(
|
|||||||
const anchorParaId = engParaIds[anchorIndex] ?? -1;
|
const anchorParaId = engParaIds[anchorIndex] ?? -1;
|
||||||
|
|
||||||
// 用所有元素(含 metadata id=-1)做映射,保证连续性
|
// 用所有元素(含 metadata id=-1)做映射,保证连续性
|
||||||
const engTextParas = engParaIds
|
const engTextParas = engParaIds.map((id, idx) => ({ id, idx }));
|
||||||
.map((id, idx) => ({ id, idx }));
|
const chnTextParas = chnParaIds.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) {
|
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);
|
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
|
||||||
return;
|
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) {
|
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;
|
const maxScroll = chn.scrollHeight - chn.clientHeight;
|
||||||
chn.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
chn.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||||
} else {
|
} 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);
|
chn.scrollTop = ratio * (chn.scrollHeight - chn.clientHeight);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -154,7 +166,8 @@ export function useSyncScroll(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (showPdf || !hasMarkdown) {
|
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);
|
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -163,7 +176,8 @@ export function useSyncScroll(
|
|||||||
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
|
const chnParas = chn.querySelectorAll('[id^="paragraph-block-"]');
|
||||||
|
|
||||||
if (engParas.length === 0 || chnParas.length === 0) {
|
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);
|
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -173,7 +187,10 @@ export function useSyncScroll(
|
|||||||
let anchorIndex = -1;
|
let anchorIndex = -1;
|
||||||
for (let i = 0; i < chnParas.length; i++) {
|
for (let i = 0; i < chnParas.length; i++) {
|
||||||
const el = chnParas[i] as HTMLElement;
|
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;
|
anchorIndex = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -196,14 +213,17 @@ export function useSyncScroll(
|
|||||||
const anchorElement = chnParas[anchorIndex] as HTMLElement;
|
const anchorElement = chnParas[anchorIndex] as HTMLElement;
|
||||||
const offsetTop = anchorElement.offsetTop;
|
const offsetTop = anchorElement.offsetTop;
|
||||||
const offsetHeight = anchorElement.offsetHeight || 1;
|
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 做映射
|
// 按段落 ID 做映射
|
||||||
const engParaIds = Array.from(engParas).map(el => {
|
const engParaIds = Array.from(engParas).map((el) => {
|
||||||
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
||||||
return m ? parseInt(m[1], 10) : -1;
|
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-(.+)/);
|
const m = (el as HTMLElement).id.match(/paragraph-block-(.+)/);
|
||||||
return m ? parseInt(m[1], 10) : -1;
|
return m ? parseInt(m[1], 10) : -1;
|
||||||
});
|
});
|
||||||
@ -211,14 +231,13 @@ export function useSyncScroll(
|
|||||||
const anchorParaId = chnParaIds[anchorIndex] ?? -1;
|
const anchorParaId = chnParaIds[anchorIndex] ?? -1;
|
||||||
|
|
||||||
// 用所有元素(含 metadata id=-1)做映射,保证连续性
|
// 用所有元素(含 metadata id=-1)做映射,保证连续性
|
||||||
const engTextParas = engParaIds
|
const engTextParas = engParaIds.map((id, idx) => ({ id, idx }));
|
||||||
.map((id, idx) => ({ id, idx }));
|
const chnTextParas = chnParaIds.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) {
|
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);
|
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
|
||||||
return;
|
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) {
|
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;
|
const maxScroll = eng.scrollHeight - eng.clientHeight;
|
||||||
eng.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
eng.scrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
|
||||||
} else {
|
} 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);
|
eng.scrollTop = ratio * (eng.scrollHeight - eng.clientHeight);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -41,13 +41,19 @@ export function useSyncState() {
|
|||||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
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 [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 [skipCompleted, setSkipCompleted] = useState<boolean>(true);
|
||||||
const [skipFailed, setSkipFailed] = useState<boolean>(false);
|
const [skipFailed, setSkipFailed] = useState<boolean>(false);
|
||||||
const [skipPrecedingFailed, setSkipPrecedingFailed] = useState<boolean>(false);
|
const [skipPrecedingFailed, setSkipPrecedingFailed] =
|
||||||
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] = useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
|
const [skipPrecedingUncompleted, setSkipPrecedingUncompleted] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
|
const [batchStatus, setBatchStatus] = useState<BatchStatus>({
|
||||||
active: false,
|
active: false,
|
||||||
@ -60,13 +66,15 @@ export function useSyncState() {
|
|||||||
logs: [],
|
logs: [],
|
||||||
});
|
});
|
||||||
const [batchError, setBatchError] = useState<string | null>(null);
|
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 logsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [showBuilder, setShowBuilder] = useState(false);
|
const [showBuilder, setShowBuilder] = useState(false);
|
||||||
const [rules, setRules] = useState<Array<{ field: string; op: string; val: string }>>([
|
const [rules, setRules] = useState<
|
||||||
{ field: 'all', op: 'AND', val: '' }
|
Array<{ field: string; op: string; val: string }>
|
||||||
]);
|
>([{ field: 'all', op: 'AND', val: '' }]);
|
||||||
|
|
||||||
// 当高级表单规则变化时,自动更新同步输入框的检索式
|
// 当高级表单规则变化时,自动更新同步输入框的检索式
|
||||||
const updateQueryFromRules = (currentRules: typeof rules) => {
|
const updateQueryFromRules = (currentRules: typeof rules) => {
|
||||||
@ -74,13 +82,16 @@ export function useSyncState() {
|
|||||||
currentRules.forEach((rule, idx) => {
|
currentRules.forEach((rule, idx) => {
|
||||||
if (!rule.val.trim()) return;
|
if (!rule.val.trim()) return;
|
||||||
let valStr = rule.val.trim();
|
let valStr = rule.val.trim();
|
||||||
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
|
if (
|
||||||
|
valStr.includes(' ') &&
|
||||||
|
!valStr.startsWith('"') &&
|
||||||
|
!valStr.startsWith('(')
|
||||||
|
) {
|
||||||
valStr = `"${valStr}"`;
|
valStr = `"${valStr}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldPart = rule.field !== 'all'
|
const fieldPart =
|
||||||
? `${rule.field}:${valStr}`
|
rule.field !== 'all' ? `${rule.field}:${valStr}` : valStr;
|
||||||
: valStr;
|
|
||||||
|
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
qParts.push(fieldPart);
|
qParts.push(fieldPart);
|
||||||
@ -92,7 +103,7 @@ export function useSyncState() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddRule = () => {
|
const handleAddRule = () => {
|
||||||
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
setRules((prev) => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveRule = (idx: number) => {
|
const handleRemoveRule = (idx: number) => {
|
||||||
@ -101,8 +112,12 @@ export function useSyncState() {
|
|||||||
updateQueryFromRules(next);
|
updateQueryFromRules(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
|
const handleRuleChange = (
|
||||||
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
|
idx: number,
|
||||||
|
key: 'field' | 'op' | 'val',
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
const next = rules.map((r, i) => (i === idx ? { ...r, [key]: value } : r));
|
||||||
setRules(next);
|
setRules(next);
|
||||||
updateQueryFromRules(next);
|
updateQueryFromRules(next);
|
||||||
};
|
};
|
||||||
@ -110,7 +125,9 @@ export function useSyncState() {
|
|||||||
// 获取历史检索配置
|
// 获取历史检索配置
|
||||||
const fetchSyncQueries = async () => {
|
const fetchSyncQueries = async () => {
|
||||||
try {
|
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);
|
setSyncQueries(res.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取检索配置列表失败', e);
|
console.error('获取检索配置列表失败', e);
|
||||||
@ -129,15 +146,19 @@ export function useSyncState() {
|
|||||||
const handleReuseQuery = (sq: SavedSyncQuery) => {
|
const handleReuseQuery = (sq: SavedSyncQuery) => {
|
||||||
setQuery(sq.query);
|
setQuery(sq.query);
|
||||||
const validSources = ['all', 'ads', 'arxiv'] as const;
|
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);
|
setLimit(sq.limit_count);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickSync = async (sq: SavedSyncQuery) => {
|
const handleQuickSync = async (sq: SavedSyncQuery) => {
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
|
|
||||||
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
|
// 立即更新前端本地状态,提供即时的 UI 反馈并避免轮询竞态
|
||||||
setStatus(prev => ({
|
setStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
active: true,
|
active: true,
|
||||||
query: sq.query,
|
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
|
// eslint-disable-next-line react-hooks/purity -- Date.now() is called at invocation time (setInterval/event handlers), not during render
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
try {
|
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);
|
setStatus(res.data);
|
||||||
if (res.data.active) {
|
if (res.data.active) {
|
||||||
startPolling();
|
startPolling();
|
||||||
@ -218,6 +241,7 @@ export function useSyncState() {
|
|||||||
clearInterval(pollIntervalRef.current);
|
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
|
// eslint-disable-next-line react-hooks/purity -- Date.now() is called at invocation time (setInterval/event handlers), not during render
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
try {
|
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);
|
setBatchStatus(res.data);
|
||||||
if (res.data.active) {
|
if (res.data.active) {
|
||||||
startBatchPolling();
|
startBatchPolling();
|
||||||
@ -277,10 +303,15 @@ export function useSyncState() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
|
|
||||||
|
const startBatchPolling = () => {
|
||||||
|
if (batchPollIntervalRef.current) return;
|
||||||
|
batchPollIntervalRef.current = setInterval(fetchBatchStatus, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const ts = Date.now();
|
const res = await axios.get<BatchStatus>('/api/batch/asset/status');
|
||||||
const res = await axios.get<BatchStatus>(`/api/batch/asset/status?t=${ts}`);
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setBatchStatus(res.data);
|
setBatchStatus(res.data);
|
||||||
if (res.data.active) {
|
if (res.data.active) {
|
||||||
@ -299,13 +330,16 @@ export function useSyncState() {
|
|||||||
clearInterval(batchPollIntervalRef.current);
|
clearInterval(batchPollIntervalRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 日志终端自动滚动到底部
|
// 日志终端自动滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = logsContainerRef.current;
|
const container = logsContainerRef.current;
|
||||||
if (container) {
|
if (container) {
|
||||||
const isCloseToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 80;
|
const isCloseToBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight <
|
||||||
|
80;
|
||||||
if (isCloseToBottom) {
|
if (isCloseToBottom) {
|
||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}
|
}
|
||||||
@ -323,13 +357,15 @@ export function useSyncState() {
|
|||||||
setEstimatedCount(null);
|
setEstimatedCount(null);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get<{ total: number }>('/api/sync/meta/count', {
|
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);
|
setEstimatedCount(res.data.total);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
const axiosError = e as { response?: { data?: string } };
|
const axiosError = e as { response?: { data?: string } };
|
||||||
setErrorMsg(axiosError.response?.data || '估算文献总量失败,请检查 API 密钥或网络。');
|
setErrorMsg(
|
||||||
|
axiosError.response?.data || '估算文献总量失败,请检查 API 密钥或网络。'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setEstimating(false);
|
setEstimating(false);
|
||||||
}
|
}
|
||||||
@ -342,8 +378,8 @@ export function useSyncState() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
|
|
||||||
setStatus(prev => ({
|
setStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
active: true,
|
active: true,
|
||||||
query: query.trim(),
|
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 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";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
@ -10,25 +10,29 @@
|
|||||||
--color-blueprint: var(--accent-blueprint);
|
--color-blueprint: var(--accent-blueprint);
|
||||||
--color-star: var(--accent-star);
|
--color-star: var(--accent-star);
|
||||||
--color-precision: var(--border-precision);
|
--color-precision: var(--border-precision);
|
||||||
|
|
||||||
--font-mono: var(--font-mono);
|
--font-mono: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
|
'Inter',
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
/* 极极简白蓝学术科技风配色 - 升级为暖调纸张感学术护眼色 */
|
/* 极极简白蓝学术科技风配色 - 升级为暖调纸张感学术护眼色 */
|
||||||
--bg-main: #faf8f5; /* 学术纸张底色 */
|
--bg-main: #faf8f5; /* 学术纸张底色 */
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-sidebar: #0f2540; /* 保持深色侧边栏作为界面骨架结构 */
|
--bg-sidebar: #0f2540; /* 保持深色侧边栏作为界面骨架结构 */
|
||||||
--text-main: #1e293b; /* 优雅深石板灰,对比度更佳 */
|
--text-main: #1e293b; /* 优雅深石板灰,对比度更佳 */
|
||||||
--text-muted: #475569; /* 中度石板灰,提高可读性 */
|
--text-muted: #475569; /* 中度石板灰,提高可读性 */
|
||||||
|
|
||||||
--accent-blueprint: #0369a1; /* 低饱和度学术蓝 */
|
--accent-blueprint: #0369a1; /* 低饱和度学术蓝 */
|
||||||
--accent-star: #b45309; /* 经典低饱和度琥珀橙 */
|
--accent-star: #b45309; /* 经典低饱和度琥珀橙 */
|
||||||
--border-precision: #e2e8f0; /* 极细轻量级分割线 */
|
--border-precision: #e2e8f0; /* 极细轻量级分割线 */
|
||||||
|
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,7 +70,9 @@ body {
|
|||||||
|
|
||||||
.console-panel-active {
|
.console-panel-active {
|
||||||
border-color: var(--accent-blueprint);
|
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 */
|
/* High contrast clean console button */
|
||||||
@ -158,7 +164,11 @@ mark {
|
|||||||
|
|
||||||
/* LaTeX-style academic publishing typography overrides */
|
/* LaTeX-style academic publishing typography overrides */
|
||||||
.prose {
|
.prose {
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
font-family:
|
||||||
|
'Inter',
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
sans-serif;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,7 +211,9 @@ mark {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Remove vertical borders and striping that are generated by default */
|
/* 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-left: none !important;
|
||||||
border-right: none !important;
|
border-right: none !important;
|
||||||
}
|
}
|
||||||
@ -221,7 +233,7 @@ mark {
|
|||||||
|
|
||||||
.prose blockquote p::before,
|
.prose blockquote p::before,
|
||||||
.prose blockquote p::after {
|
.prose blockquote p::after {
|
||||||
content: "" !important;
|
content: '' !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Formulas and Math spacing */
|
/* Formulas and Math spacing */
|
||||||
@ -231,4 +243,3 @@ mark {
|
|||||||
overflow-x: auto !important;
|
overflow-x: auto !important;
|
||||||
overflow-y: hidden !important;
|
overflow-y: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,24 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
import App from './App.tsx'
|
import App from './App.tsx';
|
||||||
|
|
||||||
// 注册 PWA Service Worker
|
// 注册 PWA Service Worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/sw.js')
|
navigator.serviceWorker
|
||||||
.then(reg => console.log('Service Worker registered with scope:', reg.scope))
|
.register('/sw.js')
|
||||||
.catch(err => console.error('Service Worker registration failed:', err));
|
.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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
)
|
);
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
// dashboard/src/features/citation/CitationPanel.tsx
|
// dashboard/src/features/citation/CitationPanel.tsx
|
||||||
import { useState } from 'react';
|
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 { CitationGalaxyCanvas } from '../components/CitationGalaxyCanvas';
|
||||||
import type { StandardPaper, CitationNetwork } from '../types';
|
import type { StandardPaper, CitationNetwork } from '../types';
|
||||||
|
|
||||||
@ -33,7 +40,13 @@ export function CitationPanel({
|
|||||||
// 统一节点点击事件:已入库直接拉取,未入库弹窗选择
|
// 统一节点点击事件:已入库直接拉取,未入库弹窗选择
|
||||||
const handleNodeClick = (bibcode: string) => {
|
const handleNodeClick = (bibcode: string) => {
|
||||||
if (!citationNetwork) return;
|
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) {
|
if (inDb) {
|
||||||
loadCitations(bibcode, false);
|
loadCitations(bibcode, false);
|
||||||
} else {
|
} else {
|
||||||
@ -43,28 +56,39 @@ export function CitationPanel({
|
|||||||
|
|
||||||
return (
|
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-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>
|
<div>
|
||||||
<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">
|
||||||
<p className="text-xs text-slate-500 mt-1">通过图谱层级快速 visual 展现当前文献的“参考文献 - 被引文献”关联脉络</p>
|
引用星系拓扑图谱
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
通过图谱层级快速 visual 展现当前文献的“参考文献 - 被引文献”关联脉络
|
||||||
|
</p>
|
||||||
</div>
|
</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 && (
|
{selectedPaper && (
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSwitchMenu(!showSwitchMenu)}
|
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" />
|
<GitFork className="w-3.5 h-3.5 text-blueprint" />
|
||||||
<span>最近文献</span>
|
<span className="hidden sm:inline">最近文献</span>
|
||||||
<ChevronDown className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`} />
|
<span className="sm:hidden">最近</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-3 h-3 transition-transform ${showSwitchMenu ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showSwitchMenu && (
|
{showSwitchMenu && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setShowSwitchMenu(false)} />
|
<div
|
||||||
<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">
|
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 && (
|
{recentlySelected.length > 0 && (
|
||||||
<div className="border-b border-slate-100 pb-1.5 mb-1.5">
|
<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" />
|
<History className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<span>最近选择</span>
|
<span>最近选择</span>
|
||||||
</div>
|
</div>
|
||||||
{recentlySelected.map(paper => (
|
{recentlySelected.map((paper) => (
|
||||||
<button
|
<button
|
||||||
key={`citation-recent-${paper.bibcode}`}
|
key={`citation-recent-${paper.bibcode}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -80,30 +104,36 @@ export function CitationPanel({
|
|||||||
setShowSwitchMenu(false);
|
setShowSwitchMenu(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||||
paper.bibcode === selectedPaper.bibcode
|
paper.bibcode === selectedPaper.bibcode
|
||||||
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
||||||
: 'border-transparent text-slate-700 font-medium'
|
: 'border-transparent text-slate-700 font-medium'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate block leading-tight text-left">{paper.title}</span>
|
<span className="truncate block leading-tight text-left">
|
||||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
|
{paper.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||||
|
{paper.bibcode}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 全部已下载文献 */}
|
{/* 全部已下载文献 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
|
<div className="px-3 py-1 text-[10px] font-bold text-slate-400 uppercase flex items-center gap-1">
|
||||||
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<span>全部已下载馆藏</span>
|
<span>全部已下载馆藏</span>
|
||||||
</div>
|
</div>
|
||||||
{library.filter(p => p.is_downloaded).length === 0 ? (
|
{library.filter((p) => p.is_downloaded).length === 0 ? (
|
||||||
<div className="px-3 py-2 text-slate-400 italic">暂无已下载文献</div>
|
<div className="px-3 py-2 text-slate-400 italic">
|
||||||
|
暂无已下载文献
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
library
|
library
|
||||||
.filter(p => p.is_downloaded)
|
.filter((p) => p.is_downloaded)
|
||||||
.map(paper => (
|
.map((paper) => (
|
||||||
<button
|
<button
|
||||||
key={`citation-lib-${paper.bibcode}`}
|
key={`citation-lib-${paper.bibcode}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -111,13 +141,17 @@ export function CitationPanel({
|
|||||||
setShowSwitchMenu(false);
|
setShowSwitchMenu(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
className={`w-full px-3 py-2 text-left hover:bg-slate-50 transition-colors flex flex-col gap-0.5 border-l-2 ${
|
||||||
paper.bibcode === selectedPaper.bibcode
|
paper.bibcode === selectedPaper.bibcode
|
||||||
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
? 'border-blueprint bg-blueprint/5 text-blueprint font-bold'
|
||||||
: 'border-transparent text-slate-700 font-medium'
|
: 'border-transparent text-slate-700 font-medium'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate block leading-tight text-left">{paper.title}</span>
|
<span className="truncate block leading-tight text-left">
|
||||||
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">{paper.bibcode}</span>
|
{paper.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400 font-semibold font-mono text-left">
|
||||||
|
{paper.bibcode}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -128,24 +162,31 @@ export function CitationPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 border border-slate-200 bg-white px-3 py-1.5 rounded-md shadow-sm">
|
<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">
|
||||||
<input
|
展示节点上限:
|
||||||
type="range"
|
</span>
|
||||||
min="20"
|
<input
|
||||||
max="200"
|
type="range"
|
||||||
|
min="20"
|
||||||
|
max="200"
|
||||||
step="5"
|
step="5"
|
||||||
value={nodeLimit}
|
value={nodeLimit}
|
||||||
onChange={(e) => setNodeLimit(Number(e.target.value))}
|
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"
|
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>
|
</div>
|
||||||
{selectedPaper && (
|
{selectedPaper && (
|
||||||
<button
|
<button
|
||||||
onClick={() => loadCitations(selectedPaper.bibcode, true)}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -154,25 +195,33 @@ export function CitationPanel({
|
|||||||
{loadingCitations ? (
|
{loadingCitations ? (
|
||||||
<div className="console-panel rounded-lg flex-1 flex flex-col items-center justify-center text-slate-500 bg-white">
|
<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" />
|
<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>
|
</div>
|
||||||
) : citationNetwork ? (
|
) : 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">
|
<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">
|
||||||
{/* 可视化画布 */}
|
{/* 可视化画布 */}
|
||||||
<div className="flex-1 relative bg-slate-50/30 min-h-[350px] md:min-h-0">
|
<div className="flex-1 relative bg-slate-50/30 min-h-[350px] md:min-h-0">
|
||||||
<CitationGalaxyCanvas
|
<CitationGalaxyCanvas
|
||||||
networks={citationHistory}
|
networks={citationHistory}
|
||||||
activeNetwork={citationNetwork}
|
activeNetwork={citationNetwork}
|
||||||
nodeLimit={nodeLimit}
|
nodeLimit={nodeLimit}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 右侧关系详情面板 */}
|
{/* 右侧关系详情面板 */}
|
||||||
<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 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>
|
<div>
|
||||||
<span className="text-[10px] font-bold text-blueprint tracking-wider block mb-2">● 中心焦点文献</span>
|
<span className="text-[10px] font-bold text-blueprint tracking-wider block mb-2">
|
||||||
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="border-t border-slate-200 pt-4">
|
<div className="border-t border-slate-200 pt-4">
|
||||||
@ -181,11 +230,13 @@ export function CitationPanel({
|
|||||||
</span>
|
</span>
|
||||||
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
|
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
|
||||||
{citationNetwork.references.length === 0 ? (
|
{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
|
<div
|
||||||
key={bib}
|
key={bib}
|
||||||
onClick={() => handleNodeClick(bib)}
|
onClick={() => handleNodeClick(bib)}
|
||||||
className="text-[10px] text-slate-700 hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-blueprint/40 cursor-pointer transition-all truncate"
|
className="text-[10px] text-slate-700 hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-blueprint/40 cursor-pointer transition-all truncate"
|
||||||
>
|
>
|
||||||
@ -202,11 +253,13 @@ export function CitationPanel({
|
|||||||
</span>
|
</span>
|
||||||
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
|
<div className="space-y-1.5 max-h-48 overflow-y-auto scrollbar-thin pr-1">
|
||||||
{citationNetwork.citations.length === 0 ? (
|
{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
|
<div
|
||||||
key={bib}
|
key={bib}
|
||||||
onClick={() => handleNodeClick(bib)}
|
onClick={() => handleNodeClick(bib)}
|
||||||
className="text-[10px] text-slate-700 hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-blueprint/40 cursor-pointer transition-all truncate"
|
className="text-[10px] text-slate-700 hover:text-blueprint font-mono py-1.5 px-2.5 rounded bg-white border border-slate-200 hover:border-blueprint/40 cursor-pointer transition-all truncate"
|
||||||
>
|
>
|
||||||
@ -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">
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,17 @@
|
|||||||
// dashboard/src/features/library/LibraryPanel.tsx
|
// dashboard/src/features/library/LibraryPanel.tsx
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { Library, RotateCw, Search, SlidersHorizontal, X, CheckCircle, AlertTriangle, BookOpen, GitFork } from 'lucide-react';
|
import {
|
||||||
|
Library,
|
||||||
|
RotateCw,
|
||||||
|
Search,
|
||||||
|
SlidersHorizontal,
|
||||||
|
X,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
BookOpen,
|
||||||
|
GitFork,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
import type { StandardPaper } from '../types';
|
import type { StandardPaper } from '../types';
|
||||||
import { CustomSelect } from '../components/CustomSelect';
|
import { CustomSelect } from '../components/CustomSelect';
|
||||||
import { PaperCard } from '../components/PaperCard';
|
import { PaperCard } from '../components/PaperCard';
|
||||||
@ -8,10 +19,54 @@ import { PaperCard } from '../components/PaperCard';
|
|||||||
interface LibraryPanelProps {
|
interface LibraryPanelProps {
|
||||||
library: StandardPaper[];
|
library: StandardPaper[];
|
||||||
fetchLibrary: () => Promise<void>;
|
fetchLibrary: () => Promise<void>;
|
||||||
setActiveTab: (tab: 'search' | 'library' | 'reader' | 'citation' | 'sync') => void;
|
setActiveTab: (
|
||||||
|
tab: 'search' | 'library' | 'reader' | 'citation' | 'sync'
|
||||||
|
) => void;
|
||||||
onShowDetail: (paper: StandardPaper) => void;
|
onShowDetail: (paper: StandardPaper) => void;
|
||||||
onOpenReader: (paper: StandardPaper) => void;
|
onOpenReader: (paper: StandardPaper) => void;
|
||||||
onOpenCitation: (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({
|
export function LibraryPanel({
|
||||||
@ -21,214 +76,195 @@ export function LibraryPanel({
|
|||||||
onShowDetail,
|
onShowDetail,
|
||||||
onOpenReader,
|
onOpenReader,
|
||||||
onOpenCitation,
|
onOpenCitation,
|
||||||
|
|
||||||
|
searchTerm,
|
||||||
|
setSearchTerm,
|
||||||
|
filterStatus,
|
||||||
|
setFilterStatus,
|
||||||
|
filterDoctype,
|
||||||
|
setFilterDoctype,
|
||||||
|
sortBy,
|
||||||
|
setSortBy,
|
||||||
|
filterAuthor,
|
||||||
|
setFilterAuthor,
|
||||||
|
filterYear,
|
||||||
|
setFilterYear,
|
||||||
|
filterJournal,
|
||||||
|
setFilterJournal,
|
||||||
|
currentPage,
|
||||||
|
setCurrentPage,
|
||||||
|
pageSize,
|
||||||
|
setPageSize,
|
||||||
|
totalCount,
|
||||||
|
loading,
|
||||||
}: LibraryPanelProps) {
|
}: 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');
|
|
||||||
|
|
||||||
// 重新同步状态反馈
|
// 重新同步状态反馈
|
||||||
const [syncing, setSyncing] = useState(false);
|
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 [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
const [filterAuthor, setFilterAuthor] = useState('');
|
|
||||||
const [filterYear, setFilterYear] = useState('');
|
|
||||||
const [filterJournal, setFilterJournal] = useState('');
|
|
||||||
|
|
||||||
// 各种状态的文献总数量
|
// 页码输入状态及处理
|
||||||
const countAll = library.length;
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
const countDownloaded = library.filter(p => p.is_downloaded).length;
|
const [inputPage, setInputPage] = useState(currentPage.toString());
|
||||||
const countUndownloaded = library.filter(p => !p.is_downloaded && !p.pdf_error && !p.html_error).length;
|
const prevPageRef = useRef(currentPage);
|
||||||
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;
|
|
||||||
|
|
||||||
// 本地检索与筛选过滤
|
// 同步inputPage与currentPage
|
||||||
const filteredLibrary = library.filter(paper => {
|
useEffect(() => {
|
||||||
// 1. 关键词全局检索 (标题、作者、摘要、Bibcode、arXiv ID、DOI)
|
if (prevPageRef.current !== currentPage) {
|
||||||
const query = searchTerm.toLowerCase().trim();
|
prevPageRef.current = currentPage;
|
||||||
if (query) {
|
setInputPage(currentPage.toString());
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
// 2. 离线状态筛选
|
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (filterStatus === 'downloaded' && !paper.is_downloaded) return false;
|
const val = e.target.value;
|
||||||
if (filterStatus === 'undownloaded' && (paper.is_downloaded || paper.pdf_error || paper.html_error)) return false;
|
if (val === '' || /^\d+$/.test(val)) {
|
||||||
if (filterStatus === 'download_failed') {
|
setInputPage(val);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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 submitPageInput = () => {
|
||||||
const sortedLibrary = [...filteredLibrary].sort((a, b) => {
|
const val = inputPage.trim();
|
||||||
if (sortBy === 'created') {
|
if (!val) {
|
||||||
const scoreA = getStatusScore(a);
|
setInputPage(currentPage.toString());
|
||||||
const scoreB = getStatusScore(b);
|
return;
|
||||||
if (scoreA !== scoreB) {
|
|
||||||
return scoreB - scoreA; // 状态高(已翻译 > 已解析 > 已下载 > 其他)的排在前面
|
|
||||||
}
|
|
||||||
// 状态相同时,按“导入时间倒序”排序(即原始数组中的索引从小到大)
|
|
||||||
const idxA = originalIndices.get(a.bibcode) ?? 99999;
|
|
||||||
const idxB = originalIndices.get(b.bibcode) ?? 99999;
|
|
||||||
return idxA - idxB;
|
|
||||||
}
|
}
|
||||||
if (sortBy === 'yearDesc') {
|
const pageNum = parseInt(val, 10);
|
||||||
return (parseInt(b.year) || 0) - (parseInt(a.year) || 0);
|
if (isNaN(pageNum) || pageNum < 1) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
setInputPage('1');
|
||||||
|
} else if (pageNum > totalPages) {
|
||||||
|
setCurrentPage(totalPages);
|
||||||
|
setInputPage(totalPages.toString());
|
||||||
|
} else {
|
||||||
|
setCurrentPage(pageNum);
|
||||||
}
|
}
|
||||||
if (sortBy === 'yearAsc') {
|
};
|
||||||
return (parseInt(a.year) || 0) - (parseInt(b.year) || 0);
|
|
||||||
|
const handlePageInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
submitPageInput();
|
||||||
|
e.currentTarget.blur();
|
||||||
}
|
}
|
||||||
if (sortBy === 'citations') {
|
};
|
||||||
return (b.citation_count || 0) - (a.citation_count || 0);
|
|
||||||
}
|
const handlePageInputBlur = () => {
|
||||||
if (sortBy === 'title') {
|
submitPageInput();
|
||||||
return a.title.localeCompare(b.title);
|
};
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleResync = useCallback(async () => {
|
const handleResync = useCallback(async () => {
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
setSyncFeedback(null);
|
setSyncFeedback(null);
|
||||||
try {
|
try {
|
||||||
await fetchLibrary();
|
await fetchLibrary();
|
||||||
const count = library.length;
|
setSyncFeedback({ type: 'success', message: '馆藏数据已同步刷新' });
|
||||||
setSyncFeedback({ type: 'success', message: `馆藏数据已刷新,共 ${count} 篇文献` });
|
|
||||||
} catch {
|
} catch {
|
||||||
setSyncFeedback({ type: 'error', message: '同步失败,请检查后端服务连接' });
|
setSyncFeedback({
|
||||||
|
type: 'error',
|
||||||
|
message: '同步失败,请检查后端服务连接',
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing(false);
|
setSyncing(false);
|
||||||
setTimeout(() => setSyncFeedback(null), 3000);
|
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 (
|
return (
|
||||||
<div className="w-full max-w-5xl mx-auto space-y-6">
|
<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>
|
<div>
|
||||||
<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">
|
||||||
<p className="text-xs text-slate-500 mt-1">查看和整理已同步离线保存的本地文献资源</p>
|
本地文献馆藏库
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
查看和整理已同步离线保存的本地文献资源
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleResync}
|
onClick={handleResync}
|
||||||
disabled={syncing}
|
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 ? '正在同步...' : '重新同步馆藏'}
|
{syncing ? '正在同步...' : '重新同步馆藏'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 同步结果反馈条 */}
|
{/* 同步结果反馈条 */}
|
||||||
{syncFeedback && (
|
{syncFeedback && (
|
||||||
<div className={`px-4 py-2.5 rounded-md text-xs font-bold flex items-center gap-2 transition-all ${
|
<div
|
||||||
syncFeedback.type === 'success'
|
className={`px-4 py-2.5 rounded-md text-xs font-bold flex items-center gap-2 transition-all select-none ${
|
||||||
? 'bg-emerald-50 border border-emerald-200/60 text-emerald-850'
|
syncFeedback.type === 'success'
|
||||||
: 'bg-rose-50 border border-rose-200/60 text-rose-850'
|
? '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}
|
{syncFeedback.message}
|
||||||
</div>
|
</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 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 flex-col sm:flex-row gap-3">
|
||||||
{/* 本地检索 */}
|
{/* 本地检索 */}
|
||||||
<div className="flex-1 space-y-1.5">
|
<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">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
placeholder="搜索文献标题、作者、摘要、Bibcode、arXiv ID 或 DOI..."
|
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"
|
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 && (
|
{searchTerm && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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="清空检索内容"
|
title="清空检索内容"
|
||||||
>
|
>
|
||||||
@ -240,30 +276,40 @@ export function LibraryPanel({
|
|||||||
|
|
||||||
{/* 状态过滤 */}
|
{/* 状态过滤 */}
|
||||||
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
|
<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
|
<CustomSelect
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={val => setFilterStatus(val)}
|
onChange={(val) => {
|
||||||
|
setFilterStatus(val);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: `全部文献 (${countAll})` },
|
{ value: 'all', label: '全部文献' },
|
||||||
{ value: 'downloaded', label: `已下载 (${countDownloaded})` },
|
{ value: 'downloaded', label: '已下载' },
|
||||||
{ value: 'parsed', label: `已解析 (${countParsed})` },
|
{ value: 'parsed', label: '已解析' },
|
||||||
{ value: 'translated', label: `已翻译 (${countTranslated})` },
|
{ value: 'translated', label: '已翻译' },
|
||||||
{ value: 'vectorized', label: `已向量化 (${countVectorized})` },
|
{ value: 'vectorized', label: '已向量化' },
|
||||||
{ value: 'undownloaded', label: `未下载 (${countUndownloaded})` },
|
{ value: 'undownloaded', label: '未下载' },
|
||||||
{ value: 'download_failed', label: `下载失败 (${countDownloadFailed})` },
|
{ value: 'download_failed', label: '下载失败' },
|
||||||
{ value: 'no_resource', label: `无资源 (${countNoResource})` },
|
{ value: 'no_resource', label: '无资源' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文献类型筛选 */}
|
{/* 文献类型筛选 */}
|
||||||
<div className="w-full sm:w-36 space-y-1.5 font-bold flex flex-col">
|
<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
|
<CustomSelect
|
||||||
value={filterDoctype}
|
value={filterDoctype}
|
||||||
onChange={val => setFilterDoctype(val)}
|
onChange={(val) => {
|
||||||
|
setFilterDoctype(val);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: '全部类型' },
|
{ 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">
|
<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
|
<CustomSelect
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={val => setSortBy(val)}
|
onChange={(val) => {
|
||||||
|
setSortBy(val);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'created', label: '默认 (导入时间)' },
|
{ value: 'created', label: '默认 (导入时间)' },
|
||||||
@ -306,8 +357,8 @@ export function LibraryPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
className={`w-full sm:w-auto px-4 py-2 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center justify-center gap-1.5 cursor-pointer ${
|
className={`w-full sm:w-auto px-4 py-2 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center justify-center gap-1.5 cursor-pointer ${
|
||||||
showAdvanced
|
showAdvanced
|
||||||
? 'bg-slate-100 border-slate-350 text-slate-855'
|
? 'bg-slate-100 border-slate-350 text-slate-855'
|
||||||
: 'btn-console btn-console-secondary'
|
: 'btn-console btn-console-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -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="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">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filterAuthor}
|
value={filterAuthor}
|
||||||
onChange={e => setFilterAuthor(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setFilterAuthor(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
placeholder="如: Althaus..."
|
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"
|
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">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filterYear}
|
value={filterYear}
|
||||||
onChange={e => setFilterYear(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setFilterYear(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
placeholder="如: 2023..."
|
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"
|
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">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filterJournal}
|
value={filterJournal}
|
||||||
onChange={e => setFilterJournal(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setFilterJournal(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
placeholder="如: ApJ 或 MNRAS..."
|
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"
|
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>
|
</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">
|
<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" />
|
<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>
|
<h3 className="font-bold text-slate-800 text-sm mb-2">
|
||||||
<p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">您在检索控制台下载的天体文献会自动同步离线保存到这里,方便进行无网双语解析阅读。</p>
|
本地文献馆藏为空
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500 mb-6 max-w-md mx-auto">
|
||||||
|
您在检索控制台下载的天体文献会自动同步离线保存到这里,方便进行无网双语解析阅读。
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('search')}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : sortedLibrary.length === 0 ? (
|
) : library.length === 0 ? (
|
||||||
<div className="console-panel p-12 rounded-lg text-center border border-slate-200 bg-white">
|
<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" />
|
<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>
|
<h3 className="font-bold text-slate-700 text-xs mb-1.5">
|
||||||
<p className="text-xs text-slate-450 max-w-sm mx-auto mb-4">请尝试修改您的馆藏检索关键词或放宽离线状态及高级元数据筛选条件。</p>
|
未找到符合筛选条件的文献
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-455 max-w-sm mx-auto mb-4">
|
||||||
|
请尝试修改您的馆藏检索关键词或放宽离线状态及高级元数据筛选条件。
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleResetFilters}
|
||||||
setSearchTerm('');
|
|
||||||
setFilterStatus('all');
|
|
||||||
setFilterDoctype('all');
|
|
||||||
setFilterAuthor('');
|
|
||||||
setFilterYear('');
|
|
||||||
setFilterJournal('');
|
|
||||||
}}
|
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center text-xs text-slate-500 font-bold px-1">
|
<div className="flex justify-between items-center text-xs text-slate-500 font-bold px-1 select-none">
|
||||||
<span>已筛选出 {sortedLibrary.length} 篇文献 / 共 {library.length} 篇</span>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{sortedLibrary.map(paper => {
|
{library.map((paper) => {
|
||||||
return (
|
return (
|
||||||
<PaperCard
|
<PaperCard
|
||||||
key={paper.bibcode}
|
key={paper.bibcode}
|
||||||
@ -405,12 +498,14 @@ export function LibraryPanel({
|
|||||||
onClick={() => onShowDetail(paper)}
|
onClick={() => onShowDetail(paper)}
|
||||||
variant="grid"
|
variant="grid"
|
||||||
statusBadge={
|
statusBadge={
|
||||||
<div
|
<div
|
||||||
title={
|
title={
|
||||||
(!paper.is_downloaded && (paper.pdf_error || paper.html_error))
|
!paper.is_downloaded &&
|
||||||
? (paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource')
|
(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('; ')}`
|
: `下载失败原因:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
className={`absolute top-0 right-0 px-2 py-0.5 text-[9px] font-bold border-b border-l rounded-bl ${
|
className={`absolute top-0 right-0 px-2 py-0.5 text-[9px] font-bold border-b border-l rounded-bl ${
|
||||||
@ -422,9 +517,10 @@ export function LibraryPanel({
|
|||||||
? 'bg-blue-50 text-blue-800 border-blue-200/60'
|
? 'bg-blue-50 text-blue-800 border-blue-200/60'
|
||||||
: paper.is_downloaded
|
: paper.is_downloaded
|
||||||
? 'bg-slate-100 text-slate-800 border-slate-250'
|
? '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'
|
? '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-rose-50 text-rose-850 border-rose-200/60 cursor-help'
|
||||||
: 'bg-slate-50 text-slate-550 border-slate-100'
|
: 'bg-slate-50 text-slate-550 border-slate-100'
|
||||||
}`}
|
}`}
|
||||||
@ -437,9 +533,10 @@ export function LibraryPanel({
|
|||||||
? '已解析'
|
? '已解析'
|
||||||
: paper.is_downloaded
|
: 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>
|
</div>
|
||||||
@ -475,16 +572,20 @@ export function LibraryPanel({
|
|||||||
footerRight={
|
footerRight={
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className={`w-1.5 h-1.5 rounded-full ${paper.has_translation ? 'bg-emerald-500' : 'bg-slate-200'}`}
|
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>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
className={`w-1.5 h-1.5 rounded-full ${paper.has_vector ? 'bg-amber-500' : 'bg-slate-200'}`}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@ -492,6 +593,137 @@ export function LibraryPanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -70,15 +70,17 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
|||||||
} = props;
|
} = 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';
|
if (typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||||
|
return chineseText ? 'chinese' : 'english';
|
||||||
|
}
|
||||||
|
if (!chineseText) {
|
||||||
|
return 'english';
|
||||||
|
}
|
||||||
|
return 'bilingual';
|
||||||
}
|
}
|
||||||
if (!chineseText) {
|
);
|
||||||
return 'english';
|
|
||||||
}
|
|
||||||
return 'bilingual';
|
|
||||||
});
|
|
||||||
|
|
||||||
const [userOverrode, setUserOverrode] = useState(false);
|
const [userOverrode, setUserOverrode] = useState(false);
|
||||||
|
|
||||||
@ -145,7 +147,7 @@ export function ReaderPanel(props: ReaderPanelProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
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
|
<ReaderToolbar
|
||||||
selectedPaper={selectedPaper}
|
selectedPaper={selectedPaper}
|
||||||
|
|||||||
@ -2,13 +2,33 @@ import { useResearchAgent } from '../hooks/useResearchAgent';
|
|||||||
import { AgentSessionSidebar } from '../components/agent/AgentSessionSidebar';
|
import { AgentSessionSidebar } from '../components/agent/AgentSessionSidebar';
|
||||||
import { AgentMessageList } from '../components/agent/AgentMessageList';
|
import { AgentMessageList } from '../components/agent/AgentMessageList';
|
||||||
import { AgentInputArea } from '../components/agent/AgentInputArea';
|
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 {
|
interface ResearchAgentPanelProps {
|
||||||
showConfirm?: (message: string, onConfirm: () => void, title?: string) => void;
|
showConfirm?: (
|
||||||
|
message: string,
|
||||||
|
onConfirm: () => void,
|
||||||
|
title?: string
|
||||||
|
) => void;
|
||||||
showAlert?: (message: string, 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 });
|
const state = useResearchAgent({ showConfirm, showAlert });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -30,7 +50,7 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
|||||||
onNewSession={state.handleNewSession}
|
onNewSession={state.handleNewSession}
|
||||||
onDeleteSession={state.handleDeleteSession}
|
onDeleteSession={state.handleDeleteSession}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 右侧会话对话展示及输入面板 */}
|
{/* 右侧会话对话展示及输入面板 */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
|
<div className="flex-1 flex flex-col overflow-hidden bg-white relative">
|
||||||
<AgentMessageList
|
<AgentMessageList
|
||||||
@ -68,8 +88,12 @@ export function ResearchAgentPanel({ showConfirm, showAlert }: ResearchAgentPane
|
|||||||
sessions={state.sessions}
|
sessions={state.sessions}
|
||||||
handleDeleteSession={state.handleDeleteSession}
|
handleDeleteSession={state.handleDeleteSession}
|
||||||
loadSessionHistory={state.loadSessionHistory}
|
loadSessionHistory={state.loadSessionHistory}
|
||||||
|
openReader={openReader}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
citations={citations}
|
||||||
|
library={library}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AgentInputArea
|
<AgentInputArea
|
||||||
input={state.input}
|
input={state.input}
|
||||||
setInput={state.setInput}
|
setInput={state.setInput}
|
||||||
|
|||||||
@ -1,6 +1,17 @@
|
|||||||
// dashboard/src/features/search/SearchPanel.tsx
|
// dashboard/src/features/search/SearchPanel.tsx
|
||||||
import React from 'react';
|
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 type { StandardPaper } from '../types';
|
||||||
import { CustomSelect } from '../components/CustomSelect';
|
import { CustomSelect } from '../components/CustomSelect';
|
||||||
import { PaperCard } from '../components/PaperCard';
|
import { PaperCard } from '../components/PaperCard';
|
||||||
@ -29,7 +40,9 @@ interface SearchPanelProps {
|
|||||||
selectedPaper: StandardPaper | null;
|
selectedPaper: StandardPaper | null;
|
||||||
setSelectedPaper: (paper: StandardPaper | null) => void;
|
setSelectedPaper: (paper: StandardPaper | null) => void;
|
||||||
openReader: (paper: StandardPaper) => 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;
|
loadCitations: (bibcode: string, reset?: boolean) => void;
|
||||||
showAlert: (msg: string, title?: string) => void;
|
showAlert: (msg: string, title?: string) => void;
|
||||||
onShowDetail: (paper: StandardPaper) => void;
|
onShowDetail: (paper: StandardPaper) => void;
|
||||||
@ -64,28 +77,30 @@ export function SearchPanel({
|
|||||||
showAlert,
|
showAlert,
|
||||||
onShowDetail,
|
onShowDetail,
|
||||||
}: SearchPanelProps) {
|
}: SearchPanelProps) {
|
||||||
|
|
||||||
const currentPage = Math.floor(searchStart / searchRows) + 1;
|
const currentPage = Math.floor(searchStart / searchRows) + 1;
|
||||||
const hasPreviousPage = searchStart > 0;
|
const hasPreviousPage = searchStart > 0;
|
||||||
const hasNextPage = searchResults.length >= searchRows;
|
const hasNextPage = searchResults.length >= searchRows;
|
||||||
|
|
||||||
const [showBuilder, setShowBuilder] = React.useState(false);
|
const [showBuilder, setShowBuilder] = React.useState(false);
|
||||||
const [rules, setRules] = React.useState<Array<{ field: string; op: string; val: string }>>([
|
const [rules, setRules] = React.useState<
|
||||||
{ field: 'all', op: 'AND', val: '' }
|
Array<{ field: string; op: string; val: string }>
|
||||||
]);
|
>([{ field: 'all', op: 'AND', val: '' }]);
|
||||||
|
|
||||||
const updateQueryFromRules = (currentRules: typeof rules) => {
|
const updateQueryFromRules = (currentRules: typeof rules) => {
|
||||||
const qParts: string[] = [];
|
const qParts: string[] = [];
|
||||||
currentRules.forEach((rule, idx) => {
|
currentRules.forEach((rule, idx) => {
|
||||||
if (!rule.val.trim()) return;
|
if (!rule.val.trim()) return;
|
||||||
let valStr = rule.val.trim();
|
let valStr = rule.val.trim();
|
||||||
if (valStr.includes(' ') && !valStr.startsWith('"') && !valStr.startsWith('(')) {
|
if (
|
||||||
|
valStr.includes(' ') &&
|
||||||
|
!valStr.startsWith('"') &&
|
||||||
|
!valStr.startsWith('(')
|
||||||
|
) {
|
||||||
valStr = `"${valStr}"`;
|
valStr = `"${valStr}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldPart = rule.field !== 'all'
|
const fieldPart =
|
||||||
? `${rule.field}:${valStr}`
|
rule.field !== 'all' ? `${rule.field}:${valStr}` : valStr;
|
||||||
: valStr;
|
|
||||||
|
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
qParts.push(fieldPart);
|
qParts.push(fieldPart);
|
||||||
@ -97,7 +112,7 @@ export function SearchPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddRule = () => {
|
const handleAddRule = () => {
|
||||||
setRules(prev => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
setRules((prev) => [...prev, { field: 'all', op: 'AND', val: '' }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveRule = (idx: number) => {
|
const handleRemoveRule = (idx: number) => {
|
||||||
@ -106,8 +121,12 @@ export function SearchPanel({
|
|||||||
updateQueryFromRules(next);
|
updateQueryFromRules(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRuleChange = (idx: number, key: 'field' | 'op' | 'val', value: string) => {
|
const handleRuleChange = (
|
||||||
const next = rules.map((r, i) => i === idx ? { ...r, [key]: value } : r);
|
idx: number,
|
||||||
|
key: 'field' | 'op' | 'val',
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
const next = rules.map((r, i) => (i === idx ? { ...r, [key]: value } : r));
|
||||||
setRules(next);
|
setRules(next);
|
||||||
updateQueryFromRules(next);
|
updateQueryFromRules(next);
|
||||||
};
|
};
|
||||||
@ -116,8 +135,13 @@ export function SearchPanel({
|
|||||||
<div className="space-y-6 w-full max-w-5xl mx-auto">
|
<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">
|
<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>
|
<h2 className="text-sm font-bold tracking-wider text-slate-900 uppercase">
|
||||||
<p className="text-slate-500 text-xs">快速检索 NASA ADS 和 arXiv 预印本学术文献,支持一键下载、格式转换与星系引用分析。</p>
|
统一文献检索平台
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-500 text-xs">
|
||||||
|
快速检索 NASA ADS 和 arXiv
|
||||||
|
预印本学术文献,支持一键下载、格式转换与星系引用分析。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 检索控制面板 */}
|
{/* 检索控制面板 */}
|
||||||
@ -129,7 +153,7 @@ export function SearchPanel({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="检索天文学文献 (支持关键字、作者、发表年份,如 'hot subdwarf year:2020-2023')"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
@ -139,8 +163,8 @@ export function SearchPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowBuilder(!showBuilder)}
|
onClick={() => setShowBuilder(!showBuilder)}
|
||||||
className={`px-4 py-3 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center gap-1.5 ${
|
className={`px-4 py-3 rounded-md border text-xs font-bold transition-all shrink-0 flex items-center gap-1.5 ${
|
||||||
showBuilder
|
showBuilder
|
||||||
? 'bg-slate-100 border-slate-350 text-slate-855'
|
? 'bg-slate-100 border-slate-350 text-slate-855'
|
||||||
: 'btn-console'
|
: 'btn-console'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -152,7 +176,11 @@ export function SearchPanel({
|
|||||||
disabled={searching}
|
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"
|
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 ? '检索中...' : '检索文献'}
|
{searching ? '检索中...' : '检索文献'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -174,11 +202,16 @@ export function SearchPanel({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{rules.map((rule, idx) => (
|
{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 ? (
|
{idx > 0 ? (
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={rule.op}
|
value={rule.op}
|
||||||
onChange={val => handleRuleChange(idx, 'op', String(val))}
|
onChange={(val) =>
|
||||||
|
handleRuleChange(idx, 'op', String(val))
|
||||||
|
}
|
||||||
className="w-full sm:w-24"
|
className="w-full sm:w-24"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'AND', label: '并且 (AND)' },
|
{ 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
|
<CustomSelect
|
||||||
value={rule.field}
|
value={rule.field}
|
||||||
onChange={val => handleRuleChange(idx, 'field', String(val))}
|
onChange={(val) =>
|
||||||
|
handleRuleChange(idx, 'field', String(val))
|
||||||
|
}
|
||||||
className="w-full sm:w-32"
|
className="w-full sm:w-32"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: '任意字段' },
|
{ value: 'all', label: '任意字段' },
|
||||||
@ -206,12 +243,14 @@ export function SearchPanel({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={rule.val}
|
value={rule.val}
|
||||||
onChange={e => handleRuleChange(idx, 'val', e.target.value)}
|
onChange={(e) =>
|
||||||
|
handleRuleChange(idx, 'val', e.target.value)
|
||||||
|
}
|
||||||
placeholder={
|
placeholder={
|
||||||
rule.field === 'year'
|
rule.field === 'year'
|
||||||
? '例如: 2020-2023 或 2022'
|
? '例如: 2020-2023 或 2022'
|
||||||
: rule.field === 'author'
|
: rule.field === 'author'
|
||||||
? '例如: Althaus'
|
? '例如: Althaus'
|
||||||
: '请输入检索词...'
|
: '请输入检索词...'
|
||||||
}
|
}
|
||||||
className="w-full sm:flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 text-xs font-medium"
|
className="w-full sm:flex-1 px-3 py-1.5 rounded-md bg-white border border-slate-300 text-slate-900 placeholder-slate-400 focus:outline-none focus:border-slate-500 text-xs font-medium"
|
||||||
@ -237,9 +276,24 @@ export function SearchPanel({
|
|||||||
<Lightbulb className="w-3.5 h-3.5 text-amber-500" />
|
<Lightbulb className="w-3.5 h-3.5 text-amber-500" />
|
||||||
检索语法小提示:
|
检索语法小提示:
|
||||||
</span>
|
</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>
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 pt-3 border-t border-slate-200">
|
<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: 'all', label: '全部数据源' },
|
||||||
{ id: 'ads', label: 'NASA ADS' },
|
{ id: 'ads', label: 'NASA ADS' },
|
||||||
{ id: 'arxiv', label: 'arXiv 预印本' },
|
{ id: 'arxiv', label: 'arXiv 预印本' },
|
||||||
].map(src => (
|
].map((src) => (
|
||||||
<label key={src.id} className="flex items-center gap-2 cursor-pointer text-xs font-bold text-slate-700">
|
<label
|
||||||
|
key={src.id}
|
||||||
|
className="flex items-center gap-2 cursor-pointer text-xs font-bold text-slate-700"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="searchSource"
|
name="searchSource"
|
||||||
checked={searchSource === src.id}
|
checked={searchSource === src.id}
|
||||||
onChange={() => setSearchSource(src.id as 'all' | 'ads' | 'arxiv')}
|
onChange={() =>
|
||||||
|
setSearchSource(src.id as 'all' | 'ads' | 'arxiv')
|
||||||
|
}
|
||||||
className="accent-slate-700"
|
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}
|
{src.label}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@ -268,10 +333,12 @@ export function SearchPanel({
|
|||||||
{/* 排序及每页条数 */}
|
{/* 排序及每页条数 */}
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<CustomSelect
|
||||||
value={searchSort}
|
value={searchSort}
|
||||||
onChange={val => handleSortChange(val)}
|
onChange={(val) => handleSortChange(val)}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'relevance', label: '相关度' },
|
{ value: 'relevance', label: '相关度' },
|
||||||
{ value: 'date_desc', label: '发表日期 (由新到旧)' },
|
{ value: 'date_desc', label: '发表日期 (由新到旧)' },
|
||||||
@ -282,10 +349,12 @@ export function SearchPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<CustomSelect
|
||||||
value={searchRows}
|
value={searchRows}
|
||||||
onChange={val => handleRowsChange(Number(val))}
|
onChange={(val) => handleRowsChange(Number(val))}
|
||||||
options={[
|
options={[
|
||||||
{ value: 10, label: '10 条' },
|
{ value: 10, label: '10 条' },
|
||||||
{ value: 15, label: '15 条' },
|
{ value: 15, label: '15 条' },
|
||||||
@ -302,7 +371,9 @@ export function SearchPanel({
|
|||||||
disabled={exporting}
|
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"
|
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
|
导出已选 ({exportingList.length}) 篇 BibTeX
|
||||||
</button>
|
</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="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">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`space-y-4 transition-all duration-300 ${searching ? 'opacity-45 pointer-events-none filter blur-[1px]' : ''}`}>
|
<div
|
||||||
{searchResults.map(paper => {
|
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 isDownloading = downloadingBibcodes[paper.bibcode] || false;
|
||||||
const isSelected = selectedPaper?.bibcode === paper.bibcode;
|
const isSelected = selectedPaper?.bibcode === paper.bibcode;
|
||||||
return (
|
return (
|
||||||
@ -368,20 +443,23 @@ export function SearchPanel({
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{paper.pdf_error === 'no_resource' && paper.html_error === 'no_resource' ? (
|
{paper.pdf_error === 'no_resource' &&
|
||||||
<span
|
paper.html_error === 'no_resource' ? (
|
||||||
|
<span
|
||||||
title="已手动标记为【无有效全文资源】,批量下载时自动跳过"
|
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"
|
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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
(paper.pdf_error || paper.html_error) && (
|
(paper.pdf_error || paper.html_error) && (
|
||||||
<span
|
<span
|
||||||
title={`自动下载失败:${[paper.pdf_error, paper.html_error].filter(Boolean).join('; ')}`}
|
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"
|
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>
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@ -390,12 +468,16 @@ export function SearchPanel({
|
|||||||
disabled={isDownloading}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer border border-slate-250 px-2.5 py-1.5 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-700 transition-all text-xs font-semibold">
|
<label className="flex items-center gap-1.5 cursor-pointer border border-slate-250 px-2.5 py-1.5 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-700 transition-all text-xs font-semibold">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -435,8 +517,20 @@ export function SearchPanel({
|
|||||||
footerRight={
|
footerRight={
|
||||||
<>
|
<>
|
||||||
{paper.doi && <span>DOI: {paper.doi}</span>}
|
{paper.doi && <span>DOI: {paper.doi}</span>}
|
||||||
<span>Bibcode: <span className="font-semibold text-slate-700">{paper.bibcode}</span></span>
|
<span>
|
||||||
{paper.citation_count > 0 && <span>被引次数: <span className="text-sky-750 font-bold">{paper.citation_count}</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -455,11 +549,12 @@ export function SearchPanel({
|
|||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" /> 上一页
|
<ChevronLeft className="w-4 h-4" /> 上一页
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span className="text-xs font-bold text-slate-600">
|
<span className="text-xs font-bold text-slate-600">
|
||||||
第 {currentPage} 页 (当前显示 {searchStart + 1} - {searchStart + searchResults.length} 条)
|
第 {currentPage} 页 (当前显示 {searchStart + 1} -{' '}
|
||||||
|
{searchStart + searchResults.length} 条)
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(searchStart + searchRows)}
|
onClick={() => handlePageChange(searchStart + searchRows)}
|
||||||
disabled={!hasNextPage || searching}
|
disabled={!hasNextPage || searching}
|
||||||
|
|||||||
@ -12,9 +12,12 @@ export function SyncPanel() {
|
|||||||
<div className="space-y-6 w-full max-w-3xl mx-auto">
|
<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">
|
<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">
|
<p className="text-slate-500 text-xs">
|
||||||
设定检索关键词,在 NASA ADS 和 arXiv 平台大批量同步学术文献索引,或针对本地文献馆藏批量执行下载、解析、翻译等学术流水线任务。
|
设定检索关键词,在 NASA ADS 和 arXiv
|
||||||
|
平台大批量同步学术文献索引,或针对本地文献馆藏批量执行下载、解析、翻译等学术流水线任务。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -88,16 +91,36 @@ export function SyncPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
|
<div className="divide-y divide-slate-100 max-h-80 overflow-y-auto pr-1 scrollbar-thin">
|
||||||
{state.syncQueries.map(sq => (
|
{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
|
||||||
|
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="space-y-1">
|
||||||
<div className="font-bold text-slate-800 break-all select-all font-mono">
|
<div className="font-bold text-slate-800 break-all select-all font-mono">
|
||||||
{sq.query}
|
{sq.query}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-500 flex items-center gap-2.5 font-semibold">
|
<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>
|
||||||
<span>数量限制: <strong className="text-slate-700">{sq.limit_count}</strong></span>
|
数据源:{' '}
|
||||||
<span>最近同步: <strong className="text-slate-700">{sq.last_run}</strong></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>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0 self-end sm:self-center select-none">
|
<div className="flex gap-2 shrink-0 self-end sm:self-center select-none">
|
||||||
|
|||||||
@ -178,8 +178,8 @@ export interface AgentTask {
|
|||||||
// ── 会话回退 (Rewind) ──
|
// ── 会话回退 (Rewind) ──
|
||||||
|
|
||||||
export interface RewindRequest {
|
export interface RewindRequest {
|
||||||
n?: number; // 回退 N 个轮次
|
n?: number; // 回退 N 个轮次
|
||||||
message_id?: number; // 或指定消息 ID
|
message_id?: number; // 或指定消息 ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RewindResponse {
|
export interface RewindResponse {
|
||||||
|
|||||||
@ -3,10 +3,10 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 历史存量数据 Unicode 异型空格兼容容灾开关
|
* 历史存量数据 Unicode 异型空格兼容容灾开关
|
||||||
*
|
*
|
||||||
* 背景:历史解析的 Markdown 数据中可能含有 \u2009 (窄空格) 或 \u00a0 (不换行空格) 等非标准水平空白符。
|
* 背景:历史解析的 Markdown 数据中可能含有 \u2009 (窄空格) 或 \u00a0 (不换行空格) 等非标准水平空白符。
|
||||||
* 后端已于 2026-06-25 实现了解析器源头净化(统一清洗为标准半角空格 \u0020)。
|
* 后端已于 2026-06-25 实现了解析器源头净化(统一清洗为标准半角空格 \u0020)。
|
||||||
*
|
*
|
||||||
* 维护说明:
|
* 维护说明:
|
||||||
* - 开启 (true):前端高亮正则和跳转匹配将兼容并自适应历史文献中的各种 Unicode 异型空格。
|
* - 开启 (true):前端高亮正则和跳转匹配将兼容并自适应历史文献中的各种 Unicode 异型空格。
|
||||||
* - 关闭 (false):前端高亮和跳转恢复为纯净的普通半角空格高效率匹配。
|
* - 关闭 (false):前端高亮和跳转恢复为纯净的普通半角空格高效率匹配。
|
||||||
@ -37,14 +37,16 @@ export interface HighlightCandidate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成天体的匹配词条列表
|
// 生成天体的匹配词条列表
|
||||||
export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidate[] {
|
export function getHighlightCandidates(
|
||||||
|
targets: TargetInfo[]
|
||||||
|
): HighlightCandidate[] {
|
||||||
const candidates: HighlightCandidate[] = [];
|
const candidates: HighlightCandidate[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
const addCandidate = (name: string, preferredName: string) => {
|
const addCandidate = (name: string, preferredName: string) => {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
// 过滤无效或太短的名称以防止在正文中误伤普通单词
|
// 过滤无效或太短的名称以防止在正文中误伤普通单词
|
||||||
if (trimmed.length < 3) return;
|
if (trimmed.length < 3) return;
|
||||||
if (/^\d+$/.test(trimmed)) return; // 纯数字
|
if (/^\d+$/.test(trimmed)) return; // 纯数字
|
||||||
@ -56,7 +58,8 @@ export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidat
|
|||||||
candidates.push({ name: trimmed, preferredName });
|
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);
|
const match = trimmed.match(spaceRegex);
|
||||||
if (match) {
|
if (match) {
|
||||||
const variant = `${match[1]}${match[2]}`;
|
const variant = `${match[1]}${match[2]}`;
|
||||||
@ -66,7 +69,7 @@ export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidat
|
|||||||
candidates.push({ name: variant, preferredName });
|
candidates.push({ name: variant, preferredName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动为无空格的常见星表前缀添加有空格变体
|
// 自动为无空格的常见星表前缀添加有空格变体
|
||||||
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 = trimmed.match(noSpaceRegex);
|
const matchNoSpace = trimmed.match(noSpaceRegex);
|
||||||
@ -97,35 +100,48 @@ export function getHighlightCandidates(targets: TargetInfo[]): HighlightCandidat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 辅助函数:在 Markdown 文本中安全高亮天体名称,跳过 HTML 标签、LaTeX公式和 Markdown 链接
|
// 辅助函数:在 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;
|
if (!candidates || candidates.length === 0) return text;
|
||||||
|
|
||||||
let result = text;
|
let result = text;
|
||||||
for (const cand of candidates) {
|
for (const cand of candidates) {
|
||||||
const escaped = cand.name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
const escaped = cand.name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
||||||
|
|
||||||
// 根据兼容开关决定是否启用宽容的 Unicode 空格正则通配
|
// 根据兼容开关决定是否启用宽容的 Unicode 空格正则通配
|
||||||
const regexStr = ENABLE_LEGACY_UNICODE_SPACE_COMPAT
|
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;
|
: escaped;
|
||||||
|
|
||||||
const regex = new RegExp(`\\b(${regexStr})\\b`, 'gi');
|
const regex = new RegExp(`\\b(${regexStr})\\b`, 'gi');
|
||||||
|
|
||||||
// 按 HTML 标签、LaTeX 行内/块级公式、Markdown 链接进行切片
|
// 按 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) => {
|
);
|
||||||
// 若是标签、公式或 Markdown 链接,则原样返回
|
|
||||||
if (
|
result = parts
|
||||||
part.startsWith('<') ||
|
.map((part) => {
|
||||||
part.startsWith('$') ||
|
// 若是标签、公式或 Markdown 链接,则原样返回
|
||||||
(part.startsWith('[') && part.includes(']('))
|
if (
|
||||||
) {
|
part.startsWith('<') ||
|
||||||
return part;
|
part.startsWith('$') ||
|
||||||
}
|
(part.startsWith('[') && part.includes(']('))
|
||||||
// plain text 部分执行天体正则高亮替换
|
) {
|
||||||
return part.replace(regex, `[$1](#celestial-${encodeURIComponent(cand.preferredName)})`);
|
return part;
|
||||||
}).join('');
|
}
|
||||||
|
// plain text 部分执行天体正则高亮替换
|
||||||
|
return part.replace(
|
||||||
|
regex,
|
||||||
|
`[$1](#celestial-${encodeURIComponent(cand.preferredName)})`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,31 +4,96 @@
|
|||||||
// 根据文献类型 (doctype) 渲染统一的 Astro 样式卡片分类徽章 (Pure View Helper)
|
// 根据文献类型 (doctype) 渲染统一的 Astro 样式卡片分类徽章 (Pure View Helper)
|
||||||
export const getDoctypeBadge = (doctype: string) => {
|
export const getDoctypeBadge = (doctype: string) => {
|
||||||
const typeMap: Record<string, { label: string; style: string }> = {
|
const typeMap: Record<string, { label: string; style: string }> = {
|
||||||
article: { label: '期刊文章', style: 'bg-blue-50 text-blue-700 border-blue-200' },
|
article: {
|
||||||
eprint: { label: '预印本', style: 'bg-purple-50 text-purple-700 border-purple-200' },
|
label: '期刊文章',
|
||||||
inproceedings: { label: '会议论文', style: 'bg-amber-50 text-amber-700 border-amber-200' },
|
style: 'bg-blue-50 text-blue-700 border-blue-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' },
|
eprint: {
|
||||||
abstract: { label: '会议摘要', style: 'bg-slate-50 text-slate-700 border-slate-200' },
|
label: '预印本',
|
||||||
catalog: { label: '星表数据', style: 'bg-indigo-50 text-indigo-700 border-indigo-200' },
|
style: 'bg-purple-50 text-purple-700 border-purple-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' },
|
inproceedings: {
|
||||||
phdthesis: { label: '博士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
|
label: '会议论文',
|
||||||
mastersthesis: { label: '硕士论文', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
|
style: 'bg-amber-50 text-amber-700 border-amber-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' },
|
proceedings: {
|
||||||
book: { label: '学术专著', style: 'bg-emerald-50 text-emerald-700 border-emerald-200' },
|
label: '会议集',
|
||||||
editorial: { label: '期刊社论', style: 'bg-slate-50 text-slate-700 border-slate-200' },
|
style: 'bg-amber-50 text-amber-700 border-amber-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' },
|
proposal: {
|
||||||
newsletter: { label: '简报新闻', style: 'bg-slate-50 text-slate-700 border-slate-200' },
|
label: '观测提案',
|
||||||
techreport: { label: '技术报告', style: 'bg-cyan-50 text-cyan-700 border-cyan-200' },
|
style: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||||
obituary: { label: '讣告', style: 'bg-slate-50 text-slate-700 border-slate-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 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 (
|
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}
|
{match.label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -94,13 +159,26 @@ export function parseMarkdownFrontMatter(markdown: string): {
|
|||||||
let key = rawKey;
|
let key = rawKey;
|
||||||
if (rawKey === '标题' || rawKey === 'title') {
|
if (rawKey === '标题' || rawKey === 'title') {
|
||||||
key = 'title';
|
key = 'title';
|
||||||
} else if (rawKey === '作者' || rawKey === 'author' || rawKey === 'authors') {
|
} else if (
|
||||||
|
rawKey === '作者' ||
|
||||||
|
rawKey === 'author' ||
|
||||||
|
rawKey === 'authors'
|
||||||
|
) {
|
||||||
key = 'author';
|
key = 'author';
|
||||||
} else if (rawKey === '出版社' || rawKey === '出版商' || rawKey === 'publisher') {
|
} else if (
|
||||||
|
rawKey === '出版社' ||
|
||||||
|
rawKey === '出版商' ||
|
||||||
|
rawKey === 'publisher'
|
||||||
|
) {
|
||||||
key = 'publisher';
|
key = 'publisher';
|
||||||
} else if (rawKey === '来源' || rawKey === 'source' || rawKey === 'url') {
|
} else if (rawKey === '来源' || rawKey === 'source' || rawKey === 'url') {
|
||||||
key = 'source';
|
key = 'source';
|
||||||
} else if (rawKey === '日期' || rawKey === '年份' || rawKey === 'date' || rawKey === 'year') {
|
} else if (
|
||||||
|
rawKey === '日期' ||
|
||||||
|
rawKey === '年份' ||
|
||||||
|
rawKey === 'date' ||
|
||||||
|
rawKey === 'year'
|
||||||
|
) {
|
||||||
key = 'date';
|
key = 'date';
|
||||||
} else if (rawKey === '标签' || rawKey === 'tags') {
|
} else if (rawKey === '标签' || rawKey === 'tags') {
|
||||||
key = 'tags';
|
key = 'tags';
|
||||||
@ -130,13 +208,19 @@ export function parseMarkdownFrontMatter(markdown: string): {
|
|||||||
if (!hasQuotes) {
|
if (!hasQuotes) {
|
||||||
// 如果没有引号,如 [Heber, Ulrich],直接去掉括号按逗号切分
|
// 如果没有引号,如 [Heber, Ulrich],直接去掉括号按逗号切分
|
||||||
const inside = val.slice(1, -1).trim();
|
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 {
|
} else {
|
||||||
metadata.author = authors;
|
metadata.author = authors;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 兼容中文逗号和英文逗号的分隔
|
// 兼容中文逗号和英文逗号的分隔
|
||||||
metadata.author = val.split(/[,,]/).map(a => a.trim()).filter(Boolean);
|
metadata.author = val
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map((a) => a.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,9 +228,8 @@ export function parseMarkdownFrontMatter(markdown: string): {
|
|||||||
|
|
||||||
// 只要解析出任一有效元数据字段,就认为解析成功;否则返回空
|
// 只要解析出任一有效元数据字段,就认为解析成功;否则返回空
|
||||||
const hasKeys = Object.keys(metadata).length > 0;
|
const hasKeys = Object.keys(metadata).length > 0;
|
||||||
return {
|
return {
|
||||||
metadata: hasKeys ? metadata : null,
|
metadata: hasKeys ? metadata : null,
|
||||||
pureMarkdown
|
pureMarkdown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,16 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [react(), tailwindcss()],
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@ -64,6 +64,8 @@ services:
|
|||||||
start_period: 15s
|
start_period: 15s
|
||||||
|
|
||||||
# ─── Mode B: Distroless 全功能单体 (Obscura/V8/BoringSSL 编译在内) ──────
|
# ─── Mode B: Distroless 全功能单体 (Obscura/V8/BoringSSL 编译在内) ──────
|
||||||
|
# docker compose up -d astroresearch-all
|
||||||
|
# docker compose down astroresearch-all
|
||||||
astroresearch-all:
|
astroresearch-all:
|
||||||
build:
|
build:
|
||||||
context: .
|
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 目录。
|
/// `library_dir` 是项目配置中的 library 目录。
|
||||||
pub fn new(library_dir: PathBuf) -> Self {
|
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 {
|
let mut manager = MemoryManager {
|
||||||
memory_dir,
|
memory_dir,
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
@ -442,7 +442,11 @@ mod tests {
|
|||||||
.expect("save should succeed");
|
.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");
|
assert!(file_path.exists(), "memory file should exist on disk");
|
||||||
|
|
||||||
// 验证内容正确
|
// 验证内容正确
|
||||||
@ -493,7 +497,11 @@ mod tests {
|
|||||||
assert_eq!(latest.unwrap().name, "V2");
|
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!(
|
assert!(
|
||||||
archive_path.exists(),
|
archive_path.exists(),
|
||||||
"old version should be archived as _v1.md"
|
"old version should be archived as _v1.md"
|
||||||
|
|||||||
@ -128,7 +128,7 @@ pub(super) async fn process_single_result(
|
|||||||
|
|
||||||
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
|
// 输出处理:小结果直接传递,大结果持久化到磁盘并返回 stub
|
||||||
// 但对于已从磁盘读取内容的工具(如 read_file),跳过持久化以防止级联
|
// 但对于已从磁盘读取内容的工具(如 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 {
|
let (processed_content, _persisted_path) = if output.skip_persist {
|
||||||
(output.content.clone(), None)
|
(output.content.clone(), None)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -86,7 +86,7 @@ impl TrajectoryExporter {
|
|||||||
terminal_reason,
|
terminal_reason,
|
||||||
};
|
};
|
||||||
|
|
||||||
let dir = library_dir.join("trajectories");
|
let dir = library_dir.join(".agent").join("trajectories");
|
||||||
fs::create_dir_all(&dir)?;
|
fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||||
@ -140,7 +140,7 @@ impl TrajectoryExporter {
|
|||||||
|
|
||||||
/// 列出所有已导出的 trajectory 文件
|
/// 列出所有已导出的 trajectory 文件
|
||||||
pub fn list_trajectories(library_dir: &Path) -> Vec<String> {
|
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) {
|
match fs::read_dir(&dir) {
|
||||||
Ok(entries) => entries
|
Ok(entries) => entries
|
||||||
.flatten()
|
.flatten()
|
||||||
|
|||||||
@ -126,7 +126,8 @@ pub async fn chat_agent(
|
|||||||
let upload_dir = state
|
let upload_dir = state
|
||||||
.config
|
.config
|
||||||
.library_dir
|
.library_dir
|
||||||
.join(".agent_images")
|
.join(".agent")
|
||||||
|
.join("images")
|
||||||
.join("uploads");
|
.join("uploads");
|
||||||
tokio::fs::create_dir_all(&upload_dir)
|
tokio::fs::create_dir_all(&upload_dir)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -237,27 +237,23 @@ pub async fn get_paper_detail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /api/library ──
|
// ── GET /api/library ──
|
||||||
#[derive(Deserialize)]
|
#[derive(Serialize)]
|
||||||
pub struct LibraryParams {
|
pub struct LibraryResponse {
|
||||||
pub limit: Option<i64>,
|
pub items: Vec<StandardPaper>,
|
||||||
pub offset: Option<i64>,
|
pub total: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取本地图书馆文献列表
|
// 获取本地图书馆文献列表
|
||||||
pub async fn get_library(
|
pub async fn get_library(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(params): Query<LibraryParams>,
|
Query(params): Query<crate::services::paper::LibraryQueryParams>,
|
||||||
) -> ApiResult<Json<Vec<StandardPaper>>> {
|
) -> ApiResult<Json<LibraryResponse>> {
|
||||||
let list = crate::services::paper::get_library_list(
|
let (list, total) =
|
||||||
&state.db,
|
crate::services::paper::get_library_list(&state.db, &state.config.library_dir, params)
|
||||||
&state.config.library_dir,
|
.await
|
||||||
params.limit,
|
.map_err(|e| AppError::internal(format!("访问本地数据库失败: {}", e)))?;
|
||||||
params.offset,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::internal(format!("访问本地数据库失败: {}", e)))?;
|
|
||||||
|
|
||||||
Ok(Json(list))
|
Ok(Json(LibraryResponse { items: list, total }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /api/export ──
|
// ── POST /api/export ──
|
||||||
|
|||||||
@ -494,7 +494,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
))
|
))
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
axum::http::header::X_FRAME_OPTIONS,
|
axum::http::header::X_FRAME_OPTIONS,
|
||||||
HeaderValue::from_static("DENY"),
|
HeaderValue::from_static("SAMEORIGIN"),
|
||||||
))
|
))
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
axum::http::header::STRICT_TRANSPORT_SECURITY,
|
axum::http::header::STRICT_TRANSPORT_SECURITY,
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
// 文献元数据数据库存取。
|
// 文献元数据数据库存取。
|
||||||
|
|
||||||
use super::model::StandardPaper;
|
use super::model::StandardPaper;
|
||||||
|
use serde::Deserialize;
|
||||||
use sqlx::{Acquire, Row, SqlitePool};
|
use sqlx::{Acquire, Row, SqlitePool};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tracing::info;
|
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(
|
pub async fn get_library_list(
|
||||||
db: &SqlitePool,
|
db: &SqlitePool,
|
||||||
library_dir: &Path,
|
library_dir: &Path,
|
||||||
limit: Option<i64>,
|
params: LibraryQueryParams,
|
||||||
offset: Option<i64>,
|
) -> Result<(Vec<StandardPaper>, i64), sqlx::Error> {
|
||||||
) -> Result<Vec<StandardPaper>, sqlx::Error> {
|
let mut where_clauses = Vec::new();
|
||||||
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 ?")
|
// 1. q 全局搜索
|
||||||
.bind(lim)
|
if let Some(ref q) = params.q {
|
||||||
.bind(off)
|
if !q.trim().is_empty() {
|
||||||
.fetch_all(db)
|
where_clauses.push("(title LIKE ? OR authors LIKE ? OR abstract LIKE ? OR bibcode LIKE ? OR arxiv_id LIKE ? OR doi LIKE ?)".to_string());
|
||||||
.await?
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
} else {
|
||||||
// 无分页参数时默认限制 2000 条,防止全量加载
|
format!("WHERE {}", where_clauses.join(" AND "))
|
||||||
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?
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- 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();
|
let mut list = Vec::new();
|
||||||
for r in rows {
|
for r in rows {
|
||||||
list.push(parse_paper_row(&r, library_dir));
|
list.push(parse_paper_row(&r, library_dir));
|
||||||
}
|
}
|
||||||
Ok(list)
|
|
||||||
|
Ok((list, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 标记/清除文献的“无资源”状态
|
/// 标记/清除文献的“无资源”状态
|
||||||
|
|||||||
@ -10,7 +10,7 @@ pub mod reader;
|
|||||||
pub use db::{
|
pub use db::{
|
||||||
check_paper_paths_in_db, clean_identifier, get_library_list, get_paper_from_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,
|
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 ingest::upload_paper_file_service;
|
||||||
pub use model::{convert_ads_doc_to_standard, convert_arxiv_to_standard, StandardPaper};
|
pub use model::{convert_ads_doc_to_standard, convert_arxiv_to_standard, StandardPaper};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user