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:
Asfmq 2026-07-03 00:11:02 +08:00
parent 26d3b2fc02
commit 0ba85d7749
78 changed files with 6159 additions and 2286 deletions

View File

@ -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

View File

@ -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

View File

@ -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
View File

@ -0,0 +1,11 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

View File

@ -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',
},
}, },
]) ])

View File

@ -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",

View File

@ -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",

View File

@ -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

View 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

View 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

View File

@ -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}

View File

@ -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) }];
} }

View File

@ -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

View File

@ -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'

View File

@ -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>

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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}

View File

@ -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>

View File

@ -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> = {};

View File

@ -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>

View File

@ -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>

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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>
); );
} }

View 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>
);
}

View File

@ -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': {

View File

@ -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>
)} )}

View File

@ -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>

View File

@ -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',
]), ]),
}; };

View File

@ -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<

View File

@ -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] ')) {

View File

@ -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: {

View File

@ -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 };

View File

@ -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(() => {

View File

@ -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>

View File

@ -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

View File

@ -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>
); );
} }

View File

@ -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"
> >

View File

@ -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'
}`} }`}
> >

View File

@ -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>
); );
} }

View File

@ -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>
</> </>
); );
} }

View File

@ -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}

View File

@ -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>
)) ))

View File

@ -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>

View File

@ -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>

View File

@ -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;
} }
} }

View File

@ -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];

View File

@ -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,
}; };
} }

View File

@ -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);
} }

View File

@ -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);
} }
}); });

View File

@ -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;
} }

View File

@ -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]
); );
}; };

View File

@ -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) {

View File

@ -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(),

View File

@ -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;
} }

View File

@ -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>
) );

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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">

View File

@ -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 {

View File

@ -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;
} }

View File

@ -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,
}; };
} }

View File

@ -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,
} },
} },
} },
}) });

View File

@ -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
View 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()

View File

@ -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"

View File

@ -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 {

View File

@ -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()

View File

@ -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

View File

@ -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 ──

View File

@ -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,

View File

@ -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))
} }
/// 标记/清除文献的“无资源”状态 /// 标记/清除文献的“无资源”状态

View File

@ -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};