From f1a633749e8b45cf9cad0802f06afe15417882a8 Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 7 Jul 2026 16:18:30 +0800 Subject: [PATCH 01/26] feat(ui): add Activity Bar for global navigation View-mode switch: files (editor+right panel), graph/linkRec (full-width center, right panel hidden). Sidebar footer buttons moved to Activity Bar. Co-authored-by: Claude (Opus 4.6) --- plan/INDEX.md | 24 +++- src/App.css | 82 +++++++++++++ src/App.tsx | 116 ++++++++---------- src/components/ActivityBar.tsx | 213 +++++++++++++++++++++++++++++++++ src/locales/en.json | 9 ++ src/locales/zh.json | 9 ++ 6 files changed, 388 insertions(+), 65 deletions(-) create mode 100644 src/components/ActivityBar.tsx diff --git a/plan/INDEX.md b/plan/INDEX.md index 067437d..c72dbcb 100644 --- a/plan/INDEX.md +++ b/plan/INDEX.md @@ -1,13 +1,33 @@ # Plan Index > 总控表:所有待办 spec 的状态、优先级一览。 -> 最后更新:2026-07-06 +> 最后更新:2026-07-07 --- ## Active -(暂无进行中的 spec) +| Spec | 优先级 | 状态 | 工期 | 说明 | +|------|--------|------|------|------| +| [Spec 0: Activity Bar](spec-00-activity-bar.md) | P0 | 待开发 | ~3 天 | 左侧全局导航栏:文件/图谱/推荐切换面板 + 想法/报告/设置快捷入口 | +| [Spec 1: 面板精简](spec-01-right-panel-simplify.md) | P0 | 待开发 | ~2 天 | 5 Tab → 3 Tab,graph/linkRec 入口由 Activity Bar 提供,复习默认开启(依赖 Spec 0) | +| [Spec 2: 选中保存 Thought](spec-02-editor-save-thought.md) | P0 | 待开发 | ~2 天 | 编辑器选中文字 → Milkdown Toolbar → ThoughtSavePopover | +| [Spec 3: 写作教练手动触发](spec-03-writing-coach-manual.md) | P1 | 待开发 | ~1 天 | Cmd+Shift+W + Command Palette 入口 | +| [Spec 4: 新手引导](spec-04-onboarding-flow.md) | P0 | 待开发 | ~2 周 | 预录示例 + 4 步引导 + 绕过 LLM 体验挑战复习(依赖 Spec 0+1+2) | + +**总工期**:~3 周(Spec 0 → Spec 1 串行 ~5 天 | Spec 2+3 并行 ~2 天 → Spec 4 ~2 周) + +**决策依据**:[产品分析:PM 视角](product-analysis-pm-202607.md) + +### 方向讨论文档(已归档,供后续 Phase 参考) + +| 文档 | 关注点 | +|------|--------| +| [产品评估 v1](_archive_product/product-evaluation-202607.md) | 首次全景评估,P0-3 已过时 | +| [产品深层评估](_archive_product/product-full-assessment-202607.md) | 知识分层假设审视 | +| [独立二审](_archive_product/product-assessment-independent-202607.md) | 对前两份的补充与修正 | +| [Thought 生命周期重设计](_archive_product/thought-lifecycle-redesign-202607.md) | 四阶段生命周期(潜伏→萌芽→成长→成熟),Phase 2 | +| [Phase 1 重审:30 秒目标](_archive_product/phase1-rethink-30s-goal-202607.md) | 潜伏机制,Phase 2 | --- diff --git a/src/App.css b/src/App.css index cfbb548..5629d71 100644 --- a/src/App.css +++ b/src/App.css @@ -186,6 +186,83 @@ body { display: none; } +/* ── Activity Bar: narrow icon rail, first flex child in .app-sidebar-column ── */ + +.activity-bar { + flex: 0 0 36px; + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0; + border-right: 1px solid rgba(0, 0, 0, 0.08); + background: var(--app-chrome-bg); + overflow: hidden; +} + +.activity-bar__top { + display: flex; + flex-direction: column; + gap: 4px; +} + +.activity-bar__bottom { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: auto; +} + +.activity-bar__btn { + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + position: relative; + color: var(--fg-dim, #666); + padding: 0; +} + +.activity-bar__btn:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--fg-default, #333); +} + +.activity-bar__btn--active { + color: var(--fg-default, #1a1a1a); +} + +.activity-bar__btn--active::before { + content: ""; + position: absolute; + left: 0; + top: 6px; + bottom: 6px; + width: 2px; + background: var(--accent-color, #0066cc); + border-radius: 1px; +} + +@media (prefers-color-scheme: dark) { + .activity-bar { + border-right-color: rgba(255, 255, 255, 0.08); + } + .activity-bar__btn { + color: var(--fg-dim, #999); + } + .activity-bar__btn:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--fg-default, #ddd); + } + .activity-bar__btn--active { + color: var(--fg-default, #eee); + } +} + .app-top-toolbar__start { flex: 0 0 var(--sidebar-width, 196px); box-sizing: border-box; @@ -2466,6 +2543,11 @@ html:lang(zh-Hans) .settings-lang-toggle .app-modal__btn { background: var(--app-chrome-bg); } +.main--full-view { + flex: 1; + overflow: auto; +} + .main__disk-notice { flex-shrink: 0; display: flex; diff --git a/src/App.tsx b/src/App.tsx index 6e43d97..84d6afd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ import { ThoughtVaultHubModal } from "./components/ThoughtVaultHubModal"; import { WorkspaceSearchModal } from "./components/WorkspaceSearchModal"; import { EditorFindBar } from "./components/EditorFindBar"; import { GraphTabShell } from "./components/GraphTabShell"; +import { ActivityBar, type LeftPanelView } from "./components/ActivityBar"; import { KF_PRIVATE_LOCK_ICON_DOC_BAR_PX } from "./constants/kfPrivateUi"; import { useKfPrivateForPath } from "./hooks/useKfPrivateForPath"; import { useOpenDocs } from "./hooks/useOpenDocs"; @@ -162,6 +163,7 @@ function App() { const [rightPanelOpen, setRightPanelOpen] = useState(true); const [rightPanelTab, setRightPanelTab] = useState("outline"); const [sidebarOpen, setSidebarOpen] = useState(true); + const [leftPanelView, setLeftPanelView] = useState("files"); const [aiSettingsOpen, setAiSettingsOpen] = useState(false); const [workspaceReady, setWorkspaceReady] = useState(false); const [initialDepthMode, setInitialDepthMode] = useState(undefined); @@ -1055,11 +1057,11 @@ function App() { openMarkdownTab={onOpenCoachMarkdownPath} >
@@ -1216,9 +1218,10 @@ function App() { isKfPrivate={isPathKfPrivate} onRenameTab={onRenameTabFromBar} onSelect={(p) => { - if (p === docState.activePath) { + if (p === docState.activePath && leftPanelView === "files") { return; } + setLeftPanelView("files"); logPerfMark("markdown.tab_switch.select", { from: docState.activePath, to: p, @@ -1359,6 +1362,13 @@ function App() { {...tauriWindowDragProps} aria-hidden={!sidebarOpen} > + setThoughtManagementPageOpen(true)} + onOpenCognitiveReport={() => setCognitiveReportOpen(true)} + onOpenSettings={() => setAiSettingsOpen(true)} + />
- {tauriRuntime && workspaceReady ? ( - - ) : null} - {tauriRuntime ? ( - - ) : null} ) : null} @@ -1492,8 +1446,43 @@ function App() { )}
+ {leftPanelView === "graph" ? ( +
+ { + setLeftPanelView("files"); + void onOpenCoachMarkdownPath(relPath); + }} + /> +
+ ) : leftPanelView === "linkRec" ? ( +
+ +
+ ) : (
{docState.activePath != null && docState.hasDiskStaleConflict(docState.activePath) && @@ -1739,7 +1728,8 @@ function App() { )}
- {showRightColumn && ( + )} + {showRightColumn && leftPanelView === "files" && (
)} - {showRightColumn && ( + {showRightColumn && leftPanelView === "files" && ( void; + onOpenThoughtManagement: () => void; + onOpenCognitiveReport: () => void; + onOpenSettings: () => void; +}; + +function FilesIcon() { + return ( + + + + ); +} + +function GraphIcon() { + return ( + + + + + + + ); +} + +function LinkRecIcon() { + return ( + + + + + + + + ); +} + +function ThoughtsIcon() { + return ( + + + + + + + + + ); +} + +function ReportIcon() { + return ( + + + + + + + + ); +} + +function SettingsIcon() { + return ( + + + + + ); +} + +const VIEW_ICONS: Record React.JSX.Element> = { + files: FilesIcon, + graph: GraphIcon, + linkRec: LinkRecIcon, +}; + +const VIEW_I18N_KEYS: Record = { + files: "activityBar.files", + graph: "activityBar.graph", + linkRec: "activityBar.linkRec", +}; + +export function ActivityBar({ + activeView, + onViewChange, + onOpenThoughtManagement, + onOpenCognitiveReport, + onOpenSettings, +}: Props) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 06ff4ba..b9a7e63 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -766,5 +766,14 @@ "editorMetaCol": "Basic info", "editorPromptCol": "System prompt & tools", "editorSaveSuccess": "Saved successfully" + }, + "activityBar": { + "label": "Activity bar", + "files": "Files", + "graph": "Understanding graph", + "linkRec": "Link recommendations", + "thoughts": "All thoughts", + "report": "Cognitive growth report", + "settings": "Settings" } } diff --git a/src/locales/zh.json b/src/locales/zh.json index a1474ea..b5000cb 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -766,5 +766,14 @@ "editorMetaCol": "基本信息", "editorPromptCol": "系统提示词与工具", "editorSaveSuccess": "保存成功" + }, + "activityBar": { + "label": "活动栏", + "files": "文件", + "graph": "理解图谱", + "linkRec": "链接推荐", + "thoughts": "全部想法", + "report": "认知成长报告", + "settings": "设置" } } From cdab5829534bb6f76b23dd6ea65b8cdae6c8e277 Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 7 Jul 2026 16:21:23 +0800 Subject: [PATCH 02/26] chore: untrack plan/INDEX.md (already in .gitignore) Co-authored-by: Claude (Opus 4.6) --- plan/INDEX.md | 96 --------------------------------------------------- 1 file changed, 96 deletions(-) delete mode 100644 plan/INDEX.md diff --git a/plan/INDEX.md b/plan/INDEX.md deleted file mode 100644 index c72dbcb..0000000 --- a/plan/INDEX.md +++ /dev/null @@ -1,96 +0,0 @@ -# Plan Index - -> 总控表:所有待办 spec 的状态、优先级一览。 -> 最后更新:2026-07-07 - ---- - -## Active - -| Spec | 优先级 | 状态 | 工期 | 说明 | -|------|--------|------|------|------| -| [Spec 0: Activity Bar](spec-00-activity-bar.md) | P0 | 待开发 | ~3 天 | 左侧全局导航栏:文件/图谱/推荐切换面板 + 想法/报告/设置快捷入口 | -| [Spec 1: 面板精简](spec-01-right-panel-simplify.md) | P0 | 待开发 | ~2 天 | 5 Tab → 3 Tab,graph/linkRec 入口由 Activity Bar 提供,复习默认开启(依赖 Spec 0) | -| [Spec 2: 选中保存 Thought](spec-02-editor-save-thought.md) | P0 | 待开发 | ~2 天 | 编辑器选中文字 → Milkdown Toolbar → ThoughtSavePopover | -| [Spec 3: 写作教练手动触发](spec-03-writing-coach-manual.md) | P1 | 待开发 | ~1 天 | Cmd+Shift+W + Command Palette 入口 | -| [Spec 4: 新手引导](spec-04-onboarding-flow.md) | P0 | 待开发 | ~2 周 | 预录示例 + 4 步引导 + 绕过 LLM 体验挑战复习(依赖 Spec 0+1+2) | - -**总工期**:~3 周(Spec 0 → Spec 1 串行 ~5 天 | Spec 2+3 并行 ~2 天 → Spec 4 ~2 周) - -**决策依据**:[产品分析:PM 视角](product-analysis-pm-202607.md) - -### 方向讨论文档(已归档,供后续 Phase 参考) - -| 文档 | 关注点 | -|------|--------| -| [产品评估 v1](_archive_product/product-evaluation-202607.md) | 首次全景评估,P0-3 已过时 | -| [产品深层评估](_archive_product/product-full-assessment-202607.md) | 知识分层假设审视 | -| [独立二审](_archive_product/product-assessment-independent-202607.md) | 对前两份的补充与修正 | -| [Thought 生命周期重设计](_archive_product/thought-lifecycle-redesign-202607.md) | 四阶段生命周期(潜伏→萌芽→成长→成熟),Phase 2 | -| [Phase 1 重审:30 秒目标](_archive_product/phase1-rethink-30s-goal-202607.md) | 潜伏机制,Phase 2 | - ---- - -## Backlog - -(暂无 backlog — 当前架构已稳定,不再主动优化已知内部问题) - ---- - -## Done - -| Spec | 优先级 | 完成日期 | 说明 | -|------|--------|----------|------| -| graph 私密节点过滤 | P0 | 2026-06-30 | 图谱泄漏私密笔记信息 | -| note.read symlink 修复 | P0 | 2026-06-30 | 符号链接逃逸漏洞 | -| link 隐私覆盖确认 | P1 | 2026-06-30 | link.suggest_related 隐私过滤 | -| [thought.read](_archive/202506-tool-gap/spec-thought-read.md) | P0 | 2026-07-01 | 想法全文读取工具 | -| [网络瞬态重试](_archive/202506-agent-loop-enhance/spec-error-recovery.md) | P1 | 2026-07-01 | 指数退避 + cancel 感知重试 | -| [目标稳定性](_archive/202507-backlog-cleanup/goal-stability/README.md) | P1 | 2026-07-01 | 目标提取钉住 + 漂移警告 | -| [计划审批](_archive/202507-backlog-cleanup/spec-plan-approval.md) | P1 | 2026-07-02 | Phase A→B 审批门(后已合并为单阶段) | -| [主动压缩](_archive/202507-backlog-cleanup/spec-proactive-compaction.md) | P2 | 2026-07-03 | 修复快照索引漂移 | -| Planning 模式简化 | P1 | 2026-07-03 | Phase A/B 合并为单次执行 | -| God Component 拆分 | P1 | 2026-07-03 | AiConversationPanel 2570→1758 行 | -| [事件可靠性](_archive/fix-event-reliability/) | P1 | 2026-07-06 | 心跳 + watchdog + 幂等 + 缓冲队列 | -| 动态预算 | P1 | 已落地 | max_tool_calls 25 + budget 耗尽优雅降级 | -| Agent Memory v2 | P1 | 已落地 | MemoryManager + agent_memory.json + LLM 提取 | - ---- - -## Closed (Won't Fix) - -以下问题经评估后决定不修复,原因见括号: - -- D-2 隐式状态机(watchdog + 幂等 + 缓冲已从事件层面解决状态不一致) -- D-4 会话重连(桌面应用刷新概率极低,审批超时已兜底) -- D-6 压缩层边界不清(功能正确,重构风险 > 收益) -- D-7 Skill 隔离(vault 非敌对数据,资源消耗已有界) -- D-8 参数过多(纯美观,不影响功能) -- 消息优先级(128K context 下几乎不触发) -- 搜索重排序(LLM 本身是更强的 reranker) -- 深度研究 / 工作记忆 / 结构化追踪 / 会话回放 / 并行 Agent / 零配置搜索 / MCP / 子 Agent / 思考可视化(关闭理由见 2026-07-03 评审) - ---- - -## Archive - -历史计划文档已移至 `_archive/`,保留完整 git 历史。包含: - -- `iter1/` — 初始 AI 能力建设(P0-P5 工具 + agent loop) -- `iter1-ai-upgrade/` — ContextGuard + 计划审查等 spec -- `unified-provider/` — 统一 Provider 重构 -- `llm-latency/` — LLM 响应延迟优化 -- `agent-memory/` — Agent 记忆系统(20 个 spec) -- `context-management-redesign/` — 上下文管理重设计(Phase 1 已完成) -- `search-agent/` — 搜索 Agent 升级分析 -- `tool-gap-analysis/` — 工具缺口分析 -- `iter2-agent-evolution/` — Agent 架构进化路线(Phase 1-4) -- `web-search-evolution/` — Web 搜索竞品分析与路线 -- `202506-security-privacy-fix/` — 安全与隐私修复(3 spec,全部完成) -- `202506-tool-gap/` — 工具缺口(thought.read 完成,其余 wontfix) -- `202506-agent-loop-enhance/` — Agent Loop 增强(重试已完成,反思循环 P3 延后) -- `202507-backlog-cleanup/` — 2026-07 Backlog 清理 -- `fix-event-reliability/` — 事件可靠性三层修复(2026-07-06 完成) -- `refactor-god-component/` — God Component 拆分方案 -- `review-agent-loop-architecture.md` — Agent Loop 架构评审 -- `session-context-management-analysis.md` — 会话上下文管理分析 From 204368792b24bd127e93046129573709c3bfab40 Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 7 Jul 2026 16:32:38 +0800 Subject: [PATCH 03/26] refactor(ui): simplify right panel to 3 tabs Remove graph/linkRec tabs (now in Activity Bar), enable challenge review by default, update disabled hint to LLM-config guidance. Co-authored-by: Claude (Opus 4.6) --- src-tauri/src/vault_config.rs | 4 +- src/App.tsx | 80 +--------------------- src/components/ChallengeReviewPanel.tsx | 2 +- src/components/RightPanelShell.tsx | 89 ++----------------------- src/locales/en.json | 1 + src/locales/zh.json | 1 + 6 files changed, 13 insertions(+), 164 deletions(-) diff --git a/src-tauri/src/vault_config.rs b/src-tauri/src/vault_config.rs index 5536002..54f7117 100644 --- a/src-tauri/src/vault_config.rs +++ b/src-tauri/src/vault_config.rs @@ -460,7 +460,7 @@ impl Default for CognitiveConfig { passive_highlight_enabled: default_passive_highlight_enabled(), passive_highlight_confidence_min: default_passive_highlight_confidence_min(), passive_highlight_inaccuracy_counts: PassiveHighlightInaccuracyCounts::default(), - independent_review_enabled: false, + independent_review_enabled: true, challenge_review_daily_cap_independent: default_challenge_review_cap_independent(), challenge_review_daily_cap_inline: default_challenge_review_cap_inline(), challenge_review_inline_dates: ChallengeReviewInlineDates::default(), @@ -1586,7 +1586,7 @@ mod tests { assert!(cfg.passive_highlight_enabled); assert!((cfg.passive_highlight_confidence_min - 0.55).abs() < f64::EPSILON); assert_eq!(cfg.passive_highlight_inaccuracy_counts.integrate, 0); - assert!(!cfg.independent_review_enabled); + assert!(cfg.independent_review_enabled); assert_eq!(cfg.challenge_review_daily_cap_independent, 3); assert_eq!(cfg.challenge_review_daily_cap_inline, 2); assert!(cfg.challenge_review_inline_dates.by_day.is_empty()); diff --git a/src/App.tsx b/src/App.tsx index 84d6afd..0870f59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -188,7 +188,7 @@ function App() { } | null>(null); const editorFindWorkspaceSeedNonceRef = useRef(0); const leftResizable = useResizable({ side: "left", defaultWidth: 196, minWidth: 150, maxWidth: 500 }); - const rightPanelFillRemainder = rightPanelTab === "ai" || rightPanelTab === "graph"; + const rightPanelFillRemainder = rightPanelTab === "ai"; const rightPanelMaxWidth = useRightPanelMaxWidthPx({ fillRemainder: rightPanelFillRemainder, leftSidebarPx: sidebarOpen ? leftResizable.width : 0, @@ -199,23 +199,6 @@ function App() { minWidth: 280, maxWidth: rightPanelMaxWidth, }); - /** 理解网络「一键加宽」前记住的右栏宽度,再次点击还原 */ - const graphPanelWidthBeforeWideRef = useRef(null); - const [graphPanelWideExpanded, setGraphPanelWideExpanded] = useState(false); - const toggleUnderstandingGraphPanelWide = useCallback(() => { - if (!graphPanelWideExpanded) { - graphPanelWidthBeforeWideRef.current = rightResizable.width; - const vw = typeof window !== "undefined" ? window.innerWidth : 1200; - rightResizable.setProgrammaticWidth(Math.floor(vw * 0.7)); - setGraphPanelWideExpanded(true); - return; - } - const prev = graphPanelWidthBeforeWideRef.current ?? 364; - graphPanelWidthBeforeWideRef.current = null; - rightResizable.setProgrammaticWidth(prev); - setGraphPanelWideExpanded(false); - }, [graphPanelWideExpanded, rightResizable.width, rightResizable.setProgrammaticWidth]); - const anyDragging = leftResizable.isDragging || rightResizable.isDragging; const editorScrollRef = useRef(null); @@ -290,21 +273,6 @@ function App() { const tauriRuntime = isTauri(); - useEffect(() => { - if (!workspaceReady || !tauriRuntime) { - setGraphPanelWideExpanded((was) => { - if (was) { - const prev = graphPanelWidthBeforeWideRef.current; - if (prev != null) { - rightResizable.setProgrammaticWidth(prev); - } - } - graphPanelWidthBeforeWideRef.current = null; - return false; - }); - } - }, [workspaceReady, tauriRuntime, rightResizable.setProgrammaticWidth]); - const { dueCount: reviewDueTabCount } = useReviewDueTabBadge({ workspaceReady, tauriRuntime, @@ -340,6 +308,7 @@ function App() { }, [reviewDueTabCount, rightPanelTab, rootPath]); const requestOpenChallengeReview = useCallback(() => { + setLeftPanelView("files"); setRightPanelOpen(true); setRightPanelTab("review"); }, []); @@ -557,12 +526,6 @@ function App() { } }, [rightPanelOpen, workspaceReady, rightPanelTab, editorReady]); - useEffect(() => { - if (rightPanelOpen && workspaceReady && rightPanelTab === "linkRec" && !editorReady) { - setRightPanelTab("outline"); - } - }, [rightPanelOpen, workspaceReady, rightPanelTab, editorReady]); - /** 成熟度 Toast 点击后:原文模式下滚动到指定行 */ useEffect(() => { const onGoto = (ev: Event) => { @@ -1747,10 +1710,8 @@ function App() { setSidebarOpen(false)} + onAfterSelectAiTab={() => setSidebarOpen(false)} outlineTabEnabled={editorReady} - graphTabEnabled={workspaceReady && tauriRuntime && !!rootPath} - linkRecTabEnabled={editorReady} tauriDragExclude={tauriRuntime} outlineToolbarEnd={ rightPanelTab === "outline" && editorReady ? ( @@ -1795,42 +1756,7 @@ function App() { /> } - linkRecPanel={ - - } aiPanel={} - graphPanel={ - { - void onOpenCoachMarkdownPath(relPath); - }} - onTogglePanelWide={ - workspaceReady && tauriRuntime ? toggleUnderstandingGraphPanelWide : undefined - } - graphPanelWideExpanded={graphPanelWideExpanded} - /> - } reviewPanel={ setRightPanelTab("ai")} />} reviewTabBadgeCount={reviewTabBadgeCount} /> diff --git a/src/components/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx index a40fe40..d7f3612 100644 --- a/src/components/ChallengeReviewPanel.tsx +++ b/src/components/ChallengeReviewPanel.tsx @@ -235,7 +235,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { {t("challengeReview.close")}
-

{t("challengeReview.panelDisabledHint")}

+

{t("challengeReview.panelNeedsLlm")}

- -
- {outlineToolbarEnd != null && outlineToolbarEnd !== false && tab !== "graph" && tab !== "linkRec" ? ( + {outlineToolbarEnd != null && outlineToolbarEnd !== false ? (
{outlineToolbarEnd}
) : null} - {/* 非激活面板:hidden + inert,避免未展示区仍可聚焦或参与读屏遍历 */}
{aiPanel}
- -
Date: Tue, 7 Jul 2026 16:38:22 +0800 Subject: [PATCH 04/26] fix(ui): keep Activity Bar visible when sidebar collapsed Co-authored-by: Claude (Opus 4.6) --- src/App.css | 15 ++++++++------- src/App.tsx | 5 ++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/App.css b/src/App.css index 5629d71..7fe8739 100644 --- a/src/App.css +++ b/src/App.css @@ -87,13 +87,13 @@ body { grid-template-columns: var(--sidebar-width, 196px) minmax(0, 1fr) var(--right-panel-width); } -/* 收起侧栏:去掉首列,主区占满下方 */ +/* 收起侧栏:首列缩到 Activity Bar 宽度(36px) */ .layout.layout--sidebar-collapsed { - grid-template-columns: minmax(0, 1fr); + grid-template-columns: 36px minmax(0, 1fr); } .layout.layout--sidebar-collapsed.layout--with-right-panel { - grid-template-columns: minmax(0, 1fr) var(--right-panel-width); + grid-template-columns: 36px minmax(0, 1fr) var(--right-panel-width); } /* 顶栏横跨整窗:左侧固定宽(与下方文件列对齐)+ 标签区 */ @@ -182,7 +182,8 @@ body { position: relative; } -.layout.layout--sidebar-collapsed .app-sidebar-column { +.layout.layout--sidebar-collapsed .app-sidebar-column > .sidebar, +.layout.layout--sidebar-collapsed .app-sidebar-column > .panel-resizer { display: none; } @@ -281,8 +282,8 @@ body { } .layout.layout--sidebar-collapsed .app-top-toolbar__start { - flex: 0 0 auto; - width: auto; + flex: 0 0 36px; + width: 36px; border-right: none; } @@ -747,7 +748,7 @@ body { } .layout.layout--sidebar-collapsed .content-area { - grid-column: 1 / -1; + grid-column: 2 / -1; } .content-area--with-right-panel { diff --git a/src/App.tsx b/src/App.tsx index 0870f59..2195afa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -191,7 +191,7 @@ function App() { const rightPanelFillRemainder = rightPanelTab === "ai"; const rightPanelMaxWidth = useRightPanelMaxWidthPx({ fillRemainder: rightPanelFillRemainder, - leftSidebarPx: sidebarOpen ? leftResizable.width : 0, + leftSidebarPx: sidebarOpen ? leftResizable.width : 36, }); const rightResizable = useResizable({ side: "right", @@ -1023,7 +1023,7 @@ function App() { className={`layout${showRightColumn && leftPanelView === "files" ? " layout--with-right-panel" : ""}${tauriRuntime ? " layout--tauri" : ""}${sidebarOpen ? "" : " layout--sidebar-collapsed"}${titlebarPlatformClass}${anyDragging ? " layout--resizing" : ""}`} style={ { - "--sidebar-width": sidebarOpen ? `${leftResizable.width}px` : undefined, + "--sidebar-width": sidebarOpen ? `${leftResizable.width}px` : "36px", "--right-panel-width": showRightColumn && leftPanelView === "files" ? `${rightResizable.width}px` : undefined, } as React.CSSProperties } @@ -1323,7 +1323,6 @@ function App() { id="app-file-directory-pane" className="app-sidebar-column" {...tauriWindowDragProps} - aria-hidden={!sidebarOpen} > Date: Tue, 7 Jul 2026 16:46:10 +0800 Subject: [PATCH 05/26] fix(ui): preserve sidebar width for thought management when collapsed Co-authored-by: Claude (Opus 4.6) --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 2195afa..fbf6246 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1023,7 +1023,7 @@ function App() { className={`layout${showRightColumn && leftPanelView === "files" ? " layout--with-right-panel" : ""}${tauriRuntime ? " layout--tauri" : ""}${sidebarOpen ? "" : " layout--sidebar-collapsed"}${titlebarPlatformClass}${anyDragging ? " layout--resizing" : ""}`} style={ { - "--sidebar-width": sidebarOpen ? `${leftResizable.width}px` : "36px", + "--sidebar-width": sidebarOpen || thoughtManagementPageOpen ? `${leftResizable.width}px` : "36px", "--right-panel-width": showRightColumn && leftPanelView === "files" ? `${rightResizable.width}px` : undefined, } as React.CSSProperties } From 4cd7de19cf5275676916767119448700af50a6cc Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 7 Jul 2026 16:58:48 +0800 Subject: [PATCH 06/26] refactor(ui): render thought management as Activity Bar view mode Co-authored-by: Claude (Opus 4.6) --- src/App.css | 7 - src/App.tsx | 421 +++++++++------------- src/components/ActivityBar.tsx | 15 +- src/components/ThoughtManagementPanel.css | 5 +- 4 files changed, 175 insertions(+), 273 deletions(-) diff --git a/src/App.css b/src/App.css index 7fe8739..17fa313 100644 --- a/src/App.css +++ b/src/App.css @@ -719,17 +719,10 @@ body { opacity: 0.92; } -/* 全屏想法管理:叠在第二行文件树+主编辑之上(顶栏仍可见) */ .app-thought-management-route { - grid-column: 1 / -1; - grid-row: 2; - z-index: 30; - min-height: 0; - min-width: 0; display: flex; flex-direction: column; background: var(--app-chrome-bg); - box-shadow: 0 -1px 0 var(--app-toolbar-bottom-line); } .content-area { diff --git a/src/App.tsx b/src/App.tsx index fbf6246..51eb75b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -172,9 +172,6 @@ function App() { const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const [cognitiveReportOpen, setCognitiveReportOpen] = useState(false); const [thoughtVaultHubOpen, setThoughtVaultHubOpen] = useState(false); - /** 顶栏入口:全屏想法管理(非右栏 Tab) */ - const [thoughtManagementPageOpen, setThoughtManagementPageOpen] = useState(false); - /** 想法管理详情正文是否相对已保存状态有未写入的编辑 */ const [thoughtMgmtBodyDirty, setThoughtMgmtBodyDirty] = useState(false); const [workspaceSearchOpen, setWorkspaceSearchOpen] = useState(false); /** 同标签重复点全文搜索结果时仍触发预览滚动 */ @@ -499,25 +496,21 @@ function App() { const editorReady = !!docState.activePath && !loadingDoc && !loadError; /** 右栏承载大纲与 AI:无打开文档时也应显示,避免分段按钮与 AI 面板被整栏卸载 */ const showRightColumn = rightPanelOpen && workspaceReady; - /** 全屏想法管理覆盖层内嵌会话 Provider(与右栏 Provider 分离;深聊依赖盘内会话列表衔接) */ const thoughtManagementSessionActive = - thoughtManagementPageOpen && workspaceReady && tauriRuntime && !!rootPath; + leftPanelView === "thoughts" && workspaceReady && tauriRuntime && !!rootPath; - const handleThoughtManagementBack = useCallback(async () => { - if (!thoughtMgmtBodyDirty) { - setThoughtManagementPageOpen(false); - return; + const changeView = useCallback(async (view: LeftPanelView) => { + if (leftPanelView === "thoughts" && view !== "thoughts" && thoughtMgmtBodyDirty) { + const ok = await ask(t("thoughtManagement.exitUnsavedMessage"), { + title: t("thoughtManagement.exitUnsavedTitle"), + kind: "warning", + }); + if (!ok) return false; } - const ok = await ask(t("thoughtManagement.exitUnsavedMessage"), { - title: t("thoughtManagement.exitUnsavedTitle"), - kind: "warning", - }); - if (ok) setThoughtManagementPageOpen(false); - }, [thoughtMgmtBodyDirty, t]); - - const handleThoughtMgmtDetailDirtyChange = useCallback((dirty: boolean) => { - setThoughtMgmtBodyDirty(dirty); - }, []); + setThoughtMgmtBodyDirty(false); + setLeftPanelView(view); + return true; + }, [leftPanelView, thoughtMgmtBodyDirty, t]); /** 选中 Outline 但尚无可用编辑器文档时自动切到 AI,避免右栏空白且无法切换 */ useEffect(() => { @@ -1023,7 +1016,7 @@ function App() { className={`layout${showRightColumn && leftPanelView === "files" ? " layout--with-right-panel" : ""}${tauriRuntime ? " layout--tauri" : ""}${sidebarOpen ? "" : " layout--sidebar-collapsed"}${titlebarPlatformClass}${anyDragging ? " layout--resizing" : ""}`} style={ { - "--sidebar-width": sidebarOpen || thoughtManagementPageOpen ? `${leftResizable.width}px` : "36px", + "--sidebar-width": sidebarOpen ? `${leftResizable.width}px` : "36px", "--right-panel-width": showRightColumn && leftPanelView === "files" ? `${rightResizable.width}px` : undefined, } as React.CSSProperties } @@ -1054,45 +1047,65 @@ function App() { >
{renderWindowControls("leading")} - {!thoughtManagementPageOpen ? ( - - ) : null} - {thoughtManagementPageOpen ? ( + + + + + + {tauriRuntime && workspaceReady ? ( ) : null} - {!thoughtManagementPageOpen ? ( +
+
+
+ { + if (p === docState.activePath && leftPanelView === "files") { + return; + } + void changeView("files").then((ok) => { + if (!ok) return; + logPerfMark("markdown.tab_switch.select", { + from: docState.activePath, + to: p, + }); + activePathSwitchTraceRef.current = startPerfTrace("markdown.tab_switch.to_next_frame", { + from: docState.activePath, + to: p, + }); + docState.setSaveError(null); + docState.setActivePath(p); + }); + }} + onClose={(p) => void docState.closeTab(p)} + onCloseAll={() => void docState.closeAllTabs()} + /> +
+
+ {tauriRuntime && workspaceReady && editorUsable && docState.saveFeedback !== "idle" ? ( + + {docState.saveFeedback === "pending_auto" + ? t("toolbar.autoSavePending") + : docState.saveFeedback === "saving" + ? t("toolbar.saving") + : docState.saveFeedback === "saved" + ? t("toolbar.saved") + : null} + + ) : null} - ) : null} - {tauriRuntime && workspaceReady && !thoughtManagementPageOpen ? ( - ) : null} -
-
- {thoughtManagementPageOpen ? ( -
- {t("thoughtManagement.title")} -
- ) : ( -
- { - if (p === docState.activePath && leftPanelView === "files") { - return; - } - setLeftPanelView("files"); - logPerfMark("markdown.tab_switch.select", { - from: docState.activePath, - to: p, - }); - activePathSwitchTraceRef.current = startPerfTrace("markdown.tab_switch.to_next_frame", { - from: docState.activePath, - to: p, - }); - docState.setSaveError(null); - docState.setActivePath(p); - }} - onClose={(p) => void docState.closeTab(p)} - onCloseAll={() => void docState.closeAllTabs()} - /> -
- )} -
- {!thoughtManagementPageOpen ? ( - <> - {tauriRuntime && workspaceReady && editorUsable && docState.saveFeedback !== "idle" ? ( - - {docState.saveFeedback === "pending_auto" - ? t("toolbar.autoSavePending") - : docState.saveFeedback === "saving" - ? t("toolbar.saving") - : docState.saveFeedback === "saved" - ? t("toolbar.saved") - : null} - - ) : null} - - - ) : null} - {tauriRuntime && workspaceReady && !thoughtManagementPageOpen ? ( - - ) : null} - {!thoughtManagementPageOpen ? ( - - ) : null} {renderWindowControls("trailing")}
@@ -1326,8 +1243,7 @@ function App() { > setThoughtManagementPageOpen(true)} + onViewChange={(v) => void changeView(v)} onOpenCognitiveReport={() => setCognitiveReportOpen(true)} onOpenSettings={() => setAiSettingsOpen(true)} /> @@ -1343,9 +1259,11 @@ function App() { tauriRuntime ? (tauriDragExcludeProps as ButtonHTMLAttributes) : undefined } onSelectFile={(p) => { - setPreferredNewMarkdownDir(parentDirOfRelPath(p)); - setLeftPanelView("files"); - void docState.openOrFocusTab(p); + void changeView("files").then((ok) => { + if (!ok) return; + setPreferredNewMarkdownDir(parentDirOfRelPath(p)); + void docState.openOrFocusTab(p); + }); }} fileOps={fileTreeFileOps} newMarkdownAction={ @@ -1444,6 +1362,26 @@ function App() { crepeApiRef={crepeEditorApiRef} /> + ) : leftPanelView === "thoughts" ? ( + thoughtManagementSessionActive ? ( +
+ + { + setLeftPanelView("files"); + void onOpenCoachMarkdownPath(relPath); + }} + /> + +
+ ) : null ) : (
{docState.activePath != null && @@ -1762,25 +1700,6 @@ function App() { )}
- {thoughtManagementSessionActive ? ( -
- - { - setThoughtManagementPageOpen(false); - void onOpenCoachMarkdownPath(relPath); - }} - /> - -
- ) : null} {aiSettingsOpen ? ( void; - onOpenThoughtManagement: () => void; onOpenCognitiveReport: () => void; onOpenSettings: () => void; }; @@ -140,18 +139,19 @@ const VIEW_ICONS: Record React.JSX.Element> = { files: FilesIcon, graph: GraphIcon, linkRec: LinkRecIcon, + thoughts: ThoughtsIcon, }; const VIEW_I18N_KEYS: Record = { files: "activityBar.files", graph: "activityBar.graph", linkRec: "activityBar.linkRec", + thoughts: "activityBar.thoughts", }; export function ActivityBar({ activeView, onViewChange, - onOpenThoughtManagement, onOpenCognitiveReport, onOpenSettings, }: Props) { @@ -180,15 +180,6 @@ export function ActivityBar({
- - {tauriRuntime && workspaceReady ? (
+ ) : null} From 765ebea96d567a47c19e83b9f710d93c339ca74b Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 7 Jul 2026 17:07:43 +0800 Subject: [PATCH 09/26] fix(ui): instant tooltip for Activity Bar buttons via CSS Co-authored-by: Claude (Opus 4.6) --- src/App.css | 21 +++++++++++++++++++++ src/components/ActivityBar.tsx | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/App.css b/src/App.css index 181390e..4fd2d72 100644 --- a/src/App.css +++ b/src/App.css @@ -233,6 +233,23 @@ body { color: var(--fg-default, #333); } +.activity-bar__btn[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + left: calc(100% + 6px); + top: 50%; + transform: translateY(-50%); + white-space: nowrap; + font-size: 0.75rem; + line-height: 1.4; + padding: 3px 8px; + border-radius: 4px; + background: #1a1a1a; + color: #f4f4f5; + pointer-events: none; + z-index: 100; +} + .activity-bar__btn--active { color: var(--fg-default, #1a1a1a); } @@ -262,6 +279,10 @@ body { .activity-bar__btn--active { color: var(--fg-default, #eee); } + .activity-bar__btn[data-tooltip]:hover::after { + background: #e4e4e7; + color: #18181b; + } } .app-top-toolbar__start { diff --git a/src/components/ActivityBar.tsx b/src/components/ActivityBar.tsx index 9d6cee9..0cb36b9 100644 --- a/src/components/ActivityBar.tsx +++ b/src/components/ActivityBar.tsx @@ -169,7 +169,7 @@ export function ActivityBar({ type="button" className={`activity-bar__btn${active ? " activity-bar__btn--active" : ""}`} aria-label={t(VIEW_I18N_KEYS[view])} - title={t(VIEW_I18N_KEYS[view])} + data-tooltip={t(VIEW_I18N_KEYS[view])} aria-pressed={active} onClick={() => onViewChange(view)} > @@ -184,7 +184,7 @@ export function ActivityBar({ type="button" className="activity-bar__btn" aria-label={t("activityBar.report")} - title={t("activityBar.report")} + data-tooltip={t("activityBar.report")} onClick={onOpenCognitiveReport} > @@ -193,7 +193,7 @@ export function ActivityBar({ type="button" className="activity-bar__btn" aria-label={t("activityBar.settings")} - title={t("activityBar.settings")} + data-tooltip={t("activityBar.settings")} onClick={onOpenSettings} > From 4b0eefbff828fcab84a3f3a4d94cfc24ac69d9fa Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 7 Jul 2026 17:10:55 +0800 Subject: [PATCH 10/26] fix(ui): allow Activity Bar tooltip overflow Co-authored-by: Claude (Opus 4.6) --- src/App.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.css b/src/App.css index 4fd2d72..02761be 100644 --- a/src/App.css +++ b/src/App.css @@ -197,7 +197,7 @@ body { padding: 8px 0; border-right: 1px solid rgba(0, 0, 0, 0.08); background: var(--app-chrome-bg); - overflow: hidden; + overflow: visible; } .activity-bar__top { From 61ced3a4cb9c6f5421f87a1a82fb83753adc5169 Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 7 Jul 2026 17:19:19 +0800 Subject: [PATCH 11/26] feat(editor): add save-as-thought button to selection toolbar Co-authored-by: Claude (Opus 4.6) --- src/App.tsx | 12 ++++++++++++ src/components/CrepeMarkdownEditor.tsx | 20 ++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 665c7d8..8488466 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,7 @@ import { LinkRecommendationPanel } from "./components/LinkRecommendationPanel"; import { RightPanelShell, type RightPanelTab } from "./components/RightPanelShell"; import { ThoughtMaturityToastHost } from "./components/ThoughtMaturityToastHost"; import { ThoughtManagementPanel } from "./components/ThoughtManagementPanel"; +import { ThoughtSavePopover } from "./components/ThoughtSavePopover"; import { ThoughtVaultHubModal } from "./components/ThoughtVaultHubModal"; import { WorkspaceSearchModal } from "./components/WorkspaceSearchModal"; import { EditorFindBar } from "./components/EditorFindBar"; @@ -173,6 +174,7 @@ function App() { const [cognitiveReportOpen, setCognitiveReportOpen] = useState(false); const [thoughtVaultHubOpen, setThoughtVaultHubOpen] = useState(false); const [thoughtMgmtBodyDirty, setThoughtMgmtBodyDirty] = useState(false); + const [editorSaveThoughtText, setEditorSaveThoughtText] = useState(null); const [workspaceSearchOpen, setWorkspaceSearchOpen] = useState(false); /** 同标签重复点全文搜索结果时仍触发预览滚动 */ const [workspaceSearchGotoEpoch, setWorkspaceSearchGotoEpoch] = useState(0); @@ -1558,6 +1560,7 @@ function App() { onEditorDispose={() => { crepeEditorApiRef.current = null; }} + onSaveAsThought={setEditorSaveThoughtText} /> @@ -1729,6 +1732,15 @@ function App() { void onOpenCoachMarkdownPath(relPath); }} /> + {editorSaveThoughtText != null && ( + setEditorSaveThoughtText(null)} + onCancel={() => setEditorSaveThoughtText(null)} + /> + )} setCognitiveReportOpen(false)} /> ); diff --git a/src/components/CrepeMarkdownEditor.tsx b/src/components/CrepeMarkdownEditor.tsx index 7787657..f1b2a5f 100644 --- a/src/components/CrepeMarkdownEditor.tsx +++ b/src/components/CrepeMarkdownEditor.tsx @@ -140,6 +140,7 @@ type Props = { ) => void | Promise; /** 输入 `[[` 时 wikilink 候选列表(工作区 Markdown 叶子);默认空 */ wikiSuggestFiles?: WikiSuggestFileRow[]; + onSaveAsThought?: (text: string) => void; }; function CrepeInner({ @@ -151,6 +152,7 @@ function CrepeInner({ onEditorDispose, onOpenInternalMarkdownLink, wikiSuggestFiles = [], + onSaveAsThought, }: Props) { const docKeyRef = useRef(docKey); docKeyRef.current = docKey; @@ -168,6 +170,8 @@ function CrepeInner({ const suppressMarkdownUntilBaselineRef = useRef(true); /** 与 queueMicrotask 配对,防止快速连续换文时误解除抑制 */ const injectGenRef = useRef(0); + const onSaveAsThoughtRef = useRef(onSaveAsThought); + onSaveAsThoughtRef.current = onSaveAsThought; /** composition 滚动修复清理函数 */ const compositionCleanupRef = useRef<(() => void) | null>(null); @@ -187,6 +191,22 @@ function CrepeInner({ [CrepeFeature.Latex]: false, }, featureConfigs: { + [CrepeFeature.Toolbar]: { + buildToolbar: (builder: { addGroup: (key: string, label: string) => { addItem: (key: string, item: { icon: string; active: (ctx: unknown) => boolean; onRun?: (ctx: unknown) => void }) => unknown } }) => { + builder.addGroup("thought", "Thought").addItem("save-thought", { + icon: ``, + active: () => false, + onRun: (ctx) => { + const view = (ctx as { get: (key: unknown) => EditorView }).get(editorViewCtx); + const { from, to } = view.state.selection; + const text = view.state.doc.textBetween(from, to, "\n"); + if (text.trim()) { + onSaveAsThoughtRef.current?.(text.trim()); + } + }, + }); + }, + }, [CrepeFeature.CodeMirror]: { languages: crepeCodeMirrorLanguages, }, From db683b9021816834849dd193f2323359e292a927 Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 7 Jul 2026 20:33:44 +0800 Subject: [PATCH 12/26] feat(writing-coach): add manual trigger, perf optimizations, and streaming Manual trigger: forwardRef + imperative handle, Cmd+Shift+W shortcut, command palette entry, persistent UI trigger button. Perf: parallel vault+thought search, json_mode, 5-min result cache, config dedup. Streaming: session_id branching to chat_stream with incremental question display via llm:stream-chunk events. Co-authored-by: Claude (Opus 4.6) --- src-tauri/src/llm/mod.rs | 5 +- src-tauri/src/tools/built_in/vault_search.rs | 1 + src-tauri/src/vault_context_search.rs | 13 +- src-tauri/src/writing_coach.rs | 141 ++++++++++++++----- src/App.tsx | 19 ++- src/components/CommandPalette.tsx | 18 ++- src/components/EditorWritingCoachHost.css | 50 +++++++ src/components/EditorWritingCoachHost.tsx | 135 ++++++++++++++++-- src/hooks/useWritingCoachTrigger.ts | 2 +- src/locales/en.json | 6 +- src/locales/zh.json | 6 +- 11 files changed, 338 insertions(+), 58 deletions(-) diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 2fd4d27..d13ff02 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -308,7 +308,7 @@ struct LlmStreamErrorPayload { message: String, } -pub(super) fn emit_chunk(app: &AppHandle, session_id: &str, delta: &str) { +pub(crate) fn emit_chunk(app: &AppHandle, session_id: &str, delta: &str) { let payload = LlmStreamChunkPayload { session_id: session_id.to_string(), delta: delta.to_string(), @@ -316,7 +316,7 @@ pub(super) fn emit_chunk(app: &AppHandle, session_id: &str, delta: &str) { let _ = app.emit("llm:stream-chunk", payload); } -pub(super) fn emit_done(app: &AppHandle, session_id: &str) -> Result<(), tauri::Error> { +pub(crate) fn emit_done(app: &AppHandle, session_id: &str) -> Result<(), tauri::Error> { app.emit( "llm:stream-done", LlmStreamDonePayload { @@ -638,6 +638,7 @@ async fn build_context_pipeline( query: query_clone, exclude_rel_paths: exclude, limits: None, + redact_private_override: None, }, ) .ok() diff --git a/src-tauri/src/tools/built_in/vault_search.rs b/src-tauri/src/tools/built_in/vault_search.rs index d901348..aff9922 100644 --- a/src-tauri/src/tools/built_in/vault_search.rs +++ b/src-tauri/src/tools/built_in/vault_search.rs @@ -125,6 +125,7 @@ impl Tool for VaultSearchKeywordTool { max_chars_per_snippet: Some(max_chars_per_snippet), ..Default::default() }), + redact_private_override: None, }; let result = tauri::async_runtime::spawn_blocking(move || { diff --git a/src-tauri/src/vault_context_search.rs b/src-tauri/src/vault_context_search.rs index 2cbb08f..8cb3cf8 100644 --- a/src-tauri/src/vault_context_search.rs +++ b/src-tauri/src/vault_context_search.rs @@ -318,6 +318,8 @@ pub struct SearchWorkspaceContextArgs { pub exclude_rel_paths: Vec, #[serde(default)] pub limits: Option, + #[serde(default)] + pub redact_private_override: Option, } #[derive(Debug, Clone, Serialize)] @@ -341,8 +343,13 @@ pub fn search_workspace_context_blocking( args: SearchWorkspaceContextArgs, ) -> Result { let started = Instant::now(); - let ai = vault_config::load_ai_config_internal(canonical_root)?; - let redact_private = redact_private_snippets(&ai); + let redact_private = match args.redact_private_override { + Some(v) => v, + None => { + let ai = vault_config::load_ai_config_internal(canonical_root)?; + redact_private_snippets(&ai) + } + }; let lim = args.limits.as_ref(); let max_files = lim.and_then(|l| l.max_files_to_scan).unwrap_or(400).max(1); @@ -591,6 +598,7 @@ mod tests { read_bytes_per_file: Some(8192), max_duration_ms: Some(5000), }), + redact_private_override: None, }, ) .unwrap(); @@ -652,6 +660,7 @@ mod tests { read_bytes_per_file: Some(8192), max_duration_ms: Some(5000), }), + redact_private_override: None, }, ) .unwrap(); diff --git a/src-tauri/src/writing_coach.rs b/src-tauri/src/writing_coach.rs index 506a755..dbd6f05 100644 --- a/src-tauri/src/writing_coach.rs +++ b/src-tauri/src/writing_coach.rs @@ -1,14 +1,19 @@ //! Writing coach: paragraph-level argumentation check + vault keyword linkage (JSON mode). -use std::sync::Arc; +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Instant; use crate::llm::create_provider; +use crate::llm::CompletionOverrides; use crate::llm::LlmChatMessage; +use tokio_util::sync::CancellationToken; use crate::lock_workspace_root; use crate::note_privacy; use crate::challenge_review; use crate::thought_retrieval::{self, SearchThoughtArgs}; -use crate::vault_config::{self, DepthMode}; +use crate::vault_config::{self, AiConfig, DepthMode}; use crate::vault_context_search::{self, SearchWorkspaceContextArgs, SearchWorkspaceLimits}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -31,9 +36,10 @@ Hard rules: pub struct AnalyzeWritingCoachArgs { pub paragraph_text: String, pub rel_path: String, - /// 与界面语言对齐(如 `zh` / `en`),用于红线过滤后兜底问句语言 #[serde(default)] pub ui_locale: Option, + #[serde(default)] + pub session_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -48,7 +54,7 @@ pub struct WritingCoachLinkItem { pub excerpt: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AnalyzeWritingCoachResponse { pub reasoning_questions: Vec, @@ -83,6 +89,25 @@ struct PrepareOutcome { whitelist_keys: std::collections::HashSet, user_body: String, ui_locale: Option, + ai_config: AiConfig, +} + +const CACHE_TTL_SECS: u64 = 300; + +struct CacheEntry { + response: AnalyzeWritingCoachResponse, + created: Instant, +} + +fn cache_map() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn hash_paragraph(text: &str) -> u64 { + let mut h = std::hash::DefaultHasher::new(); + text.hash(&mut h); + h.finish() } /// 模型输出问句全被过滤时的一条中性兜底(与 SYSTEM 要求「不评判、只提问」一致) @@ -215,6 +240,10 @@ fn prepare_blocking( return Err("paragraph_text is empty".to_string()); } + let ai_config = vault_config::load_ai_config_internal(root) + .map_err(|e| e.to_string())?; + let redact_private = ai_config.should_redact_private(); + let md_count = count_markdown_files_at_least(root, 5)?; let knowledge_module_skipped = md_count < 5; @@ -223,22 +252,32 @@ fn prepare_blocking( let query = paragraph_to_search_query(paragraph); if !knowledge_module_skipped && !query.is_empty() { - let vault_res = vault_context_search::search_workspace_context_blocking( - root, - SearchWorkspaceContextArgs { - query: query.clone(), - exclude_rel_paths: vec![rel_path.clone()], - limits: Some(SearchWorkspaceLimits { - max_files_to_scan: Some(120), - max_snippets: Some(3), - max_chars_per_snippet: Some(400), - max_total_chars: Some(4000), - read_bytes_per_file: Some(48 * 1024), - max_duration_ms: Some(3000), - }), - }, - )?; + let vault_args = SearchWorkspaceContextArgs { + query: query.clone(), + exclude_rel_paths: vec![rel_path.clone()], + limits: Some(SearchWorkspaceLimits { + max_files_to_scan: Some(120), + max_snippets: Some(3), + max_chars_per_snippet: Some(400), + max_total_chars: Some(4000), + read_bytes_per_file: Some(48 * 1024), + max_duration_ms: Some(3000), + }), + redact_private_override: Some(redact_private), + }; + let thought_args = SearchThoughtArgs { + query, + exclude_rel_paths: vec![rel_path.clone()], + max_results: 3, + }; + + let (vault_result, thought_result) = std::thread::scope(|s| { + let h1 = s.spawn(|| vault_context_search::search_workspace_context_blocking(root, vault_args)); + let h2 = s.spawn(|| thought_retrieval::search_thought_blocking(root, thought_args)); + (h1.join().unwrap(), h2.join().unwrap()) + }); + let vault_res = vault_result?; for sn in vault_res.snippets.into_iter().take(3) { if matches!(sn.kind, vault_context_search::VaultSnippetKind::PrivateOmitted) { continue; @@ -257,15 +296,7 @@ fn prepare_blocking( candidates.push(item); } - let thought_res = thought_retrieval::search_thought_blocking( - root, - SearchThoughtArgs { - query, - exclude_rel_paths: vec![rel_path.clone()], - max_results: 3, - }, - )?; - + let thought_res = thought_result?; for th in thought_res.thoughts.into_iter().take(3) { let rp = norm_rel_path(&th.rel_path); let title = format!("{} — {}", title_from_rel_path(&rp), th.thought_id); @@ -305,6 +336,7 @@ fn prepare_blocking( whitelist_keys, user_body, ui_locale: args.ui_locale.clone(), + ai_config, })) } @@ -368,10 +400,23 @@ fn filter_response( #[tauri::command] pub async fn analyze_writing_coach( + app_handle: tauri::AppHandle, workspace: State<'_, crate::WorkspaceState>, http_client: State<'_, Arc>, args: AnalyzeWritingCoachArgs, ) -> Result { + let paragraph_hash = hash_paragraph(&args.paragraph_text); + let cache_key = (paragraph_hash, args.rel_path.clone()); + + if let Ok(map) = cache_map().lock() { + if let Some(entry) = map.get(&cache_key) { + if entry.created.elapsed().as_secs() < CACHE_TTL_SECS { + return Ok(entry.response.clone()); + } + } + } + + let session_id = args.session_id.clone(); let root = lock_workspace_root(&workspace)?; let root_for_prep = root.clone(); let prep_opt = tauri::async_runtime::spawn_blocking(move || prepare_blocking(&root_for_prep, &args)) @@ -386,9 +431,7 @@ pub async fn analyze_writing_coach( }); }; - let ai = vault_config::load_ai_config_internal(&root) - .map_err(|e| e.to_string())?; - let provider = create_provider(&ai, None, http_client.inner())?; + let provider = create_provider(&prep.ai_config, None, http_client.inner())?; let msgs = vec![ LlmChatMessage { @@ -403,14 +446,42 @@ pub async fn analyze_writing_coach( }, ]; - let raw = provider.chat_completion(&msgs, None).await - .map_err(|e| e.to_string())?; + let raw = match session_id { + Some(ref sid) if !sid.is_empty() => { + let cancel = CancellationToken::new(); + let result = provider + .chat_stream(&app_handle, sid, msgs, None, cancel) + .await + .map_err(|e| e.to_string())?; + result.content + } + _ => { + let overrides = CompletionOverrides { + json_mode: true, + ..Default::default() + }; + provider + .chat_completion(&msgs, Some(&overrides)) + .await + .map_err(|e| e.to_string())? + } + }; let slice = extract_json_object(&raw).map_err(|_| "invalid coach JSON".to_string())?; let parsed: CoachJsonRaw = serde_json::from_str(&slice).map_err(|e| format!("failed to parse coach JSON: {e}"))?; - Ok(filter_response(parsed, &prep)) + let response = filter_response(parsed, &prep); + + if let Ok(mut map) = cache_map().lock() { + map.retain(|_, e| e.created.elapsed().as_secs() < CACHE_TTL_SECS); + map.insert(cache_key, CacheEntry { + response: response.clone(), + created: Instant::now(), + }); + } + + Ok(response) } #[cfg(test)] @@ -446,6 +517,7 @@ mod tests { whitelist_keys: std::collections::HashSet::new(), user_body: String::new(), ui_locale: Some("zh-CN".to_string()), + ai_config: serde_json::from_str("{}").unwrap(), }; let parsed = CoachJsonRaw { reasoning_questions: vec!["建议你改成更简洁的表述。".to_string()], @@ -467,6 +539,7 @@ mod tests { whitelist_keys: std::collections::HashSet::new(), user_body: String::new(), ui_locale: Some("en".to_string()), + ai_config: serde_json::from_str("{}").unwrap(), }; let parsed = CoachJsonRaw { reasoning_questions: vec!["Please rewrite this paragraph.".to_string()], diff --git a/src/App.tsx b/src/App.tsx index 8488466..9c530e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,7 +32,7 @@ import type { CrepeMarkdownEditorApi } from "./components/CrepeMarkdownEditor"; import { CognitiveReportPanel } from "./components/CognitiveReportPanel"; import { CommandPalette } from "./components/CommandPalette"; import { EditorThoughtsPanel } from "./components/EditorThoughtsPanel"; -import { EditorWritingCoachHost } from "./components/EditorWritingCoachHost"; +import { EditorWritingCoachHost, type EditorWritingCoachHostHandle } from "./components/EditorWritingCoachHost"; import { RightPanelReviewTab } from "./components/RightPanelReviewTab"; import { LinkRecommendationPanel } from "./components/LinkRecommendationPanel"; import { RightPanelShell, type RightPanelTab } from "./components/RightPanelShell"; @@ -205,6 +205,7 @@ function App() { const wikiHeadingNavRetryGenerationRef = useRef(0); const rawSourceTextareaRef = useRef(null); const crepeEditorApiRef = useRef(null); + const writingCoachRef = useRef(null); /** 全文搜索打开笔记后:待预览态滚到近似命中行 */ const pendingWorkspaceSearchGotoRef = useRef<{ relPath: string; line: number } | null>(null); const docState = useOpenDocs(workspaceReady); @@ -686,6 +687,16 @@ function App() { return; } + // ⌘⇧W / Ctrl+Shift+W:手动触发写作教练(编辑器内也需响应) + if (e.shiftKey && (e.key === "w" || e.key === "W")) { + if (!editorUsableForShortcutRef.current) { + return; + } + e.preventDefault(); + writingCoachRef.current?.triggerManually(); + return; + } + // ⌘F / Ctrl+F:篇内查找(焦点在正文或原文区时) if (!e.shiftKey && (e.key === "f" || e.key === "F")) { const el = e.target; @@ -1582,6 +1593,7 @@ function App() { /> ) : null} setWorkspaceSearchOpen(true) : undefined } + onTriggerWritingCoach={ + editorUsable + ? () => writingCoachRef.current?.triggerManually() + : undefined + } /> void; onOpenThoughtVaultHub?: () => void; onOpenWorkspaceSearch?: () => void; + onTriggerWritingCoach?: () => void; }; export function CommandPalette({ @@ -27,6 +28,7 @@ export function CommandPalette({ onOpenCognitiveReport, onOpenThoughtVaultHub, onOpenWorkspaceSearch, + onTriggerWritingCoach, }: Props) { const { t } = useTranslation(); const [q, setQ] = useState(""); @@ -41,9 +43,12 @@ export function CommandPalette({ onOpenThoughtVaultHubRef.current = onOpenThoughtVaultHub; const onOpenWorkspaceSearchRef = useRef(onOpenWorkspaceSearch); onOpenWorkspaceSearchRef.current = onOpenWorkspaceSearch; + const onTriggerWritingCoachRef = useRef(onTriggerWritingCoach); + onTriggerWritingCoachRef.current = onTriggerWritingCoach; const hasThoughtVaultHub = onOpenThoughtVaultHub != null; const hasWorkspaceSearch = onOpenWorkspaceSearch != null; + const hasWritingCoach = onTriggerWritingCoach != null; const items: CommandItem[] = useMemo(() => { const base: CommandItem[] = [ @@ -79,8 +84,19 @@ export function CommandPalette({ }, }); } + if (hasWritingCoach) { + base.push({ + id: "writing-coach", + label: t("commandPalette.writingCoach"), + keywords: "写作教练 writing coach 逻辑追问 reasoning", + onSelect: () => { + onTriggerWritingCoachRef.current?.(); + onCloseRef.current(); + }, + }); + } return base; - }, [t, hasThoughtVaultHub, hasWorkspaceSearch]); + }, [t, hasThoughtVaultHub, hasWorkspaceSearch, hasWritingCoach]); const filtered = useMemo(() => { const s = q.trim().toLowerCase(); diff --git a/src/components/EditorWritingCoachHost.css b/src/components/EditorWritingCoachHost.css index b474242..a9eb138 100644 --- a/src/components/EditorWritingCoachHost.css +++ b/src/components/EditorWritingCoachHost.css @@ -6,3 +6,53 @@ pointer-events: none; z-index: 3; } + +.writing-coach-trigger { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid color-mix(in srgb, #0f172a 10%, transparent); + border-radius: 6px; + background: color-mix(in srgb, #fff 85%, transparent); + color: #64748b; + cursor: pointer; + pointer-events: auto; + opacity: 0.5; + transition: opacity 0.18s ease, color 0.18s ease, box-shadow 0.18s ease; + z-index: 4; +} + +.writing-coach-trigger:hover { + opacity: 1; + color: var(--toolbar-accent, #396cd8); + box-shadow: 0 1px 4px color-mix(in srgb, var(--toolbar-accent, #396cd8) 18%, transparent); +} + +.writing-coach-trigger:focus-visible { + opacity: 1; + outline: 2px solid color-mix(in srgb, var(--toolbar-accent, #396cd8) 55%, transparent); + outline-offset: 1px; +} + +@media (prefers-color-scheme: dark) { + .writing-coach-trigger { + border-color: color-mix(in srgb, #fff 12%, transparent); + background: color-mix(in srgb, #fff 6%, transparent); + color: #94a3b8; + } + + .writing-coach-trigger:hover { + color: color-mix(in srgb, var(--toolbar-accent, #60a5fa) 88%, #fff); + box-shadow: 0 1px 4px color-mix(in srgb, var(--toolbar-accent, #60a5fa) 16%, transparent); + } + + .writing-coach-trigger:focus-visible { + outline-color: color-mix(in srgb, var(--toolbar-accent, #60a5fa) 65%, transparent); + } +} diff --git a/src/components/EditorWritingCoachHost.tsx b/src/components/EditorWritingCoachHost.tsx index e659cf0..bbf21a7 100644 --- a/src/components/EditorWritingCoachHost.tsx +++ b/src/components/EditorWritingCoachHost.tsx @@ -1,18 +1,58 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { getAppLocale } from "../i18n"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; import type { DepthMode } from "../types/cognitiveTypes"; import type { VaultConfigForUi } from "../types/vaultAiConfig"; import type { AnalyzeWritingCoachResponse } from "../types/writingCoach"; import { dispatchVaultConfigUpdated, VAULT_CONFIG_UPDATED_EVENT } from "../utils/vaultConfigBroadcast"; -import { useWritingCoachTrigger } from "../hooks/useWritingCoachTrigger"; +import { nearestTextblockText, useWritingCoachTrigger } from "../hooks/useWritingCoachTrigger"; import type { CrepeMarkdownEditorApi } from "./CrepeMarkdownEditor"; +import { useTranslation } from "react-i18next"; import { WritingCoachBubble } from "./WritingCoachBubble"; import { endPerfTrace, startPerfTrace } from "../utils/perfTrace"; import "./EditorWritingCoachHost.css"; const FADE_MS = 480; +function extractStreamedQuestions(text: string): string[] { + const keyIdx = text.indexOf('"reasoningQuestions"'); + if (keyIdx === -1) return []; + const bracketIdx = text.indexOf("[", keyIdx); + if (bracketIdx === -1) return []; + const after = text.slice(bracketIdx + 1); + const questions: string[] = []; + let pos = 0; + while (pos < after.length) { + const c = after[pos]; + if (c === "]") break; + if (c === '"') { + let end = pos + 1; + let str = ""; + while (end < after.length) { + if (after[end] === "\\" && end + 1 < after.length) { + str += after[end + 1]; + end += 2; + continue; + } + if (after[end] === '"') break; + str += after[end]; + end++; + } + if (end >= after.length) break; + questions.push(str); + pos = end + 1; + } else { + pos++; + } + } + return questions; +} + +export type EditorWritingCoachHostHandle = { + triggerManually: () => void; +}; + type Props = { editorApiRef: React.MutableRefObject; activePath: string | null; @@ -33,13 +73,14 @@ function cooldownActive(iso: string | undefined): boolean { return Number.isFinite(t) && t > Date.now(); } -export function EditorWritingCoachHost({ +export const EditorWritingCoachHost = forwardRef(function EditorWritingCoachHost({ editorApiRef, activePath, workspaceReady, showMarkdownSource, onOpenMarkdownPath, -}: Props) { +}, ref) { + const { t } = useTranslation(); const hostRef = useRef(null); const [depthMode, setDepthMode] = useState("auto"); const [writingCoachEnabled, setWritingCoachEnabled] = useState(true); @@ -213,34 +254,80 @@ export function EditorWritingCoachHost({ clearBubbleTimers(); }, [activePath, clearBubbleTimers]); - const onBubbleClick = useCallback(() => { - clearBubbleTimers(); - setBubbleVisible(false); - setBubbleFading(false); + const analyzeAndShowPanel = useCallback((text: string) => { setPanelOpen(true); setPanelLoading(true); setPanelError(null); setCoachData(null); - const text = paragraphRef.current; const rel = activePath ?? ""; if (!isTauri() || !rel) { setPanelLoading(false); setPanelError("Not available."); return; } + const sessionId = `wc-${crypto.randomUUID()}`; + let accumulated = ""; + const unlisteners: UnlistenFn[] = []; + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + for (const fn of unlisteners) fn(); + unlisteners.length = 0; + }; void (async () => { try { + const chunkUn = await listen<{ sessionId: string; delta: string }>( + "llm:stream-chunk", + (e) => { + if (e.payload.sessionId !== sessionId) return; + accumulated += e.payload.delta; + const questions = extractStreamedQuestions(accumulated); + if (questions.length > 0) { + setCoachData((prev) => ({ + reasoningQuestions: questions, + links: prev?.links ?? [], + knowledgeModuleSkipped: prev?.knowledgeModuleSkipped ?? false, + })); + } + }, + ); + unlisteners.push(chunkUn); const resp = await invoke("analyze_writing_coach", { - args: { paragraphText: text, relPath: rel, uiLocale: getAppLocale() }, + args: { paragraphText: text, relPath: rel, uiLocale: getAppLocale(), sessionId }, }); setCoachData(resp); } catch (e) { setPanelError(e instanceof Error ? e.message : String(e)); } finally { + cleanup(); setPanelLoading(false); } })(); - }, [activePath, clearBubbleTimers]); + }, [activePath]); + + const onBubbleClick = useCallback(() => { + clearBubbleTimers(); + setBubbleVisible(false); + setBubbleFading(false); + analyzeAndShowPanel(paragraphRef.current); + }, [analyzeAndShowPanel, clearBubbleTimers]); + + const handleManualTrigger = useCallback(() => { + const view = editorApiRef.current?.getEditorView(); + if (!view || !activePath) return; + const text = nearestTextblockText(view.state); + if (!text.trim()) return; + clearBubbleTimers(); + setBubbleVisible(false); + setBubbleFading(false); + triggerLockRef.current = true; + analyzeAndShowPanel(text); + }, [activePath, analyzeAndShowPanel, clearBubbleTimers, editorApiRef]); + + useImperativeHandle(ref, () => ({ + triggerManually: handleManualTrigger, + }), [handleManualTrigger]); const onCollapsePanel = useCallback(() => { setPanelOpen(false); @@ -257,8 +344,30 @@ export function EditorWritingCoachHost({ return null; } + const showTriggerBtn = !bubbleVisible && !panelOpen && !showMarkdownSource; + return ( -
+
+ {showTriggerBtn ? ( + + ) : null}
); -} +}); diff --git a/src/hooks/useWritingCoachTrigger.ts b/src/hooks/useWritingCoachTrigger.ts index 61bdb85..5636846 100644 --- a/src/hooks/useWritingCoachTrigger.ts +++ b/src/hooks/useWritingCoachTrigger.ts @@ -10,7 +10,7 @@ function countCodePoints(s: string): number { } /** 取光标所在最近 textblock 的纯文本 */ -function nearestTextblockText(state: EditorView["state"]): string { +export function nearestTextblockText(state: EditorView["state"]): string { const { $from } = state.selection; // 光标多在 text 内:parent 常为 textblock(Milkdown 多层包裹时比自 depth 向下更稳) const parent = $from.parent; diff --git a/src/locales/en.json b/src/locales/en.json index 49a95c0..e8b7f3e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -68,7 +68,8 @@ "writingCoachLoading": "Analyzing…", "writingCoachError": "Could not load suggestions.", "writingCoachKnowledge": "Related notes", - "writingCoachNoSuggestions": "No suggestions right now." + "writingCoachNoSuggestions": "No suggestions right now.", + "writingCoachTrigger": "Analyze current paragraph" }, "diskNotice": { "text": "This file was modified on disk while you have unsaved edits in the editor. Reload loads the on-disk version and discards your current buffer for this tab. Dismiss to keep editing (you may need to resolve the conflict manually).", @@ -652,7 +653,8 @@ "footerHint": "Esc to close · ⌘⇧P / Ctrl+Shift+P · ⌘F / Ctrl+F find in editor", "cognitiveReport": "Cognitive growth report", "thoughtVaultHub": "All thoughts hub", - "workspaceSearch": "Search workspace" + "workspaceSearch": "Search workspace", + "writingCoach": "Writing coach" }, "editorFind": { "title": "Find in document", diff --git a/src/locales/zh.json b/src/locales/zh.json index c0f4d5a..ba6b74d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -68,7 +68,8 @@ "writingCoachLoading": "正在分析…", "writingCoachError": "无法加载建议。", "writingCoachKnowledge": "可能关联的笔记", - "writingCoachNoSuggestions": "暂时没有建议。" + "writingCoachNoSuggestions": "暂时没有建议。", + "writingCoachTrigger": "分析当前段落" }, "diskNotice": { "text": "磁盘上的文件已变更,而编辑器中仍有未保存修改。重新加载会采用磁盘版本并丢弃本标签当前的缓冲区内容。忽略可继续编辑(你可能需要手动解决冲突)。", @@ -652,7 +653,8 @@ "footerHint": "Esc 关闭 · ⌘⇧P / Ctrl+Shift+P · 正文内 ⌘F / Ctrl+F 篇内查找", "cognitiveReport": "认知成长报告", "thoughtVaultHub": "全库想法中心", - "workspaceSearch": "全文搜索 Vault" + "workspaceSearch": "全文搜索 Vault", + "writingCoach": "写作教练" }, "editorFind": { "title": "篇内查找", From dd2897264f059b1940a70eb99af4d6d763d4fced Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Wed, 8 Jul 2026 10:29:20 +0800 Subject: [PATCH 13/26] feat(onboarding): add 4-step onboarding wizard Co-authored-by: Claude (Opus 4.6) --- resources/onboarding/sample_challenges.json | 51 +++ src-tauri/src/lib.rs | 8 +- src-tauri/src/onboarding.rs | 92 ++++++ src/App.tsx | 15 + src/components/CommandPalette.tsx | 18 +- src/components/OnboardingOverlay.css | 300 ++++++++++++++++++ src/components/OnboardingOverlay.tsx | 334 ++++++++++++++++++++ src/locales/en.json | 33 +- src/locales/zh.json | 33 +- 9 files changed, 878 insertions(+), 6 deletions(-) create mode 100644 resources/onboarding/sample_challenges.json create mode 100644 src-tauri/src/onboarding.rs create mode 100644 src/components/OnboardingOverlay.css create mode 100644 src/components/OnboardingOverlay.tsx diff --git a/resources/onboarding/sample_challenges.json b/resources/onboarding/sample_challenges.json new file mode 100644 index 0000000..b65ef3c --- /dev/null +++ b/resources/onboarding/sample_challenges.json @@ -0,0 +1,51 @@ +{ + "thoughts": [ + { + "key": "feynman", + "body_zh": "费曼学习法的核心不是「简化」——是「发现你无法简化的地方」。当你无法用简单语言解释一个概念时,说明你的理解有漏洞。", + "body_en": "The core of the Feynman technique isn't 'simplifying' — it's 'discovering what you can't simplify.' When you can't explain a concept in plain language, it reveals gaps in your understanding.", + "summary_zh": "费曼学习法的核心洞察", + "summary_en": "Core insight of the Feynman technique" + }, + { + "key": "spaced-repetition", + "body_zh": "间隔效应说明大脑在「快要忘记」的时刻复习效果最好。这解释了为什么考前突击虽然能通过考试,但两周后什么都不记得。", + "body_en": "The spacing effect shows that the brain learns best when reviewing at the moment of near-forgetting. This explains why cramming before an exam might pass the test, but leaves nothing two weeks later.", + "summary_zh": "间隔重复与遗忘曲线", + "summary_en": "Spaced repetition and the forgetting curve" + }, + { + "key": "hicks-law", + "body_zh": "少即是多不是美学口号——它是认知负载理论的应用。每增加一个选项,用户做决定的时间指数增长(Hick's Law)。", + "body_en": "Less is more isn't an aesthetic slogan — it's applied cognitive load theory. Each additional option increases decision time exponentially (Hick's Law).", + "summary_zh": "Hick's Law 与认知负载", + "summary_en": "Hick's Law and cognitive load" + } + ], + "challenges": [ + { + "thoughtKey": "feynman", + "questionType": "apply", + "question_zh": "你最近学了一个新的编程概念,请试着用费曼学习法的思路来检验你是否真正理解了它。你会怎么做?", + "question_en": "You recently learned a new programming concept. How would you apply the Feynman technique to test whether you truly understand it?", + "sampleFeedback_zh": "很好的思考!你抓住了费曼学习法的关键——不是简单重复,而是通过「教别人」来暴露理解的盲区。坚持这个习惯,你会发现学习效率大幅提升。", + "sampleFeedback_en": "Great thinking! You've grasped the key insight — it's not about simplifying, but about exposing gaps in understanding through teaching. Keep this habit, and you'll see a significant boost in learning efficiency." + }, + { + "thoughtKey": "spaced-repetition", + "questionType": "explain", + "question_zh": "为什么「快要忘记」的时刻复习效果最好?用你自己的话解释这背后的原理。", + "question_en": "Why is reviewing most effective at the moment of near-forgetting? Explain the underlying principle in your own words.", + "sampleFeedback_zh": "不错的解释!关键在于:大脑在「费力回忆」时形成的记忆痕迹更深。就像锻炼肌肉,适度的挑战才能促进成长。", + "sampleFeedback_en": "Nice explanation! The key point: the brain forms deeper memory traces when 'effortful retrieval' is required. Like exercising muscles, moderate challenge promotes growth." + }, + { + "thoughtKey": "hicks-law", + "questionType": "apply", + "question_zh": "想象你正在设计一个设置页面,有 15 个选项。根据 Hick's Law,你会如何优化用户体验?", + "question_en": "Imagine you're designing a settings page with 15 options. Based on Hick's Law, how would you optimize the user experience?", + "sampleFeedback_zh": "很棒的思路!把选项分组、设置合理的默认值、隐藏高级选项——这些都是 Hick's Law 的实际应用。好的设计就是帮用户减少不必要的决策。", + "sampleFeedback_en": "Great approach! Grouping options, setting sensible defaults, hiding advanced options — these are all practical applications of Hick's Law. Good design helps users avoid unnecessary decisions." + } + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c4712f..6ddb5d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ mod understanding_graph; mod link_recommendation; mod topic_network; mod tools; +mod onboarding; mod skills; /// 供前端展示的文件树节点(目录带 children,Markdown 文件为叶子) @@ -64,7 +65,7 @@ struct MarkdownFileSignature { modified_ns: String, } -struct WorkspaceState { +pub(crate) struct WorkspaceState { /// 当前已授权工作区根目录,仅允许命令访问这里 root: Mutex>, /// 本应用已成功写入的路径与时间,用于忽略紧随其后的 notify @@ -214,7 +215,7 @@ pub(crate) fn sanitize_io_error(err: std::io::Error, permission_context: &'stati } } -fn lock_workspace_root(state: &tauri::State<'_, WorkspaceState>) -> Result { +pub(crate) fn lock_workspace_root(state: &tauri::State<'_, WorkspaceState>) -> Result { let guard = match state.root.lock() { Ok(g) => g, Err(poisoned) => { @@ -1800,7 +1801,8 @@ pub fn run() { skills::commands::update_custom_skill, skills::commands::delete_custom_skill, skills::commands::reload_custom_skills, - skills::commands::list_available_tools + skills::commands::list_available_tools, + onboarding::seed_onboarding_content ]) .setup(|app| { use tauri::Manager; diff --git a/src-tauri/src/onboarding.rs b/src-tauri/src/onboarding.rs new file mode 100644 index 0000000..df1da8e --- /dev/null +++ b/src-tauri/src/onboarding.rs @@ -0,0 +1,92 @@ +use chrono::Utc; +use serde::Serialize; +use std::fs; +use std::path::Path; + +use crate::vault_thoughts_db; + +const SEED_MARKER: &str = ".knowforge/onboarding_seeded"; + +struct SeedThought { + body: &'static str, + summary: &'static str, +} + +const SEED_THOUGHTS: &[SeedThought] = &[ + SeedThought { + body: "费曼学习法的核心不是「简化」——是「发现你无法简化的地方」。当你无法用简单语言解释一个概念时,说明你的理解有漏洞。\n\nThe core of the Feynman technique isn't 'simplifying' — it's 'discovering what you can't simplify.' When you can't explain a concept in plain language, it reveals gaps in your understanding.", + summary: "Feynman technique: expose understanding gaps", + }, + SeedThought { + body: "间隔效应说明大脑在「快要忘记」的时刻复习效果最好。这解释了为什么考前突击虽然能通过考试,但两周后什么都不记得。\n\nThe spacing effect shows that the brain learns best when reviewing at the moment of near-forgetting. This explains why cramming might pass the test, but leaves nothing two weeks later.", + summary: "Spaced repetition and the forgetting curve", + }, + SeedThought { + body: "少即是多不是美学口号——它是认知负载理论的应用。每增加一个选项,用户做决定的时间指数增长(Hick's Law)。\n\nLess is more isn't an aesthetic slogan — it's applied cognitive load theory. Each additional option increases decision time exponentially (Hick's Law).", + summary: "Hick's Law and cognitive load", + }, +]; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SeedResult { + pub seeded: bool, + pub thought_ids: Vec, +} + +#[tauri::command] +pub async fn seed_onboarding_content( + state: tauri::State<'_, crate::WorkspaceState>, +) -> Result { + let root = crate::lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || seed_blocking(&root)) + .await + .map_err(|e| e.to_string())? +} + +fn seed_blocking(vault_root: &Path) -> Result { + let marker = vault_root.join(SEED_MARKER); + if marker.exists() { + return Ok(SeedResult { + seeded: false, + thought_ids: Vec::new(), + }); + } + + let conn = vault_thoughts_db::open_thoughts_db(vault_root)?; + let now = Utc::now().to_rfc3339(); + let mut ids = Vec::new(); + + for seed in SEED_THOUGHTS { + let thought_id = format!("thought-{}", uuid::Uuid::new_v4().simple()); + let note_stable_id = thought_id.clone(); + let note_rel_path = format!(".knowforge/standalone/{thought_id}"); + + vault_thoughts_db::upsert_thought_body( + &conn, + &thought_id, + ¬e_stable_id, + ¬e_rel_path, + seed.body, + Some(seed.summary), + "seedling", + true, + true, + &now, + &now, + 0, + Some(&now), + )?; + ids.push(thought_id); + } + + if let Some(parent) = marker.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create marker dir: {e}"))?; + } + fs::write(&marker, "seeded").map_err(|e| format!("write marker: {e}"))?; + + Ok(SeedResult { + seeded: true, + thought_ids: ids, + }) +} diff --git a/src/App.tsx b/src/App.tsx index 9c530e0..f91342c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,7 @@ import { WorkspaceSearchModal } from "./components/WorkspaceSearchModal"; import { EditorFindBar } from "./components/EditorFindBar"; import { GraphTabShell } from "./components/GraphTabShell"; import { ActivityBar, type LeftPanelView } from "./components/ActivityBar"; +import { OnboardingOverlay } from "./components/OnboardingOverlay"; import { KF_PRIVATE_LOCK_ICON_DOC_BAR_PX } from "./constants/kfPrivateUi"; import { useKfPrivateForPath } from "./hooks/useKfPrivateForPath"; import { useOpenDocs } from "./hooks/useOpenDocs"; @@ -176,6 +177,7 @@ function App() { const [thoughtMgmtBodyDirty, setThoughtMgmtBodyDirty] = useState(false); const [editorSaveThoughtText, setEditorSaveThoughtText] = useState(null); const [workspaceSearchOpen, setWorkspaceSearchOpen] = useState(false); + const [onboardingOpen, setOnboardingOpen] = useState(false); /** 同标签重复点全文搜索结果时仍触发预览滚动 */ const [workspaceSearchGotoEpoch, setWorkspaceSearchGotoEpoch] = useState(0); const [editorFindOpen, setEditorFindOpen] = useState(false); @@ -313,6 +315,13 @@ function App() { setRightPanelTab("review"); }, []); + useEffect(() => { + if (!workspaceReady || !tauriRuntime) return; + if (localStorage.getItem("knowforge:onboardingCompleted")) return; + invoke("seed_onboarding_content").catch(() => {}); + setOnboardingOpen(true); + }, [workspaceReady, tauriRuntime]); + const { setPreferredNewMarkdownDir, fileTreeFileOps, @@ -1732,6 +1741,7 @@ function App() { ? () => writingCoachRef.current?.triggerManually() : undefined } + onStartOnboarding={() => setOnboardingOpen(true)} /> )} setCognitiveReportOpen(false)} /> + setOnboardingOpen(false)} + tauriRuntime={tauriRuntime} + /> ); } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 125a504..90784f1 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -20,6 +20,7 @@ type Props = { onOpenThoughtVaultHub?: () => void; onOpenWorkspaceSearch?: () => void; onTriggerWritingCoach?: () => void; + onStartOnboarding?: () => void; }; export function CommandPalette({ @@ -29,6 +30,7 @@ export function CommandPalette({ onOpenThoughtVaultHub, onOpenWorkspaceSearch, onTriggerWritingCoach, + onStartOnboarding, }: Props) { const { t } = useTranslation(); const [q, setQ] = useState(""); @@ -45,10 +47,13 @@ export function CommandPalette({ onOpenWorkspaceSearchRef.current = onOpenWorkspaceSearch; const onTriggerWritingCoachRef = useRef(onTriggerWritingCoach); onTriggerWritingCoachRef.current = onTriggerWritingCoach; + const onStartOnboardingRef = useRef(onStartOnboarding); + onStartOnboardingRef.current = onStartOnboarding; const hasThoughtVaultHub = onOpenThoughtVaultHub != null; const hasWorkspaceSearch = onOpenWorkspaceSearch != null; const hasWritingCoach = onTriggerWritingCoach != null; + const hasOnboarding = onStartOnboarding != null; const items: CommandItem[] = useMemo(() => { const base: CommandItem[] = [ @@ -95,8 +100,19 @@ export function CommandPalette({ }, }); } + if (hasOnboarding) { + base.push({ + id: "onboarding", + label: t("commandPalette.onboarding"), + keywords: "引导 新手 onboarding guide tutorial 教程", + onSelect: () => { + onStartOnboardingRef.current?.(); + onCloseRef.current(); + }, + }); + } return base; - }, [t, hasThoughtVaultHub, hasWorkspaceSearch, hasWritingCoach]); + }, [t, hasThoughtVaultHub, hasWorkspaceSearch, hasWritingCoach, hasOnboarding]); const filtered = useMemo(() => { const s = q.trim().toLowerCase(); diff --git a/src/components/OnboardingOverlay.css b/src/components/OnboardingOverlay.css new file mode 100644 index 0000000..68bb566 --- /dev/null +++ b/src/components/OnboardingOverlay.css @@ -0,0 +1,300 @@ +.onboarding__scrim { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, 0.5); +} + +.onboarding__card { + width: 100%; + max-width: 520px; + max-height: min(90vh, 680px); + overflow-y: auto; + padding: 28px 32px 24px; + background: var(--color-bg, #fff); + border-radius: 14px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.22); + box-sizing: border-box; +} + +.onboarding__step-indicator { + text-align: center; + font-size: 12px; + color: var(--color-text-secondary, #888); + margin-bottom: 16px; + letter-spacing: 0.04em; +} + +.onboarding__step { + display: flex; + flex-direction: column; + gap: 12px; +} + +.onboarding__title { + margin: 0; + font-size: 22px; + font-weight: 700; + color: var(--color-text, #222); + text-align: center; +} + +.onboarding__subtitle { + margin: 0; + font-size: 14px; + color: var(--color-text-secondary, #666); + text-align: center; +} + +.onboarding__desc { + margin: 0; + font-size: 13.5px; + line-height: 1.6; + color: var(--color-text-secondary, #555); +} + +/* Step 2: thought card */ +.onboarding__thought-card { + display: flex; + gap: 10px; + padding: 14px 16px; + background: var(--color-bg-secondary, #f7f7f8); + border-radius: 8px; + border-left: 3px solid var(--color-accent, #4f9cf7); +} + +.onboarding__thought-maturity { + font-size: 20px; + flex-shrink: 0; +} + +.onboarding__thought-body { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--color-text, #333); +} + +/* Step 2: challenge */ +.onboarding__challenge { + display: flex; + flex-direction: column; + gap: 10px; +} + +.onboarding__question { + margin: 0; + font-size: 13.5px; + font-weight: 500; + line-height: 1.5; + color: var(--color-text, #333); + padding: 10px 14px; + background: var(--color-bg-secondary, #f0f4f8); + border-radius: 6px; +} + +.onboarding__answer-input { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + font-size: 13px; + line-height: 1.5; + border: 1px solid var(--color-border, #d0d5dd); + border-radius: 6px; + resize: vertical; + font-family: inherit; + background: var(--color-bg, #fff); + color: var(--color-text, #333); +} + +.onboarding__answer-input:focus { + outline: none; + border-color: var(--color-accent, #4f9cf7); + box-shadow: 0 0 0 2px rgba(79, 156, 247, 0.15); +} + +/* Step 2: feedback */ +.onboarding__feedback { + display: flex; + flex-direction: column; + gap: 8px; +} + +.onboarding__feedback-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-secondary, #888); +} + +.onboarding__feedback-text { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--color-text, #333); + padding: 10px 14px; + background: var(--color-bg-success, #ecfdf3); + border-radius: 6px; + border-left: 3px solid #22c55e; +} + +.onboarding__maturity-animation { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 8px 0; + font-size: 28px; +} + +.onboarding__maturity-arrow { + font-size: 18px; + color: var(--color-text-secondary, #888); + animation: onboarding-arrow-pulse 1.2s ease-in-out infinite; +} + +@keyframes onboarding-arrow-pulse { + 0%, 100% { opacity: 0.4; transform: translateX(0); } + 50% { opacity: 1; transform: translateX(4px); } +} + +.onboarding__maturity-hint { + margin: 0; + font-size: 12px; + color: var(--color-text-secondary, #888); + text-align: center; +} + +/* Step 3: AI form */ +.onboarding__ai-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.onboarding__field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.onboarding__field-label { + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary, #666); +} + +.onboarding__field-input { + padding: 8px 10px; + font-size: 13px; + border: 1px solid var(--color-border, #d0d5dd); + border-radius: 6px; + font-family: inherit; + background: var(--color-bg, #fff); + color: var(--color-text, #333); +} + +.onboarding__field-input:focus { + outline: none; + border-color: var(--color-accent, #4f9cf7); + box-shadow: 0 0 0 2px rgba(79, 156, 247, 0.15); +} + +.onboarding__ai-saved { + text-align: center; +} + +.onboarding__ai-saved-msg { + font-size: 15px; + font-weight: 500; + color: #22c55e; + margin: 12px 0; +} + +/* Step 4: tip cards */ +.onboarding__tips { + display: flex; + flex-direction: column; + gap: 10px; +} + +.onboarding__tip-card { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 12px 14px; + background: var(--color-bg-secondary, #f7f7f8); + border-radius: 8px; +} + +.onboarding__tip-icon { + font-size: 20px; + flex-shrink: 0; + line-height: 1; + padding-top: 2px; +} + +.onboarding__tip-text { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; + line-height: 1.4; + color: var(--color-text, #333); +} + +.onboarding__tip-text strong { + font-weight: 600; +} + +.onboarding__tip-text span { + color: var(--color-text-secondary, #666); + font-size: 12.5px; +} + +/* Shared actions */ +.onboarding__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +.onboarding__btn { + padding: 8px 18px; + font-size: 13px; + font-weight: 500; + border: none; + border-radius: 6px; + cursor: pointer; + font-family: inherit; + transition: background 0.15s, opacity 0.15s; +} + +.onboarding__btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.onboarding__btn--primary { + background: var(--color-accent, #4f9cf7); + color: #fff; +} + +.onboarding__btn--primary:hover:not(:disabled) { + filter: brightness(1.08); +} + +.onboarding__btn--secondary { + background: var(--color-bg-secondary, #eee); + color: var(--color-text-secondary, #555); +} + +.onboarding__btn--secondary:hover:not(:disabled) { + background: var(--color-border, #ddd); +} diff --git a/src/components/OnboardingOverlay.tsx b/src/components/OnboardingOverlay.tsx new file mode 100644 index 0000000..2e32548 --- /dev/null +++ b/src/components/OnboardingOverlay.tsx @@ -0,0 +1,334 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { invoke } from "@tauri-apps/api/core"; +import sampleData from "../../resources/onboarding/sample_challenges.json"; +import "./OnboardingOverlay.css"; + +type Step = 1 | 2 | 3 | 4; + +type Props = { + open: boolean; + onClose: () => void; + tauriRuntime: boolean; +}; + +const TOTAL_STEPS = 4; + +export function OnboardingOverlay({ open, onClose, tauriRuntime }: Props) { + const { t, i18n } = useTranslation(); + const isZh = i18n.language.startsWith("zh"); + const [step, setStep] = useState(1); + const [answer, setAnswer] = useState(""); + const [showFeedback, setShowFeedback] = useState(false); + const [providerUrl, setProviderUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [aiSaved, setAiSaved] = useState(false); + const [aiSaving, setAiSaving] = useState(false); + const textareaRef = useRef(null); + + const challenge = sampleData.challenges[0]; + const thought = sampleData.thoughts[0]; + + useEffect(() => { + if (open) { + setStep(1); + setAnswer(""); + setShowFeedback(false); + setProviderUrl(""); + setApiKey(""); + setAiSaved(false); + setAiSaving(false); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + finish(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); + + const finish = useCallback(() => { + localStorage.setItem("knowforge:onboardingCompleted", "true"); + onClose(); + }, [onClose]); + + const goNext = useCallback(() => { + setStep((s) => (s < TOTAL_STEPS ? ((s + 1) as Step) : s)); + }, []); + + const goPrev = useCallback(() => { + setStep((s) => (s > 1 ? ((s - 1) as Step) : s)); + }, []); + + const handleSubmitAnswer = useCallback(() => { + setShowFeedback(true); + }, []); + + const handleSaveAi = useCallback(async () => { + if (!tauriRuntime || !providerUrl.trim()) return; + setAiSaving(true); + try { + const providerId = "onboarding-provider"; + await invoke("save_vault_config_patch", { + patch: { + ai: { + activeProviderId: providerId, + providers: [ + { + id: providerId, + label: "AI Provider", + baseUrl: providerUrl.trim(), + apiKey: apiKey.trim() || undefined, + isRemote: true, + }, + ], + }, + }, + }); + setAiSaved(true); + } catch { + // silently fail — user can configure later + } finally { + setAiSaving(false); + } + }, [tauriRuntime, providerUrl, apiKey]); + + if (!open) return null; + + const stepIndicator = ( +
+ {t("onboarding.stepOf", { current: step, total: TOTAL_STEPS })} +
+ ); + + return ( +
+
e.stopPropagation()} + > + {stepIndicator} + + {step === 1 && ( +
+

{t("onboarding.step1Title")}

+

{t("onboarding.step1Subtitle")}

+

{t("onboarding.step1Desc")}

+
+ + +
+
+ )} + + {step === 2 && ( +
+

{t("onboarding.step2Title")}

+

{t("onboarding.step2Desc")}

+ +
+ 🌱 +

+ {isZh ? thought.body_zh : thought.body_en} +

+
+ +
+

+ {isZh ? challenge.question_zh : challenge.question_en} +

+ + {!showFeedback ? ( + <> +