diff --git a/CHANGELOG.md b/CHANGELOG.md index f8116e2..925b753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.7.5] - 2026-07-08 + +### Added +- Activity Bar for global navigation (files, graph, thoughts) with instant CSS tooltips +- Save-as-thought button in editor selection toolbar +- 4-step onboarding wizard for new users +- Writing coach: manual trigger, performance optimizations, and streaming support +- Active model label displayed below the AI chat input box + +### Changed +- Link recommendations moved from full-screen view to right sidebar tab (4th tab, disabled when no document is open) +- Thought management rendered as Activity Bar view mode instead of a separate panel +- Right panel simplified to 3 core tabs (outline, AI, review) plus link-rec +- Open-folder action moved from top toolbar to sidebar footer +- ThoughtSavePopover redesigned: section dividers, labels, search icon, options-bar grouping +- Depth selector removed from thought-save popover; default depth changed to "deep" +- New-file and new-folder icons redesigned for better visual distinction +- Redundant "workspace keyword search" toggle removed from AI panel (semantic search covers it) + +### Fixed +- Editor scroll position now saves/restores correctly when switching document tabs +- macOS traffic light controls vertically centered in the toolbar +- Sidebar toggle icon no longer clipped when sidebar is collapsed on macOS +- Search icon stays visible when sidebar is collapsed +- File tree auto-expands when clicking the files icon in Activity Bar +- Activity Bar remains visible when sidebar is collapsed +- Thought-save popover dropdown now closes on blur + ## [0.7.4] - 2026-07-06 ### Added diff --git a/package.json b/package.json index 05b58df..7227fc1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "knowforge", "private": true, - "version": "0.7.4", + "version": "0.7.5", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/plan/INDEX.md b/plan/INDEX.md deleted file mode 100644 index 067437d..0000000 --- a/plan/INDEX.md +++ /dev/null @@ -1,76 +0,0 @@ -# Plan Index - -> 总控表:所有待办 spec 的状态、优先级一览。 -> 最后更新:2026-07-06 - ---- - -## Active - -(暂无进行中的 spec) - ---- - -## 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` — 会话上下文管理分析 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/Cargo.lock b/src-tauri/Cargo.lock index 75012fe..ce71a71 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2664,7 +2664,7 @@ dependencies = [ [[package]] name = "knowforge" -version = "0.7.4" +version = "0.7.5" dependencies = [ "aho-corasick", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4b22715..2906d32 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "knowforge" -version = "0.7.4" +version = "0.7.5" description = "Knowforge desktop app" authors = ["caichangqing"] edition = "2024" 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/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/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-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_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-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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ffa9500..2767aaf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Knowforge", - "version": "0.7.4", + "version": "0.7.5", "identifier": "com.knowforge.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index c61d735..89be8ac 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -11,7 +11,8 @@ "shadow": true, "backgroundColor": "#e8e8e8", "titleBarStyle": "Overlay", - "hiddenTitle": true + "hiddenTitle": true, + "trafficLightPosition": { "x": 14, "y": 15 } } ] } diff --git a/src/App.css b/src/App.css index cfbb548..7351d77 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); } /* 顶栏横跨整窗:左侧固定宽(与下方文件列对齐)+ 标签区 */ @@ -108,15 +108,9 @@ body { z-index: 2; } -/* macOS Overlay 标题栏:系统已处理窗口圆角,不再需要自定义裁切;内容下移避免被交通灯遮挡 */ -.layout--platform-mac #root { +/* macOS Overlay 标题栏:系统已处理窗口圆角,不再需要自定义裁切 */ +.layout.layout--platform-mac { border-radius: 0; - overflow: visible; -} - -.layout--platform-mac .layout { - border-radius: 0; - padding-top: 28px; } .layout.layout--tauri [data-tauri-drag-region] { @@ -182,10 +176,109 @@ 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; } +/* ── 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: visible; +} + +.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[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); +} + +.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); + } + .activity-bar__btn[data-tooltip]:hover::after { + background: #e4e4e7; + color: #18181b; + } +} + .app-top-toolbar__start { flex: 0 0 var(--sidebar-width, 196px); box-sizing: border-box; @@ -204,11 +297,12 @@ body { } .layout.layout--sidebar-collapsed .app-top-toolbar__start { - flex: 0 0 auto; - width: auto; + flex: 0 0 36px; + width: 36px; border-right: none; } + .app-top-toolbar__editor { flex: 1; min-width: 0; @@ -489,6 +583,12 @@ body { gap: calc(8px * var(--tool-strip-scale)); } +/* 侧栏折叠时放开宽度约束,让 74px 左内边距 + 按钮不被裁切 */ +.layout--platform-mac.layout--sidebar-collapsed .app-top-toolbar__start { + flex-basis: auto; + width: auto; +} + .layout--platform-windows .app-top-toolbar__window-controls--desktop, .layout--platform-linux .app-top-toolbar__window-controls--desktop { gap: 0; @@ -641,17 +741,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 { @@ -670,7 +763,7 @@ body { } .layout.layout--sidebar-collapsed .content-area { - grid-column: 1 / -1; + grid-column: 2 / -1; } .content-area--with-right-panel { @@ -1237,6 +1330,14 @@ body { border-top: 1px solid rgba(0, 0, 0, 0.08); } +.ai-chat__model-label { + font-size: 10px; + line-height: 1.3; + text-align: right; + color: var(--kf-text-muted, #94a3b8); + padding: 0 2px; +} + /* 输入区与底栏工具条分区:正文仅在 textarea 内,不叠压深度/发送 */ .ai-chat__composer-field { width: 100%; @@ -1460,6 +1561,14 @@ body { border-radius: 4px; background: rgba(0, 0, 0, 0.028); border: 1px solid rgba(0, 0, 0, 0.06); + cursor: pointer; + font: inherit; + color: inherit; + text-align: inherit; +} + +.sidebar__root:hover { + background: rgba(0, 0, 0, 0.06); } .sidebar__root-icon { @@ -2466,6 +2575,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; @@ -3369,6 +3483,10 @@ html:lang(zh-Hans) .settings-lang-toggle .app-modal__btn { border-color: rgba(255, 255, 255, 0.1); } + .sidebar__root:hover { + background: rgba(255, 255, 255, 0.08); + } + .sidebar__root-icon { color: #a1a1aa; opacity: 0.65; diff --git a/src/App.tsx b/src/App.tsx index 6e43d97..c28e3b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,16 +32,19 @@ 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"; 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"; 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"; @@ -162,6 +165,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); @@ -170,11 +174,10 @@ 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 [editorSaveThoughtText, setEditorSaveThoughtText] = useState(null); const [workspaceSearchOpen, setWorkspaceSearchOpen] = useState(false); + const [onboardingOpen, setOnboardingOpen] = useState(false); /** 同标签重复点全文搜索结果时仍触发预览滚动 */ const [workspaceSearchGotoEpoch, setWorkspaceSearchGotoEpoch] = useState(0); const [editorFindOpen, setEditorFindOpen] = useState(false); @@ -186,10 +189,10 @@ 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, + leftSidebarPx: sidebarOpen ? leftResizable.width : 36, }); const rightResizable = useResizable({ side: "right", @@ -197,23 +200,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); @@ -221,6 +207,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); @@ -288,21 +275,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, @@ -338,10 +310,18 @@ function App() { }, [reviewDueTabCount, rightPanelTab, rootPath]); const requestOpenChallengeReview = useCallback(() => { + setLeftPanelView("files"); setRightPanelOpen(true); 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, @@ -528,25 +508,22 @@ 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); + if (view === "files") setSidebarOpen(true); + return true; + }, [leftPanelView, thoughtMgmtBodyDirty, t]); /** 选中 Outline 但尚无可用编辑器文档时自动切到 AI,避免右栏空白且无法切换 */ useEffect(() => { @@ -555,12 +532,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) => { @@ -726,6 +697,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; @@ -1055,11 +1036,11 @@ function App() { openMarkdownTab={onOpenCoachMarkdownPath} >
@@ -1089,45 +1070,42 @@ 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) { - 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()} - /> -
- )} -
- {!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")}
@@ -1357,8 +1240,13 @@ function App() { id="app-file-directory-pane" className="app-sidebar-column" {...tauriWindowDragProps} - aria-hidden={!sidebarOpen} > + void changeView(v)} + onOpenCognitiveReport={() => setCognitiveReportOpen(true)} + onOpenSettings={() => setAiSettingsOpen(true)} + />
+ {leftPanelView === "graph" ? ( +
+ { + setLeftPanelView("files"); + void onOpenCoachMarkdownPath(relPath); + }} + /> +
+ ) : leftPanelView === "thoughts" ? ( + thoughtManagementSessionActive ? ( +
+ + { + setLeftPanelView("files"); + void onOpenCoachMarkdownPath(relPath); + }} + /> + +
+ ) : null + ) : (
{docState.activePath != null && docState.hasDiskStaleConflict(docState.activePath) && @@ -1687,6 +1559,7 @@ function App() { onEditorDispose={() => { crepeEditorApiRef.current = null; }} + onSaveAsThought={setEditorSaveThoughtText} />
@@ -1708,6 +1581,7 @@ function App() { /> ) : null} - {showRightColumn && ( + )} + {showRightColumn && leftPanelView === "files" && (
)} - {showRightColumn && ( + {showRightColumn && leftPanelView === "files" && ( setSidebarOpen(false)} + onAfterSelectAiTab={() => setSidebarOpen(false)} outlineTabEnabled={editorReady} - graphTabEnabled={workspaceReady && tauriRuntime && !!rootPath} - linkRecTabEnabled={editorReady} + linkRecTabEnabled={docState.activePath != null} tauriDragExclude={tauriRuntime} outlineToolbarEnd={ rightPanelTab === "outline" && editorReady ? ( @@ -1805,6 +1679,7 @@ function App() { /> } + aiPanel={} linkRecPanel={ } - aiPanel={} - graphPanel={ - { - void onOpenCoachMarkdownPath(relPath); - }} - onTogglePanelWide={ - workspaceReady && tauriRuntime ? toggleUnderstandingGraphPanelWide : undefined - } - graphPanelWideExpanded={graphPanelWideExpanded} - /> - } reviewPanel={ setRightPanelTab("ai")} />} reviewTabBadgeCount={reviewTabBadgeCount} /> )}
- {thoughtManagementSessionActive ? ( -
- - { - setThoughtManagementPageOpen(false); - void onOpenCoachMarkdownPath(relPath); - }} - /> - -
- ) : null} {aiSettingsOpen ? ( setWorkspaceSearchOpen(true) : undefined } + onTriggerWritingCoach={ + editorUsable + ? () => writingCoachRef.current?.triggerManually() + : undefined + } + onStartOnboarding={() => setOnboardingOpen(true)} /> + {editorSaveThoughtText != null && ( + setEditorSaveThoughtText(null)} + onCancel={() => setEditorSaveThoughtText(null)} + /> + )} setCognitiveReportOpen(false)} /> + setOnboardingOpen(false)} + tauriRuntime={tauriRuntime} + /> ); } diff --git a/src/components/ActivityBar.tsx b/src/components/ActivityBar.tsx new file mode 100644 index 0000000..60b238b --- /dev/null +++ b/src/components/ActivityBar.tsx @@ -0,0 +1,180 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; + +export type LeftPanelView = "files" | "graph" | "thoughts"; + +type Props = { + activeView: LeftPanelView; + onViewChange: (view: LeftPanelView) => void; + onOpenCognitiveReport: () => void; + onOpenSettings: () => void; +}; + +function FilesIcon() { + return ( + + + + ); +} + +function GraphIcon() { + return ( + + + + + + + ); +} + +function ThoughtsIcon() { + return ( + + + + + + + + + ); +} + +function ReportIcon() { + return ( + + + + + + + + ); +} + +function SettingsIcon() { + return ( + + + + + ); +} + +const VIEW_ICONS: Record React.JSX.Element> = { + files: FilesIcon, + graph: GraphIcon, + thoughts: ThoughtsIcon, +}; + +const VIEW_I18N_KEYS: Record = { + files: "activityBar.files", + graph: "activityBar.graph", + thoughts: "activityBar.thoughts", +}; + +export function ActivityBar({ + activeView, + onViewChange, + onOpenCognitiveReport, + onOpenSettings, +}: Props) { + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/src/components/AiConversationPanel.tsx b/src/components/AiConversationPanel.tsx index 1dcb7ae..d532a95 100644 --- a/src/components/AiConversationPanel.tsx +++ b/src/components/AiConversationPanel.tsx @@ -144,7 +144,6 @@ export function AiConversationPanel() { workspaceReady, tauriRuntime, includeVaultContext, - setIncludeVaultContext, isVaultSearching, setIsVaultSearching, depthMode, @@ -220,6 +219,7 @@ export function AiConversationPanel() { /** Iter 5 #4:工具调用总开关从 vault config 读取(默认 true,旧 vault 缺字段时取 true)。 * 通过 VAULT_CONFIG_UPDATED_EVENT 在设置保存后实时同步,无需重开会话。 */ const [toolsEnabled, setToolsEnabled] = useState(true); + const [activeModelLabel, setActiveModelLabel] = useState(null); /** Iter 5 #3:内置 skill manifest 缓存。skill 在后端 setup() 注册一次后不变,挂载时取一次即可。 */ const [skillsCache, setSkillsCache] = useState([]); @@ -241,6 +241,9 @@ export function AiConversationPanel() { const cfg = await invoke("get_vault_config_for_ui"); if (disposed) return; setToolsEnabled(cfg.ai.toolsEnabled !== false); + const p = cfg.ai?.providers?.find((x: ProviderProfileForUi) => x.id === cfg.ai?.activeProviderId); + const model = p?.lastUsedModel?.trim() || p?.defaultModel?.trim() || null; + setActiveModelLabel(model ? `${p!.label} / ${model}` : null); } catch { /* 加载失败保持默认 true,避免阻塞用户 */ } @@ -1463,15 +1466,6 @@ export function AiConversationPanel() { } onSaved={handleThoughtSaved} onCancel={handleThoughtPopoverCancel} - depthSlot={ - - } /> ) : null} @@ -1603,17 +1597,6 @@ export function AiConversationPanel() { ) : null} -
{slashOpen ? ( @@ -1718,6 +1701,9 @@ export function AiConversationPanel() {
+ {activeModelLabel ? ( +
{activeModelLabel}
+ ) : null} {copyToast ? ( 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")}

+ ) : null}
); -} +}); diff --git a/src/components/FileTree.tsx b/src/components/FileTree.tsx index aec98c1..d81a56e 100644 --- a/src/components/FileTree.tsx +++ b/src/components/FileTree.tsx @@ -528,10 +528,12 @@ export function FileTree({ strokeLinejoin="round" aria-hidden={true} > - + - - + + + + ) : null} @@ -558,9 +560,10 @@ export function FileTree({ strokeLinejoin="round" aria-hidden={true} > - - - + + + + ) : null} 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 ? ( + <> +