diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..f9b1eaca --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +PROTOC = { value = ".tools/protoc-34.1-win64/bin/protoc.exe", relative = true } diff --git a/.gitignore b/.gitignore index 9a9f924c..c01f03e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies node_modules/ +.tools/ # Build outputs dist/ @@ -49,3 +50,8 @@ pnpm-debug.log* # Brainstorm assets (not tracked in source control) .superpowers/ .claude/ + +# OMX runtime state (keep durable plans, ignore session-local cache/log/state) +.omx/* +!.omx/plans/ +!.omx/plans/*.md diff --git a/.omx/plans/draft-feature-ui-integration-plan.md b/.omx/plans/draft-feature-ui-integration-plan.md new file mode 100644 index 00000000..ae63ceb4 --- /dev/null +++ b/.omx/plans/draft-feature-ui-integration-plan.md @@ -0,0 +1,389 @@ +# Draft / 底稿 v1 设计实施计划 + +状态:RALPLAN 共识计划(非交互模式) +范围:只设计第一阶段 Draft / 底稿功能;不实现模板匹配、DOCX/PDF 导出、长文项目、diff guard。 + +## 1. RALPLAN-DR Summary + +### Principles + +1. Draft 必须是用户显式创建的正式工作资产,不从 Chat 自动生成。 +2. Draft 属于项目本地状态,持久化在项目 `.llm-wiki` 内,但前端不暴露 JSON 文件夹。 +3. Draft 创建时复制正文和 references,成为 durable snapshot,不依赖易失的 Chat 临时引用状态。 +4. UI 必须贴合现有左侧功能栏、内容区、Zustand store、Tauri 文件读写模式。 +5. v1 必须小而闭环:创建、列表、查看、编辑、删除、持久化、引用展示。 + +### Decision Drivers + +1. 人类用户工作流:Chat 回答 → 设为底稿 → 左侧 Drafts 管理。 +2. 数据安全:项目切换、自动保存、重新打开项目不能串数据或丢数据。 +3. 清晰边界:Draft 不污染 Wiki/Search/Graph;保存到 Wiki 和导出是后续显式动作。 + +### Viable Options + +| Option | Pros | Cons | Decision | +|---|---|---|---| +| 左侧 Drafts 入口 + `.llm-wiki/drafts.json` | 符合用户认可的演示;项目内持久化;不污染 wiki;改动可控 | 增加 store/hydration/autosave 面 | Chosen | +| Chat 右侧临时 Draft 面板 | UI 更轻 | 生命周期太像 Chat 附属物;弱化“底稿管理”心智 | Rejected | +| Draft 直接保存为 `wiki/` Markdown | 复用文件树和编辑器 | 未完成内容污染 Wiki/Search/Graph,违背工作中底稿语义 | Rejected for v1 | +| `.llm-wiki/drafts/*.json` 每稿一文件 | 后续版本/附件扩展更强 | v1 文件生命周期更复杂 | Deferred | + +## 2. 架构结论(ADR) + +### Decision + +实现 Draft / 底稿 v1 为一个左侧功能栏中的一等入口 `Drafts`,数据保存在项目目录: + +```text +{project}/.llm-wiki/drafts.json +``` + +前端展示为 Drafts 工作区,而不是展示 `.llm-wiki` 文件夹或 `drafts.json`。 + +### Drivers + +- 用户需要把 Chat 中满意的回复固化为“可继续加工的正式底稿”。 +- Draft 必须保留引用来源,服务未来模板、导出和审计。 +- 普通 Chat 默认行为不能被模板、导出、任务系统污染。 +- 实现应复用现有 app patterns,避免迁移 pkm-tool 结构。 + +### Alternatives considered + +- Chat 附属面板:初期轻,但不利于长期管理多份底稿。 +- Wiki Markdown 草稿:可见性强,但会污染 Wiki 语义。 +- 每 Draft 单文件:更适合版本化后期,不适合 v1 最小闭环。 + +### Consequences + +- 新增一个 project-scoped draft store 和持久化文件。 +- 需要认真处理 project switch + debounced autosave。 +- 为后续 version history/export/diff guard 留出清晰数据锚点。 + +## 3. 用户视角功能逻辑 + +### 3.1 创建底稿 + +1. 用户在 Chat 中收到一条 assistant 回复。 +2. 回复 action row 显示:复制、保存到 Wiki、设为底稿、重新生成。 +3. 用户点击“设为底稿”。 +4. 系统复制该 message 的正文、references、会话来源、时间戳。 +5. 系统提示“已设为底稿”。 +6. 左侧 Drafts 入口出现数量提示或高亮。 +7. 用户进入 Drafts 页面管理底稿。 + +### 3.2 管理底稿 + +Drafts 页面保持现有 UI 风格:左侧列表 + 主内容区。 + +列表显示: + +- 标题 +- 更新时间 +- 引用数量 +- 来源标签:来自 Chat + +详情显示: + +- 可编辑标题 +- 可编辑 Markdown 正文 +- 引用来源列表 +- 来源会话/消息时间/内容 hash 等元信息 +- 删除按钮 + +v1 不做导出按钮主流程;可以不展示未来按钮,避免伪完成。 + +## 4. 数据模型 + +新增 `src/stores/draft-store.ts`: + +```ts +import type { MessageReference, DisplayMessage } from "@/stores/chat-store" + +export interface DraftRecord { + id: string + title: string + content: string + createdAt: number + updatedAt: number + status: "draft" + references?: MessageReference[] + source: { + kind: "chat-message" + conversationId: string + messageId: string + messageTimestamp: number + contentHash: string + } +} +``` + +注意:`messageId` 只能作为 advisory provenance,因为当前 Chat ID 由内存计数生成。真正更稳的来源锚点是 `conversationId + messageTimestamp + contentHash`。 + +持久化 envelope: + +```json +{ + "version": 1, + "drafts": [] +} +``` + +加载策略: + +- 文件不存在:返回空数组。 +- JSON 损坏:返回空数组,不让 UI 崩溃;可 console warn。 +- 未来版本:保守忽略未知字段。 + +## 5. 前端融合设计 + +### 5.1 左侧功能栏 + +修改 `src/components/layout/icon-sidebar.tsx`: + +- 增加 Drafts 导航项。 +- 使用与现有 nav item 相同的 Tooltip、active state、badge 视觉。 +- 图标建议:`NotebookText` 或 `FilePenLine`(lucide-react 已在用)。 + +### 5.2 路由状态 + +修改 `src/stores/wiki-store.ts`: + +- `activeView` union 增加 `"drafts"`。 + +修改 `src/components/layout/content-area.tsx`: + +- 显式处理 `activeView === "drafts"`,渲染 ``。 +- 不要依赖 default fallback,避免错误回到 Chat。 + +### 5.3 Chat message action + +修改 `src/components/chat/chat-message.tsx`: + +- 仅 assistant message 显示“设为底稿”。 +- 该按钮必须接收完整 `DisplayMessage`,不能只传 content。 +- 创建 Draft 后可切换按钮状态为“已设为底稿”或保持可重复创建但给出明确反馈;v1 推荐防重复:同一 message 内容 hash 已存在时提示“已存在底稿”。 + +### 5.4 References 复用 + +当前 references panel 在 `chat-message.tsx` 内部。建议抽出: + +```text +src/components/references/cited-references-panel.tsx +``` + +规则: + +- Chat 仍可支持 `lastQueryPages` fallback。 +- DraftsView 只使用 DraftRecord 内复制的 `references`。 +- Draft 不依赖 transient `lastQueryPages`。 + +### 5.5 DraftsView + +新增: + +```text +src/components/drafts/drafts-view.tsx +``` + +可选拆分: + +```text +src/components/drafts/draft-list.tsx +src/components/drafts/draft-editor.tsx +``` + +UI 风格: + +- 用现有 border、muted、scroll area、button 风格。 +- 不做复杂富文本,v1 用 textarea 或现有 markdown 预览/编辑风格即可。 +- 空态文案:提示“从 Chat 回复点击设为底稿”。 + +## 6. 后端 / 文件逻辑 + +无需新增 Rust command。复用 `src/commands/fs.ts`: + +- `readFile` +- `writeFile` +- `createDirectory` + +持久化建议放在新文件而不是继续膨胀 `persist.ts`: + +```text +src/lib/draft-persist.ts +``` + +导出: + +```ts +loadDrafts(projectPath: string): Promise +saveDrafts(projectPath: string, drafts: DraftRecord[]): Promise +``` + +内部确保: + +```text +{project}/.llm-wiki +``` + +存在。 + +## 7. 生命周期与自动保存 + +这是 Critic 认为最高风险的部分。 + +### 7.1 Project open + +修改 `src/App.tsx`: + +- 打开项目后加载 drafts。 +- 使用 silent hydration,避免刚 load 触发 autosave 覆盖磁盘。 + +### 7.2 Project reset / switch + +修改 `src/lib/reset-project-state.ts`: + +- 清空 draft store。 +- 清空时必须 silent,避免把空 draft 写入旧项目或新项目。 + +### 7.3 Autosave + +修改 `src/lib/auto-save.ts`: + +- Draft 创建和删除:立即 flush。 +- Draft 内容/标题编辑:debounced save。 +- 计时器创建时捕获 project path。 +- 计时器触发时再次读取当前 project path;不一致则 abort。 +- reset/switch 时清理 pending draft timer。 + +建议 store 支持: + +```ts +isHydrating: boolean +setDrafts(drafts, { silent: true }) +clearDrafts({ silent: true }) +``` + +## 8. 实施步骤 + +1. 新增 Draft 类型和 Zustand store:`src/stores/draft-store.ts`。 +2. 新增持久化 helper:`src/lib/draft-persist.ts`。 +3. 接入 App 打开项目加载与 reset 清理:`src/App.tsx`、`src/lib/reset-project-state.ts`。 +4. 接入 autosave:`src/lib/auto-save.ts`。 +5. 扩展 activeView 和左侧导航:`src/stores/wiki-store.ts`、`src/components/layout/icon-sidebar.tsx`。 +6. 扩展内容区路由:`src/components/layout/content-area.tsx`。 +7. 抽出 references 组件,并更新 Chat 使用:`src/components/chat/chat-message.tsx` + `src/components/references/...`。 +8. 添加 Chat “设为底稿”按钮,创建 Draft 时复制完整 message 数据。 +9. 新增 Drafts UI:`src/components/drafts/drafts-view.tsx`。 +10. 更新 i18n:`src/i18n/en.json`、`src/i18n/zh.json`。 +11. 添加测试:store、persist、autosave race、i18n parity、build。 + +## 9. Acceptance Criteria + +1. Assistant message 显示“设为底稿”,user message 不显示。 +2. 点击后创建 Draft,正文与原 message 一致。 +3. Draft references 与原 assistant message references 一致。 +4. Draft 保存在 `{project}/.llm-wiki/drafts.json`。 +5. 重启/重新打开项目后 Draft 仍存在。 +6. 切换项目不会显示上一项目 Draft。 +7. pending autosave 不会写入错误项目。 +8. 删除 Draft 后立即从 UI 消失,并在重新打开后仍删除。 +9. 普通 Chat 的发送、streaming、Copy、Save to Wiki、Regenerate、references panel 行为不变。 +10. 中英文 i18n key 保持 parity。 + +## 10. Verification Plan + +建议命令: + +```powershell +npm run test -- src/i18n/i18n-parity.test.ts +npm run test -- src/stores/draft-store.test.ts src/lib/draft-persist.test.ts +npm run build +``` + +实际执行时按 repo test 命名调整。 + +必须覆盖: + +- `createDraftFromMessage` 复制 content/references/source。 +- missing/corrupt drafts file fallback。 +- versioned persistence roundtrip。 +- silent hydration/reset 不触发保存。 +- project path guarded debounce。 +- create/delete immediate flush。 +- Drafts activeView route。 + +手工 smoke: + +1. 打开项目 A。 +2. Chat 生成带 references 的回复。 +3. 设为底稿。 +4. 打开 Drafts,看见正文和 references。 +5. 修改标题/正文,等待自动保存。 +6. 重启或重新打开 A,确认仍存在。 +7. 切换项目 B,确认 A 的 Draft 不出现。 +8. 切回 A,确认 Draft 仍在。 +9. 删除 Draft,重启确认删除持久化。 + +## 11. Risks and Mitigations + +| Risk | Mitigation | +|---|---| +| 自动保存串项目 | timer 捕获 path,触发时比对当前 path | +| hydration 覆盖磁盘 | silent setDrafts / suppressPersistence | +| messageId 不稳定 | messageId 仅 advisory,增加 contentHash/timestamp | +| references 逻辑重复 | 抽共享组件,Draft 不用 lastQueryPages | +| v1 范围膨胀 | 不做模板、导出、长文、diff guard | +| Draft 与 Wiki 概念混淆 | `.llm-wiki` 存储;UI 叫 Drafts,不进文件树 | + +## 12. Available Agent Types Roster + +- `executor`:实现 store/persistence/UI wiring。 +- `test-automator`:补单元和集成测试。 +- `reviewer`:检查回归、项目切换安全、UI 一致性。 +- `verifier`:最终验证验收标准与测试证据。 +- `designer`:如后续需要更精细 UI,可审 Drafts 页面布局。 + +## 13. Follow-up Staffing Guidance + +### `$ralph` 路径 + +适合单主线推进。建议顺序: + +1. executor 实现 store + persist + lifecycle。 +2. executor 实现 sidebar/content/chat action/DraftsView。 +3. test-automator 加测试。 +4. verifier 跑 build/test/smoke。 + +### `$team` 路径 + +适合并行: + +- Lane A executor:`draft-store.ts`、`draft-persist.ts`、lifecycle/autosave。 +- Lane B executor:sidebar/content/chat action/DraftsView。 +- Lane C test-automator:store/persist/autosave/i18n tests。 +- Lane D reviewer/verifier:最终 review。 + +Launch hint: + +```text +$team implement .omx/plans/draft-feature-ui-integration-plan.md +``` + +Team verification path: + +- team 先证明 unit/integration/build 通过; +- Ralph 或 verifier 再做项目切换与 UI smoke 验证。 + +## 14. Goal-Mode Follow-up Suggestions + +- `$ultragoal`:如果希望把 Draft v1 作为 durable goal 分阶段完成。 +- `$performance-goal`:不适用;本任务不是性能优化。 +- `$autoresearch-goal`:不适用;本任务不是研究交付。 + +## 15. Applied Architect/Critic Hardening + +- 明确 `messageId` 不可作为 durable identity。 +- 明确 DraftsView 不依赖 `lastQueryPages`。 +- 明确 project-switch debounced save 是最高风险。 +- 明确 create/delete 需要立即 flush,edit 才 debounce。 +- 明确不把 Draft 暴露为文件树文件夹。 diff --git a/draft-feature-demo.html b/draft-feature-demo.html new file mode 100644 index 00000000..dc0e248b --- /dev/null +++ b/draft-feature-demo.html @@ -0,0 +1,674 @@ + + + + + + LLM Wiki Draft 功能交互演示 + + + +
+
+
+

