diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f99dfdf..00bab9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,7 @@ name: Test -# 快速测试:每次 push / PR 跑 Rust 单测 + 前端类型检查/构建。 -# 跑在 macOS(本项目的主平台,无需额外系统依赖)。 +# CI:前端(类型检查 + 构建)与 Rust(fmt + clippy + 单测)并行。 +# Rust job 跑 macOS(本项目主平台,含 macOS 专属代码);前端纯 JS,跑 ubuntu 更快省时。 on: push: branches: [main] @@ -12,8 +12,9 @@ concurrency: cancel-in-progress: true jobs: - test: - runs-on: macos-latest + frontend: + name: Frontend — type-check + build + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -28,19 +29,44 @@ jobs: node-version: 22 cache: pnpm - - name: Setup Rust + - name: Install JS deps + run: pnpm install --frozen-lockfile + + - name: Type-check (tsc --noEmit) + run: pnpm exec tsc --noEmit + + - name: Build (tsc + vite) + run: pnpm build + + rust: + name: Rust — fmt + clippy + test + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust (+ rustfmt, clippy) uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy - name: Rust cache uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri - - name: Install JS deps - run: pnpm install --frozen-lockfile + - name: Format check + run: cargo fmt --manifest-path src-tauri/Cargo.toml --all --check - - name: Rust unit tests + - name: Clippy (deny warnings) + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings + + - name: Unit tests run: cargo test --manifest-path src-tauri/Cargo.toml - - name: Frontend type-check + build - run: pnpm build + # 聚合门禁:满足分支保护要求的必需 check「test」(两个并行 job 都绿才绿)。 + test: + name: test + needs: [frontend, rust] + runs-on: ubuntu-latest + steps: + - run: echo "frontend + rust 均通过" 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/src/engine.rs b/src-tauri/src/engine.rs index 1e0f39b..9eb9c95 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -92,24 +92,51 @@ pub struct Usage { #[serde(tag = "kind", rename_all = "camelCase")] pub enum DomainEvent { #[serde(rename_all = "camelCase")] - Started { task_id: String, argv: Vec }, + Started { + task_id: String, + argv: Vec, + }, #[serde(rename_all = "camelCase")] - ThreadStarted { thread_id: String }, + ThreadStarted { + thread_id: String, + }, TurnStarted, - Reasoning { text: String }, - AssistantMessage { text: String }, - CommandRun { command: String, status: Option }, + Reasoning { + text: String, + }, + AssistantMessage { + text: String, + }, + CommandRun { + command: String, + status: Option, + }, /// 产物发现:codex 改了文件。 #[serde(rename_all = "camelCase")] - Artifact { path: String, change_kind: String }, - Plan { steps: serde_json::Value }, + Artifact { + path: String, + change_kind: String, + }, + Plan { + steps: serde_json::Value, + }, /// 轻量进度(如 todo_list 更新),不刷屏。 - Progress { text: String }, - TurnCompleted { usage: Usage }, + Progress { + text: String, + }, + TurnCompleted { + usage: Usage, + }, /// 容错透传:未知事件或解析失败,原样给前端控制台。 #[serde(rename_all = "camelCase")] - Raw { codex_type: String, json: serde_json::Value }, - EngineError { class: String, message: String }, + Raw { + codex_type: String, + json: serde_json::Value, + }, + EngineError { + class: String, + message: String, + }, #[serde(rename_all = "camelCase")] Finished { outcome: String, // success | failure | cancelled @@ -185,7 +212,10 @@ pub fn run_task( // task 私有目录,用于 -o last_message.txt let task_dir = std::env::temp_dir().join("nature-app").join(&task_id); std::fs::create_dir_all(&task_dir).map_err(|e| format!("create task dir: {e}"))?; - let last_message_path = task_dir.join("last_message.txt").to_string_lossy().to_string(); + let last_message_path = task_dir + .join("last_message.txt") + .to_string_lossy() + .to_string(); let bin = resolve_codex_bin(); let argv = build_argv(&spec, &last_message_path); @@ -301,7 +331,10 @@ pub fn run_task( if !still_running { break; // 进程已退出,交给 stdout 线程收尾 } - let idle = wd_activity.lock().map(|t| t.elapsed().as_secs()).unwrap_or(0); + let idle = wd_activity + .lock() + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); if idle > IDLE_LIMIT_SECS { wd_handle.cancelled.store(true, Ordering::SeqCst); wd_ch @@ -493,9 +526,7 @@ fn map_line( message: msg, }] } - "item.started" | "item.completed" | "item.updated" => { - map_item(&v, t == "item.completed") - } + "item.started" | "item.completed" | "item.updated" => map_item(&v, t == "item.completed"), other => vec![DomainEvent::Raw { codex_type: other.to_string(), json: v, @@ -724,7 +755,9 @@ mod tests { &mut u, ); assert_eq!(tid.as_deref(), Some("abc-123")); - assert!(matches!(&evs[0], DomainEvent::ThreadStarted { thread_id } if thread_id == "abc-123")); + assert!( + matches!(&evs[0], DomainEvent::ThreadStarted { thread_id } if thread_id == "abc-123") + ); } #[test] @@ -737,7 +770,9 @@ mod tests { &mut u, ); assert_eq!(u.input_tokens, 44467); - assert!(matches!(&evs[0], DomainEvent::TurnCompleted { usage } if usage.output_tokens == 74)); + assert!( + matches!(&evs[0], DomainEvent::TurnCompleted { usage } if usage.output_tokens == 74) + ); } #[test] @@ -769,7 +804,9 @@ mod tests { &mut tid, &mut u, ); - assert!(matches!(&evs[0], DomainEvent::AssistantMessage { text } if text.contains("chart.png"))); + assert!( + matches!(&evs[0], DomainEvent::AssistantMessage { text } if text.contains("chart.png")) + ); } #[test] @@ -777,7 +814,9 @@ mod tests { let mut tid = None; let mut u = Usage::default(); let evs = map_line(r#"{"type":"some.future.event","foo":1}"#, &mut tid, &mut u); - assert!(matches!(&evs[0], DomainEvent::Raw { codex_type, .. } if codex_type == "some.future.event")); + assert!( + matches!(&evs[0], DomainEvent::Raw { codex_type, .. } if codex_type == "some.future.event") + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e252d5..3216347 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -70,10 +70,16 @@ fn check_doctor() -> DoctorReport { &["soffice", "libreoffice"], &["/Applications/LibreOffice.app/Contents/MacOS/soffice"], ); - let pandoc = find_bin(&["pandoc"], &["/opt/homebrew/bin/pandoc", "/usr/local/bin/pandoc"]); + let pandoc = find_bin( + &["pandoc"], + &["/opt/homebrew/bin/pandoc", "/usr/local/bin/pandoc"], + ); let pdflatex = find_bin( &["pdflatex"], - &["/Library/TeX/texbin/pdflatex", "/usr/local/texlive/bin/pdflatex"], + &[ + "/Library/TeX/texbin/pdflatex", + "/usr/local/texlive/bin/pdflatex", + ], ); let tools = vec![ ToolCheck { diff --git a/src-tauri/src/renderer.rs b/src-tauri/src/renderer.rs index cb3bad3..a4754e0 100644 --- a/src-tauri/src/renderer.rs +++ b/src-tauri/src/renderer.rs @@ -107,8 +107,7 @@ pub async fn preview_plot( .map_err(|e| format!("写 plot_data.json 失败: {e}"))?; // 5. 复制模板为 plot.py(模板从脚本同目录读 JSON) - fs::copy(&template_path, work.join("plot.py")) - .map_err(|e| format!("复制模板失败: {e}"))?; + fs::copy(&template_path, work.join("plot.py")).map_err(|e| format!("复制模板失败: {e}"))?; // 6. 获取 python(优先已就绪的 uv venv,回退系统 python3) let python: PathBuf = if pyenv::is_ready() { diff --git a/src-tauri/src/skills.rs b/src-tauri/src/skills.rs index bcfb606..4a0f161 100644 --- a/src-tauri/src/skills.rs +++ b/src-tauri/src/skills.rs @@ -5,6 +5,7 @@ //! - manifest.yaml:`always_load`(路径列表)、`axes..{detect, values(有序 map), default?, multi?}`、 //! `references.on_demand[].{condition, path}`。 //! - 三档 formCapability:有 axes → axes;有 manifest 无 axes → manifestNoAxes;无 manifest(reviewer)→ proseOnly。 +//! //! 用 serde_yaml::Value 防御式读取,容忍各 skill 的 schema 差异。 use std::path::{Path, PathBuf}; @@ -111,7 +112,10 @@ fn parse_axes(m: &serde_yaml::Value) -> Vec { }) .unwrap_or_default(); let multi = av.get("multi").and_then(|x| x.as_bool()).unwrap_or(false); - let default_value = av.get("default").and_then(|x| x.as_str()).map(|s| s.to_string()); + let default_value = av + .get("default") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); out.push(Axis { name, values, @@ -132,7 +136,11 @@ fn parse_on_demand(m: &serde_yaml::Value) -> Vec { .filter_map(|e| { Some(OnDemandRef { condition: e.get("condition").and_then(|c| c.as_str())?.to_string(), - path: e.get("path").and_then(|p| p.as_str()).unwrap_or("").to_string(), + path: e + .get("path") + .and_then(|p| p.as_str()) + .unwrap_or("") + .to_string(), }) }) .collect() @@ -288,7 +296,12 @@ mod tests { fn loads_all_eleven_nature_skills() { let skills = load_skills(&root()); // 11 个 nature-* skill - assert_eq!(skills.len(), 11, "应解析 11 个 nature-* skill,实际 {}", skills.len()); + assert_eq!( + skills.len(), + 11, + "应解析 11 个 nature-* skill,实际 {}", + skills.len() + ); assert!(skills.iter().all(|s| s.id.starts_with("nature-"))); } @@ -302,7 +315,7 @@ mod tests { assert!(backend.blocking_gate); assert!(!backend.multi); assert_eq!(backend.values, vec!["python", "r"]); // 保持声明顺序 - // 描述应来自 SKILL.md frontmatter(含中文触发词) + // 描述应来自 SKILL.md frontmatter(含中文触发词) assert!(fig.description.contains("科研绘图") || fig.description.contains("figure")); } 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..a566a5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,43 +1,21 @@ 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 type { PlotSpec, PlotData } from "./types/plot"; +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; -} - -const STATUS_LABEL: Record = { - stable: "Stable", - beta: "Beta", - draft: "Draft", -}; - -// 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 网络" }, -]; - -// 图表微调编辑器的演示数据(阶段三后将替换为 codex 生成的真实 plot_spec/plot_data)。 +// 图表微调的演示数据:figure 任务未导出 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", @@ -46,12 +24,8 @@ const DEMO_PLOT_SPEC: PlotSpec = { 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, + 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" }, }, @@ -63,426 +37,126 @@ const DEMO_PLOT_DATA: PlotData = { ], }; -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() { +function Shell() { + const { t, lang } = useI18n(); 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 [view, setView] = useState("home"); + const [sel, setSel] = useState(null); + const [recent, setRecent] = useState([]); + const [editorParams, setEditorParams] = useState<{ spec: PlotSpec; data: PlotData } | null>(null); + + const [dangerSandbox, setDangerSandbox] = useState(false); + const [defaultNetwork, setDefaultNetwork] = useState(true); + + const run = useRun({ dangerSandbox, lang, t }); + + 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); - } + activeRunIdRef.current = id; + setRecent((r) => [item, ...r.filter((x) => x.title !== item.title)].slice(0, 6)); + run.launch(spec); + setView("run"); } - function run() { - if (!form.valid) return; - setOriginalInput(form.userInput); - const hint = isFigure && chartHint ? `\n参考图型(chart-atlas):${chartHint}` : ""; - launch(form.instruction + hint); + function openRecent(item: RecentItem) { + if (item.id === activeRunIdRef.current) { setView("run"); return; } + startLaunch(item.spec); } - // 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); + function openEditor() { + setEditorParams(run.plotParams ?? { spec: DEMO_PLOT_SPEC, data: DEMO_PLOT_DATA }); } - async function cancel() { - if (taskIdRef.current) { - await invoke("cancel_task", { taskId: taskIdRef.current }); - push("已请求取消", "err"); - } + // 图表微调:全屏接管(ChartEditor 自带返回按钮) + if (editorParams) { + return ( +
+ setEditorParams(null)} /> +
+ ); } 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 (