From c0d93e2103e087387d9b4140b6259c819822734d Mon Sep 17 00:00:00 2001 From: Cmochance <3216202644@qq.com> Date: Sun, 21 Jun 2026 13:20:30 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(ui):=20=E9=87=8D=E6=9E=84=E4=B8=BA?= =?UTF-8?q?=E7=8E=B0=E4=BB=A3=20Agent=20=E5=B7=A5=E4=BD=9C=E5=8F=B0(?= =?UTF-8?q?=E4=B8=89=E6=A0=8F=20+=20=E6=B7=B1=E6=B5=85=E5=8F=8C=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=20+=20i18n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全 app 重做视觉:cyanotype 设计系统、三栏 shell(侧栏 / 事件流 / 产物面板)、四视图(首页 / 新建任务 / 运行 / 设置) - i18n:中 / 英整页切换(零混排);自托管字体 Space Grotesk + IBM Plex Mono(离线打包,不依赖 CDN) - 运行状态上移到 useRun 控制器(App 持有,RunView 转纯展示):修复启动崩溃循环、追加指令清空对话、离开再回重复执行、运行指示灯错位 - 产物面板按 skill 路由:reader 双语 / reviewer 三栏 / citation 列表 / polishing 原稿对照;图片 cache-bust;读取失败显式提示 - 放开 fs / asset 能力域到所选工作目录;恢复 engineError 补救文案、首启自举失败横幅、退出码、命令失败态 - 修 BibTeX 嵌套花括号解析截断;抽取共享 lib(路径工具 / 选择器);清理无用文件(App.css、ArtifactPreview) --- design/agent-workspace.html | 718 ++++++++++++++++++++++++++++ package.json | 2 + pnpm-lock.yaml | 16 + src-tauri/capabilities/default.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.css | 346 -------------- src/App.tsx | 552 ++++----------------- src/components/ArtifactPanel.tsx | 189 ++++++++ src/components/ArtifactPreview.tsx | 122 ----- src/components/CitationList.tsx | 94 ++-- src/components/DynamicForm.tsx | 44 +- src/components/Home.tsx | 127 +++++ src/components/ReaderBilingual.tsx | 27 +- src/components/ReviewerView.tsx | 71 +-- src/components/RunView.tsx | 176 +++++++ src/components/Settings.tsx | 257 +++++----- src/components/Sidebar.tsx | 115 +++++ src/components/TaskConfig.tsx | 114 +++++ src/i18n.tsx | 400 ++++++++++++++++ src/icons.tsx | 58 +++ src/lib.ts | 26 + src/main.tsx | 6 + src/skillsMeta.ts | 106 ++++ src/styles.css | 410 ++++++++++++++++ src/theme.tsx | 58 +++ src/useRun.ts | 211 ++++++++ 26 files changed, 3067 insertions(+), 1182 deletions(-) create mode 100644 design/agent-workspace.html delete mode 100644 src/App.css create mode 100644 src/components/ArtifactPanel.tsx delete mode 100644 src/components/ArtifactPreview.tsx create mode 100644 src/components/Home.tsx create mode 100644 src/components/RunView.tsx create mode 100644 src/components/Sidebar.tsx create mode 100644 src/components/TaskConfig.tsx create mode 100644 src/i18n.tsx create mode 100644 src/icons.tsx create mode 100644 src/lib.ts create mode 100644 src/skillsMeta.ts create mode 100644 src/styles.css create mode 100644 src/theme.tsx create mode 100644 src/useRun.ts diff --git a/design/agent-workspace.html b/design/agent-workspace.html new file mode 100644 index 0000000..3c8af51 --- /dev/null +++ b/design/agent-workspace.html @@ -0,0 +1,718 @@ + + + + + +Nature App · 全 App 现代 UI 原型 + + + + + + +
+ + + +
+ + +
+
+
+
+ 研究流水线 · 本地运行 +

从检索到投稿,一条流水线跑完。

+

描述你要推进的研究任务,或挑一个技能开始 —— 全程在你的机器上,由 Codex 驱动。

+
+
+
+ +
+ + + + + +
+
+
+
+

技能

按研究流程编排 · 共 11 个
+
检索 & 阅读
+
+ + +
+
写作 · 润色 · 引用
+
+ + + +
+
图 & 数据
+
+ + +
+
评审 · 回复
+
+ + +
+
转换产出
+
+ + +
+
+
+

最近任务

查看全部
+
+ + + +
+
+
+
+
+ + +
+
+ + 绘图nature-figure + stable +
+
+
+
配置任务
+

出一张出版级图表

+
+
工作目录
~/projects/cell-assay
+
输入文件
assay_means.csv
+
+
+
绘图后端 必填
+
图型参考 chart-atlas · 可选
+
从 10 类图型里选一个引导绘图 +
+ + + + + +
+
+
+
+
+ +
+
+
必填轴已就绪 · 准备运行
+
+
+
+ + +
+
+
+ 绘图nature-figure + 运行中 +
~/projects/cell-assayin 38.2k · out 4.1k
+
+ +
+
+
+
+
14:02:11
+
把这组对照实验数据画成分组柱状图,三个时间点 × 对照/给药两组,加上标准差误差棒;配色用蓝绿系,导出 PNG 和 SVG 到工作目录。
assay_means.csv
+
+
+
Nature Agent14:02:13
+
思考 · 3 步2.4s +

读取 assay_means.csv:3 组时间点(0h / 24h / 48h)× 2 条件(对照 / 给药),每格含均值与标准差。

选择 matplotlib 后端。分组柱状图最贴合"两条件对比 × 时间序列",误差棒用列里的 SD。蓝绿系取 #2E7FB8 / #4FB49A,符合无障碍对比。

同时导出 .png(300 dpi)与 .svg(矢量,便于投稿再编辑)。

+
+
+
+
执行计划
+
解析 CSV,确认数据形状
+
生成绘图脚本 plot.py
+
运行脚本,导出 PNG / SVG
+
+
+
$codex exec --json --sandbox workspace-write完成
+
$uv run python plot.py --out chart.png chart.svg运行中
+
+ + +

已生成分组柱状图,三个时间点 × 两条件,误差棒取各组标准差:

  • 配色:对照 #2E7FB8 / 给药 #4FB49A,色觉友好
  • 导出:chart.png(300 dpi)+ chart.svg(矢量,可二次编辑)
  • 48h 给药组相对对照上升约 2.3×,差异最显著

需要换横向、改配色或加显著性标记,在右侧"再改一版"说一句即可,会基于 plot.py 增量改。

+
turn 完成in 38,210cached 31,040out 4,118reasoning 1,902
+
正在写出 chart.svg…
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
设置
+

环境体检 & 偏好

+
+

Codex 引擎

驱动全部技能的本地 CLI
就绪
+
登录态已登录
版本codex-cli 0.21.0
路径/opt/homebrew/bin/codex
+
+
+

Python 环境(uv)

隔离 venv,根治系统 Python 冲突
+
venv~/.nature-app/.venv
Python3.13.1 (pinned)
uv/opt/homebrew/bin/uv
+
首次或更新 skill 后运行
+
+
+

外部工具

技能按需调用
+
pandoc/opt/homebrew/bin/pandoc
LibreOffice/Applications/LibreOffice.app
R未找到 · 仅 R 绘图后端需要
+
+
+

偏好

默认行为与外观
+
+
外观主题
深色 / 浅色 / 跟随系统
+
+
+
默认允许联网
技能可访问网络(检索 / MCP 需要)
+
默认模型
codex exec 使用的模型
+
+
+
+

高级

仅在明确知道风险时启用
+
全放开沙箱 (dangerFullAccess)
解除工作目录限制,技能可读写整机文件 — 谨慎
+
+
+
+
+ +
+ + + +
+ + + + + + diff --git a/package.json b/package.json index e9b89bf..49d7d29 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/space-grotesk": "^5.2.10", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-fs": "^2.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70a1ac0..8db9f9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@fontsource/ibm-plex-mono': + specifier: ^5.2.7 + version: 5.2.7 + '@fontsource/space-grotesk': + specifier: ^5.2.10 + version: 5.2.10 '@tauri-apps/api': specifier: ^2 version: 2.11.1 @@ -296,6 +302,12 @@ packages: cpu: [x64] os: [win32] + '@fontsource/ibm-plex-mono@5.2.7': + resolution: {integrity: sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==} + + '@fontsource/space-grotesk@5.2.10': + resolution: {integrity: sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1246,6 +1258,10 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fontsource/ibm-plex-mono@5.2.7': {} + + '@fontsource/space-grotesk@5.2.10': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1b0c7a1..17a5455 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,7 +10,7 @@ "dialog:default", { "identifier": "fs:allow-read-text-file", - "allow": [{ "path": "$HOME/**" }] + "allow": [{ "path": "$HOME/**" }, { "path": "**" }] } ] } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7b23144..7783bb1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ "devCsp": "default-src 'self'; img-src 'self' asset: http://asset.localhost data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' ws: http://localhost:1420 ipc: http://ipc.localhost", "assetProtocol": { "enable": true, - "scope": ["$HOME/**"] + "scope": ["$HOME/**", "**"] } } }, diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 0589400..0000000 --- a/src/App.css +++ /dev/null @@ -1,346 +0,0 @@ -:root { - font-family: -apple-system, "Segoe UI", system-ui, sans-serif; - color: #1c1c1e; - background: #f5f5f7; -} - -* { box-sizing: border-box; } -body { margin: 0; } - -.app { - display: flex; - flex-direction: column; - height: 100vh; - padding: 12px 16px; - gap: 12px; -} - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; -} -.topbar h1 { font-size: 18px; margin: 0; } -.tag { - font-size: 11px; - background: #0f4d92; - color: #fff; - padding: 1px 6px; - border-radius: 6px; - vertical-align: middle; -} -.engine { display: flex; gap: 10px; font-size: 12px; align-items: center; } - -.form { display: flex; flex-direction: column; gap: 8px; } -.row { display: flex; align-items: center; gap: 10px; } -.spacer { flex: 1; } -.path { - font-size: 12px; - color: #555; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -textarea { - width: 100%; - font: inherit; - font-size: 13px; - padding: 8px; - border: 1px solid #ccc; - border-radius: 8px; - resize: vertical; -} - -button { - font: inherit; - font-size: 13px; - padding: 6px 14px; - border: 1px solid #c4c4c6; - border-radius: 8px; - background: #fff; - cursor: pointer; -} -button:hover { background: #ececef; } -button.primary { background: #0f4d92; color: #fff; border-color: #0f4d92; } -button.primary:disabled { opacity: 0.5; cursor: not-allowed; } -button.danger { background: #b64342; color: #fff; border-color: #b64342; } - -.panes { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - flex: 1; - min-height: 0; -} - -.console { - background: #1c1c1e; - color: #e6e6e6; - font-family: ui-monospace, "SF Mono", Menlo, monospace; - font-size: 12px; - line-height: 1.5; - padding: 10px; - border-radius: 10px; - overflow: auto; -} -.line { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; } -.line .ts { color: #6b6b70; flex-shrink: 0; } -.line.cmd .txt { color: #7fb3ff; } -.line.msg .txt { color: #fff; font-weight: 600; } -.line.ok .txt { color: #8bcf8b; } -.line.err .txt { color: #ff8a87; } -.line.warn .txt { color: #e8c060; } -.line.dim .txt { color: #9a9aa0; } - -.artifacts { - background: #fff; - border: 1px solid #e2e2e6; - border-radius: 10px; - padding: 10px; - overflow: auto; - display: flex; - flex-wrap: wrap; - gap: 12px; - align-content: flex-start; -} -.artifacts figure { margin: 0; max-width: 100%; } -.artifacts img { - max-width: 100%; - border: 1px solid #e2e2e6; - border-radius: 6px; - background: #fff; -} -.artifacts figcaption { font-size: 11px; color: #666; margin-top: 4px; } - -.ok { color: #1a7f37; } -.err { color: #b64342; } -.dim { color: #8a8a8e; } - -/* ---- 目录 ---- */ -.catalog { flex: 1; overflow: auto; } -.catalog-hint { font-size: 13px; color: #666; margin: 4px 2px 12px; } -.load-error { color: #b64342; font-size: 13px; padding: 12px; border: 1px solid #f0c9c8; border-radius: 8px; background: #fdf4f4; } -.setup-warn { color: #8a5a00; font-size: 12px; padding: 8px 12px; border: 1px solid #f0d9a8; border-radius: 8px; background: #fdf8ec; margin-bottom: 12px; } -.cards { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 12px; -} -.card { - text-align: left; - display: flex; - flex-direction: column; - gap: 8px; - padding: 14px; - border: 1px solid #e2e2e6; - border-radius: 12px; - background: #fff; - cursor: pointer; - min-height: 130px; -} -.card:hover { border-color: #0f4d92; background: #fbfcfe; } -.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } -.card-name { font-weight: 600; font-size: 14px; } -.card-desc { - font-size: 12px; - color: #555; - margin: 0; - line-height: 1.5; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; -} -.card-foot { display: flex; gap: 8px; margin-top: auto; } -.cap { font-size: 11px; color: #888; background: #f0f0f2; padding: 1px 7px; border-radius: 6px; } - -.badge { font-size: 11px; padding: 1px 8px; border-radius: 999px; color: #fff; flex-shrink: 0; } -.badge.stable { background: #1a7f37; } -.badge.beta { background: #0f4d92; } -.badge.draft { background: #8a8a8e; } - -/* ---- 运行视图 ---- */ -.runview { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; } -.run-head { display: flex; align-items: center; gap: 12px; } -.run-title { font-weight: 600; font-size: 15px; } -.link { border: none; background: none; color: #0f4d92; cursor: pointer; padding: 0; font-size: 13px; } -.link:hover { text-decoration: underline; background: none; } - -/* ---- DynamicForm ---- */ -.dynform { display: flex; flex-direction: column; gap: 10px; } -.axis { display: flex; flex-direction: column; gap: 6px; } -.axis-label { font-size: 12px; font-weight: 600; color: #333; display: flex; align-items: center; gap: 8px; } -.req { color: #b64342; font-weight: 400; font-size: 11px; } -.multi { color: #888; font-weight: 400; font-size: 11px; } -.chips { display: flex; flex-wrap: wrap; gap: 6px; } -.chip { - font-size: 12px; - padding: 4px 12px; - border: 1px solid #c4c4c6; - border-radius: 999px; - background: #fff; -} -.chip.on { background: #0f4d92; color: #fff; border-color: #0f4d92; } -.chip.disabled { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; } -.fallback-note { font-size: 12px; color: #777; background: #f7f7f9; padding: 8px 10px; border-radius: 8px; } - -/* ---- 产物预览 ---- */ -.preview { display: flex; flex-direction: column; gap: 12px; } -.result-card { border: 1px solid #d6e2f0; border-radius: 10px; overflow: hidden; } -.result-head { - font-size: 12px; - font-weight: 600; - color: #0f4d92; - background: #eef4fb; - padding: 6px 12px; -} -.result-head.plain { color: #555; background: #f2f2f4; } - -/* polishing 原稿/润色对照 */ -.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } -.compare-col { border: 1px solid #d6e2f0; border-radius: 10px; overflow: hidden; } -.orig { white-space: pre-wrap; word-break: break-word; font-family: inherit; font-size: 13px; line-height: 1.6; background: none; padding: 0; margin: 0; } - -/* reviewer 三栏 */ -.reviewer { display: flex; flex-direction: column; gap: 10px; padding: 8px; } -.rv-block { border: 1px solid #e2e2e6; border-radius: 8px; overflow: hidden; } -.rv-cols { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } -.rv-col { border: 1px solid #d6e2f0; border-radius: 8px; overflow: hidden; min-width: 0; } -.rv-col .md { font-size: 12px; } - -/* reader 双语对照 */ -.bilingual { display: flex; flex-direction: column; gap: 8px; padding: 4px; } -.bi-row { border-bottom: 1px solid #eee; padding-bottom: 8px; } -.bi-src { font-size: 11px; color: #999; margin-bottom: 4px; } -.bi-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } -.bi-en { font-size: 13px; line-height: 1.6; color: #333; } -.bi-zh { font-size: 13px; line-height: 1.7; color: #1a1a1a; } - -/* 引用列表 */ -.cite-actions { margin: 6px 0; } -.cite-list { margin: 6px 0 0; padding-left: 20px; font-size: 12px; } -.cite-list li { margin-bottom: 8px; } -.cite-title { font-weight: 600; color: #222; } -.cite-meta { color: #666; font-size: 11px; margin-top: 2px; } -.cite-doi { color: #0f4d92; } -.md { padding: 4px 14px; font-size: 13px; line-height: 1.6; color: #222; } -.md :first-child { margin-top: 6px; } -.md pre { background: #f5f5f7; padding: 10px; border-radius: 8px; overflow: auto; font-size: 12px; } -.md code { background: #f0f0f2; padding: 1px 4px; border-radius: 4px; font-size: 12px; } -.md pre code { background: none; padding: 0; } -.md table { border-collapse: collapse; font-size: 12px; } -.md th, .md td { border: 1px solid #ddd; padding: 4px 8px; } -.art { border: 1px solid #e2e2e6; border-radius: 8px; padding: 8px 10px; } -.art > summary { font-size: 12px; font-weight: 600; cursor: pointer; color: #333; } -.art img { max-width: 100%; border-radius: 6px; } -.art figcaption { font-size: 11px; color: #666; margin-top: 4px; } -.art .code { font-size: 11px; max-height: 280px; overflow: auto; background: #f5f5f7; padding: 8px; border-radius: 6px; } -.file-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.file-row .fname { font-size: 12px; } - -/* ---- 顶栏附加 ---- */ -.gear { padding: 2px 8px; font-size: 15px; line-height: 1; border-radius: 8px; } -.warn-pill { font-size: 11px; background: #b64342; color: #fff; padding: 1px 8px; border-radius: 999px; } -.small { font-size: 11px; } - -/* ---- 设置 / 体检 ---- */ -.settings-wrap { flex: 1; overflow: auto; display: flex; flex-direction: column; gap: 8px; } -.settings { max-width: 720px; } -.settings h2 { font-size: 16px; margin: 6px 0 12px; } -.doctor-block { border: 1px solid #e2e2e6; border-radius: 10px; padding: 10px 14px; margin-bottom: 12px; background: #fff; } -.doctor-block h3 { font-size: 13px; margin: 0 0 8px; color: #333; } -.doctor-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 3px 0; flex-wrap: wrap; } -.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } -.dot.ok { background: #1a7f37; } -.dot.err { background: #b64342; } -.d-label { font-weight: 600; min-width: 90px; } -.d-detail { color: #555; } -.d-hint { color: #999; font-size: 11px; } -.prep { margin-top: 8px; } -.danger-toggle { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; } -.danger-toggle .warn { color: #b64342; font-weight: 600; } - -/* ---- chart-atlas 选图器 ---- */ -.atlas > summary { font-size: 12px; font-weight: 600; color: #333; cursor: pointer; } -.atlas-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); - gap: 8px; - margin-top: 8px; -} -.atlas-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 6px; - border: 1px solid #d6d6da; - border-radius: 8px; - background: #fff; - font-size: 11px; -} -.atlas-item img { width: 100%; height: 60px; object-fit: contain; } -.atlas-item.on { border-color: #0f4d92; background: #eef4fb; } - -/* ---- figure 再改一版 ---- */ -.refine { border-top: 1px dashed #d6d6da; padding-top: 10px; display: flex; flex-direction: column; gap: 6px; } -.refine-input { flex: 1; font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 8px; } - -/* ---- 图表微调编辑器 (ChartEditor) ---- */ -.chart-editor { display: flex; flex-direction: column; height: 100%; gap: 8px; overflow: hidden; } -.ce-header { display: flex; align-items: center; gap: 10px; } -.ce-header h2 { font-size: 16px; margin: 0; } -.ce-status { font-size: 12px; color: #888; } -.ce-status.loading { color: #0f4d92; font-weight: 600; } -.ce-body { display: flex; gap: 12px; flex: 1; min-height: 0; } -.ce-params { width: 400px; overflow-y: auto; padding-right: 4px; display: flex; flex-direction: column; gap: 8px; } -.ce-preview { - flex: 1; min-width: 0; background: #fff; border: 1px solid #c4c4c6; - border-radius: 10px; display: flex; align-items: center; justify-content: center; - overflow: hidden; padding: 16px; -} -.ce-preview-img { max-width: 100%; max-height: 100%; object-fit: contain; } -.ce-placeholder { color: #aaa; font-size: 14px; } -.ce-error { color: #b64342; font-size: 13px; background: #fef2f2; padding: 10px 14px; border-radius: 8px; margin-bottom: 8px; white-space: pre-wrap; word-break: break-word; } - -/* 参数面板分区 */ -.ce-section { background: #fff; border: 1px solid #e0e0e4; border-radius: 10px; overflow: hidden; } -.ce-section > summary { cursor: pointer; padding: 8px 12px; font-size: 13px; font-weight: 600; user-select: none; background: #f5f5f7; } -.ce-section-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } - -/* 字段 */ -.ce-field { display: flex; flex-direction: column; gap: 3px; } -.ce-field-label { font-size: 11px; color: #888; } -.ce-field input, .ce-field select { font: inherit; font-size: 13px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 6px; } -.ce-row { display: flex; gap: 8px; } -.ce-row .ce-field { flex: 1; } -.ce-check { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; } - -/* 颜色选择器 */ -.ce-color { display: flex; align-items: center; gap: 6px; } -.ce-swatches { display: flex; gap: 3px; flex-wrap: wrap; } -.ce-swatch { width: 18px; height: 18px; border-radius: 4px; border: 2px solid transparent; cursor: pointer; padding: 0; } -.ce-swatch.on { border-color: #0f4d92; } -.ce-color-input { width: 28px; height: 28px; border: 1px solid #ccc; border-radius: 6px; padding: 0; cursor: pointer; } -.ce-color-text { width: 70px; font: inherit; font-size: 12px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 6px; } - -/* 序列块 */ -.ce-series { border: 1px solid #e8e8ec; border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 6px; } -.ce-series-head { display: flex; align-items: center; gap: 6px; } -.ce-series-name { flex: 1; font: inherit; font-size: 13px; font-weight: 600; padding: 3px 6px; border: 1px solid #ddd; border-radius: 6px; } - -/* 微调入口按钮 */ -.ce-entry { display: flex; gap: 8px; align-items: center; } -.ce-entry-hint { font-size: 11px; color: #888; } -button.ce-launch { background: #0f4d92; color: #fff; border: 1px solid #0f4d92; font-size: 13px; padding: 6px 14px; border-radius: 8px; cursor: pointer; } -button.ce-launch:hover { background: #0a3d75; } - -/* 热图矩阵编辑器 */ -.ce-matrix-toolbar { display: flex; align-items: center; gap: 6px; } -.ce-matrix-size { font-size: 11px; color: #888; } -.ce-matrix-toolbar button { font-size: 11px; padding: 2px 8px; } -.ce-matrix { overflow-x: auto; display: flex; flex-direction: column; gap: 2px; } -.ce-matrix-row { display: flex; gap: 2px; } -.ce-matrix-cell { width: 42px; font-size: 11px; padding: 3px 4px; border: 1px solid #ccc; border-radius: 4px; text-align: center; } -select { font: inherit; font-size: 13px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 6px; } diff --git a/src/App.tsx b/src/App.tsx index b585c86..de5ace7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,488 +1,126 @@ import { useEffect, useRef, useState } from "react"; -import { invoke, Channel, convertFileSrc } from "@tauri-apps/api/core"; -import { open } from "@tauri-apps/plugin-dialog"; -import { readTextFile } from "@tauri-apps/plugin-fs"; -import type { DomainEvent, EngineStatus, TaskSpec } from "./types/engine"; +import { invoke } from "@tauri-apps/api/core"; +import type { EngineStatus } from "./types/engine"; import type { SkillDescriptor } from "./types/skill"; -import DynamicForm, { type DynamicFormResult } from "./components/DynamicForm"; -import ArtifactPreview from "./components/ArtifactPreview"; +import { LangProvider, useI18n } from "./i18n"; +import { ThemeProvider } from "./theme"; +import { skillName } from "./skillsMeta"; +import { useRun, type LaunchSpec } from "./useRun"; +import Sidebar, { type ViewName, type RecentItem } from "./components/Sidebar"; +import Home from "./components/Home"; +import TaskConfig from "./components/TaskConfig"; +import RunView from "./components/RunView"; import Settings from "./components/Settings"; -import ChartEditor from "./components/ChartEditor"; -import type { PlotSpec, PlotData } from "./types/plot"; -import "./App.css"; +import "./styles.css"; -interface LogLine { - t: string; - text: string; - cls?: string; -} +function Shell() { + const { t, lang } = useI18n(); + const [engine, setEngine] = useState(null); + const [skills, setSkills] = useState([]); + const [skillsError, setSkillsError] = useState(null); -const STATUS_LABEL: Record = { - stable: "Stable", - beta: "Beta", - draft: "Draft", -}; + const [view, setView] = useState("home"); + const [sel, setSel] = useState(null); + const [recent, setRecent] = useState([]); -// chart-atlas 10 类图型(bundled assets/chart-atlas/*.png) -const CHART_ATLAS: { file: string; label: string }[] = [ - { file: "atlas-01-bar-charts.png", label: "bar 柱状" }, - { file: "atlas-02-line-trends.png", label: "line 趋势" }, - { file: "atlas-03-heatmaps.png", label: "heatmap 热图" }, - { file: "atlas-04-scatter-bubble.png", label: "scatter 散点" }, - { file: "atlas-05-radar-polar.png", label: "radar 雷达" }, - { file: "atlas-06-distributions.png", label: "distribution 分布" }, - { file: "atlas-07-forest-interval.png", label: "forest 森林" }, - { file: "atlas-08-area-stacked.png", label: "area 堆叠" }, - { file: "atlas-09-image-plates.png", label: "image 图板" }, - { file: "atlas-10-network-matrix.png", label: "network 网络" }, -]; + const [dangerSandbox, setDangerSandbox] = useState(false); + const [defaultNetwork, setDefaultNetwork] = useState(true); -// 图表微调编辑器的演示数据(阶段三后将替换为 codex 生成的真实 plot_spec/plot_data)。 -const DEMO_XS = Array.from({ length: 21 }, (_, i) => +(i * 0.5).toFixed(1)); -const DEMO_PLOT_SPEC: PlotSpec = { - chart_type: "line", - title: "Sample Data Visualization", - x_label: "Time", - y_label: "Amplitude", - x_unit: "s", - style: { - figure_size: [8, 5], - dpi: 150, - font_family: "serif", - font_size: 12, - grid: true, - grid_alpha: 0.3, - legend: { enabled: true, location: "best" }, - spines: { enabled: true, width: 1.2, color: "#333" }, - }, -}; -const DEMO_PLOT_DATA: PlotData = { - series: [ - { name: "sin", x: DEMO_XS, y: DEMO_XS.map((x) => +Math.sin(x).toFixed(4)), color: "#1a1a1a", line_width: 2.0, visible: true }, - { name: "cos", x: DEMO_XS, y: DEMO_XS.map((x) => +Math.cos(x).toFixed(4)), color: "#c73e3a", line_width: 1.5, visible: true }, - ], -}; + const run = useRun({ dangerSandbox, lang, t }); -function ChartAtlas({ - skillDir, - selected, - onSelect, -}: { - skillDir: string; - selected: string | null; - onSelect: (label: string | null) => void; -}) { - return ( -
- chart-atlas 选图型(可选,引导绘图){selected && ` · 已选:${selected}`} -
- {CHART_ATLAS.map((a) => ( - - ))} -
-
- ); -} - -export default function App() { - const [engine, setEngine] = useState(null); - const [skills, setSkills] = useState([]); - const [selected, setSelected] = useState(null); - const [showSettings, setShowSettings] = useState(false); - const [dangerSandbox, setDangerSandbox] = useState(false); - const [skillsError, setSkillsError] = useState(null); + const idRef = useRef(0); + const activeRunIdRef = useRef(null); useEffect(() => { invoke("check_engine").then(setEngine).catch(() => setEngine(null)); invoke("list_skills") - .then((s) => { - setSkills(s); - setSkillsError(null); - }) + .then((s) => { setSkills(s); setSkillsError(null); }) .catch((e) => setSkillsError(String(e))); }, []); - function goHome() { - setSelected(null); - setShowSettings(false); - } - - return ( -
-
-

- Nature App M2 -

-
- {dangerSandbox && 全放开沙箱} - {engine ? ( - <> - - {engine.loggedIn ? "● 已登录" : "○ 未登录"} - - {engine.version ?? "codex 未知"} - - ) : ( - 检测引擎中… - )} - -
-
- - {showSettings ? ( -
- - -
- ) : selected ? ( - setSelected(null)} /> - ) : ( - - )} -
- ); -} - -function Catalog({ - skills, - error, - onPick, -}: { - skills: SkillDescriptor[]; - error: string | null; - onPick: (s: SkillDescriptor) => void; -}) { - if (error) { - return ( -
-
加载 skill 列表失败:{error}
-
- ); - } - return ( -
-

选择一个科研 skill(共 {skills.length} 个)

-
- {skills.map((s) => ( - - ))} -
-
- ); -} - -function RunView({ - skill, - dangerSandbox, - onBack, -}: { - skill: SkillDescriptor; - dangerSandbox: boolean; - onBack: () => void; -}) { - const [workdir, setWorkdir] = useState(""); - const [files, setFiles] = useState([]); - const [form, setForm] = useState({ instruction: "", valid: false, userInput: "" }); - const [needsNetwork, setNeedsNetwork] = useState(true); - const [running, setRunning] = useState(false); - const [log, setLog] = useState([]); - const [artifacts, setArtifacts] = useState([]); - const [result, setResult] = useState(null); - const [tokens, setTokens] = useState({ in: 0, out: 0 }); - const [chartHint, setChartHint] = useState(null); - const [refineInput, setRefineInput] = useState(""); - const [originalInput, setOriginalInput] = useState(""); - const taskIdRef = useRef(null); - const gotResultRef = useRef(false); - - const isFigure = skill.id === "nature-figure"; - const [showEditor, setShowEditor] = useState(false); - const [loadedSpec, setLoadedSpec] = useState(null); - const [loadedData, setLoadedData] = useState(null); - // 最近一次生成的绘图脚本(用于"再改一版"回灌) - const lastPy = [...artifacts].reverse().find((p) => p.endsWith(".py")) || null; - - // 任务完成后,检测 codex 是否导出了 plot_spec/plot_data,有则用于图表微调 - async function tryLoadPlotParams() { - if (!workdir) return; - try { - const specStr = await readTextFile(`${workdir}/plot_spec.json`); - const dataStr = await readTextFile(`${workdir}/plot_data.json`); - setLoadedSpec(JSON.parse(specStr)); - setLoadedData(JSON.parse(dataStr)); - push("已加载绘图参数,可点击「图表微调」实时调整", "ok"); - } catch { - // 参数文件不存在或解析失败,清除旧参数避免编辑器渲染过期数据 - setLoadedSpec(null); - setLoadedData(null); - } - } - - function push(text: string, cls?: string) { - setLog((prev) => [...prev, { t: new Date().toLocaleTimeString(), text, cls }]); - } - - async function pickWorkdir() { - const dir = await open({ directory: true, multiple: false, title: "选择工作目录(产物根)" }); - if (typeof dir === "string") setWorkdir(dir); - } - - async function pickFiles() { - const f = await open({ multiple: true, title: "选择输入文件" }); - if (Array.isArray(f)) setFiles(f as string[]); - else if (typeof f === "string") setFiles([f]); - } - - async function launch(instruction: string) { - if (!workdir) return; - setLog([]); - setArtifacts([]); - setResult(null); - setTokens({ in: 0, out: 0 }); - setLoadedSpec(null); - setLoadedData(null); - setRunning(true); - taskIdRef.current = null; - gotResultRef.current = false; - - const channel = new Channel(); - channel.onmessage = (ev) => { - switch (ev.kind) { - case "started": - taskIdRef.current = ev.taskId; - push("$ codex " + ev.argv.join(" "), "cmd"); - break; - case "threadStarted": - push("thread: " + ev.threadId, "dim"); - break; - case "turnStarted": - push("— turn 开始 —", "dim"); - break; - case "reasoning": - push("[思考] " + ev.text, "dim"); - break; - case "commandRun": - push("[运行] " + ev.command + (ev.status ? ` (${ev.status})` : ""), "cmd"); - break; - case "assistantMessage": - push(ev.text, "msg"); - setResult(ev.text); // 保留最后一条作为最终结果 - gotResultRef.current = true; - break; - case "artifact": - push(`[产物 ${ev.changeKind}] ${ev.path}`, "ok"); - setArtifacts((p) => (p.includes(ev.path) ? p : [...p, ev.path])); - break; - case "turnCompleted": - push( - `tokens: in ${ev.usage.input_tokens} (cached ${ev.usage.cached_input_tokens}) / out ${ev.usage.output_tokens}`, - "dim" - ); - setTokens((p) => ({ - in: p.in + ev.usage.input_tokens, - out: p.out + ev.usage.output_tokens, - })); - break; - case "plan": - push("[计划] 已更新", "dim"); - break; - case "progress": - push(`[进度] ${ev.text}`, "dim"); - break; - case "engineError": { - const cta: Record = { - notLoggedIn: " → 请在终端运行 codex login", - networkBlocked: " → 勾选'允许联网'后重试", - notInstalled: " → 未检测到 codex,请先安装", - timeout: "", - }; - push(`[错误:${ev.class}] ${ev.message}${cta[ev.class] ?? ""}`, "err"); - break; - } - case "raw": - push(`[raw:${ev.codexType}]`, "dim"); - break; - case "finished": - if (ev.outcome === "cancelled") { - push("— 已取消 —", "dim"); - } else if (ev.outcome === "success" && ev.artifactCount === 0 && !gotResultRef.current) { - push( - "⚠ 退出码为 0,但未检测到任何产物或回复 —— 可能未真正完成(常见:沙箱拦截写入 / 指令未触发 skill)", - "warn" - ); - } else { - push( - `— 结束:${ev.outcome}(exit=${ev.exitCode ?? "?"})—`, - ev.outcome === "success" ? "ok" : "err" - ); - } - setRunning(false); - if (ev.outcome === "success") tryLoadPlotParams(); - break; - } - }; - - const spec: TaskSpec = { - instruction, - workdir, - sandboxTier: dangerSandbox ? "dangerFullAccess" : "workspaceWrite", - needsNetwork, + // 运行指示灯:按当前运行对应的 recent id 更新,且仅在值变化时写(避免无意义重渲染/循环) + useEffect(() => { + const id = activeRunIdRef.current; + if (!id) return; + setRecent((r) => { + const idx = r.findIndex((x) => x.id === id); + if (idx < 0 || r[idx].running === run.running) return r; + const next = [...r]; + next[idx] = { ...next[idx], running: run.running }; + return next; + }); + }, [run.running]); + + function openSkill(s: SkillDescriptor) { setSel(s); setView("newtask"); } + + function startLaunch(spec: LaunchSpec) { + const id = String(++idRef.current); + const item: RecentItem = { + id, + title: spec.title || (spec.skill ? skillName(spec.skill.id, lang, spec.skill.name) : t("run.agent")), + skillId: spec.skill?.id ?? null, + skillName: spec.skill ? skillName(spec.skill.id, lang, spec.skill.name) : t("run.agent"), + running: true, + at: new Date().toLocaleTimeString(), + spec, }; - - try { - await invoke("run_skill_task", { spec, onEvent: channel }); - } catch (e) { - push("启动失败:" + String(e), "err"); - setRunning(false); - } - } - - function run() { - if (!form.valid) return; - setOriginalInput(form.userInput); - const hint = isFigure && chartHint ? `\n参考图型(chart-atlas):${chartHint}` : ""; - launch(form.instruction + hint); + activeRunIdRef.current = id; + setRecent((r) => [item, ...r.filter((x) => x.title !== item.title)].slice(0, 6)); + run.launch(spec); + setView("run"); } - // figure"再改一版":读上一版脚本 + 新要求,重跑生成新版本 - async function refine() { - if (!lastPy || !refineInput.trim()) return; - let code = ""; - try { - code = await readTextFile(lastPy); - } catch (e) { - push("⚠ 读取上一版脚本失败,将从零生成(丢失基线):" + String(e), "warn"); - } - const v = artifacts.filter((p) => /chart(_v\d+)?\.png$/i.test(p)).length + 1; - const instr = [ - `请使用技能「nature-figure」。这是当前的绘图脚本(${lastPy.split("/").pop()}):`, - "```python", - code, - "```", - `请按以下修改重新生成图,并把新版本保存为 chart_v${v}.png 和 chart_v${v}.svg 到当前工作目录(保留旧版本不要覆盖):`, - refineInput.trim(), - ].join("\n"); - setRefineInput(""); - launch(instr); - } - - async function cancel() { - if (taskIdRef.current) { - await invoke("cancel_task", { taskId: taskIdRef.current }); - push("已请求取消", "err"); - } + function openRecent(item: RecentItem) { + // 当前运行/最近一次:直接回到该视图,不重跑(避免重复执行) + if (item.id === activeRunIdRef.current) { setView("run"); return; } + // 其它历史项:用同一配置重跑该任务(每个条目都可用) + startLaunch(item.spec); } return ( - showEditor ? ( - setShowEditor(false)} +
+ setView("home")} + onOpenSkill={openSkill} + onOpenRecent={openRecent} /> - ) : ( -
-
- - {skill.id} - {STATUS_LABEL[skill.status] ?? skill.status} -
- {(tokens.in > 0 || tokens.out > 0) && ( - 累计 tokens: in {tokens.in.toLocaleString()} / out {tokens.out.toLocaleString()} - )} -
- -
-
- - {workdir || "(未选择)"} -
-
- - {files.length ? `${files.length} 个文件` : "(可选)"} -
- - - {isFigure && } - {isFigure && ( -
- - {loadedSpec ? "已加载生成参数,可实时微调" : "参数化编辑器:实时调整配色 / 字号 / 线宽,本地渲染无需重跑"} -
- )} + {view === "run" && run.active && } + {view === "newtask" && sel && ( + setView("home")} onLaunch={startLaunch} /> + )} -
- -
- {running ? ( - + {view === "home" && ( +
+ {skillsError ? ( +
{skillsError}
) : ( - + )} -
+
+ )} - {isFigure && lastPy && !running && ( -
-
再改一版(基于上一版脚本 {lastPy.split("/").pop()})
-
- setRefineInput(e.target.value)} - placeholder="例如:把柱子改成横向、配色用蓝绿、加误差棒…" - /> - -
-
- )} -
+ {view === "settings" && ( +
+ +
+ )} +
+ ); +} -
-
- {log.length === 0 &&
控制台输出会显示在这里…
} - {log.map((l, i) => ( -
- {l.t} - {l.text} -
- ))} -
-
- -
-
- - ) +export default function App() { + return ( + + + + + ); } diff --git a/src/components/ArtifactPanel.tsx b/src/components/ArtifactPanel.tsx new file mode 100644 index 0000000..04d1ed3 --- /dev/null +++ b/src/components/ArtifactPanel.tsx @@ -0,0 +1,189 @@ +import { useEffect, useState } from "react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { readTextFile } from "@tauri-apps/plugin-fs"; +import { openPath } from "@tauri-apps/plugin-opener"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { useI18n } from "../i18n"; +import { Icon } from "../icons"; +import { ext, fileName, dirOf } from "../lib"; +import ReaderBilingual from "./ReaderBilingual"; +import ReviewerView from "./ReviewerView"; +import CitationList from "./CitationList"; + +const IMG = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]; +const TEXT = [".md", ".markdown", ".txt"]; +const CODE = [".py", ".r", ".json", ".csv", ".tsv", ".yaml", ".yml", ".tex"]; +const CITE = [".bib", ".ris", ".nbib", ".enw"]; + +interface Props { + result: string | null; + artifacts: string[]; + skillId: string | null; + original: string; + nonce: number; + canRefine: boolean; + refineBasedOn: string | null; + onRefine: (text: string) => void; +} + +export default function ArtifactPanel({ result, artifacts, skillId, original, nonce, canRefine, refineBasedOn, onRefine }: Props) { + const { t, lang } = useI18n(); + const tabs: string[] = [...(result ? ["__result__"] : []), ...artifacts]; + const [active, setActive] = useState(null); + const [texts, setTexts] = useState>({}); + const [errs, setErrs] = useState>({}); + const [refineText, setRefineText] = useState(""); + + useEffect(() => { + if (active && tabs.includes(active)) return; + const firstImg = artifacts.find((p) => IMG.includes(ext(p))); + setActive(firstImg ?? tabs[0] ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artifacts.length, result]); + + useEffect(() => { + artifacts.forEach((p) => { + const e = ext(p); + if ((TEXT.includes(e) || CODE.includes(e) || CITE.includes(e)) && !(p in texts) && !(p in errs)) { + readTextFile(p) + .then((tx) => setTexts((m) => ({ ...m, [p]: tx }))) + .catch((err) => setErrs((m) => ({ ...m, [p]: String(err) }))); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artifacts]); + + if (tabs.length === 0) { + return ( + <> +
{t("art.title")}
+
{t("art.empty")}
+ + ); + } + + const isReviewer = skillId === "nature-reviewer"; + const isPolish = skillId === "nature-polishing"; + + return ( + <> +
+ {t("art.title")} + {artifacts.length} +
+ +
+ {tabs.map((p) => { + const label = p === "__result__" ? t("art.result") : fileName(p); + const icon = p === "__result__" ? "shield" : IMG.includes(ext(p)) ? "image" : CODE.includes(ext(p)) ? "code" : "file"; + return ( + + ); + })} +
+ +
+ {active === "__result__" && result && ( + isReviewer ? + : isPolish ? ( +
+
+
{t("art.original")}
+
{original}
+
+
+
{t("art.polished")}
+
{result}
+
+
+ ) + :
{result}
+ )} + + {active && active !== "__result__" && ( + + )} + + {active && active !== "__result__" && ( +
+ + +
+ )} + + {canRefine && ( +
+
{t("art.iterate")}{refineBasedOn ? ` · ${t("art.iterateBasedOn", { file: refineBasedOn })}` : ""}
+
+ setRefineText(e.target.value)} + placeholder={t("art.iteratePh")} + onKeyDown={(e) => { if (e.key === "Enter" && refineText.trim()) { onRefine(refineText); setRefineText(""); } }} + /> + +
+
+ )} +
+ + ); +} + +function ArtifactView({ path, texts, errs, t, lang, skillId, nonce }: { + path: string; texts: Record; errs: Record; + t: (k: string) => string; lang: string; skillId: string | null; nonce: number; +}) { + const e = ext(path); + + if (skillId === "nature-reader" && TEXT.includes(e)) return ; + if (skillId === "nature-citation" && CITE.includes(e)) return ; + if (skillId === "nature-reviewer" && TEXT.includes(e)) { + if (errs[path]) return ; + const tx = texts[path]; + return tx == null ? {t("art.loading")} : ; + } + + if (IMG.includes(e)) { + return ( +
+ {tx == null ? {t("art.loading")} : {tx}}; + } + if (CODE.includes(e)) { + if (errs[path]) return ; + const tx = texts[path]; + return
{tx ?? t("art.loading")}
; + } + if (CITE.includes(e)) return ; + + return ( +
+ +
+ ); +} + +function ReadError({ path, err, lang }: { path: string; err: string; lang: string }) { + const zh = lang === "zh"; + return ( +
+ {(zh ? "读取失败:" : "Failed to read: ") + err} +
+ +
+
+ ); +} diff --git a/src/components/ArtifactPreview.tsx b/src/components/ArtifactPreview.tsx deleted file mode 100644 index 6b30ffc..0000000 --- a/src/components/ArtifactPreview.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { readTextFile } from "@tauri-apps/plugin-fs"; -import { openPath } from "@tauri-apps/plugin-opener"; -import ReviewerView from "./ReviewerView"; -import ReaderBilingual from "./ReaderBilingual"; -import CitationList from "./CitationList"; - -const IMG = ["png", "svg", "jpg", "jpeg", "gif", "webp"]; -const CITATION = ["bib", "ris", "enw", "nbib"]; -const TEXT = ["md", "txt", "json", "csv", "tsv", "rdf", "log", "py", "tex"]; - -const ext = (p: string) => (p.split(".").pop() || "").toLowerCase(); -const base = (p: string) => p.split("/").pop() || p; - -function TextArtifact({ path, isMd }: { path: string; isMd: boolean }) { - const [content, setContent] = useState(null); - const [err, setErr] = useState(null); - useEffect(() => { - readTextFile(path).then(setContent).catch((e) => setErr(String(e))); - }, [path]); - return ( -
- {base(path)} - {err &&
读取失败:{err}
} - {content != null && - (isMd ? ( -
- {content} -
- ) : ( -
{content.slice(0, 20000)}
- ))} -
- ); -} - -function FileArtifact({ path, skillId }: { path: string; skillId?: string }) { - const e = ext(path); - // reader 的 paper.md → 双语对照视图(按真实 spec 解析) - if (skillId === "nature-reader" && base(path) === "paper.md") { - return ( -
- -
- ); - } - if (IMG.includes(e)) { - return ( -
- {path} -
{base(path)}
-
- ); - } - if (CITATION.includes(e)) return ; - if (TEXT.includes(e)) return ; - // 其它(docx/pptx/pdf 等)→ 用系统软件打开 - return ( -
- {base(path)} - -
- ); -} - -export default function ArtifactPreview({ - result, - artifacts, - skillId, - original, -}: { - result: string | null; - artifacts: string[]; - skillId?: string; - original?: string; -}) { - if (!result && artifacts.length === 0) { - return
产物会显示在这里…
; - } - const showPolishCompare = skillId === "nature-polishing" && original && result; - const showReviewer = skillId === "nature-reviewer" && result; - return ( -
- {showPolishCompare ? ( -
-
-
原稿
-
-
{original}
-
-
-
-
润色 / 结果
-
- {result!} -
-
-
- ) : showReviewer ? ( -
-
审稿报告
- -
- ) : ( - result && ( -
-
最终结果
-
- {result} -
-
- ) - )} - {artifacts.map((p) => ( - - ))} -
- ); -} diff --git a/src/components/CitationList.tsx b/src/components/CitationList.tsx index d0fe7b9..3693c97 100644 --- a/src/components/CitationList.tsx +++ b/src/components/CitationList.tsx @@ -1,28 +1,41 @@ import { useEffect, useState } from "react"; import { readTextFile } from "@tauri-apps/plugin-fs"; import { openPath } from "@tauri-apps/plugin-opener"; +import { useI18n } from "../i18n"; +import { Icon } from "../icons"; +import { ext } from "../lib"; -interface Entry { - title?: string; - authors?: string; - year?: string; - doi?: string; -} +interface Entry { title?: string; authors?: string; year?: string; doi?: string; } + +const clean = (s: string) => s.replace(/[{}]/g, "").trim(); -const base = (p: string) => p.split("/").pop() || p; -const ext = (p: string) => (p.split(".").pop() || "").toLowerCase(); +// 括号配平的 BibTeX 字段提取:支持 {... {nested} ...} 与 "..." 与裸值,不再被首个内层 } 截断。 +function bibField(chunk: string, name: string): string | undefined { + const re = new RegExp(name + "\\s*=\\s*", "i"); + const m = re.exec(chunk); + if (!m) return undefined; + const i = m.index + m[0].length; + const open = chunk[i]; + if (open === "{") { + let depth = 0; + for (let j = i; j < chunk.length; j++) { + if (chunk[j] === "{") depth++; + else if (chunk[j] === "}") { depth--; if (depth === 0) return clean(chunk.slice(i + 1, j)); } + } + return clean(chunk.slice(i + 1)); + } + if (open === '"') { + const end = chunk.indexOf('"', i + 1); + return end > 0 ? clean(chunk.slice(i + 1, end)) : undefined; + } + const mm = chunk.slice(i).match(/^([^,\n]+)/); + return mm ? clean(mm[1]) : undefined; +} function parseBib(t: string): Entry[] { const out: Entry[] = []; for (const chunk of t.split(/@\w+\s*\{/).slice(1)) { - const field = (name: string) => - (chunk.match(new RegExp(name + "\\s*=\\s*[{\"]([^}\"]*)", "i")) || [])[1]?.trim(); - out.push({ - title: field("title"), - authors: field("author"), - year: field("year"), - doi: field("doi"), - }); + out.push({ title: bibField(chunk, "title"), authors: bibField(chunk, "author"), year: bibField(chunk, "year"), doi: bibField(chunk, "doi") }); } return out; } @@ -30,12 +43,7 @@ function parseBib(t: string): Entry[] { function parseRis(t: string): Entry[] { const out: Entry[] = []; let cur: Entry & { _au?: string[] } = { _au: [] }; - const push = () => { - if (cur.title || cur._au?.length) { - out.push({ ...cur, authors: cur._au?.join("; ") }); - } - cur = { _au: [] }; - }; + const push = () => { if (cur.title || cur._au?.length) out.push({ ...cur, authors: cur._au?.join("; ") }); cur = { _au: [] }; }; for (const line of t.split(/\r?\n/)) { const m = line.match(/^([A-Z][A-Z0-9])\s{2}-\s?(.*)$/); if (!m) continue; @@ -55,7 +63,7 @@ function parseEnw(t: string): Entry[] { const out: Entry[] = []; for (const chunk of t.split(/\n(?=%0)/)) { const au: string[] = []; - let e: Entry = {}; + const e: Entry = {}; for (const line of chunk.split(/\r?\n/)) { const m = line.match(/^%(.)\s(.*)$/); if (!m) continue; @@ -72,15 +80,18 @@ function parseEnw(t: string): Entry[] { function parse(path: string, text: string): Entry[] { const e = ext(path); - if (e === "bib") return parseBib(text); - if (e === "ris" || e === "nbib") return parseRis(text); - if (e === "enw") return parseEnw(text); + if (e === ".bib") return parseBib(text); + if (e === ".ris" || e === ".nbib") return parseRis(text); + if (e === ".enw") return parseEnw(text); return []; } export default function CitationList({ path }: { path: string }) { + const { t, lang } = useI18n(); + const zh = lang === "zh"; const [text, setText] = useState(null); const [err, setErr] = useState(null); + useEffect(() => { readTextFile(path).then(setText).catch((e) => setErr(String(e))); }, [path]); @@ -88,36 +99,27 @@ export default function CitationList({ path }: { path: string }) { const entries = text ? parse(path, text) : []; return ( -
- - {base(path)} {entries.length > 0 && `· ${entries.length} 条引用`} - - {err &&
读取失败:{err}
} +
+ {err &&
{(zh ? "读取失败:" : "Failed to read: ") + err}
}
- + {entries.length > 0 && {zh ? `${entries.length} 条引用` : `${entries.length} refs`}}
{entries.length > 0 ? (
    {entries.map((e, i) => ( -
  1. -
    {e.title || "(无标题)"}
    -
    - {[e.authors, e.year].filter(Boolean).join(" · ")} - {e.doi && ( - <> - {" · "} - {e.doi} - - )} -
    +
  2. +
    {e.title || (zh ? "(无标题)" : "(untitled)")}
    +
    {[e.authors, e.year].filter(Boolean).join(" · ")}
    + {e.doi &&
    {e.doi}
    }
  3. ))}
) : ( - text != null &&
{text.slice(0, 8000)}
+ text != null &&
{text.slice(0, 8000)}
)} -
+ ); } diff --git a/src/components/DynamicForm.tsx b/src/components/DynamicForm.tsx index 29f1023..b6b9209 100644 --- a/src/components/DynamicForm.tsx +++ b/src/components/DynamicForm.tsx @@ -1,10 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import type { Axis, SkillDescriptor } from "../types/skill"; +import { useI18n } from "../i18n"; // 首版 figure 只支持 Python,R 置灰(计划:R 留后期)。 -function disabledValue(skillId: string, axisName: string, value: string): string | null { +function disabledHint(skillId: string, axisName: string, value: string, zh: boolean): string | null { if (skillId === "nature-figure" && axisName === "backend" && value === "r") { - return "R 后端即将支持(首版仅 Python)"; + return zh ? "R 后端即将支持(首版仅 Python)" : "R backend coming soon (Python only for now)"; } return null; } @@ -23,17 +24,16 @@ interface Props { function initialSelections(skill: SkillDescriptor): Record { const sel: Record = {}; - for (const ax of skill.axes) { - sel[ax.name] = ax.defaultValue ? [ax.defaultValue] : []; - } + for (const ax of skill.axes) sel[ax.name] = ax.defaultValue ? [ax.defaultValue] : []; return sel; } export default function DynamicForm({ skill, files, onChange }: Props) { + const { t, lang } = useI18n(); + const zh = lang === "zh"; const [sel, setSel] = useState>(() => initialSelections(skill)); const [userInput, setUserInput] = useState(""); - // 切换 skill 时重置 useEffect(() => { setSel(initialSelections(skill)); setUserInput(""); @@ -46,19 +46,22 @@ export default function DynamicForm({ skill, files, onChange }: Props) { return userInput.trim().length > 0; }, [skill, sel, userInput]); + // 发给 Codex 的指令:脚手架随界面语言走,skill id / 文件路径等专名保持一致。 const instruction = useMemo(() => { - const lines: string[] = [`请使用技能「${skill.id}」完成以下任务。`]; + const lines: string[] = [ + zh ? `请使用技能「${skill.id}」完成以下任务。` : `Use the skill "${skill.id}" to complete the task below.`, + ]; for (const ax of skill.axes) { const v = sel[ax.name]; if (v && v.length) lines.push(`- ${ax.name}: ${v.join(", ")}`); } - lines.push("", "任务要求:", userInput.trim()); + lines.push("", zh ? "任务要求:" : "Task:", userInput.trim()); if (files.length) { - lines.push("", "可访问以下输入文件:"); + lines.push("", zh ? "可访问以下输入文件:" : "Input files available:"); files.forEach((f) => lines.push(`- ${f}`)); } return lines.join("\n"); - }, [skill, sel, userInput, files]); + }, [skill, sel, userInput, files, zh]); useEffect(() => { onChange({ instruction, valid, userInput }); @@ -79,15 +82,15 @@ export default function DynamicForm({ skill, files, onChange }: Props) {
{skill.formCapability === "axes" ? ( skill.axes.map((ax) => ( -
+
{ax.name} - {ax.blockingGate && *必选} - {ax.multi && 可多选} + {ax.blockingGate && {t("common.required")}} + {ax.multi && {zh ? "可多选" : "multi"}}
{ax.values.map((v) => { - const dis = disabledValue(skill.id, ax.name, v); + const dis = disabledHint(skill.id, ax.name, v, zh); const on = (sel[ax.name] ?? []).includes(v); return (