Draft / 底稿功能交互演示

+

+ 这个 HTML 用一个通用项目资料示例演示:Chat 回答不会自动变成正式材料;只有用户明确点击“设为底稿”后,系统才把这条回复固化为 Draft,并保留引用来源,供后续修改、套模板、导出使用。 +

+
+
演示重点:最小改动 · 最大产品价值
+
+ +
+ + +
+
+
+
Chat
+
普通 Chat 默认行为保持不变;Draft 是用户显式点击后的正式动作。
+
+ References 已随回复保存 +
+ +
+
+
+ 请基于当前项目资料,帮我整理一份“项目阶段性汇报”的初稿,要求包含进展、问题和下一步。 +
+
+ +
+
+
+ 项目阶段性汇报初稿 +

+ 当前项目已经完成资料导入、Wiki 初步编译、核心问题归纳和部分风险识别。已有材料显示,项目的主要价值在于把分散文档转化为可检索、可追溯、可持续更新的知识结构。 +

+

+ 一、阶段进展
+ 1. 已建立资料源目录,并完成多份项目文件的摄入。
+ 2. Wiki 页面已形成初步分类,包括资料摘要、关键概念和阶段性问题。
+ 3. Chat 可以基于 Wiki 内容回答问题,并在回答中显示引用来源。 +

+

+ 二、当前问题
+ 1. 部分材料之间存在口径不一致,需要进入 Review 流程复核。
+ 2. AI 回复如果只停留在聊天记录里,不利于沉淀成正式材料。
+ 3. 正式输出前需要明确底稿版本和引用依据。 +

+

+ 三、下一步建议
+ 优先将高质量 Chat 回复设为底稿,并在底稿基础上进行局部修改、模板匹配和最终导出。 +

+
+ +
+ 📄 wiki/sources/project-overview.md + 📄 wiki/concepts/material-workbench.md + 📄 wiki/synthesis/current-risks.md +
+ +
+ + + + +
+
+
+
+ +
+
输入消息……普通提问仍然走原 Chat 流程,不会自动套模板或自动导出。
+ +
+
+ + +
+ +
+
1. Chat 生成基于 Wiki 检索和 references 回答。
+
2. 用户判断只有满意回复才点击“设为底稿”。
+
3. Draft 固化保存正文、来源会话、引用页面。
+
4. 后续加工局部修改、版本、diff guard 都围绕 Draft。
+
5. 正式输出导出前确认 Draft 版本和来源。
+
+
+ +
✅ 已设为底稿:项目阶段性汇报初稿
+ + + + diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 544efc18..5c1df109 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -1,6 +1,8 @@ use std::fs; use std::io::Read as IoRead; use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant, UNIX_EPOCH}; use calamine::{Reader, open_workbook_auto, Data}; @@ -18,6 +20,151 @@ const MEDIA_EXTS: &[&str] = &[ ]; const LEGACY_DOC_EXTS: &[&str] = &["doc", "xls", "ppt", "pages", "numbers", "key", "epub"]; +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MarkitdownConversion { + pub ok: bool, + pub markdown: Option, + pub error: Option, + pub timed_out: bool, +} + +fn failed_markitdown(error: impl Into) -> MarkitdownConversion { + MarkitdownConversion { + ok: false, + markdown: None, + error: Some(error.into()), + timed_out: false, + } +} + +fn timeout_markitdown(program: &str) -> MarkitdownConversion { + MarkitdownConversion { + ok: false, + markdown: None, + error: Some(format!("MarkItDown conversion timed out via {program}")), + timed_out: true, + } +} + +fn run_markitdown_candidate( + program: &str, + prefix_args: &[&str], + path: &str, + timeout: Duration, +) -> Result { + let script = r#" +import sys + +try: + from markitdown import MarkItDown +except Exception as exc: + sys.stderr.write(f"MARKITDOWN_IMPORT_FAILED: {type(exc).__name__}: {exc}") + sys.exit(3) + +try: + result = MarkItDown().convert(sys.argv[1]) + markdown = getattr(result, "markdown", None) + if markdown is None: + markdown = getattr(result, "text_content", None) + if markdown is None: + markdown = str(result) + sys.stdout.write(str(markdown)) +except Exception as exc: + sys.stderr.write(f"MARKITDOWN_CONVERT_FAILED: {type(exc).__name__}: {exc}") + sys.exit(4) +"#; + + let mut command = Command::new(program); + command + .args(prefix_args) + .arg("-c") + .arg(script) + .arg(path) + .env("PYTHONUTF8", "1") + .env("PYTHONIOENCODING", "utf-8") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command.spawn().map_err(|e| e.to_string())?; + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_status)) => { + let output = child.wait_with_output().map_err(|e| e.to_string())?; + if output.status.success() { + let markdown = String::from_utf8_lossy(&output.stdout).to_string(); + if markdown.trim().is_empty() { + return Ok(failed_markitdown(format!( + "MarkItDown returned empty markdown via {program}" + ))); + } + return Ok(MarkitdownConversion { + ok: true, + markdown: Some(markdown), + error: None, + timed_out: false, + }); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Ok(failed_markitdown(if stderr.is_empty() { + format!("MarkItDown failed via {program} with status {}", output.status) + } else { + stderr + })); + } + Ok(None) => { + if started.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return Ok(timeout_markitdown(program)); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(e) => return Err(e.to_string()), + } + } +} + +fn run_markitdown(path: &str) -> MarkitdownConversion { + let mut candidates: Vec<(String, Vec<&str>)> = Vec::new(); + if let Ok(program) = std::env::var("LLM_WIKI_PYTHON") { + if !program.trim().is_empty() { + candidates.push((program, vec![])); + } + } + candidates.push(("python".to_string(), vec![])); + candidates.push(("python3".to_string(), vec![])); + #[cfg(target_os = "windows")] + candidates.push(("py".to_string(), vec!["-3"])); + + let mut errors: Vec = Vec::new(); + for (program, prefix_args) in candidates { + match run_markitdown_candidate(&program, &prefix_args, path, Duration::from_secs(60)) { + Ok(result) if result.ok => return result, + Ok(result) => { + if result.timed_out { + return result; + } + if let Some(error) = result.error { + errors.push(format!("{program}: {error}")); + } + } + Err(error) => { + errors.push(format!("{program}: {error}")); + } + } + } + + failed_markitdown(if errors.is_empty() { + "No Python executable found for MarkItDown".to_string() + } else { + errors.join(" | ") + }) +} + #[tauri::command] pub async fn read_file(path: String) -> Result { // `spawn_blocking` is REQUIRED, not a perf nicety. The body does @@ -107,6 +254,21 @@ pub async fn preprocess_file(path: String) -> Result { .map_err(|e| format!("preprocess_file blocking task join error: {e}"))? } +#[tauri::command] +pub async fn convert_with_markitdown(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + run_guarded("convert_with_markitdown", || { + let p = Path::new(&path); + if !p.is_file() { + return Ok(failed_markitdown(format!("Source file does not exist: {path}"))); + } + Ok(run_markitdown(&path)) + }) + }) + .await + .map_err(|e| format!("convert_with_markitdown blocking task join error: {e}"))? +} + fn cache_path_for(original: &Path) -> std::path::PathBuf { let parent = original.parent().unwrap_or(Path::new(".")); let cache_dir = parent.join(".cache"); @@ -1301,6 +1463,28 @@ pub async fn file_exists(path: String) -> Result { .map_err(|e| format!("file_exists blocking task join error: {e}"))? } +#[tauri::command] +pub async fn file_modified_ms(path: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + run_guarded("file_modified_ms", || { + let modified = match fs::metadata(&path) { + Ok(metadata) => metadata.modified().ok(), + Err(_) => None, + }; + let Some(modified) = modified else { + return Ok(None); + }; + let millis = modified + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + Ok(Some(millis)) + }) + }) + .await + .map_err(|e| format!("file_modified_ms blocking task join error: {e}"))? +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 22502381..5433856f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,3 +3,4 @@ pub mod extract_images; pub mod fs; pub mod project; pub mod vectorstore; +pub mod web_access; diff --git a/src-tauri/src/commands/web_access.rs b/src-tauri/src/commands/web_access.rs new file mode 100644 index 00000000..d0f5e35b --- /dev/null +++ b/src-tauri/src/commands/web_access.rs @@ -0,0 +1,125 @@ +use serde::Serialize; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartWebAccessProxyResult { + pub ok: bool, + pub message: String, + pub script_path: String, + pub pid: Option, +} + +#[tauri::command] +pub async fn start_web_access_proxy( + script_path: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || start_web_access_proxy_blocking(script_path)) + .await + .map_err(|e| format!("start_web_access_proxy blocking task join error: {e}"))? +} + +fn start_web_access_proxy_blocking( + script_path: Option, +) -> Result { + let script = resolve_script_path(script_path)?; + validate_script_path(&script)?; + let node = which::which("node").map_err(|_| { + "未找到 node 命令。请先安装 Node.js,并确认 node 已加入 PATH。".to_string() + })?; + + let mut command = Command::new(node); + command + .arg(&script) + .current_dir(script.parent().unwrap_or_else(|| Path::new("."))) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + + let child = command + .spawn() + .map_err(|e| format!("启动 WebAccess 检查脚本失败:{e}"))?; + let pid = child.id(); + + Ok(StartWebAccessProxyResult { + ok: true, + message: "已发送 WebAccess 代理启动请求。若 Chrome 弹出远程调试授权,请点击允许,然后重新检查连接。" + .to_string(), + script_path: script.display().to_string(), + pid: Some(pid), + }) +} + +fn resolve_script_path(script_path: Option) -> Result { + let trimmed = script_path.unwrap_or_default().trim().to_string(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(expand_home_vars(&trimmed))); + } + + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .map_err(|_| "无法定位用户目录,请手动填写 WebAccess check-deps.mjs 路径。".to_string())?; + + Ok(PathBuf::from(home) + .join(".agents") + .join("skills") + .join("web-access") + .join("scripts") + .join("check-deps.mjs")) +} + +fn validate_script_path(path: &Path) -> Result<(), String> { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if file_name != "check-deps.mjs" { + return Err("安全限制:只能启动名为 check-deps.mjs 的 WebAccess 检查脚本。".to_string()); + } + + if !path.exists() { + return Err(format!("WebAccess 脚本不存在:{}", path.display())); + } + if !path.is_file() { + return Err(format!("WebAccess 脚本路径不是文件:{}", path.display())); + } + + Ok(()) +} + +fn expand_home_vars(value: &str) -> String { + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default(); + + let expanded = value.replace("%USERPROFILE%", &home).replace("$HOME", &home); + if expanded == "~" { + home + } else if let Some(rest) = expanded.strip_prefix("~/").or_else(|| expanded.strip_prefix("~\\")) { + PathBuf::from(home).join(rest).display().to_string() + } else { + expanded + } +} + +#[cfg(test)] +mod tests { + use super::expand_home_vars; + + #[test] + fn expands_common_home_placeholders() { + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_default(); + let expanded = expand_home_vars("%USERPROFILE%/.agents/skills/web-access/scripts/check-deps.mjs"); + assert!(expanded.contains(&home)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 207cee6a..f9763675 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,10 +82,12 @@ pub fn run() { commands::fs::copy_file, commands::fs::copy_directory, commands::fs::preprocess_file, + commands::fs::convert_with_markitdown, commands::fs::delete_file, commands::fs::find_related_wiki_pages, commands::fs::create_directory, commands::fs::file_exists, + commands::fs::file_modified_ms, commands::fs::read_file_as_base64, commands::project::create_project, commands::project::open_project, @@ -103,6 +105,7 @@ pub fn run() { commands::claude_cli::claude_cli_detect, commands::claude_cli::claude_cli_spawn, commands::claude_cli::claude_cli_kill, + commands::web_access::start_web_access_proxy, commands::extract_images::extract_pdf_images_cmd, commands::extract_images::extract_office_images_cmd, commands::extract_images::extract_and_save_pdf_images_cmd, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2cd8b6ab..569b57ae 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "com.llmwiki.app", "build": { "beforeDevCommand": "npm run dev", - "devUrl": "http://localhost:1420", + "devUrl": "http://127.0.0.1:1420", "beforeBuildCommand": "npm run build", "frontendDist": "../dist" }, diff --git a/src/App.tsx b/src/App.tsx index f3a6fdc6..c6ada295 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { useWikiStore } from "@/stores/wiki-store" import { useReviewStore } from "@/stores/review-store" import { useChatStore } from "@/stores/chat-store" import { listDirectory, openProject } from "@/commands/fs" -import { getLastProject, getRecentProjects, saveLastProject, loadLlmConfig, loadLanguage, loadSearchApiConfig, loadEmbeddingConfig, loadMultimodalConfig, loadOutputLanguage, loadProviderConfigs, loadActivePresetId, loadProxyConfig } from "@/lib/project-store" +import { getLastProject, getRecentProjects, saveLastProject, loadLlmConfig, loadLanguage, loadSearchApiConfig, loadWebAccessConfig, loadEmbeddingConfig, loadMultimodalConfig, loadOutputLanguage, loadProviderConfigs, loadActivePresetId, loadProxyConfig } from "@/lib/project-store" import { loadReviewItems, loadChatHistory } from "@/lib/persist" import { setupAutoSave } from "@/lib/auto-save" import { startClipWatcher } from "@/lib/clip-watcher" @@ -210,6 +210,10 @@ function App() { if (savedSearchConfig) { useWikiStore.getState().setSearchApiConfig(savedSearchConfig) } + const savedWebAccessConfig = await loadWebAccessConfig() + if (savedWebAccessConfig) { + useWikiStore.getState().setWebAccessConfig(savedWebAccessConfig) + } const savedEmbeddingConfig = await loadEmbeddingConfig() if (savedEmbeddingConfig) { useWikiStore.getState().setEmbeddingConfig(savedEmbeddingConfig) @@ -328,7 +332,7 @@ function App() { const validated = await openProject(proj.path) await handleProjectOpened(validated) } catch (err) { - window.alert(`Failed to open project: ${err}`) + window.alert(`打开项目失败:${err}`) } } @@ -336,14 +340,14 @@ function App() { const selected = await open({ directory: true, multiple: false, - title: "Open Wiki Project", + title: "打开 Wiki 项目", }) if (!selected) return try { const proj = await openProject(selected) await handleProjectOpened(proj) } catch (err) { - window.alert(`Failed to open project: ${err}`) + window.alert(`打开项目失败:${err}`) } } @@ -360,7 +364,7 @@ function App() { if (loading) { return (
- Loading... + 正在加载...
) } diff --git a/src/commands/fs.ts b/src/commands/fs.ts index bc0ac9e5..e5bed4dc 100644 --- a/src/commands/fs.ts +++ b/src/commands/fs.ts @@ -31,6 +31,19 @@ export async function preprocessFile(path: string): Promise { return invoke("preprocess_file", { path }) } +export interface MarkitdownConversion { + ok: boolean + markdown?: string | null + error?: string | null + timedOut: boolean +} + +export async function convertWithMarkitdown( + path: string, +): Promise { + return invoke("convert_with_markitdown", { path }) +} + export async function deleteFile(path: string): Promise { return invoke("delete_file", { path }) } @@ -50,6 +63,10 @@ export async function fileExists(path: string): Promise { return invoke("file_exists", { path }) } +export async function fileModifiedMs(path: string): Promise { + return invoke("file_modified_ms", { path }) +} + /** Mirror of `commands::fs::FileBase64` (Rust side). */ export interface FileBase64 { base64: string diff --git a/src/commands/web-access.ts b/src/commands/web-access.ts new file mode 100644 index 00000000..b3a5029c --- /dev/null +++ b/src/commands/web-access.ts @@ -0,0 +1,14 @@ +import { invoke } from "@tauri-apps/api/core" + +export interface StartWebAccessProxyResult { + ok: boolean + message: string + scriptPath: string + pid?: number | null +} + +export async function startWebAccessProxy(scriptPath?: string): Promise { + return invoke("start_web_access_proxy", { + scriptPath: scriptPath?.trim() || null, + }) +} diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index a8ade743..508d7fad 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -54,7 +54,7 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder }: ChatInpu dir="auto" onChange={handleInput} onKeyDown={handleKeyDown} - placeholder={placeholder ?? "Type a message... (Enter to send, Shift+Enter for newline)"} + placeholder={placeholder ?? "输入消息...(Enter 发送,Shift+Enter 换行)"} disabled={isStreaming} rows={1} className="flex-1 resize-none rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50" @@ -66,7 +66,7 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder }: ChatInpu size="icon" onClick={onStop} className="shrink-0" - title="Stop generation" + title="停止生成" > @@ -76,7 +76,7 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder }: ChatInpu onClick={handleSend} disabled={!value.trim()} className="shrink-0" - title="Send message" + title="发送消息" > diff --git a/src/components/chat/chat-message.tsx b/src/components/chat/chat-message.tsx index ae23d8eb..64c1cf0f 100644 --- a/src/components/chat/chat-message.tsx +++ b/src/components/chat/chat-message.tsx @@ -111,9 +111,9 @@ export function ChatMessage({ message, isLastAssistant, onRegenerate }: ChatMess type="button" onClick={onRegenerate} className="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors" - title="Regenerate this response" + title="重新生成此回复" > - Regenerate + 重新生成 )} @@ -144,10 +144,10 @@ function CopyButton({ content }: { content: string }) { type="button" onClick={handleCopy} className="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors" - title="Copy to clipboard" + title="复制到剪贴板" > {copied ? : } - {copied ? "Copied!" : "Copy"} + {copied ? "已复制!" : "复制"} ) } @@ -168,7 +168,7 @@ function SaveToWikiButton({ content, visible }: { content: string; visible: bool // (so CJK titles don't collapse to empty) and the HHMMSS // timestamp suffix guarantees same-day saves stay distinct. const firstLine = content.split("\n")[0].replace(/^#+\s*/, "").trim() - const title = firstLine.slice(0, 60) || "Saved Query" + const title = firstLine.slice(0, 60) || "已保存查询" const { date, fileName } = makeQueryFileName(title) const filePath = `${pp}/wiki/queries/${fileName}` @@ -256,10 +256,10 @@ function SaveToWikiButton({ content, visible }: { content: string; visible: bool onClick={handleSave} disabled={saving} className="self-start inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors" - title="Save to wiki" + title="保存到 Wiki" > - {saved ? "Saved!" : saving ? "Saving..." : "Save to Wiki"} + {saved ? "已保存!" : saving ? "保存中..." : "保存到 Wiki"} ) } @@ -442,7 +442,7 @@ function CitedReferencesPanel({ content, savedReferences }: { content: string; s className="flex w-full items-center gap-1.5 px-2 py-1 text-muted-foreground hover:text-foreground transition-colors" > - References ({citedPages.length}) + 引用({citedPages.length}) {hasMore && ( expanded ? @@ -511,7 +511,7 @@ function CitedReferencesPanel({ content, savedReferences }: { content: string; s type="button" onClick={() => handleJumpToImageSource(info.firstUrl!, page.path)} className="flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[10px] text-blue-600 hover:bg-blue-100/40 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors" - title={`Open original document at first image (${info.count} image${info.count === 1 ? "" : "s"} on this page)`} + title={`打开第一张图片所在的原始文档(此页面共 ${info.count} 张图片)`} > {info.count} @@ -534,7 +534,7 @@ function CitedReferencesPanel({ content, savedReferences }: { content: string; s onClick={() => setExpanded(true)} className="w-full text-center text-[10px] text-muted-foreground hover:text-primary pt-0.5" > - +{citedPages.length - MAX_COLLAPSED} more... + 还有 {citedPages.length - MAX_COLLAPSED} 项... )} @@ -774,8 +774,8 @@ function StreamingThinkingBlock({ content }: { content: string }) {
💭 - Thinking... - {lines.length} lines + 思考中... + {lines.length} 行
{visibleLines.map((line, i) => ( @@ -806,7 +806,7 @@ function ThinkingBlock({ content }: { content: string }) { className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-xs text-amber-700 dark:text-amber-400 hover:bg-amber-100/50 dark:hover:bg-amber-900/20 transition-colors" > 💭 - Thought for {lines.length} lines + 已思考 {lines.length} 行 {expanded ? "▼" : "▶"} diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx index 65ef8ad5..df23dca3 100644 --- a/src/components/chat/chat-panel.tsx +++ b/src/components/chat/chat-panel.tsx @@ -54,14 +54,14 @@ function ConversationSidebar() { onClick={() => createConversation()} > - New Chat + 新对话
{sorted.length === 0 ? (

- No conversations yet + 暂无对话

) : ( sorted.map((conv) => { @@ -105,7 +105,7 @@ function ConversationSidebar() { {msgCount > 0 && ( <> · - {msgCount} msgs + {msgCount} 条消息 )}
@@ -416,7 +416,7 @@ export function ChatPanel() { // save-worthy detection removed — user has direct "Save to Wiki" button on each message }, onError: (err) => { - finalizeStream(`Error: ${err.message}`, undefined) + finalizeStream(`错误:${err.message}`, undefined) abortRef.current = null }, }, @@ -484,8 +484,8 @@ export function ChatPanel() {
-

Start a new conversation

-

Click "New Chat" to begin

+

开始新对话

+

点击“新对话”开始

) : ( @@ -522,7 +522,7 @@ export function ChatPanel() { className="w-full gap-2" > - Write to Wiki + 写入 Wiki
)} @@ -535,8 +535,8 @@ export function ChatPanel() { isStreaming={isStreaming} placeholder={ mode === "ingest" - ? "Discuss the source or ask follow-up questions..." - : "Type a message..." + ? "讨论资料内容或继续追问..." + : "输入消息..." } /> diff --git a/src/components/editor/file-preview.tsx b/src/components/editor/file-preview.tsx index da8e5296..d8deda52 100644 --- a/src/components/editor/file-preview.tsx +++ b/src/components/editor/file-preview.tsx @@ -41,13 +41,13 @@ export function FilePreview({ filePath, textContent }: FilePreviewProps) { case "audio": return case "pdf": - return + return case "code": return case "data": return case "text": - return + return case "document": return default: @@ -279,7 +279,7 @@ function BinaryPlaceholder({

{filePath}

- Preview not available for this file type + 暂不支持预览此文件类型

) diff --git a/src/components/editor/frontmatter-panel.tsx b/src/components/editor/frontmatter-panel.tsx index 52951177..4f54009c 100644 --- a/src/components/editor/frontmatter-panel.tsx +++ b/src/components/editor/frontmatter-panel.tsx @@ -133,7 +133,7 @@ export function FrontmatterPanel({ data }: FrontmatterPanelProps) { {origin && (
- Origin: + 来源: {origin}
)} @@ -143,7 +143,7 @@ export function FrontmatterPanel({ data }: FrontmatterPanelProps) {
- Sources + 资料来源 ({sources.length})
@@ -170,7 +170,7 @@ export function FrontmatterPanel({ data }: FrontmatterPanelProps) {
- Related + 相关页面 ({related.length})
@@ -195,7 +195,7 @@ export function FrontmatterPanel({ data }: FrontmatterPanelProps) { {/* Extras (any other key/values we didn't surface above) ──── */} {extras.length > 0 && (
-
More
+
更多
{extras.map(([k, v]) => (
@@ -233,7 +233,7 @@ function SourceCard({ {mode === "read" ? ( diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx index 0abd9c21..12eaddbc 100644 --- a/src/components/error-boundary.tsx +++ b/src/components/error-boundary.tsx @@ -29,13 +29,13 @@ export class ErrorBoundary extends Component { if (this.props.fallback) return this.props.fallback return (
-

Something went wrong

+

出错了

{this.state.error?.message}

) diff --git a/src/components/graph/graph-view.tsx b/src/components/graph/graph-view.tsx index 92fdda7a..632a229b 100644 --- a/src/components/graph/graph-view.tsx +++ b/src/components/graph/graph-view.tsx @@ -29,14 +29,14 @@ const NODE_TYPE_COLORS: Record = { } const NODE_TYPE_LABELS: Record = { - entity: "Entity", - concept: "Concept", - source: "Source", - query: "Query", - synthesis: "Synthesis", - overview: "Overview", - comparison: "Comparison", - other: "Other", + entity: "实体", + concept: "概念", + source: "来源", + query: "查询", + synthesis: "综合", + overview: "概览", + comparison: "对比", + other: "其他", } const COMMUNITY_COLORS = [ @@ -370,7 +370,7 @@ export function GraphView() { setKnowledgeGaps(detectKnowledgeGaps(result.nodes, result.edges, result.communities)) lastLoadedVersion.current = useWikiStore.getState().dataVersion } catch (err) { - const message = err instanceof Error ? err.message : "Failed to build graph" + const message = err instanceof Error ? err.message : "构建关系图失败" setError(message) } finally { setLoading(false) @@ -523,7 +523,7 @@ export function GraphView() { return (
-

Open a project to view the graph

+

打开项目后查看关系图

) } @@ -532,7 +532,7 @@ export function GraphView() { return (
-

Building graph...

+

正在构建关系图...

) } @@ -542,7 +542,7 @@ export function GraphView() {

{error}

- +
) } @@ -551,8 +551,8 @@ export function GraphView() { return (
-

No pages yet

-

Import sources to start building the knowledge graph

+

暂无页面

+

导入资料后开始构建知识图谱

) } @@ -564,14 +564,14 @@ export function GraphView() {
- Knowledge Graph + 知识图谱
- {filteredGraph.nodes.length}/{nodes.length} pages - {filteredGraph.edges.length}/{edges.length} links + {filteredGraph.nodes.length}/{nodes.length} 个页面 + {filteredGraph.edges.length}/{edges.length} 条链接 {hiddenCount > 0 && ( - {hiddenCount} hidden + 已隐藏 {hiddenCount} )}
@@ -584,7 +584,7 @@ export function GraphView() { className="text-xs gap-1 h-7" > - Filter + 筛选 {filtersActive && ( )} {(surprisingConns.filter((c) => !dismissedInsights.has(c.key)).length > 0 || knowledgeGaps.length > 0) && (
-
Quick filters
+
快速筛选
-
Max links
+
最大链接数
- Hide nodes above this link count + 隐藏超过该链接数的节点
-
Node types
+
节点类型
{Object.entries(NODE_TYPE_LABELS) .filter(([type]) => (typeCounts[type] ?? 0) > 0) @@ -801,7 +801,7 @@ export function GraphView() { {filters.hiddenNodeIds.size > 0 && (
-
Hidden nodes
+
已隐藏节点
{[...filters.hiddenNodeIds].map((nodeId) => { const node = nodes.find((n) => n.id === nodeId) @@ -817,7 +817,7 @@ export function GraphView() { return { ...prev, hiddenNodeIds: next } })} > - Show + 显示
) @@ -827,7 +827,7 @@ export function GraphView() { )}
- Showing {filteredGraph.nodes.length} of {nodes.length} pages and {filteredGraph.edges.length} of {edges.length} links. + 显示 {filteredGraph.nodes.length}/{nodes.length} 个页面,{filteredGraph.edges.length}/{edges.length} 条链接。
@@ -841,7 +841,7 @@ export function GraphView() { >
{contextNode.label}
-
{contextNode.linkCount} links
+
{contextNode.linkCount} 条链接
)} @@ -864,7 +864,7 @@ export function GraphView() {
- {colorMode === "type" ? "Node Types" : "Communities"} + {colorMode === "type" ? "节点类型" : "社区"}
{colorMode === "type" && filters.hiddenTypes.size > 0 && ( @@ -873,9 +873,9 @@ export function GraphView() { size="sm" className="h-6 text-[10px] px-1" onClick={() => setFilters((prev) => ({ ...prev, hiddenTypes: new Set() }))} - title="Show all types" + title="显示所有类型" > - Show all + 全部显示 )} @@ -914,7 +914,7 @@ export function GraphView() { return { ...prev, hiddenTypes: next } }) }} - title="Double-click to toggle visibility" + title="双击切换可见性" > {typeCounts[type]} - {isHidden && hidden} + {isHidden && 已隐藏}
) })} @@ -949,11 +949,11 @@ export function GraphView() { }} /> - {c.topNodes[0] ?? `Cluster ${c.id}`} + {c.topNodes[0] ?? `聚类 ${c.id}`} {c.nodeCount} {c.cohesion < 0.15 && c.nodeCount >= 3 && ( - ! + ! )}
))} @@ -971,7 +971,7 @@ export function GraphView() {
- Insights + 洞察
) @@ -1083,7 +1083,7 @@ export function GraphView() {
- Deep Research + 深度研究
{!researchDialog.loading && (
) : (
- +
- +
{researchDialog.queries.map((q, idx) => (
diff --git a/src/components/layout/activity-panel.tsx b/src/components/layout/activity-panel.tsx index b59b674e..dea6f069 100644 --- a/src/components/layout/activity-panel.tsx +++ b/src/components/layout/activity-panel.tsx @@ -72,9 +72,9 @@ export function ActivityPanel() { const activeCount = queueSummary.pending + queueSummary.processing if (activeCount === 0) return if (!window.confirm( - `Cancel all ${activeCount} queued/processing task${activeCount > 1 ? "s" : ""}? ` + - `Partial files from the in-progress task will be removed. ` + - `Failed tasks will be kept so you can retry them.`, + `取消全部 ${activeCount} 个排队/处理中的任务?` + + `正在处理任务的临时文件会被移除。` + + `失败任务会保留,之后仍可重试。`, )) return cancelAllTasks() }, [project, queueSummary.pending, queueSummary.processing]) @@ -98,14 +98,14 @@ export function ActivityPanel() { let statusText = "" if (queueSummary.processing > 0 || queueSummary.pending > 0) { const done = queueSummary.total - queueSummary.pending - queueSummary.processing - statusText = `Queue: ${done}/${queueSummary.total}` - if (queueSummary.failed > 0) statusText += ` (${queueSummary.failed} failed)` + statusText = `队列:${done}/${queueSummary.total}` + if (queueSummary.failed > 0) statusText += `(${queueSummary.failed} 个失败)` } else if (runningCount > 0) { - statusText = `Processing: ${latestItem?.title ?? "..."}` + statusText = `处理中:${latestItem?.title ?? "..."}` } else if (queueSummary.failed > 0) { - statusText = `${queueSummary.failed} failed task${queueSummary.failed > 1 ? "s" : ""}` + statusText = `${queueSummary.failed} 个失败任务` } else { - statusText = `Done: ${latestItem?.title ?? "All tasks complete"}` + statusText = `完成:${latestItem?.title ?? "所有任务已完成"}` } const isActive = runningCount > 0 || queueSummary.processing > 0 || queueSummary.pending > 0 @@ -137,17 +137,17 @@ export function ActivityPanel() { {hasQueue && (queueSummary.processing > 0 || queueSummary.pending > 0) && (
- Ingest Queue + 提取队列 - {queueSummary.total - queueSummary.pending - queueSummary.processing}/{queueSummary.total} complete + 已完成 {queueSummary.total - queueSummary.pending - queueSummary.processing}/{queueSummary.total} {queueSummary.pending + queueSummary.processing >= 2 && ( )}
@@ -190,7 +190,7 @@ export function ActivityPanel() { onClick={clearDone} className="w-full px-3 py-1 text-center text-[10px] text-muted-foreground hover:underline" > - Clear completed + 清除已完成 )}
@@ -224,7 +224,7 @@ function QueueRow({ task, onRetry, onCancel }: { task: IngestTask; onRetry: (id: @@ -233,7 +233,7 @@ function QueueRow({ task, onRetry, onCancel }: { task: IngestTask; onRetry: (id: @@ -273,7 +273,7 @@ function ActivityRow({ item, onCancel }: { item: ActivityItem; onCancel?: () => diff --git a/src/components/layout/icon-sidebar.tsx b/src/components/layout/icon-sidebar.tsx index a95f7546..020dc39d 100644 --- a/src/components/layout/icon-sidebar.tsx +++ b/src/components/layout/icon-sidebar.tsx @@ -113,7 +113,7 @@ export function IconSidebar({ onSwitchProject }: IconSidebarProps) { )} - Deep Research + 深度研究
{/* Bottom: daemon status + settings + switch project */} @@ -131,10 +131,10 @@ export function IconSidebar({ onSwitchProject }: IconSidebarProps) { /> - {daemonStatus === "running" && "Clip server running"} - {daemonStatus === "starting" && "Clip server starting..."} - {daemonStatus === "port_conflict" && "Port 19827 is occupied. Web Clipper unavailable."} - {daemonStatus === "error" && "Clip server error. Restarting..."} + {daemonStatus === "running" && "剪藏服务运行中"} + {daemonStatus === "starting" && "剪藏服务启动中..."} + {daemonStatus === "port_conflict" && "端口 19827 已被占用,网页剪藏不可用。"} + {daemonStatus === "error" && "剪藏服务出错,正在重启..."} diff --git a/src/components/layout/knowledge-tree.tsx b/src/components/layout/knowledge-tree.tsx index 54fc694f..0a371a7b 100644 --- a/src/components/layout/knowledge-tree.tsx +++ b/src/components/layout/knowledge-tree.tsx @@ -19,16 +19,16 @@ interface WikiPageInfo { } const TYPE_CONFIG: Record = { - overview: { icon: Layout, label: "Overview", color: "text-yellow-500", order: 0 }, - entity: { icon: Users, label: "Entities", color: "text-blue-500", order: 1 }, - concept: { icon: Lightbulb, label: "Concepts", color: "text-purple-500", order: 2 }, - source: { icon: BookOpen, label: "Sources", color: "text-orange-500", order: 3 }, - synthesis: { icon: GitMerge, label: "Synthesis", color: "text-red-500", order: 4 }, - comparison: { icon: BarChart3, label: "Comparisons", color: "text-emerald-500",order: 5 }, - query: { icon: HelpCircle, label: "Queries", color: "text-green-500", order: 6 }, + overview: { icon: Layout, label: "概览", color: "text-yellow-500", order: 0 }, + entity: { icon: Users, label: "实体", color: "text-blue-500", order: 1 }, + concept: { icon: Lightbulb, label: "概念", color: "text-purple-500", order: 2 }, + source: { icon: BookOpen, label: "来源", color: "text-orange-500", order: 3 }, + synthesis: { icon: GitMerge, label: "综合", color: "text-red-500", order: 4 }, + comparison: { icon: BarChart3, label: "对比", color: "text-emerald-500",order: 5 }, + query: { icon: HelpCircle, label: "查询", color: "text-green-500", order: 6 }, } -const DEFAULT_CONFIG = { icon: FileText, label: "Other", color: "text-muted-foreground", order: 99 } +const DEFAULT_CONFIG = { icon: FileText, label: "其他", color: "text-muted-foreground", order: 99 } export function KnowledgeTree() { const project = useWikiStore((s) => s.project) @@ -105,7 +105,7 @@ export function KnowledgeTree() { if (selectedFile === pagePath) setSelectedFile(null) } catch (err) { console.error("[KnowledgeTree] delete failed:", err) - window.alert(`Failed to delete: ${err}`) + window.alert(`删除失败:${err}`) } finally { setDeletingPath(null) } @@ -116,7 +116,7 @@ export function KnowledgeTree() { if (!project) { return (
- No project open + 未打开项目
) } @@ -154,7 +154,7 @@ export function KnowledgeTree() { {sortedGroups.length === 0 && (
- No wiki pages yet. Import sources to get started. + 暂无 Wiki 页面。导入资料后开始构建。
)} @@ -263,7 +263,7 @@ function RawSourcesSection() { )} - Raw Sources + 原始资料 {sources.length} {expanded && ( @@ -364,7 +364,7 @@ function DeleteButton({ size="icon" className={`h-6 w-6 shrink-0 cursor-default ${className}`} disabled - title={`Deleting ${name}…`} + title={`正在删除 ${name}...`} > @@ -380,10 +380,10 @@ function DeleteButton({ e.stopPropagation() onClick() }} - title={`Click again to delete ${name} and clean up references`} + title={`再次点击删除 ${name} 并清理引用`} > - Confirm + 确认 ) } @@ -396,7 +396,7 @@ function DeleteButton({ e.stopPropagation() onClick() }} - title={`Delete ${name} (and clean up references)`} + title={`删除 ${name}(并清理引用)`} > diff --git a/src/components/layout/research-panel.tsx b/src/components/layout/research-panel.tsx index e3542124..df3961dd 100644 --- a/src/components/layout/research-panel.tsx +++ b/src/components/layout/research-panel.tsx @@ -36,7 +36,7 @@ export function ResearchPanel() { const topic = inputValue.trim() if (!topic || !project) return if (searchApiConfig.provider === "none" || !searchApiConfig.apiKey) { - window.alert("Web Search not configured. Go to Settings → Web Search to add a Tavily or SerpApi API key.") + window.alert("尚未配置网页搜索。请到「设置 > 网页搜索」添加 Tavily 或 SerpApi API Key。") return } queueResearch(normalizePath(project.path), topic, llmConfig, searchApiConfig) @@ -48,10 +48,10 @@ export function ResearchPanel() {
- Deep Research + 深度研究 {(running.length > 0 || queued.length > 0) && ( - {running.length} active{queued.length > 0 ? `, ${queued.length} queued` : ""} + {running.length} 个进行中{queued.length > 0 ? `,${queued.length} 个排队中` : ""} )}
@@ -73,7 +73,7 @@ export function ResearchPanel() { if (isImeComposing(e)) return if (e.key === "Enter") handleStartResearch() }} - placeholder="Enter a research topic..." + placeholder="输入研究主题..." className="flex-1 rounded border bg-background px-2 py-1 text-xs outline-none placeholder:text-muted-foreground focus:ring-1 focus:ring-ring" /> {!thinkingCollapsed && (
@@ -232,12 +232,12 @@ function ResearchTaskCard({ task, onRemove }: { task: ResearchTask; onRemove: (i }[task.status] const statusText = { - queued: "Queued", - searching: "Searching web...", - synthesizing: "Synthesizing...", - saving: "Saving to wiki...", - done: task.savedPath ? "Saved" : "Done", - error: "Failed", + queued: "排队中", + searching: "正在搜索网页...", + synthesizing: "正在综合...", + saving: "正在保存到 Wiki...", + done: task.savedPath ? "已保存" : "已完成", + error: "失败", }[task.status] async function handleOpenSaved() { @@ -281,7 +281,7 @@ function ResearchTaskCard({ task, onRemove }: { task: ResearchTask; onRemove: (i {task.webResults.length > 0 && (
- Sources ({task.webResults.length}) + 来源({task.webResults.length})
{task.webResults.map((r, i) => ( @@ -307,7 +307,7 @@ function ResearchTaskCard({ task, onRemove }: { task: ResearchTask; onRemove: (i {task.savedPath && ( )} {(task.status === "done" || task.status === "error") && ( @@ -318,7 +318,7 @@ function ResearchTaskCard({ task, onRemove }: { task: ResearchTask; onRemove: (i onClick={() => onRemove(task.id)} > - Remove + 移除 )}
diff --git a/src/components/lint/lint-view.tsx b/src/components/lint/lint-view.tsx index cac42681..ba8cba3d 100644 --- a/src/components/lint/lint-view.tsx +++ b/src/components/lint/lint-view.tsx @@ -20,10 +20,10 @@ import { readFile, writeFile, listDirectory } from "@/commands/fs" import { normalizePath } from "@/lib/path-utils" const typeConfig: Record = { - orphan: { icon: Unlink, label: "Orphan Page" }, - "broken-link": { icon: Link2Off, label: "Broken Link" }, - "no-outlinks": { icon: ArrowUpRight, label: "No Outbound Links" }, - semantic: { icon: BrainCircuit, label: "Semantic Issue" }, + orphan: { icon: Unlink, label: "孤立页面" }, + "broken-link": { icon: Link2Off, label: "失效链接" }, + "no-outlinks": { icon: ArrowUpRight, label: "没有出站链接" }, + semantic: { icon: BrainCircuit, label: "语义问题" }, } export function LintView() { @@ -83,7 +83,7 @@ export function LintView() { } } setSelectedFile(candidates[0]) - setFileContent(`Unable to load: ${page}`) + setFileContent(`无法加载:${page}`) } async function handleFix(result: LintResult, index: number) { @@ -116,13 +116,13 @@ export function LintView() { const pagePath = `${pp}/wiki/${result.page}` useReviewStore.getState().addItem({ type: "confirm", - title: `Fix broken link in ${result.page}`, + title: `修复 ${result.page} 中的失效链接`, description: result.detail, affectedPages: [result.page], options: [ - { label: "Open & Edit", action: `open:${result.page}` }, - { label: "Delete Page", action: `delete:${pagePath}` }, - { label: "Skip", action: "Skip" }, + { label: "打开并编辑", action: `open:${result.page}` }, + { label: "删除页面", action: `delete:${pagePath}` }, + { label: "跳过", action: "Skip" }, ], }) setResults((prev) => prev.filter((_, i) => i !== index)) @@ -133,12 +133,12 @@ export function LintView() { // Send to Review — user should add links manually useReviewStore.getState().addItem({ type: "suggestion", - title: `Add cross-references to ${result.page}`, - description: "This page has no outbound [[wikilinks]]. Consider adding cross-references to related entities and concepts.", + title: `为 ${result.page} 添加交叉引用`, + description: "此页面没有出站 [[wikilinks]]。建议添加指向相关实体和概念的交叉引用。", affectedPages: [result.page], options: [ - { label: "Open & Edit", action: `open:${result.page}` }, - { label: "Skip", action: "Skip" }, + { label: "打开并编辑", action: `open:${result.page}` }, + { label: "跳过", action: "Skip" }, ], }) setResults((prev) => prev.filter((_, i) => i !== index)) @@ -153,8 +153,8 @@ export function LintView() { description: result.detail, affectedPages: result.affectedPages ?? [result.page], options: [ - { label: "Open & Edit", action: `open:${result.page}` }, - { label: "Skip", action: "Skip" }, + { label: "打开并编辑", action: `open:${result.page}` }, + { label: "跳过", action: "Skip" }, ], }) setResults((prev) => prev.filter((_, i) => i !== index)) @@ -177,7 +177,7 @@ export function LintView() { if (!project) return const pp = normalizePath(project.path) const pagePath = `${pp}/wiki/${result.page}` - const confirmed = window.confirm(`Delete orphan page "${result.page}"?`) + const confirmed = window.confirm(`删除孤立页面“${result.page}”?`) if (!confirmed) return try { @@ -207,10 +207,10 @@ export function LintView() {
-

Wiki Lint

+

Wiki 检查

{hasRun && results.length > 0 && ( - {results.length} issue{results.length !== 1 ? "s" : ""} + {results.length} 个问题 )}
@@ -222,7 +222,7 @@ export function LintView() { checked={runSemantic} onChange={(e) => setRunSemantic(e.target.checked)} /> - Semantic (LLM) + 语义检查(LLM)
@@ -239,19 +239,19 @@ export function LintView() { {!hasRun ? (
-

Run lint to check wiki health

-

Checks for orphan pages, broken links, and more

+

运行检查以评估 Wiki 健康度

+

检查孤立页面、失效链接等问题

) : results.length === 0 ? (
-

All clear!

-

No issues found.

+

一切正常!

+

未发现问题。

) : (
{warnings.length > 0 && ( - + )} {warnings.map((result, i) => ( ))} {infos.length > 0 && ( - + )} {infos.map((result, i) => { const realIndex = warnings.length + i @@ -363,7 +363,7 @@ function LintCard({ className="h-6 text-xs gap-1" onClick={() => onOpenPage(result.page)} > - Open + 打开 {onDelete && ( )}
diff --git a/src/components/mermaid-diagram.tsx b/src/components/mermaid-diagram.tsx index a3a49a08..f35fce71 100644 --- a/src/components/mermaid-diagram.tsx +++ b/src/components/mermaid-diagram.tsx @@ -82,7 +82,7 @@ export function MermaidDiagram({ code }: MermaidDiagramProps) { if (error) { return (
-

Mermaid syntax error

+

Mermaid 语法错误

{error}
) @@ -101,7 +101,7 @@ export function MermaidDiagram({ code }: MermaidDiagramProps) { type="button" onClick={() => setExpanded(true)} className="absolute top-2 right-2 z-10 rounded-md bg-background/80 px-1.5 py-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/diagram:opacity-100" - title="Enlarge diagram" + title="放大图表" > @@ -113,11 +113,11 @@ export function MermaidDiagram({ code }: MermaidDiagramProps) { ) : visible ? (
- Rendering diagram... + 正在渲染图表...
) : (
- Diagram + 图表
)}
diff --git a/src/components/project/create-project-dialog.tsx b/src/components/project/create-project-dialog.tsx index 85859489..5d4673a6 100644 --- a/src/components/project/create-project-dialog.tsx +++ b/src/components/project/create-project-dialog.tsx @@ -40,7 +40,7 @@ export function CreateProjectDialog({ open: isOpen, onOpenChange, onCreated }: C const selected = await open({ directory: true, multiple: false, - title: "Select Parent Directory", + title: "选择父目录", }) if (selected) { setPath(selected) @@ -49,11 +49,11 @@ export function CreateProjectDialog({ open: isOpen, onOpenChange, onCreated }: C async function handleCreate() { if (!name.trim() || !path.trim()) { - setError("Name and path are required") + setError("请填写项目名称和路径") return } if (!language) { - setError("Please pick an AI output language") + setError("请选择 AI 输出语言") return } setCreating(true) @@ -94,20 +94,20 @@ export function CreateProjectDialog({ open: isOpen, onOpenChange, onCreated }: C - Create New Wiki Project + 新建 Wiki 项目
- - setName(e.target.value)} placeholder="my-research-wiki" /> + + setName(e.target.value)} placeholder="我的研究 Wiki" />
- +

- All AI-generated content (wiki pages, chat replies, research - output) will use this language. You can change it later in - Settings → Output. + 所有 AI 生成内容(Wiki 页面、聊天回复、研究结果)都会使用此语言。 + 之后可在「设置 > 输出偏好」中修改。

- +
- setPath(e.target.value)} placeholder="/Users/you/projects" className="flex-1" /> + setPath(e.target.value)} placeholder="请选择父目录" className="flex-1" /> @@ -152,8 +151,8 @@ export function CreateProjectDialog({ open: isOpen, onOpenChange, onCreated }: C {error &&

{error}

}
- - + +
diff --git a/src/components/review/review-view.tsx b/src/components/review/review-view.tsx index 343f6ab4..a51d35b3 100644 --- a/src/components/review/review-view.tsx +++ b/src/components/review/review-view.tsx @@ -18,11 +18,11 @@ import { writeFile, readFile, listDirectory, deleteFile } from "@/commands/fs" import { normalizePath } from "@/lib/path-utils" const typeConfig: Record = { - contradiction: { icon: AlertTriangle, label: "Contradiction", color: "text-amber-500" }, - duplicate: { icon: Copy, label: "Possible Duplicate", color: "text-blue-500" }, - "missing-page": { icon: FileQuestion, label: "Missing Page", color: "text-purple-500" }, - confirm: { icon: MessageSquare, label: "Needs Confirmation", color: "text-foreground" }, - suggestion: { icon: Lightbulb, label: "Suggestion", color: "text-emerald-500" }, + contradiction: { icon: AlertTriangle, label: "矛盾", color: "text-amber-500" }, + duplicate: { icon: Copy, label: "可能重复", color: "text-blue-500" }, + "missing-page": { icon: FileQuestion, label: "缺失页面", color: "text-purple-500" }, + confirm: { icon: MessageSquare, label: "需要确认", color: "text-foreground" }, + suggestion: { icon: Lightbulb, label: "建议", color: "text-emerald-500" }, } export function ReviewView() { @@ -39,7 +39,7 @@ export function ReviewView() { if (action === "__deep_research__" && project) { const searchConfig = useWikiStore.getState().searchApiConfig if (searchConfig.provider === "none" || !searchConfig.apiKey) { - window.alert("Web Search not configured. Go to Settings → Web Search to add a Tavily or SerpApi API key first.") + window.alert("尚未配置网页搜索。请先到「设置 > 网页搜索」添加 Tavily 或 SerpApi API Key。") return } const item = items.find((i) => i.id === id) @@ -48,7 +48,7 @@ export function ReviewView() { // Use pre-generated search queries if available, otherwise fall back to title const topic = item.title.replace(/^(Save to Wiki|Create|Research)[:\s]*/i, "").trim() || item.description.split("\n")[0] queueResearch(pp, topic, llmConfig, searchConfig, item.searchQueries) - resolveItem(id, "Queued for research") + resolveItem(id, "已加入研究队列") } else { resolveItem(id, action) } @@ -68,7 +68,7 @@ export function ReviewView() { .trimEnd() // Generate filename - const firstLine = cleanContent.split("\n").find((l) => l.trim() && !l.startsWith("