From e68d283651de97147b0e58fcbe2948e0323b73d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 5 Aug 2026 22:15:34 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20UI/UX=E9=81=97=E7=95=99?= =?UTF-8?q?=E6=89=B9(=E5=AE=A1=E8=AE=A1/AI=E5=91=BD=E4=BB=A4=E5=B1=82/?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E7=BB=84=E4=BB=B6,=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=89=8D=E5=9F=BA=E7=BA=BF=E6=94=B6=E5=B0=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod - AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation - 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n - 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试 --- .../df-storage/tests/project_soft_delete.rs | 200 +++++++++++- .../aichat历史会话实证诊断-2026-08-04.md | 82 +++++ src-tauri/src/commands/ai/audit/mod.rs | 38 +++ src-tauri/src/commands/ai/audit/record.rs | 2 +- .../src/commands/ai/commands/conversation.rs | 4 +- .../src/commands/ai/commands/provider.rs | 2 +- src-tauri/src/commands/ai/fetch_search.rs | 2 +- src-tauri/src/commands/ai/fetch_url.rs | 24 +- src-tauri/src/commands/ai/generate_image.rs | 81 ++++- src-tauri/src/commands/ai/skills.rs | 10 +- src-tauri/src/commands/ai/tool_registry.rs | 8 +- src-tauri/src/commands/ai/tools/file.rs | 38 ++- src-tauri/src/commands/ai/tools/mod.rs | 1 + src-tauri/src/commands/idea.rs | 111 +++++-- src/App.vue | 5 +- src/components/AiChat.vue | 2 +- src/components/Paginator.vue | 2 +- src/components/ai/ApprovalOverlay.vue | 4 +- src/components/ai/CIStatus.vue | 2 +- src/components/ai/ConflictResolver.vue | 2 +- src/components/ai/ConversationSidebar.vue | 2 +- src/components/ai/TopBar.vue | 2 +- .../dashboard/ActiveProjectsPanel.vue | 2 +- src/components/ideas/IdeaDetail.vue | 151 ++++++++- src/components/knowledge/KnowledgeDetail.vue | 2 +- src/components/layout/AppSidebar.vue | 6 +- src/components/project/DependencyGraph.vue | 111 ++++++- src/components/project/GitChanges.vue | 10 +- src/components/project/ModuleNode.vue | 2 +- src/components/project/ProjectCard.vue | 2 +- src/components/settings/ConnectionPanel.vue | 2 +- .../workflow/WorkflowDagDisplay.vue | 6 +- src/composables/ai/useAiPanel.ts | 83 ++++- src/composables/ai/useMessageScroll.ts | 2 +- src/composables/ai/useStreamRenderer.ts | 4 +- src/composables/ai/useToolCard.ts | 16 +- src/composables/useMarkdown.ts | 6 + src/i18n/en/auditLog.ts | 9 + src/i18n/en/ideas.ts | 9 +- src/i18n/en/nav.ts | 2 +- src/i18n/zh-CN/auditLog.ts | 9 + src/i18n/zh-CN/ideas.ts | 7 + src/i18n/zh-CN/nav.ts | 2 +- src/main.ts | 8 +- src/styles/global.css | 4 +- src/views/ApprovalPopup.vue | 6 +- src/views/AuditLog.vue | 298 +++++++++++++++++- src/views/Dashboard.vue | 10 +- src/views/Projects.vue | 2 +- 49 files changed, 1257 insertions(+), 138 deletions(-) create mode 100644 docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md diff --git a/crates/df-storage/tests/project_soft_delete.rs b/crates/df-storage/tests/project_soft_delete.rs index bab1a02..b3324a4 100644 --- a/crates/df-storage/tests/project_soft_delete.rs +++ b/crates/df-storage/tests/project_soft_delete.rs @@ -5,9 +5,16 @@ //! 2. soft_delete / list_active / list_deleted / restore — 回收站生命周期。 //! 3. allowed_columns_for 按表隔离 — update_field 误传跨表列名在白名单阶段被拒。 -use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo}; +use df_storage::crud::{ + BranchRepo, ModuleDependencyRepo, NodeExecutionRepo, ProjectEventRepo, ProjectModuleRepo, + ProjectRepo, ProjectServiceRepo, ReleaseRepo, TaskLinkRepo, TaskRepo, WorkflowRepo, +}; use df_storage::db::Database; -use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord}; +use df_storage::models::{ + BranchRecord, ModuleDependencyRecord, NodeExecutionRecord, ProjectEventRecord, + ProjectModuleRecord, ProjectRecord, ProjectServiceRecord, ReleaseRecord, TaskRecord, + WorkflowRecord, +}; use df_types::types::{ProjectStatus, TaskStatus}; // ---------- fixtures ---------- @@ -153,6 +160,195 @@ async fn purge_with_descendants_is_atomic_on_missing_child_table_row() { assert!(!projects.purge_with_descendants("p1").await.unwrap()); } +#[tokio::test] +async fn purge_with_descendants_cascades_v29_v35_new_tables() { + // G1.2:purge 级联补全 V29-V35 新增表(project_modules/module_dependencies/ + // project_events/project_services/task_links)+ 经 workflow_executions 间接到项目的 + // node_executions。此前 purge 只删 branches/releases/tasks/projects 四表,带 + // 模块/事件/服务的工程在 PRAGMA foreign_keys=ON(无 ON DELETE CASCADE)下必 FK + // 违例回滚(latent bug)。本测试断言全部关联子表按 FK 拓扑 最深子表→父 级联清空。 + let db = Database::open_in_memory().await.expect("open_in_memory"); + let projects = ProjectRepo::new(&db); + let tasks = TaskRepo::new(&db); + let releases = ReleaseRepo::new(&db); + let branches = BranchRepo::new(&db); + let workflows = WorkflowRepo::new(&db); + let node_execs = NodeExecutionRepo::new(&db); + let events = ProjectEventRepo::new(&db); + let services = ProjectServiceRepo::new(&db); + let modules = ProjectModuleRepo::new(&db); + let deps = ModuleDependencyRepo::new(&db); + let links = TaskLinkRepo::new(&db); + + // p1 全谱系子记录(V1 四表 + V29-V35 新表) + projects.insert(project("p1")).await.unwrap(); + tasks.insert(task("t1", "p1")).await.unwrap(); + tasks.insert(task("t2", "p1")).await.unwrap(); + releases.insert(release("r1", "p1")).await.unwrap(); + branches.insert(branch("b1", "p1")).await.unwrap(); + workflows + .insert(WorkflowRecord { + id: "w1".into(), + name: "wf".into(), + dag_json: "{}".into(), + status: "pending".into(), + triggered_by: None, + project_id: Some("p1".into()), + task_id: None, + created_at: now_ts(), + completed_at: None, + }) + .await + .unwrap(); + node_execs + .insert(NodeExecutionRecord { + id: "n1".into(), + workflow_id: "w1".into(), + node_id: "node1".into(), + node_type: "ai".into(), + status: "pending".into(), + input_json: None, + output_json: None, + error_message: None, + started_at: None, + completed_at: None, + }) + .await + .unwrap(); + events + .insert(ProjectEventRecord { + id: "e1".into(), + project_id: "p1".into(), + event_type: "task_created".into(), + entity_type: Some("task".into()), + entity_id: Some("t1".into()), + from_state: None, + to_state: None, + context_json: None, + source: Some("human".into()), + conversation_id: None, + created_at: now_ts(), + }) + .await + .unwrap(); + services + .insert_validated(ProjectServiceRecord { + id: "s1".into(), + project_id: "p1".into(), + name: "db".into(), + service_type: "mysql".into(), + endpoint: Some("localhost:3306".into()), + config_json: None, + environment: "development".into(), + remark: None, + created_at: now_ts(), + updated_at: now_ts(), + }) + .await + .unwrap(); + modules + .insert(ProjectModuleRecord { + id: "m1".into(), + project_id: "p1".into(), + name: "mod-1".into(), + path: "C:/p1/mod1".into(), + git_url: None, + stack: None, + auto_detected: false, + sort_order: 0, + created_at: now_ts(), + updated_at: now_ts(), + description: None, + status: None, + }) + .await + .unwrap(); + modules + .insert(ProjectModuleRecord { + id: "m2".into(), + project_id: "p1".into(), + name: "mod-2".into(), + path: "C:/p1/mod2".into(), + git_url: None, + stack: None, + auto_detected: false, + sort_order: 1, + created_at: now_ts(), + updated_at: now_ts(), + description: None, + status: None, + }) + .await + .unwrap(); + deps + .insert(ModuleDependencyRecord { + id: "d1".into(), + project_id: "p1".into(), + from_module_id: "m1".into(), + to_module_id: "m2".into(), + dep_type: "library".into(), + label: None, + created_at: now_ts(), + }) + .await + .unwrap(); + links + .create_link("l1", "t1", "t2", "relates_to", None) + .await + .unwrap(); + + // 邻居项目 p2 只插一个 module,验证隔离(不波及) + projects.insert(project("p2")).await.unwrap(); + modules + .insert(ProjectModuleRecord { + id: "mp2".into(), + project_id: "p2".into(), + name: "mod-p2".into(), + path: "C:/p2/mod".into(), + git_url: None, + stack: None, + auto_detected: false, + sort_order: 0, + created_at: now_ts(), + updated_at: now_ts(), + description: None, + status: None, + }) + .await + .unwrap(); + + let affected = projects.purge_with_descendants("p1").await.unwrap(); + assert!(affected, "应命中 p1"); + + // p1 全谱系子记录消失(最深子表→父逐层清空) + assert!(projects.get_by_id("p1").await.unwrap().is_none()); + assert!(tasks.get_by_id("t1").await.unwrap().is_none()); + assert!(tasks.get_by_id("t2").await.unwrap().is_none()); + assert!(releases.get_by_id("r1").await.unwrap().is_none()); + assert!(branches.get_by_id("b1").await.unwrap().is_none()); + assert!(workflows.get_by_id("w1").await.unwrap().is_none()); + assert!(node_execs.get_by_id("n1").await.unwrap().is_none()); + assert!(links.get_by_id("l1").await.unwrap().is_none(), "task_links 应级联清空"); + assert!(modules.get_by_id("m1").await.unwrap().is_none()); + assert!(modules.get_by_id("m2").await.unwrap().is_none()); + assert!( + deps.list_by_field("project_id", "p1").await.unwrap().is_empty(), + "module_dependencies 应级联清空" + ); + assert!( + events.get_by_project("p1", 200).await.unwrap().is_empty(), + "project_events 应级联清空" + ); + assert!( + services.list_by_project("p1").await.unwrap().is_empty(), + "project_services 应级联清空" + ); + + // 邻居 p2 完好(其 module 未被波及) + assert!(projects.get_by_id("p2").await.unwrap().is_some()); + assert!(modules.get_by_id("mp2").await.unwrap().is_some()); +} + // ============================================================ // 2. soft_delete / list_active / list_deleted / restore // ============================================================ diff --git a/docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md b/docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md new file mode 100644 index 0000000..c62ab85 --- /dev/null +++ b/docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md @@ -0,0 +1,82 @@ +# aichat 历史会话实证诊断 —— 弱模型工具行为失控 + +> 日期:2026-08-04 | 类型:会话实证分析(走查,未实施代码) | 关联:[待办 AC-1~5](../todo.md) + +## 触发背景 + +用户在 DevFlow aichat 发"你好",模型(sensenova-6.7-flash-lite)却调用: +- `list_project_modules({"project_id": "DevFlow"})` → 空 +- `search_files({"pattern": "scan", "path": "E:/wk-lab/devflow/src-tauri", "recursive": true})` + +## 数据来源与方法 + +- prod 库:`Roaming/top.1216.devflow/devflow.db`(92MB,76 会话/9928 消息/6392 工具执行) +- dev 库:`Roaming/top.1216.devflow/devflow-dev.db`(59MB,98 会话/7853 消息,trace 日志确认当前实例所用) +- 方法:复制副本 + node:sqlite 只读查询,按会话 dump 消息序列 + 工具执行记录反推行为 + +> 注:用户贴的"你好"会话原始调用(`"project_id":"DevFlow"` + `pattern:"scan"`)在三个库中均**未检索到**, +> 疑未落库或已清理。但同类行为在库中有大量实证,结论不依赖该单例。 + +## 实证 1:重复探索是系统性现象(非偶发) + +**同会话同工具同参数 ≥3 次的重复组合:83 个**(6392 次执行中)。 + +典型实例: + +| 会话 | 重复 | 内容 | +|------|------|------| +| `09e7abfa` | 目录列 3 次 + 读同文件 6 次 | `list_directory(devflow)` → `(devflow,max_depth:2)` → `(devflow,recursive:false)` → `run_command(Get-ChildItem)`;`PROGRESS.md` 被 read_file(3 种 limit)+ run_command Get-Content 读 6 次 | +| `f89d129a` | read_file 同文件 6 次 | `docs/待决策.md` | +| `e0b5d8fc` | update_task 同参 8 次 | 同 field/id/value 反复提交 | +| `b505d7c7` | patch_file / grep / list_directory 各 5-6 次 | 同参数反复 | +| `f64dee94` | file_info × 3 文件各 4 次 + list_projects 4 次 | 重复状态查询 | + +机制佐证:`prompt.rs:107` 已写"重复调用检测"规则,但**对弱模型纯说教无效** —— 需要机制层拦截。 + +## 实证 2:传 name 当 id,系统清单不给 UUID + +- `list_tasks(project_id:"DevFlow")` 出现于 8+ 个不同会话 +- `"meta-kit"` / `"devflow"` / `"HaoGamePlatfProject"` / `"u-ask"` / `"planning"` 等 name 全被当 id 传 +- **全部返回空**(`list_tasks` 需 UUID),模型不理解"为什么空",继续换工具瞎探 + +根因:`prompt.rs:304` 注入项目清单格式为 `- {name} ({status}): {description}`,**无 UUID**。模型只能拿 name 猜。 + +## 实证 3:read_symbol 被弃用,AST 代码智能落地空转 + +- read_file **2582** 次 vs read_symbol **36** 次(采用率 ~1.4%) +- 近 15 个 read_file 会话中 read_symbol 几乎全为 0(276:0, 166:0, 161:3, 139:3...) +- `prompt.rs:98` 大段引导"优先 read_symbol",弱模型基本不用 → AST 代码智能(7c2e3b2,目标降 token 24.4x)未生效,全文回灌照旧 + +## 实证 4:问候本身不触发工具,但弱模型任务中失控 + +- 修正:纯"你好"第一轮模型会正常回复能力介绍(09e7abfa/1982ff08 均如此),**0 例**"问候后紧邻调工具" +- 用户贴的"你好犯傻"更可能是:会话有历史上下文(scan/工程模块任务残留),模型把"你好"当继续信号,或 system prompt 全量 55 工具 + "优先使用工具"引导诱使弱模型"准备上下文" +- 一旦进入实质任务,弱模型即进入重复探索/绕行/参数错误循环 + +## 实证 5:失败工具画像(浪费的往返) + +| 工具 | 失败次数 | 类别 | +|------|---------|------| +| run_command | 67 | 命令执行失败/超时 | +| read_file | 55 | 路径错/授权 | +| search_files | 33 | 路径/参数 | +| advance_task | 31 | 状态机拒绝(非法迁移) | +| patch_file | 27 | 精确匹配失败 | +| delete_file | 22 | 授权/路径 | + +## 根因归纳 + +1. **工具全量暴露**:`Intent::Chat → &[]`(intent.rs:462)→ `filter_tool_defs` 回全量 55 工具(intent.rs:520)。弱模型面对全量工具无法自控。 +2. **弱模型能力天花板**:sensenova-6.7-flash-lite 判别"结果已足够/该停手"的能力弱,参数映射(name→id)能力弱。 +3. **机制缺位**:重复调用只靠 prompt 说教,无机制拦截;探索无预算上限。 +4. **上下文注入缺陷**:清单无 UUID;超大会话(504msgs/363tools)漂移放大。 + +## 待办映射 + +| 待办 | 内容 | 机制杠杆 | +|------|------|---------| +| AC-1 | 同参数重复调用缓存/拦截 | `agentic/mod.rs` 工具执行前查重,≥2 次返缓存结果 | +| AC-2 | 探索预算上限 + 目录列举去重 | loop 内轮次工具计数 + list_directory 结果快照 | +| AC-3 | 清单注入补 UUID | `prompt.rs:304` 项目行加 id(或注入 name→id 映射表) | +| AC-4 | read_symbol 机制化采用 | read_file 命中已注册符号时追加引导提示/自动换 read_symbol | +| AC-5 | 失败工具逐类复盘 | run_command 超时/advance_task 状态机提示前置 | diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 88c1814..d026ccd 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -12,6 +12,7 @@ use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft}; +use super::tools::entity_resolve; // utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。 // 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块 @@ -256,6 +257,43 @@ pub(crate) async fn process_tool_calls( }) .collect(); + // ── 实体参数 name→id 解析(机制层,治 list_tasks(project_id="moyu") 空返回)── + // resolve_entity_ids 只改 args / 返 Err;失败走结构化 failed envelope(镜像 path_denied), + // 不执行,让 LLM 拿到可行动错误自修。单点漏斗:auto + 审批 + 目录授权全部执行路径统一拿到已解析 id。 + let mut resolved_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::with_capacity(drafts.len()); + for (idx, draft, args) in drafts { + match entity_resolve::resolve_entity_ids(db, &draft.name, &args).await { + Ok(resolved) => resolved_drafts.push((idx, draft, resolved)), + Err(e) => { + let err_payload = serde_json::json!({ + "status": "failed", + "error": e.to_string(), + }).to_string(); + // 短 lock 段:push tool_result(纯写,无 await) + { + let __lock_t = std::time::Instant::now(); + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_payload)); + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] process_tool_calls:268 持锁 {:?} (含 lock 等待)", __hold); + } + } + let ev = AiChatEvent::AiToolCallCompleted { + id: draft.id.clone(), + result: serde_json::Value::String(err_payload.clone()), + conversation_id: Some(conv_id.to_string()), + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); + audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, + "failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await; + } + } + } + let drafts = resolved_drafts; + // ── 文件工具路径授权预校验 ── // 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search) // 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单): diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 43be64a..0f11d26 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -184,7 +184,7 @@ pub async fn list_tool_executions( query: Option, ) -> Result { let q = query.unwrap_or_default(); - let limit = q.limit.unwrap_or(50); + let _limit = q.limit.unwrap_or(50); let offset = q.offset.unwrap_or(0); let audit_q = AuditQuery::from(q); diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 68634e4..8c31841 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -568,7 +568,9 @@ pub async fn ai_conversation_delete( state: State<'_, AppState>, conversation_id: String, ) -> Result<(), String> { - state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?; + // G1.3:单事务删 ai_messages 子行 + ai_conversations 主行(孤儿根治)。 + // 顺序:先删数据再摘内存(下方 per_conv.remove)——防后台在途 save 在删主行后复活孤儿消息。 + state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?; let mut session = state.ai_session.lock().await; // 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap, diff --git a/src-tauri/src/commands/ai/commands/provider.rs b/src-tauri/src/commands/ai/commands/provider.rs index f5023ba..d290bdd 100644 --- a/src-tauri/src/commands/ai/commands/provider.rs +++ b/src-tauri/src/commands/ai/commands/provider.rs @@ -108,7 +108,7 @@ pub async fn ai_save_provider( provider_type: String, model_configs: Vec, ) -> Result { - // F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), + // 接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label), // 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。 // 编辑已有提供商时保留原 created_at,避免被覆盖 // F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。 diff --git a/src-tauri/src/commands/ai/fetch_search.rs b/src-tauri/src/commands/ai/fetch_search.rs index d294eec..17e79dc 100644 --- a/src-tauri/src/commands/ai/fetch_search.rs +++ b/src-tauri/src/commands/ai/fetch_search.rs @@ -200,7 +200,7 @@ fn extract_results(html: &str) -> Vec { "snippet": snip.cloned().unwrap_or_default(), })); } - (None, Some(snip)) => { + (None, Some(_snip)) => { // 残留 snippet 无对应标题(罕见,DDG 结构异常时兜底):跳过,无标题无 URL 无意义。 // 不构造半残条目,保持每条都有 title+url(让 LLM 能 fetch_url)。 } diff --git a/src-tauri/src/commands/ai/fetch_url.rs b/src-tauri/src/commands/ai/fetch_url.rs index 95f8f12..467b53b 100644 --- a/src-tauri/src/commands/ai/fetch_url.rs +++ b/src-tauri/src/commands/ai/fetch_url.rs @@ -566,8 +566,18 @@ fn find_obscura() -> Option { /// 从 markdown 启发式提取 title:首个 `# 一级标题` 或首个非空文本行(≤120 chars)。 /// obscura dump markdown 无独立 title 字段,此为最佳近似(静态管线有 ,此函数仅 render 分支用)。 fn extract_title_from_markdown(md: &str) -> Option<String> { + // 代码块围栏状态:true=当前在 ``` 代码块内,块内行全部跳过(不当作标题)。 + // 修复:原实现只跳过 ``` 围栏行本身,块内容行(如 "code block")会被误当标题。 + let mut in_code_block = false; for line in md.lines() { let t = line.trim(); + if t.starts_with("```") { + in_code_block = !in_code_block; // 切换围栏状态(``` 开或闭) + continue; + } + if in_code_block { + continue; // 代码块内:跳过 + } if t.is_empty() { continue; } @@ -579,10 +589,13 @@ fn extract_title_from_markdown(md: &str) -> Option<String> { } } // 否则取首个非空、非表格/代码块的行(去掉 markdown 强调前缀) - if !t.starts_with('|') && !t.starts_with("```") { + if !t.starts_with('|') { let cleaned: String = t.trim_start_matches(|c: char| c == '*' || c == '-').trim().to_string(); - if !cleaned.is_empty() && cleaned.chars().count() <= 120 { - return Some(cleaned); + // 超长行(>120)截断到 120 作标题(innerText 纯文本无结构时首行即正文, + // 原实现整行 >120 直接跳过 → title 为 None;截断保留可读标题)。 + if !cleaned.is_empty() { + let sliced: String = cleaned.chars().take(120).collect(); + return Some(sliced); } } } @@ -815,8 +828,9 @@ mod tests { #[test] fn test_substantial_content_real_markdown_passes() { - // 真实正文:600 chars,无空壳标记,应判有实质内容 - let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。\n".repeat(15); + // 真实正文:>500 chars(达到 has_substantial_content 的 500 字符下限阈值), + // 无空壳标记,应判有实质内容。原测试 md 仅 494 chars(<500)被前置阈值拦截误判空壳。 + let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。异步与同步的区别在于并发模型。\n".repeat(15); assert!(has_substantial_content(&md)); } diff --git a/src-tauri/src/commands/ai/generate_image.rs b/src-tauri/src/commands/ai/generate_image.rs index 1dd145b..d494df1 100644 --- a/src-tauri/src/commands/ai/generate_image.rs +++ b/src-tauri/src/commands/ai/generate_image.rs @@ -475,6 +475,7 @@ fn body_snippet(v: &Value) -> String { #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; // ── build_images_url:端点拼接规则 ── @@ -641,8 +642,6 @@ mod tests { // 边界:估算值 = MAX_IMAGE_BYTES + 1 → 应被拒 // 反推 b64 长度:要使 len*3/4 == MAX+1,len = (MAX+1)*4/3 let target = MAX_IMAGE_BYTES + 1; - let b64_len = (target as u128 * 4 / 3) as usize; - let estimated = estimate_decoded_bytes(b64_len); // 因整数除法,估算可能略小于 target,但应保证 > MAX_IMAGE_BYTES // 用更精确的 b64_len:target*4/3 + 4(向上补一个 base64 块),保证估算严格 > MAX let b64_len_safe = ((target as u128 * 4 + 2) / 3) as usize; @@ -795,4 +794,82 @@ mod tests { msg ); } + + // ── 真实 SenseNova U1-Fast 图像生成验证(#[ignore],手动跑) ── + // + // 端到端验证:用真实 db 副本(SenseNova provider 配置)+ OS keyring 解析 api_key + + // 真实调 https://token.sensenova.cn/v1/images/generations 生成一张测试图并下载落地。 + // 覆盖 generate_image 全链路:provider 选择(host 匹配)/ build_images_url 端点拼接 / + // 请求构造 / 响应解析 / SSRF 防护下载落地。 + // + // 运行方式: + // set DEVFLOW_LIVE_DB=<真实 devflow.db 路径(含 wal)> + // cargo test -p devflow --lib -- --ignored live_sensenova_u1_fast --nocapture + // + // 需真实 API 凭证(付费),默认 #[ignore] 不随 CI 跑。落盘到 <cwd>/tmp_generated/ 便于清理。 + + /// 构造带持久授权目录的 AllowedDirs,指向项目内 tmp_generated/ 输出目录。 + fn mk_allowed_with_generated_dir() -> (AllowedDirs, PathBuf) { + let out_dir = std::env::current_dir() + .expect("读 cwd") + .join("tmp_generated"); + let mut persistent = std::collections::HashSet::new(); + persistent.insert(out_dir.clone()); + (AllowedDirs { persistent, session: Default::default(), once: Default::default() }, out_dir) + } + + #[tokio::test] + #[ignore] // 真实 API 调用(付费),需 DEVFLOW_LIVE_DB 环境变量,手动跑 + async fn live_sensenova_u1_fast() { + let db_path = std::env::var("DEVFLOW_LIVE_DB") + .map(PathBuf::from) + .expect("请设置 DEVFLOW_LIVE_DB=<真实 devflow.db 路径> 以跑真实 API 验证"); + assert!( + db_path.exists(), + "DEVFLOW_LIVE_DB 指向的 db 不存在: {}", + db_path.display() + ); + + // 打开真实 db(会跑迁移,用真实路径;读 SenseNova provider + keyring 解析 key) + let db = Arc::new(Database::open(&db_path).await.expect("打开真实 db")); + let (allowed, out_dir) = mk_allowed_with_generated_dir(); + + // 走 execute_generate_image 全链路:model 默认 sensenova-u1-fast → host 匹配 SenseNova provider + let args = json!({ + "prompt": "一张极简科技风信息图,主题「DevFlow AI 工作流」,顶部大标题,三个步骤图标(规划→执行→核查),浅蓝灰配色", + "size": "1536x2752", + "n": 1 + }); + let result = execute_generate_image(args, &db, &Arc::new(RwLock::new(allowed))) + .await + .expect("execute_generate_image 应成功生成并下载图片"); + + let path = result["path"].as_str().expect("result 应有 path"); + let bytes = result["bytes_written"].as_u64().expect("result 应有 bytes_written"); + let model = result["model"].as_str().unwrap_or(""); + let provider_id = result["provider_id"].as_str().unwrap_or(""); + println!("✅ 生成成功:"); + println!(" model : {}", model); + println!(" provider : {}", provider_id); + println!(" 本地路径 : {}", path); + println!(" 字节数 : {}", bytes); + + // 断言:文件真实落盘 + 非空 + 是图片(PNG 魔数) + assert!( + std::path::Path::new(path).exists(), + "图片应真实落盘: {}", + path + ); + assert!(bytes > 1000, "图片应 > 1KB,实际 {} bytes", bytes); + let head = std::fs::read(path).expect("读图片头").into_iter().take(8).collect::<Vec<_>>(); + assert!( + head.starts_with(&[0x89, b'P', b'N', b'G']) || head.starts_with(&[0xFF, 0xD8, 0xFF]), + "应为 PNG/JPEG 魔数,实际头字节: {:?}", + head + ); + + // 清理输出目录(测后不留临时图) + let _ = std::fs::remove_dir_all(&out_dir); + println!(" (输出目录已清理:{})", out_dir.display()); + } } diff --git a/src-tauri/src/commands/ai/skills.rs b/src-tauri/src/commands/ai/skills.rs index 4cfeab4..06ebbc2 100644 --- a/src-tauri/src/commands/ai/skills.rs +++ b/src-tauri/src/commands/ai/skills.rs @@ -288,11 +288,11 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>; /// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。 /// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。 /// -/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces +/// `scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces /// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包 /// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。 /// -/// 锁中毒(P1-260617-3 加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。 +/// 锁中毒(加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。 /// 中毒 → tracing::error! 记录 + 返 None(调用方 `skills_cached` 得空 Vec / `read_skill_content_stripped` /// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建, /// 返空后下次 `invalidate_skills` 或进程重启自愈。 @@ -346,11 +346,11 @@ async fn skills_lock_async() -> Option<SkillsGuard> { /// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`(clone), /// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。 /// -/// P1-260617-3:改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking +/// 改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking /// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。 pub(crate) async fn skills_cached() -> Vec<SkillInfo> { let g = skills_lock_async().await; - // 锁中毒 → None → unwrap_or_default() 得空 Vec(对齐 P1-260617-3 中毒降级)。 + // 锁中毒 → None → unwrap_or_default() 得空 Vec(中毒降级)。 g.and_then(|g| g.clone()).unwrap_or_default() } @@ -376,7 +376,7 @@ pub(crate) fn invalidate_skills() { /// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。 /// 缓存未命中返 None;文件读失败返 None。 /// -/// P1-260617-3:改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。 +/// 改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。 /// /// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop, /// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。 diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index fa743f9..922f645 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -1251,8 +1251,8 @@ mod tests { /// /// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。 /// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。 - fn make_test_get_app_config_ctx(db: &Arc<Database>) -> super::get_app_config::GetAppConfigCtx { - super::get_app_config::GetAppConfigCtx { + fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx { + crate::commands::ai::get_app_config::GetAppConfigCtx { db: db.clone(), agent_max_iterations: Arc::new(AtomicUsize::new( crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS, @@ -1586,7 +1586,7 @@ mod tests { let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); let returned = res["returned_lines"].as_u64().expect("missing returned_lines"); assert_eq!(returned, 15, "无 offset + limit=15 应返回 15 行(旧 bug 固定返 500)"); - assert_eq!(res["has_more"], true, "600 行只取 15 行,应有更多"); + assert_eq!(res["truncated"], true, "600 行只取 15 行,应有更多"); fs::remove_dir_all(&tmp).ok(); } @@ -1613,7 +1613,7 @@ mod tests { let res = registry.execute("read_file", args).await.expect("read_file 执行失败"); let returned = res["returned_lines"].as_u64().expect("missing returned_lines"); assert_eq!(returned, 500, "无 offset 无 limit 应默认返回 500 行"); - assert_eq!(res["has_more"], true, "600 行只取 500 行,应有更多"); + assert_eq!(res["truncated"], true, "600 行只取 500 行,应有更多"); fs::remove_dir_all(&tmp).ok(); } diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs index 3c077fb..c979fd1 100644 --- a/src-tauri/src/commands/ai/tools/file.rs +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -150,13 +150,13 @@ pub fn register( // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。 // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。 let line_count = content.lines().count(); - let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() { + let (result, offset_used, has_more, limit) = if let Some(offset) = args["offset"].as_u64() { let lines: Vec<&str> = content.lines().collect(); let skip = offset as usize; let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize; let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect(); let more = (skip + page.len()) < line_count; - (page.join("\n"), Some(skip), more) + (page.join("\n"), Some(skip), more, limit) } else { // 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义), // 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。 @@ -168,13 +168,26 @@ pub fn register( .min(2000) as usize; let page: Vec<&str> = content.lines().take(limit).collect(); let more = line_count > page.len(); - (page.join("\n"), None, more) + (page.join("\n"), None, more, limit) }; + // 截断明文化:治模型把 has_more 当"被压缩/损坏"反复重读同文件(实证 read_file 同文件重复读 6 次)。 + // returned_lines=本次返回行数,consumed=累计已读(offset+本次),truncated_info 明确剩余行数+续读方式。 + let returned_lines = result.lines().count(); + let consumed = offset_used.unwrap_or(0) + returned_lines; + let truncated_info = if has_more { + Some(format!( + "文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={} 参数(可配合 limit={limit})", + offset_used.map(|o| o.to_string()).unwrap_or_else(|| "0".to_string()), + line_count - consumed, + consumed, + )) + } else { None }; Ok(serde_json::json!({ "path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count, "offset": offset_used, - "returned_lines": result.lines().count(), - "has_more": has_more, + "returned_lines": returned_lines, + "truncated": has_more, + "truncated_info": truncated_info, })) } ); @@ -1034,7 +1047,7 @@ pub fn register( registry, dummy: Arc<()>, "run_command", - "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。", + "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。返回含 succeeded 布尔(按 exit_code==0 判定)。注意:部分命令非零退出属正常语义(如 git diff 无差异返回 1),此时以 stdout/stderr 内容为准,勿仅凭 exit_code≠0 判失败。", RiskLevel::High, schema: object_schema(vec![ ("command", "string", true), @@ -1108,6 +1121,7 @@ pub fn register( let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT); Ok(serde_json::json!({ + "succeeded": result.exit_code == Some(0), "command": command, "working_dir": working_dir, "exit_code": result.exit_code, @@ -1289,11 +1303,15 @@ mod tests { let win_start = char_start.saturating_sub(context_chars); let win_end = (char_start + context_chars).min(total_chars); let window: String = line.chars().skip(win_start).take(win_end - win_start).collect(); + // 结构:window 前可加前导 …(仅 win_start>0,match 行首不加),窗口标记附加在 window 后。 + // 修复:原 format 在 window 前有字面量 …(±N字符窗口)…,致 match 行首(win_start=0) + // 也以 … 开头,违反「行首不前导 …」语义。 + let lead = if win_start > 0 { "…" } else { "" }; format!( - "{}…(±{}字符窗口)…{}", - if win_start > 0 { "…" } else { "" }, - context_chars, - window + "{lead}{window}…(±{context_chars}字符窗口)…", + lead = lead, + window = window, + context_chars = context_chars ) } diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index 309819c..e8bf40d 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -23,3 +23,4 @@ pub mod idea; pub mod trash; pub mod file; pub mod download_file; +pub mod entity_resolve; diff --git a/src-tauri/src/commands/idea.rs b/src-tauri/src/commands/idea.rs index 4612d60..3a996f1 100644 --- a/src-tauri/src/commands/idea.rs +++ b/src-tauri/src/commands/idea.rs @@ -266,7 +266,6 @@ pub async fn evaluate_idea( state: State<'_, AppState>, id: String, ) -> Result<IdeaRecord, String> { - // 取出灵感 let record = state .ideas .get_by_id(&id) @@ -274,13 +273,7 @@ pub async fn evaluate_idea( .map_err(err_str)? .ok_or_else(|| format!("灵感不存在: {id}"))?; - let idea = record_to_idea(&record); - - // 多维评分(0-10,IPC 层 *10 缩放为 0-100) - let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea); - - // 对抗式评估(构造注入:从 DB 读默认 provider 装配 LLM,无 provider/构造失败 → 启发式兜底) - // F-01 阶段5: 透传 model_configs 池,evaluate_with_llm 经路由选模型(池空兜底 default_model)。 + // 构造 engine(单次评估) let provider = build_default_provider(&state).await; let engine = match provider { Some((p, pool)) => { @@ -288,6 +281,92 @@ pub async fn evaluate_idea( } None => df_ideas::adversarial::AdversarialEngine::heuristic(), }; + + evaluate_one(&state, record, &engine).await +} + +/// 批量评估灵感:复用同一 AdversarialEngine 实例(同 provider/model_pool),逐条评估并持久化。 +/// +/// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。 +/// - provider 构造仅一次(批量场景减少重复初始化开销)。 +/// - 返回 BatchEvalResult { success, errors },前端据此展示部分成功/失败。 +#[tauri::command] +pub async fn evaluate_ideas_batch( + state: State<'_, AppState>, + ids: Vec<String>, +) -> Result<BatchEvalResult, String> { + let provider = build_default_provider(&state).await; + let engine = match provider { + Some((p, pool)) => { + df_ideas::adversarial::AdversarialEngine::with_pool(Arc::from(p), pool) + } + None => df_ideas::adversarial::AdversarialEngine::heuristic(), + }; + + let mut success = Vec::new(); + let mut errors = Vec::new(); + + for id in ids { + let record = match state.ideas.get_by_id(&id).await { + Ok(Some(r)) => r, + Ok(None) => { + errors.push(BatchEvalError { + id: id.clone(), + error: format!("灵感不存在: {id}"), + }); + continue; + } + Err(e) => { + errors.push(BatchEvalError { + id: id.clone(), + error: e.to_string(), + }); + continue; + } + }; + match evaluate_one(&state, record, &engine).await { + Ok(updated) => success.push(updated), + Err(e) => errors.push(BatchEvalError { id, error: e }), + } + } + + Ok(BatchEvalResult { success, errors }) +} + +/// 批量评估结果 +#[derive(Debug, serde::Serialize)] +pub struct BatchEvalResult { + /// 成功评估的灵感记录 + pub success: Vec<IdeaRecord>, + /// 失败的灵感 ID + 错误信息 + pub errors: Vec<BatchEvalError>, +} + +/// 批量评估单项错误 +#[derive(Debug, serde::Serialize)] +pub struct BatchEvalError { + pub id: String, + pub error: String, +} + +/// 单条灵感评估内部函数(evaluate_idea / evaluate_ideas_batch 共用)。 +/// +/// 接收已构造的 AdversarialEngine(批量场景复用同一实例),完成: +/// 1. 多维评分 + 对抗评估 +/// 2. 组装 ai_analysis / scores JSON +/// 3. 原子写回主表(update_full) +/// 4. 追加评估历史快照(idea_evaluations,含 version 唯一约束重试) +async fn evaluate_one( + state: &State<'_, AppState>, + record: IdeaRecord, + engine: &df_ideas::adversarial::AdversarialEngine, +) -> Result<IdeaRecord, String> { + let id = record.id.clone(); + let idea = record_to_idea(&record); + + // 多维评分(0-10,IPC 层 *10 缩放为 0-100) + let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea); + let eval = engine.evaluate(&idea).await.map_err(err_str)?; // 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐) @@ -334,7 +413,6 @@ pub async fn evaluate_idea( let score_value = (scores.overall * 10.0).round() as i64; // 构造完整记录后单次原子写回(update_full 保留 id 与 created_at)。 - // ai_analysis/scores_json 按值 move 进主表记录后,下方历史快照仍需复用 → 此处 clone 保留绑定。 let updated = IdeaRecord { scores: Some(scores_json.clone()), ai_analysis: Some(ai_analysis.clone()), @@ -349,15 +427,8 @@ pub async fn evaluate_idea( .await .map_err(err_str)?; - // 追加一条评估历史快照(idea_evaluations 审计表,version 单调递增)。 - // 主表 update_full 成功后再追加,保证主表先落;历史表为额外冗余列(evaluated_by - // 独立冗余,ai_analysis JSON 内的 evaluated_by 字段保留不删)。 - // - // version 并发重复兜底(V25 唯一约束 + 重试):version 此前由 - // `list_by_idea().first().version + 1` 算出,读-改-写非原子,并发评估同一灵感 - // 可能写出相同 version。V25 在 idea_evaluations(idea_id, version) 上加了唯一索引, - // 此处捕获唯一约束冲突 → 重新查最新 version 重算并重试(上限 3 次防死循环)。 - // 单用户桌面应用并发概率极低,但唯一约束 + 重试是数据完整性兜底,值得做。 + // 追加评估历史快照(idea_evaluations 审计表,version 单调递增)。 + // version 并发重复兜底(V25 唯一约束 + 重试)。 let mut attempt = 0; let max_attempts = 3; loop { @@ -383,10 +454,6 @@ pub async fn evaluate_idea( match state.idea_evaluations.insert(eval_record).await { Ok(_) => break, Err(e) => { - // 唯一约束冲突(SQLite extended code 2067 / SQLITE_CONSTRAINT_UNIQUE) - // → version 并发重复,命中且未达上限则重试(重新查 version);否则向上抛错。 - // 检测逻辑收口到 df_storage::crud::is_unique_constraint_err,集中维护、 - // 大小写不敏感,不再散落脆弱的英文文案 contains。 if is_unique_constraint_err(&e) && attempt < max_attempts { tracing::warn!( "灵感 {id} 评估历史 version 唯一约束冲突,重试 {}/{}", diff --git a/src/App.vue b/src/App.vue index fcd2b1a..cc813c5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -109,11 +109,12 @@ function initTheme() { } } -// Ctrl+I / Cmd+I 切换 AI 面板 +// Ctrl+I / Cmd+I 三态循环:关闭 → 分栏打开 → 最大化 → 关闭(cyclePanelMode) +// UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰 function handleKeydown(e: KeyboardEvent) { if ((e.ctrlKey || e.metaKey) && e.key === 'i') { e.preventDefault() - aiStore.togglePanel() + aiStore.cyclePanelMode() } } diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 1037610..bf9f3db 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -274,7 +274,7 @@ const projectsStore = useProjectStore() // 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。 // 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至 // TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消。 -// 滚动边沿/跟随意图重置(SW-260618-18/B-260618-24)随 MessageList 迁移,由子组件自管。 +// 滚动边沿/跟随意图重置(SW-260618-18)随 MessageList 迁移,由子组件自管。 watch(() => store.state.activeConversationId, () => { if (editingMsgId.value) cancelEdit() }) diff --git a/src/components/Paginator.vue b/src/components/Paginator.vue index de8aaf7..275c218 100644 --- a/src/components/Paginator.vue +++ b/src/components/Paginator.vue @@ -147,7 +147,7 @@ function onPageSizeChange(val: string) { } .page-btn:hover:not(:disabled) { - background: rgba(108, 99, 255, 0.06); + background: var(--df-accent-bg); color: var(--df-text); } diff --git a/src/components/ai/ApprovalOverlay.vue b/src/components/ai/ApprovalOverlay.vue index c4788ed..6c1dd3b 100644 --- a/src/components/ai/ApprovalOverlay.vue +++ b/src/components/ai/ApprovalOverlay.vue @@ -123,7 +123,7 @@ function onDismiss() { border: 0.5px solid var(--df-accent); border-left: 3px solid var(--df-accent); border-radius: var(--df-radius-md); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(123, 111, 240, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--df-accent-soft); cursor: pointer; transition: all 0.18s var(--df-ease); user-select: none; @@ -155,7 +155,7 @@ function onDismiss() { min-width: 18px; height: 18px; padding: 0 5px; - border-radius: 9px; + border-radius: 8px; background: var(--df-danger); color: #fff; font-size: 10px; diff --git a/src/components/ai/CIStatus.vue b/src/components/ai/CIStatus.vue index 1f11d55..5a82f71 100644 --- a/src/components/ai/CIStatus.vue +++ b/src/components/ai/CIStatus.vue @@ -118,6 +118,6 @@ onMounted(async () => { .ci-item--success { border-color: var(--df-success); } .ci-item--failure { border-color: var(--df-danger); } .ci-item--pending { border-color: var(--df-warning); } -.ci-item-icon { font-size: 10px; font-weight: 700; } +.ci-item-icon { font-size: 10px; font-weight: 500; } .ci-item:hover { opacity: 0.8; } </style> diff --git a/src/components/ai/ConflictResolver.vue b/src/components/ai/ConflictResolver.vue index 797b1a3..b5f237f 100644 --- a/src/components/ai/ConflictResolver.vue +++ b/src/components/ai/ConflictResolver.vue @@ -143,7 +143,7 @@ watch( .conflict-type-badge { font-size: 10px; padding: 2px 6px; - border-radius: 3px; + border-radius: 4px; } .badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; } .badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; } diff --git a/src/components/ai/ConversationSidebar.vue b/src/components/ai/ConversationSidebar.vue index 943efd0..2cdd371 100644 --- a/src/components/ai/ConversationSidebar.vue +++ b/src/components/ai/ConversationSidebar.vue @@ -549,7 +549,7 @@ body.ai-sidebar-resizing * { font-size: 10px; color: var(--df-text-dim); border: 0.5px solid var(--df-border); - border-radius: 3px; + border-radius: 4px; padding: 0 4px; user-select: none; } diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index 0a26ebc..c4e69b8 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -1001,7 +1001,7 @@ onBeforeUnmount(() => { white-space: nowrap; } .ai-goal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--df-success); flex-shrink: 0; } -.ai-goal-check { color: var(--df-success); font-weight: bold; flex-shrink: 0; } +.ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; } .ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; } .ai-goal-remove:hover { color: var(--df-danger, #e5484d); diff --git a/src/components/dashboard/ActiveProjectsPanel.vue b/src/components/dashboard/ActiveProjectsPanel.vue index b56360c..2bcf300 100644 --- a/src/components/dashboard/ActiveProjectsPanel.vue +++ b/src/components/dashboard/ActiveProjectsPanel.vue @@ -130,7 +130,7 @@ const displayProjects = computed(() => letter-spacing: 0.3px; text-transform: uppercase; } -.chip-coding { background: rgba(123,111,240,0.12); color: var(--df-accent); } +.chip-coding { background: var(--df-accent-soft); color: var(--df-accent); } .chip-testing { background: rgba(240,199,94,0.12); color: var(--df-warning); } .chip-release { background: rgba(61,219,160,0.12); color: var(--df-success); } .chip-planning { background: rgba(94,175,240,0.12); color: var(--df-info); } diff --git a/src/components/ideas/IdeaDetail.vue b/src/components/ideas/IdeaDetail.vue index 6ce52cf..7acd580 100644 --- a/src/components/ideas/IdeaDetail.vue +++ b/src/components/ideas/IdeaDetail.vue @@ -180,9 +180,16 @@ </template> <!-- 编辑态 --> <template v-else> - <div v-if="candidateIdeas.length > 0" class="related-edit-list"> + <div class="related-search" v-if="candidateIdeas.length > 0"> + <input + v-model="relatedSearch" + class="related-search-input" + :placeholder="$t('ideas.relatedSearchPlaceholder')" + /> + </div> + <div v-if="filteredCandidates.length > 0" class="related-edit-list"> <label - v-for="idea in candidateIdeas" + v-for="idea in filteredCandidates" :key="idea.id" class="related-edit-item" > @@ -202,10 +209,15 @@ </template> </div> - <!-- 传统评分条(score-bar) --> + <!-- 传统评分条(score-bar,从旧评估体系保留,默认折叠以降低信息密度) --> <div class="detail-section"> - <h3>{{ $t('ideas.multiScoreTitle') }}</h3> - <div class="score-bar-chart" v-if="parseScores(idea.scores).length > 0"> + <h3> + <button class="section-toggle-btn" @click="scoreBarExpanded = !scoreBarExpanded"> + {{ $t('ideas.multiScoreTitle') }} + <span class="section-toggle-arrow" :class="{ 'is-open': scoreBarExpanded }">▾</span> + </button> + </h3> + <div v-show="scoreBarExpanded" class="score-bar-chart" v-if="parseScores(idea.scores).length > 0"> <div class="score-bar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name"> <span class="score-bar-label">{{ dim.name }}</span> <div class="score-bar-track"> @@ -220,10 +232,30 @@ <!-- 标签 --> <div class="detail-section"> <h3>{{ $t('ideas.tagsTitle') }}</h3> - <div class="tag-list" v-if="parseTags(idea.tags).length > 0"> - <span class="tag" v-for="tag in parseTags(idea.tags)" :key="tag">{{ tag }}</span> - </div> - <div v-else class="eval-report eval-report--muted">{{ $t('ideas.noTags') }}</div> + <template v-if="tagsEditing"> + <input + v-model="tagsEditInput" + class="detail-tags-edit-input" + :placeholder="$t('ideas.tagsPlaceholder')" + @keyup.enter="saveTagsEdit" + /> + <div class="tags-edit-actions"> + <button class="btn btn-primary btn-sm" :disabled="savingTags" @click="saveTagsEdit"> + {{ savingTags ? $t('common.loading') : $t('common.save') }} + </button> + <button class="btn btn-ghost btn-sm" :disabled="savingTags" @click="cancelTagsEdit">{{ $t('common.cancel') }}</button> + </div> + </template> + <template v-else> + <div class="tag-list" v-if="parseTags(idea.tags).length > 0"> + <span class="tag" v-for="tag in parseTags(idea.tags)" :key="tag">{{ tag }}</span> + <button class="btn btn-ghost btn-sm" @click="startTagsEdit">{{ $t('ideas.editTags') }}</button> + </div> + <div v-else class="eval-report eval-report--muted"> + {{ $t('ideas.noTags') }} + <button class="btn btn-ghost btn-sm" @click="startTagsEdit">{{ $t('ideas.editTags') }}</button> + </div> + </template> </div> <!-- 状态管理:P1-④ select → badge 点击(当前态显色 badge,点击展开下拉选项) --> @@ -292,6 +324,7 @@ const emit = defineEmits<{ (e: 'status-change', value: IdeaStatus): void (e: 'update-desc', value: string): void (e: 'update-related', ids: string[]): void + (e: 'update-tags', tags: string): void }>() const { t } = useI18n() @@ -336,6 +369,8 @@ const descExpanded = ref(false) // P0-① 详细论据(正反方/行动建议)折叠:默认收起,降低首屏信息密度,结论前置后用户按需展开 const debateExpanded = ref(false) +// score-bar 默认折叠(旧评估体系,降低信息密度) +const scoreBarExpanded = ref(false) function measureDesc() { const el = descRef.value @@ -348,6 +383,7 @@ function measureDesc() { watch(() => props.idea.id, () => { descExpanded.value = false debateExpanded.value = false + scoreBarExpanded.value = false void nextTick(measureDesc) }) watch(() => props.idea.description, () => void nextTick(measureDesc)) @@ -386,6 +422,15 @@ const vClickOutside: Directive<HTMLElement, () => void> = { } // ===== 关联灵感(related_ids JSON 数组字符串)===== +// 关联编辑搜索 +const relatedSearch = ref('') +const filteredCandidates = computed(() => { + const q = relatedSearch.value.trim().toLowerCase() + if (!q) return candidateIdeas.value + return candidateIdeas.value.filter( + i => (i.title ?? '').toLowerCase().includes(q) || (i.description ?? '').toLowerCase().includes(q), + ) +}) // 解析 props.idea.related_ids(JSON 字符串数组,null/空/非法 → []) const relatedIds = computed<string[]>(() => { const raw = props.idea.related_ids @@ -426,6 +471,7 @@ function confirmRelate() { function cancelRelate() { relatedEditing.value = false + relatedSearch.value = '' } function toggleSelect(id: string) { @@ -437,6 +483,33 @@ function toggleSelect(id: string) { } } +// ===== 标签编辑(show + edit 双态)===== +const tagsEditing = ref(false) +const tagsEditInput = ref('') +const savingTags = ref(false) + +function startTagsEdit() { + tagsEditInput.value = parseTags(props.idea.tags).join(', ') + tagsEditing.value = true +} + +async function saveTagsEdit() { + if (savingTags.value) return + savingTags.value = true + try { + const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean) + emit('update-tags', JSON.stringify(tags)) + tagsEditing.value = false + await nextTick() + } finally { + savingTags.value = false + } +} + +function cancelTagsEdit() { + tagsEditing.value = false +} + // 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示 function statusLabelKey(status: IdeaStatus): string { return props.statusOptions.find(s => s.value === status)?.labelKey ?? status @@ -639,7 +712,7 @@ watch(() => props.idea.id, loadHistory) color: var(--df-text-secondary); line-height: 1.6; padding: 14px 16px; - background: rgba(108, 99, 255, 0.06); + background: var(--df-accent-bg); border-left: 3px solid var(--df-accent); border-radius: 0 8px 8px 0; } @@ -884,7 +957,7 @@ watch(() => props.idea.id, loadHistory) font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); - background: rgba(108, 99, 255, 0.12); + background: var(--df-accent-soft); color: var(--df-accent); } .latest-tag { @@ -937,7 +1010,7 @@ watch(() => props.idea.id, loadHistory) .tag { font-size: 12px; padding: 4px 10px; border-radius: var(--df-radius-xs); - background: rgba(108, 99, 255, 0.1); + background: var(--df-accent-bg); color: var(--df-accent); } @@ -946,13 +1019,13 @@ watch(() => props.idea.id, loadHistory) .related-chip { font-size: 12px; padding: 4px 10px; border-radius: var(--df-radius-xs); - background: rgba(108, 99, 255, 0.1); + background: var(--df-accent-bg); color: var(--df-accent); text-decoration: none; cursor: pointer; transition: background 0.15s; } -.related-chip:hover { background: rgba(108, 99, 255, 0.2); } +.related-chip:hover { background: var(--df-accent-soft); } .related-chip-missing { cursor: default; opacity: 0.6; } .related-edit-list { @@ -971,6 +1044,52 @@ watch(() => props.idea.id, loadHistory) } .related-edit-item input { cursor: pointer; } .related-edit-actions { display: flex; gap: 8px; } +/* 关联编辑搜索框 */ +.related-search { margin-bottom: 8px; } +.related-search-input { + width: 100%; + box-sizing: border-box; + padding: 6px 10px; + border: 0.5px solid var(--df-border); + border-radius: var(--df-radius-sm); + background: var(--df-bg); + color: var(--df-text); + font-size: 13px; + outline: none; +} +.related-search-input:focus { + border-color: var(--df-accent); +} + +/* ===== 标签编辑 ===== */ +.detail-tags-edit-input { + width: 100%; + box-sizing: border-box; + padding: 6px 10px; + border: 0.5px solid var(--df-border); + border-radius: var(--df-radius-sm); + background: var(--df-bg); + color: var(--df-text); + font-size: 13px; + outline: none; +} +.detail-tags-edit-input:focus { + border-color: var(--df-accent); +} +.tags-edit-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} +.tag-list .btn-ghost { + font-size: 11px; + padding: 2px 8px; +} +.eval-report .btn-ghost { + font-size: 11px; + padding: 2px 8px; + margin-left: 6px; +} /* ===== 按钮已提取到 global.css 全局(.btn/.btn-primary/.btn-ghost/.btn-sm) ===== */ /* IdeaDetail 特有按钮定位 */ @@ -1057,8 +1176,8 @@ watch(() => props.idea.id, loadHistory) font-size: 13px; transition: background 0.15s; } -.status-menu-item:hover { background: rgba(108, 99, 255, 0.08); } -.status-menu-item.active { background: rgba(108, 99, 255, 0.12); font-weight: 500; } +.status-menu-item:hover { background: var(--df-accent-bg); } +.status-menu-item.active { background: var(--df-accent-soft); font-weight: 500; } /* menu 内 badge 取消 emoji 之外的全局 hover 等副作用,仅作色标 */ .status-menu-item .status-tag { cursor: pointer; } diff --git a/src/components/knowledge/KnowledgeDetail.vue b/src/components/knowledge/KnowledgeDetail.vue index 765f870..428b4df 100644 --- a/src/components/knowledge/KnowledgeDetail.vue +++ b/src/components/knowledge/KnowledgeDetail.vue @@ -368,7 +368,7 @@ watch(renderedContent, () => { /* ===== 知识内容 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css)===== */ .detail-content.ai-md { white-space: normal; } .detail-tags { display: flex; gap: 6px; flex-wrap: wrap; } -.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: rgba(108,99,255,0.1); color: var(--df-accent); } +.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: var(--df-accent-bg); color: var(--df-accent); } .muted { color: var(--df-text-dim); font-size: 12px; } /* 状态徽章 / 置信度 */ diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index d0462eb..bee84a1 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -68,8 +68,8 @@ <!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) --> <button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')"> <span class="nav-link-icon"> - <svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg> - <svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> + <svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg> + <svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> </span> <span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span> <span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span> @@ -150,7 +150,7 @@ const secondaryNav = [ { path: '/knowledge', label: 'nav.knowledge', svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' }, { path: '/audit', label: 'nav.audit', - svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>' }, + svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' }, ] </script> diff --git a/src/components/project/DependencyGraph.vue b/src/components/project/DependencyGraph.vue index 5dd8eff..9c3bbde 100644 --- a/src/components/project/DependencyGraph.vue +++ b/src/components/project/DependencyGraph.vue @@ -17,6 +17,22 @@ <div>{{ $t('dependencyGraph.empty') }}</div> </div> + <!-- 删除依赖确认弹层 --> + <ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" /> + + <!-- 边删除按钮(HTML 遮罩层):edge hover 时定位到边中点显示,leave 时隐藏。 + 用 HTML 而非 X6 port 方案,因为 graph.fromJSON 重建所有 edge 会清掉动态加的 port。 --> + <button + v-if="deleteDepBtnVisible" + class="dep-edge-delete-btn" + :style="deleteDepBtnStyle" + @click="onDeleteDepBtnClick" + type="button" + :title="$t('fileExplorer.deleteCurrentModule')" + > + <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> + </button> + <!-- 添加依赖弹窗 --> <div v-if="showAddDep" class="dep-graph__modal-overlay" @click.self="showAddDep = false"> <div class="dep-graph__modal"> @@ -60,12 +76,15 @@ */ import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue' import { useRouter } from 'vue-router' +import { useI18n } from 'vue-i18n' import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6' import dagre from 'dagre' import '@antv/x6-vue-shape' import { Message } from '@arco-design/web-vue' import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module' +import { useConfirm } from '@/composables/useConfirm' import ModuleNode from './ModuleNode.vue' +import ConfirmDialog from '@/components/ConfirmDialog.vue' const props = defineProps<{ projectId: string @@ -81,8 +100,15 @@ const containerRef = ref<HTMLElement | null>(null) const modules = ref<ProjectModuleRecord[]>([]) const dependencies = ref<ModuleDependencyRecord[]>([]) const loading = ref(true) +const { t } = useI18n() +const { confirmDialog, confirmState, answerConfirm } = useConfirm() let graph: Graph | null = null +// 边删除按钮(HTML 遮罩层) +const deleteDepBtnVisible = ref(false) +const deleteDepBtnStyle = ref<{ left: string; top: string }>({ left: '0px', top: '0px' }) +let deleteDepTarget: ModuleDependencyRecord | null = null + // 添加依赖弹窗 const showAddDep = ref(false) const depFrom = ref('') @@ -165,8 +191,9 @@ function renderGraph() { } }) - // 边数据(module_dependencies 表) + // 边数据(module_dependencies 表);edge id 用 dep.id,删除时按 id 反查源/目标 const edges = dependencies.value.map(d => ({ + id: d.id, source: d.from_module_id, target: d.to_module_id, labels: d.label ? [{ text: d.label }] : [{ text: d.dep_type }], @@ -206,6 +233,7 @@ function buildGraph() { }) renderGraph() + bindEdgeDeleteButtons() } function fitContent() { @@ -254,6 +282,65 @@ async function exportPNG() { } } +/** 删除依赖(二次确认 + 重绘)。 */ +async function confirmRemoveDep(dep: ModuleDependencyRecord) { + const fromName = modules.value.find(m => m.id === dep.from_module_id)?.name ?? dep.from_module_id + const toName = modules.value.find(m => m.id === dep.to_module_id)?.name ?? dep.to_module_id + const ok = await confirmDialog(`确定删除「${fromName} → ${toName}」的依赖吗?此操作不可撤销。`, t('common.delete')) + if (!ok) return + try { + await moduleApi.removeModuleDependency(dep.id) + Message.info('依赖已删除') + await loadModules() + renderGraph() + bindEdgeDeleteButtons() + } catch (e) { + console.error('[DependencyGraph] 删除依赖失败:', e) + Message.error('删除依赖失败') + } +} + +/** 点击遮罩层上的删除按钮触发。 */ +function onDeleteDepBtnClick() { + const dep = deleteDepTarget + if (dep) void confirmRemoveDep(dep) + hideEdgeDeleteBtn() +} + +/** edge hover 时显示删除按钮(定位到边中点屏幕坐标)。 */ +function showEdgeDeleteBtn(edge: any) { + const depId = String(edge.id) + const dep = dependencies.value.find(d => d.id === depId) + if (!dep || !containerRef.value || !graph) return + deleteDepTarget = dep + // 算边中点(view 坐标)→ 容器像素坐标:transform.scale + translate + const bbox = edge.getBBox() + const mx = bbox.x + bbox.width / 2 + const my = bbox.y + bbox.height / 2 + const ts = graph.translate() + const sc = graph.scale() + const viewX = mx * sc.sx + ts.tx + const viewY = my * sc.sy + ts.ty + deleteDepBtnStyle.value = { left: `${viewX - 8}px`, top: `${viewY - 8}px` } + deleteDepBtnVisible.value = true +} + +/** edge leave 时隐藏删除按钮。 */ +function hideEdgeDeleteBtn() { + deleteDepBtnVisible.value = false + deleteDepTarget = null +} + +/** 给所有 edge 绑 hover 显示删除按钮的监听。 */ +function bindEdgeDeleteButtons() { + if (!graph) return + try { graph.off('edge:mouseenter', onEdgeEnter); graph.off('edge:mouseleave', onEdgeLeave) } catch { /* ignore */ } + function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) } + function onEdgeLeave() { hideEdgeDeleteBtn() } + graph.on('edge:mouseenter', onEdgeEnter) + graph.on('edge:mouseleave', onEdgeLeave) +} + /** 依赖类型 → 边颜色 */ function depTypeColor(depType: string): string { switch (depType) { @@ -378,4 +465,26 @@ onBeforeUnmount(() => { .dep-graph__modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } + +/* 边删除按钮(HTML 遮罩层,绝对定位在 .dep-graph__canvas 上) */ +.dep-edge-delete-btn { + position: absolute; + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: var(--df-danger); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + box-shadow: 0 2px 6px rgba(0,0,0,0.4); + transition: background 0.15s; + pointer-events: auto; +} +.dep-edge-delete-btn:hover { + background: #c24040; +} </style> diff --git a/src/components/project/GitChanges.vue b/src/components/project/GitChanges.vue index 0f450ba..b7d8705 100644 --- a/src/components/project/GitChanges.vue +++ b/src/components/project/GitChanges.vue @@ -559,7 +559,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true }) color: var(--df-text-dim); cursor: pointer; padding: 2px 6px; - border-radius: 3px; + border-radius: 4px; display: flex; align-items: center; transition: all 0.15s; @@ -725,7 +725,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true }) .gc-commit-diff-toggle { background: none; border: 0.5px solid var(--df-border); color: var(--df-text-dim); cursor: pointer; font-size: 11px; - padding: 2px 8px; border-radius: 3px; + padding: 2px 8px; border-radius: 4px; } .gc-commit-diff-toggle:hover { color: var(--df-text); border-color: var(--df-accent); } .gc-commit-diff .gc-diff-content { flex: 1; min-height: 0; } @@ -738,7 +738,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true }) transform: translateY(12px); opacity: 0; } -.gc-file-status { width: 20px; text-align: center; font-size: 11px; font-weight: 700; flex-shrink: 0; } +.gc-file-status { width: 20px; text-align: center; font-size: 11px; font-weight: 500; flex-shrink: 0; } .gc-st-modified { color: #f0a020; } .gc-st-added { color: #4caf50; } .gc-st-deleted { color: #e05050; } @@ -806,8 +806,8 @@ watch(() => props.moduleId, loadStatus, { immediate: true }) .gc-branch-tag { font-size: 9px; padding: 1px 5px; - border-radius: 3px; - background: rgba(108,99,255,0.15); + border-radius: 4px; + background: var(--df-accent-soft); color: var(--df-accent); } .gc-branch-picker-hint { diff --git a/src/components/project/ModuleNode.vue b/src/components/project/ModuleNode.vue index 8dc5e27..6f8ff31 100644 --- a/src/components/project/ModuleNode.vue +++ b/src/components/project/ModuleNode.vue @@ -101,7 +101,7 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3)) .module-node__tag { font-size: 9px; padding: 1px 6px; - border-radius: 3px; + border-radius: 4px; background: rgba(83, 120, 232, 0.15); color: #7c9aff; } diff --git a/src/components/project/ProjectCard.vue b/src/components/project/ProjectCard.vue index 2e29c1f..181aa5c 100644 --- a/src/components/project/ProjectCard.vue +++ b/src/components/project/ProjectCard.vue @@ -133,7 +133,7 @@ watch(() => props.view, () => nextTick(measureDesc)) border-radius: var(--df-radius-sm); } .stage-design { background: rgba(100,181,246,0.15); color: var(--df-info); } -.stage-coding { background: rgba(108,99,255,0.15); color: var(--df-accent); } +.stage-coding { background: var(--df-accent-soft); color: var(--df-accent); } .stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); } .stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); } diff --git a/src/components/settings/ConnectionPanel.vue b/src/components/settings/ConnectionPanel.vue index 463e985..8405e59 100644 --- a/src/components/settings/ConnectionPanel.vue +++ b/src/components/settings/ConnectionPanel.vue @@ -251,7 +251,7 @@ onMounted(loadConnections) font-weight: 500; } .type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); } -.type-ssh { background: rgba(108,99,255,0.15); color: var(--df-accent); } +.type-ssh { background: var(--df-accent-soft); color: var(--df-accent); } .type-redis { background: rgba(255,107,107,0.15); color: var(--df-danger); } .type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); } diff --git a/src/components/workflow/WorkflowDagDisplay.vue b/src/components/workflow/WorkflowDagDisplay.vue index f6bc9c5..c3282e6 100644 --- a/src/components/workflow/WorkflowDagDisplay.vue +++ b/src/components/workflow/WorkflowDagDisplay.vue @@ -229,7 +229,7 @@ function cancelEdit() { .wf-dag-node-name { font-weight: 500; white-space: nowrap; } .wf-dag-node-type { font-size: 10px; color: var(--df-text-dim, #888); - background: var(--df-bg-card, #f0f0f0); padding: 0 4px; border-radius: 3px; + background: var(--df-bg-card, #f0f0f0); padding: 0 4px; border-radius: 4px; } .wf-dag-node-status { font-size: 11px; } .wf-dag-node-status-dot { @@ -270,7 +270,7 @@ function cancelEdit() { } .wf-dag-cond-label--uncond { background: transparent; color: var(--df-text-dim, #aaa); } .wf-dag-cond-label--editable { - cursor: pointer; font-size: 11px; padding: 1px 6px; border-radius: 3px; + cursor: pointer; font-size: 11px; padding: 1px 6px; border-radius: 4px; transition: background 0.15s; } .wf-dag-cond-label--editable:hover { background: rgba(74,144,226,0.18); } @@ -284,7 +284,7 @@ function cancelEdit() { .wf-dag-edge-arrow { color: var(--df-text-primary); white-space: nowrap; } .wf-dag-edge-input { flex: 1; min-width: 0; font-family: var(--df-font-mono); font-size: 11px; - padding: 2px 6px; border: 0.5px solid var(--df-border); border-radius: 3px; + padding: 2px 6px; border: 0.5px solid var(--df-border); border-radius: 4px; background: var(--df-bg); color: var(--df-text); outline: none; } .wf-dag-edge-input:focus { border-color: var(--df-accent); } diff --git a/src/composables/ai/useAiPanel.ts b/src/composables/ai/useAiPanel.ts index 6a5e280..b90c495 100644 --- a/src/composables/ai/useAiPanel.ts +++ b/src/composables/ai/useAiPanel.ts @@ -1,4 +1,4 @@ -//! 面板与持久化 — togglePanel/toggleMaximize + UI 持久化(restoreUiState/persistUiState) +//! 面板与持久化 — togglePanel/toggleMaximize/cyclePanelMode + UI 持久化(restoreUiState/persistUiState) //! + token 开关 + providers/skills/clearChat //! //! 模块级私有: @@ -13,7 +13,9 @@ import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' import { state } from '@/stores/ai' import { setStreaming } from './streamingGuard' -import type { AiProviderConfig } from '@/api/types' +import { nextMsgId } from './aiShared' +import { t } from '@/i18n/i18n-helpers' +import type { AiMessage, AiProviderConfig } from '@/api/types' const appSettings = useAppSettingsStore() @@ -46,16 +48,25 @@ function applyUiState(s: any) { state.sidebarWidth = clampSidebarWidth(s.sidebarWidth) } -// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局(模块级单例 state) +// 启动恢复:从 appSettings(SQLite)读取上次的 UI 布局 + activeProvider(模块级单例 state) +// F-260616-10: activeProvider 恢复对齐 df-ai-active-conv 模式;loadProviders 后还会二次校验 +// DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。 function restoreUiState() { applyUiState(appSettings.get<any>('df-ai-ui', null)) + const savedProvider = appSettings.get<string | null>('df-ai-active-provider', null) + if (savedProvider) state.activeProvider = savedProvider } -restoreUiState() -// F-260616-10: 启动恢复 activeProvider(对齐 df-ai-active-conv 模式)。 -// loadProviders 后还会二次校验 DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。 -const savedProvider = appSettings.get<string | null>('df-ai-active-provider', null) -if (savedProvider) state.activeProvider = savedProvider +// 循环依赖兜底:stores/ai ↔ useAiPanel 互 import,顶层 restoreUiState 执行时 state 可能尚未 +// 初始化(TDZ)。冷启动靠 appSettings 缓存为空 → applyUiState(null) 提前 return 绕过访问; +// 但 HMR 热重载 stores/ai.ts 时 appSettings 已有缓存,restoreUiState 真正访问 state 触发 +// ReferenceError: Cannot access 'state' before initialization。对齐 stores/ai.ts __bindMessages +// 既存模式:try 失败则微任务延迟重试,待 state 就绪后落库恢复。 +try { + restoreUiState() +} catch { + Promise.resolve().then(restoreUiState) +} // loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次) watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any)) @@ -96,6 +107,34 @@ function toggleMaximize() { persistUiState() } +/** + * Ctrl+I 三态循环(关闭 → 分栏打开 → 最大化 → 关闭)。 + * + * 与 togglePanel(二态开关)区分:本函数仅供全局快捷键 Ctrl+I 用;UI 按钮(侧栏 AI 按钮 / + * TopBar 关闭钮)与审批浮层激活仍走 togglePanel,保持"开/关"二态语义不被破坏。 + * + * 循环方向逐级增强再回关闭,贴合"要用 AI → 专注看 AI → 用完了"心智: + * 关闭(panelOpen=false) → 分栏打开(panelOpen=true, maximized=false) + * 分栏打开(maximized=false) → 最大化(maximized=true,全宽占满 main) + * 最大化(maximized=true) → 关闭(panelOpen=false, maximized=false) + * + * detached 模式 no-op:分离窗口下主窗口不渲染面板,循环无意义;分离窗口自身走系统标题栏最大化。 + */ +function cyclePanelMode() { + // 分离窗口模式:主窗口无面板,三态循环无意义(no-op;关闭分离窗口时 destroyed 钩子自恢复 panelOpen) + if (state.detached) return + if (!state.panelOpen) { + state.panelOpen = true + state.maximized = false + } else if (!state.maximized) { + state.maximized = true + } else { + state.panelOpen = false + state.maximized = false + } + persistUiState() +} + // ── Providers / Skills / 清屏 ── /** 拉取 provider 列表(用于顶部 bar 展示与切换) + 同步 DB 默认 provider 到 activeProvider */ @@ -129,17 +168,35 @@ async function setProvider(providerId: string) { /** 清空当前会话消息(后端 + 本地 state) */ async function clearChat() { - await aiApi.clearChat() - state.messages = [] - state.currentText = '' - state.pendingApprovals = [] - setStreaming(false, { reason: 'clearChat' }) + try { + await aiApi.clearChat() + state.messages = [] + state.currentText = '' + state.pendingApprovals = [] + setStreaming(false, { reason: 'clearChat' }) + } catch (e) { + // A2-B9(G3.2):清空失败需反馈,原裸 await 失败静默(异常进 unhandledrejection,用户无感)。 + // 后端裁决「先停 loop → DB 单事务 → 内存清」:DB 失败时内存不动(回滚一致),故此处 + // 只推错误气泡告知用户(对齐 useAiConversations.pushConvOpFail 模式),不吞异常日志。 + // 注意:不 rethrow——confirmClearChat 无 try,裸抛会进 unhandledrejection 且用户无感; + // 错误气泡已足够反馈,且状态保持未清(与后端内存一致)。 + const msg = e instanceof Error ? e.message : String(e) + state.messages.push({ + id: `conv-op-fail-${nextMsgId()}`, + role: 'assistant', + content: t('aiChat.clearError', { msg }), + isError: true, + timestamp: Date.now(), + } as AiMessage) + console.error('[AI] 清空对话失败:', e) + } } export function useAiPanel() { return { togglePanel, toggleMaximize, + cyclePanelMode, loadProviders, loadSkills, setProvider, diff --git a/src/composables/ai/useMessageScroll.ts b/src/composables/ai/useMessageScroll.ts index 89ed6e1..769b800 100644 --- a/src/composables/ai/useMessageScroll.ts +++ b/src/composables/ai/useMessageScroll.ts @@ -16,7 +16,7 @@ export function useMessageScroll(containerRef: Ref<HTMLDivElement | undefined>) // 回到底部按钮:内容溢出且不在底部时显示 const showBackToBottom = ref(false) - // B-260618-24: 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。 + // 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。 const isFollowingBottom = ref(true) // 边沿检测:上一帧是否在底部(用于触发 onNearBottom 回调) let wasNearBottom = true diff --git a/src/composables/ai/useStreamRenderer.ts b/src/composables/ai/useStreamRenderer.ts index e205a5e..b731032 100644 --- a/src/composables/ai/useStreamRenderer.ts +++ b/src/composables/ai/useStreamRenderer.ts @@ -30,7 +30,7 @@ export interface UseStreamRendererOptions { saveSelection: () => void /** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */ restoreSelection: () => void - /** B-260618-24:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */ + /** 跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */ isFollowingBottom: Ref<boolean> | ComputedRef<boolean> /** 回到底部操作 */ scrollToBottom: () => void @@ -185,7 +185,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) { streamingBlocks.value = renderStreamingBlocks(lastStreamText) nextTick(() => { opts.restoreSelection() - // B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。 + // rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。 // 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF, // 滚到渲染前高度)致"差一截"。 if (opts.isFollowingBottom.value) opts.scrollToBottom() diff --git a/src/composables/ai/useToolCard.ts b/src/composables/ai/useToolCard.ts index 9573684..678556a 100644 --- a/src/composables/ai/useToolCard.ts +++ b/src/composables/ai/useToolCard.ts @@ -26,6 +26,10 @@ import type { AiToolCallInfo } from '@/api/types' /** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */ export interface ToolResult { path?: string + // A1-B5: 只读工具(git status/diff/log)失败路径后端返 {success:false,error} 合法 JSON 对象(非 Err), + // 前端 isToolFailure 靠此字段判失败(防绿框假成功) + success?: boolean + error?: string pattern?: string from?: string to?: string @@ -131,9 +135,11 @@ export function shouldKeepOpen(tc: AiToolCallInfo): boolean { * * 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误), * 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。 - * 此函数区分两类失败: + * 此函数区分三类失败: * - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败 - * - 通用工具失败:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."), + * - 通用工具 success===false:只读工具(git status/diff/log)失败返 {success:false,error} + * 合法 JSON 对象(非 Err),parseResult 命中但显式 success 为 false → 判失败 + * - 通用工具 raw 文本:AR-6 路径后端塞 serde_json::Value::String("工具 X 执行失败: ..."), * 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号 * 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式, * 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。 @@ -150,8 +156,12 @@ export function isToolFailure(tc: AiToolCallInfo): boolean { // run_command 但无 exit_code(异常)→ 保守判失败 if (r) return true } + // 通用工具:可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返 + // {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功 + const r = parseResult(tc.result) + if (r && r.success === false) return true // 通用工具:parseResult 返回 null(非 JSON,AR-6 错误字符串路径)→ 看文本前缀 - if (!parseResult(tc.result)) { + if (!r) { const raw = typeof tc.result === 'string' ? tc.result : '' if (!raw) return false // UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串, diff --git a/src/composables/useMarkdown.ts b/src/composables/useMarkdown.ts index 2aed899..6a7586e 100644 --- a/src/composables/useMarkdown.ts +++ b/src/composables/useMarkdown.ts @@ -88,6 +88,12 @@ function highlightCode(code: string, infostring: string): string { // infostring 形如 "ts" / "javascript" / "rust,norun" —— 取首个 token 去空白小写 const langRaw = (infostring || '').split(/[\s,]/)[0]?.trim().toLowerCase() ?? '' const lang = _LANG_ALIASES[langRaw] ?? langRaw + // Mermaid 图:保留原始语法(渲染方按需 mermaid.render 成图,不经过 highlight 误处理)。 + // 全项目 markdown 渲染共用此 renderer,统一让 mermaid 块以原文 class="language-mermaid" 呈现。 + if (lang === 'mermaid') { + const safe = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') + return `<pre><code class="hljs language-mermaid">${safe}</code></pre>` + } let highlighted: string try { if (lang && hljs.getLanguage(lang)) { diff --git a/src/i18n/en/auditLog.ts b/src/i18n/en/auditLog.ts index 5e89dcd..9d307c5 100644 --- a/src/i18n/en/auditLog.ts +++ b/src/i18n/en/auditLog.ts @@ -50,5 +50,14 @@ export default { human: 'Human', auto: 'Auto', }, + // Tab navigation + tabAudit: 'Approval History', + tabConversations: 'Conversations', + // Conversation history + convEmpty: 'No conversations yet', + convLoading: 'Loading conversations…', + convTruncated: 'Content truncated (expand to view full conversation)', + convSelectHint: '← Select a conversation to view full interaction log', + convReasoning: 'Reasoning (thinking)', }, } diff --git a/src/i18n/en/ideas.ts b/src/i18n/en/ideas.ts index a79e259..9b49a7b 100644 --- a/src/i18n/en/ideas.ts +++ b/src/i18n/en/ideas.ts @@ -89,6 +89,7 @@ export default { relatedManage: 'Manage relations', relatedConfirm: 'Confirm', relatedCancel: 'Cancel', + relatedSearchPlaceholder: 'Search idea title or description...', // Idea stats panel (used by IdeasPanel) statsPending: 'Pending', @@ -97,7 +98,8 @@ export default { // Tags tagsTitle: '🏷️ Tags', - noTags: 'No tags yet', + noTags: 'No tags', + editTags: 'Edit tags', // Status management statusTitle: '📋 Status', @@ -139,5 +141,10 @@ export default { confirmStatus: 'Change idea "{title}" status from {from} to {to}?', promoteFailed: 'Promotion failed', evalFailed: 'Evaluation failed', + // Batch operations + batchDelete: 'Batch delete', + confirmBatchDelete: 'Delete {n} selected ideas? This cannot be undone.', + batchDeleteSuccess: 'Deleted {n} idea(s)', + selectedCount: '{n} selected', }, } diff --git a/src/i18n/en/nav.ts b/src/i18n/en/nav.ts index d7229ee..70b6555 100644 --- a/src/i18n/en/nav.ts +++ b/src/i18n/en/nav.ts @@ -5,7 +5,7 @@ export default { projects: 'Projects', tasks: 'Tasks', knowledge: 'Knowledge', - audit: 'Audit Log', + audit: 'History', settings: 'Settings', workspace: 'Workspace', traceability: 'Traceability', diff --git a/src/i18n/zh-CN/auditLog.ts b/src/i18n/zh-CN/auditLog.ts index 55b47d0..edd4294 100644 --- a/src/i18n/zh-CN/auditLog.ts +++ b/src/i18n/zh-CN/auditLog.ts @@ -50,5 +50,14 @@ export default { human: '人工', auto: '自动', }, + // Tab 导航 + tabAudit: '审批历史', + tabConversations: '对话记录', + // 对话历史 + convEmpty: '暂无对话记录', + convLoading: '加载对话列表…', + convTruncated: '内容过长已截断(展开查看完整对话)', + convSelectHint: '← 选择左侧一次对话查看完整交互日志', + convReasoning: '推理过程(thinking)', }, } diff --git a/src/i18n/zh-CN/ideas.ts b/src/i18n/zh-CN/ideas.ts index fc92d0c..c1dfd87 100644 --- a/src/i18n/zh-CN/ideas.ts +++ b/src/i18n/zh-CN/ideas.ts @@ -80,6 +80,7 @@ export default { relatedManage: '管理关联', relatedConfirm: '确定', relatedCancel: '取消', + relatedSearchPlaceholder: '搜索灵感标题或描述...', // 灵感统计看板(IdeasPanel 引用) statsPending: '待评估', @@ -89,6 +90,7 @@ export default { // 标签 tagsTitle: '🏷️ 标签', noTags: '暂无标签', + editTags: '编辑标签', // 状态管理 statusTitle: '📋 状态管理', @@ -139,5 +141,10 @@ export default { confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?', promoteFailed: '立项失败', evalFailed: '评估失败', + // 批量操作 + batchDelete: '批量删除', + confirmBatchDelete: '确定删除选中的 {n} 个灵感?此操作不可撤销。', + batchDeleteSuccess: '已删除 {n} 个灵感', + selectedCount: '已选 {n} 项', }, } diff --git a/src/i18n/zh-CN/nav.ts b/src/i18n/zh-CN/nav.ts index 4bd9996..4dbebde 100644 --- a/src/i18n/zh-CN/nav.ts +++ b/src/i18n/zh-CN/nav.ts @@ -5,7 +5,7 @@ export default { projects: '项目', tasks: '任务', knowledge: '知识库', - audit: '审批历史', + audit: '历史记录', settings: '设置', workspace: '工作区', traceability: '追溯', diff --git a/src/main.ts b/src/main.ts index 063d5a3..85ae0ea 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,4 +16,10 @@ app.config.errorHandler = (err, instance, info) => { console.error(`[Vue error] <${tag}> ${info}:`, err); }; -app.use(router).use(i18n).mount("#app"); +app.use(router).use(i18n); + +// 等路由就绪再 mount:分离窗口(approval-popup / ai-detached / fe-detached)的 hash 路由须先解析完, +// App.vue 的 isDetached(route.path === '/approval-popup' ...)才命中真值。否则初始帧 route.path='/' +// → isDetached=false → AppLayout 把完整主布局(侧栏+主区+AI 面板)渲染进分离小窗 +// (approval-popup 380×480 尤为明显,看起来像「小小的完整 DevFlow 界面」)。 +router.isReady().then(() => app.mount("#app")); diff --git a/src/styles/global.css b/src/styles/global.css index 8adce2e..1085d49 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,6 +1,6 @@ /* ═══════════════════════════════════════════════════════════ DevFlow Design System — Claude 设计系统规范 - 扁平化 · 0.5px 边框 · 8/12px 圆角 · 400/500 字重 + 扁平化 · 0.5px 边框 · 8/12px 圆角 · 400/500/600 字重 紫/青绿/琥珀/珊瑚 四色体系 · 深色优先 ═══════════════════════════════════════════════════════════ */ @@ -59,7 +59,7 @@ --df-pad-panel: 14px 16px; /* 面板/卡片内边距 */ --df-msg-max-width: 90%; /* AI 气泡/工具卡片最大宽度(对齐不撑满) */ - /* — Typography: 仅 400/500 字重 — */ + /* — Typography: 400/500/600 字重 — */ --df-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --df-font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace; diff --git a/src/views/ApprovalPopup.vue b/src/views/ApprovalPopup.vue index 12216eb..63e29ef 100644 --- a/src/views/ApprovalPopup.vue +++ b/src/views/ApprovalPopup.vue @@ -513,7 +513,7 @@ function onDragEnd() { isDragging.value = false } border: 0.5px solid var(--df-accent); border-left: 3px solid var(--df-accent); border-radius: var(--df-radius-md); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(123, 111, 240, 0.2); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--df-accent-soft); overflow: hidden; user-select: none; } @@ -554,7 +554,7 @@ function onDragEnd() { isDragging.value = false } background: var(--df-danger); color: #fff; font-size: 9px; - font-weight: 700; + font-weight: 500; line-height: 16px; text-align: center; border: 1.5px solid var(--df-bg-card); @@ -730,7 +730,7 @@ function onDragEnd() { isDragging.value = false } border-radius: var(--df-radius-xs, 3px); font-family: var(--df-font-mono); font-size: 9px; - font-weight: 700; + font-weight: 500; line-height: 1; background: rgba(0, 0, 0, 0.18); opacity: 0.7; diff --git a/src/views/AuditLog.vue b/src/views/AuditLog.vue index 5b849cd..d173fc3 100644 --- a/src/views/AuditLog.vue +++ b/src/views/AuditLog.vue @@ -3,14 +3,28 @@ <header class="page-header"> <h1>{{ t('auditLog.title') }}</h1> <div class="header-actions"> - <button class="btn btn-ghost" @click="reload" :disabled="loading"> + <button class="btn btn-ghost" @click="reloadAudit" :disabled="loading" v-if="activeTab === 'audit'"> <svg v-if="loading" class="audit-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg> {{ t('auditLog.refresh') }} </button> </div> </header> - <p class="page-desc">{{ t('auditLog.desc') }}</p> + <!-- Tab 导航 --> + <nav class="detail-tabs audit-tabs"> + <button class="tab-btn" :class="{ 'tab-active': activeTab === 'audit' }" type="button" @click="switchTab('audit')"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> + {{ t('auditLog.tabAudit') }} + </button> + <button class="tab-btn" :class="{ 'tab-active': activeTab === 'conversations' }" type="button" @click="switchTab('conversations')"> + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg> + {{ t('auditLog.tabConversations') }} + </button> +</nav> + + <!-- ═══ Tab: 审批历史 ═══ --> + <template v-if="activeTab === 'audit'"> + <p class="page-desc">{{ t('auditLog.desc') }}</p> <!-- 筛选栏 --> <div class="filter-bar audit-filters"> @@ -95,6 +109,62 @@ <span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}<span class="pager-total">{{ t('auditLog.pager.total', { n: total }) }}</span></span> <button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button> </div> + </template> + <!-- ═══ Tab: 对话历史 ═══ --> + <template v-else> + <div class="conv-split"> + <!-- 左侧:对话标题列表 --> + <div class="conv-list-panel"> + <div class="conv-list" v-if="conversations.length > 0"> + <div v-for="conv in conversations" :key="conv.id" class="conv-list-item" :class="{ active: expandedConv === conv.id }" @click="toggleConv(conv.id)"> + <span class="conv-list-item-title">{{ conv.title || '—' }}</span> + <span class="conv-list-item-date">{{ formatDate(conv.updated_at) }}</span> + </div> + </div> + <div v-else-if="!convLoading" class="empty-state">{{ t('auditLog.convEmpty') }}</div> + <div v-if="convLoading && conversations.length === 0" class="empty-state"><span class="spinner"></span>{{ t('auditLog.convLoading') }}</div> + </div> + <!-- 右侧:完整交互日志 --> + <div class="conv-detail-panel"> + <div v-if="!expandedConv" class="conv-detail-empty">{{ t('auditLog.convSelectHint') }}</div> + <div v-else-if="convLoading" class="conv-detail-loading"><span class="spinner"></span>{{ t('auditLog.loading') }}</div> + <div v-else-if="convError" class="conv-detail-error">{{ convError }}</div> + <div v-else class="conv-messages"> + <div v-for="(msg, mi) in convMessages" :key="mi" class="conv-msg" :class="'conv-msg--' + msg.role"> + <!-- 头部:角色 + model + 时间 + token --> + <div class="conv-msg-head"> + <span class="conv-msg-role">{{ msg.role === 'user' ? '🧑 用户' : msg.role === 'assistant' ? '🤖 助手' : msg.role === 'tool' ? '🔧 工具' : '⚙️ 系统' }}</span> + <span v-if="msg.model" class="conv-msg-model">{{ msg.model }}</span> + <span v-if="msg.tool_call_id" class="conv-msg-toolname">tool_call: {{ msg.tool_call_id }}</span> + <span v-if="msg.timestamp" class="conv-msg-time">{{ formatDate(msg.timestamp) }}</span> + <span v-if="msg.prompt_tokens || msg.completion_tokens" class="conv-msg-token"> + in={{ msg.prompt_tokens ?? 0 }} · out={{ msg.completion_tokens ?? 0 }}<template v-if="msg.reasoning_tokens"> · think={{ msg.reasoning_tokens }}</template> + </span> + </div> + <!-- 推理内容(DeepSeek thinking) --> + <div v-if="msg.reasoning_content" class="conv-msg-reasoning"> + <div class="conv-msg-reasoning-label">🧠 {{ t('auditLog.convReasoning') }}</div> + <pre class="conv-msg-text conv-msg-text--reasoning">{{ msg.reasoning_content }}</pre> + </div> + <!-- 正文:完整内容(发往/来自 LLM 的完整载荷) --> + <div class="conv-msg-body"> + <pre class="conv-msg-text">{{ msg.content || (msg.role === 'tool' ? '—' : '') }}</pre> + </div> + <!-- 工具调用(assistant 发起,完整 name + arguments JSON) --> + <div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="conv-msg-tools"> + <div v-for="(tc, tci) in msg.tool_calls" :key="tci" class="conv-msg-toolcall"> + <div class="conv-msg-toolcall-head"> + <span class="conv-msg-toolcall-name">🔧 {{ tc.function?.name || '工具调用' }}</span> + <span v-if="tc.id" class="conv-msg-toolcall-id">{{ tc.id }}</span> + </div> + <pre v-if="tc.function?.arguments" class="conv-msg-toolcall-args">{{ tc.function.arguments }}</pre> + </div> + </div> + </div> + </div> + </div> + </div> +</template> </div> </template> @@ -104,6 +174,8 @@ import { useI18n } from 'vue-i18n' import { invoke } from '@tauri-apps/api/core' import { formatRelative, formatDate } from '@/utils/time' import { usePersistedRef } from '@/composables/usePersistedRef' +import { aiApi } from '@/api/ai' +import type { AiConversationSummary } from '@/api/types' const { t } = useI18n() @@ -228,7 +300,78 @@ async function load() { } } -function reload() { +// ── Tab �── +const activeTab = usePersistedRef<'audit' | 'conversations'>('auditLog.activeTab', 'audit') +function switchTab(tab: 'audit' | 'conversations') { + activeTab.value = tab + if (tab === 'conversations') void loadConversations() +} + +// ── 对话历史 ── +const conversations = ref<AiConversationSummary[]>([]) +const expandedConv = ref<string | null>(null) +const convLoading = ref(false) +const convError = ref('') +interface ConvToolCall { + id?: string + type?: string + function?: { name: string; arguments: string } +} +interface ConvMessage { + role: string + content: string + model?: string | null + /** Unix 毫秒(ChatMessage.timestamp) */ + timestamp?: number | null + reasoning_content?: string | null + /** 工具调用(Vec<ToolCall>,role=assistant 时可能有) */ + tool_calls?: ConvToolCall[] | null + /** 工具调用 ID(role=tool 时) */ + tool_call_id?: string | null + parts?: { type?: string; text?: string; base64?: string; url?: string }[] | null + prompt_tokens?: number | null + completion_tokens?: number | null + reasoning_tokens?: number | null + status?: string | null +} +const convMessages = ref<ConvMessage[]>([]) + +async function loadConversations() { + convLoading.value = true + try { + const list = await aiApi.listConversations(200, false) + // 按 updated_at 降序排列 + conversations.value = (list ?? []).sort((a, b) => (b.updated_at > a.updated_at ? 1 : b.updated_at < a.updated_at ? -1 : 0)) + } catch (e) { + conversations.value = [] + console.error('[AuditLog] 加载对话列表失败:', e) + } finally { + convLoading.value = false + } +} +async function toggleConv(id: string) { + if (expandedConv.value === id) { + expandedConv.value = null + convMessages.value = [] + return + } + expandedConv.value = id + convLoading.value = true + convError.value = '' + try { + const json = await aiApi.exportConversation(id, 'json') + const parsed = JSON.parse(json) as ConvMessage[] ?? [] + convMessages.value = parsed || [] + } catch (e) { + convError.value = String(e) + convMessages.value = [] + } finally { + convLoading.value = false + } +} + + +function reloadAudit() { if (loading.value) return offset.value = 0 expandedId.value = null @@ -278,6 +421,7 @@ function decidedClass(d: string): string { onMounted(() => { void load() + if (activeTab.value === 'conversations') void loadConversations() }) </script> @@ -476,4 +620,152 @@ onMounted(() => { } /* empty-state 已提取到 global.css 全局 */ + +/* ===== Tab 导航(对齐 ProjectDetail 同款交互) ===== */ +.audit-tabs { + display: flex; + gap: 4px; + border-bottom: 0.5px solid var(--df-border); + margin-bottom: 16px; +} +.audit-tabs .tab-btn { + padding: 8px 16px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--df-text-dim); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + margin-bottom: -0.5px; +} +.audit-tabs .tab-btn:hover { color: var(--df-text); } +.audit-tabs .tab-btn.tab-active { + color: var(--df-text); + border-bottom-color: var(--df-accent); +} + +/* ===== 对话历史 ===== */ +.conv-split { + display: grid; + grid-template-columns: 260px 1fr; + gap: var(--df-gap-grid); + min-height: 400px; +} +.conv-list-panel { + background: var(--df-bg-card); + border: 0.5px solid var(--df-border); + border-radius: var(--df-radius-lg); + overflow-y: auto; + max-height: calc(100vh - 220px); +} +.conv-list-item { + padding: 12px 14px; + border-bottom: 0.5px solid var(--df-border); + cursor: pointer; + transition: background 0.12s; + display: flex; + flex-direction: column; + gap: 4px; +} +.conv-list-item:last-child { border-bottom: none; } +.conv-list-item:hover { background: var(--df-bg-card-hover); } +.conv-list-item.active { background: var(--df-accent-soft); border-left: 3px solid var(--df-accent); } +.conv-list-item-title { + font-size: 13px; + font-weight: 500; + color: var(--df-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.conv-list-item-date { font-size: 11px; color: var(--df-text-dim); } +.conv-detail-panel { + background: var(--df-bg-card); + border: 0.5px solid var(--df-border); + border-radius: var(--df-radius-lg); + padding: var(--df-pad-panel); + overflow-y: auto; + max-height: calc(100vh - 220px); +} +.conv-detail-empty, .conv-detail-loading, .conv-detail-error { + display: flex; align-items: center; justify-content: center; + height: 100%; min-height: 200px; + font-size: 13px; color: var(--df-text-dim); +} +.conv-detail-error { color: var(--df-danger); } +.conv-messages { display: flex; flex-direction: column; gap: 10px; } +.conv-msg { + padding: 8px 10px; + border-radius: var(--df-radius-sm); + font-size: 12px; +} +.conv-msg--user { background: var(--df-accent-bg); } +.conv-msg--assistant { background: var(--df-bg-raised); } +.conv-msg--tool { background: rgba(90,99,128,0.1); } +.conv-msg-head { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 4px; + flex-wrap: wrap; +} +.conv-msg-role { font-size: 12px; font-weight: 500; color: var(--df-text); } +.conv-msg-model, .conv-msg-toolname, .conv-msg-token { + font-size: 10px; padding: 1px 6px; + border-radius: var(--df-radius-xs); + background: rgba(90,99,128,0.2); color: var(--df-text-dim); +} +.conv-msg-token { font-family: var(--df-font-mono); color: var(--df-info); background: var(--df-info-bg); } +.conv-msg-time { font-size: 10px; color: var(--df-text-dim); margin-left: auto; } +.conv-msg-body { min-width: 0; } +.conv-msg-text { + margin: 0; + font-size: 12px; + color: var(--df-text-secondary); + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--df-font-sans); +} +/* 推理过程(thinking)块 */ +.conv-msg-reasoning { + margin: 4px 0; + padding: 6px 10px; + border-left: 2px solid var(--df-info); + background: var(--df-info-bg); + border-radius: var(--df-radius-sm); +} +.conv-msg-reasoning-label { + font-size: 10px; color: var(--df-info); font-weight: 500; margin-bottom: 4px; +} +.conv-msg-text--reasoning { + font-size: 11px; color: var(--df-text-dim); font-style: italic; +} +/* 工具调用块(完整 name + arguments JSON) */ +.conv-msg-tools { margin-top: 4px; display: flex; flex-direction: column; gap: 4px; } +.conv-msg-toolcall { + padding: 6px 10px; + border-radius: var(--df-radius-sm); + background: var(--df-warning-bg); + border: 0.5px solid rgba(240,199,94,0.2); +} +.conv-msg-toolcall-head { + display: flex; align-items: center; gap: 8px; margin-bottom: 4px; +} +.conv-msg-toolcall-name { font-size: 11px; font-weight: 500; color: var(--df-warning); } +.conv-msg-toolcall-id { font-size: 10px; color: var(--df-text-dim); font-family: var(--df-font-mono); } +.conv-msg-toolcall-args { + margin: 0; + font-size: 11px; + color: var(--df-text-secondary); + background: var(--df-bg); + border-radius: var(--df-radius-xs); + padding: 6px 8px; + font-family: var(--df-font-mono); + white-space: pre-wrap; + word-break: break-word; + max-height: 240px; + overflow-y: auto; +} </style> diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index c9e974d..4dc4f5f 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -11,10 +11,6 @@ <svg v-if="loading" class="dash-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg> <svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg> </button> - <button class="btn btn-primary btn-sm" @click="quickCapture"> - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> - {{ $t('dashboard.captureIdea') }} - </button> </div> </header> @@ -58,7 +54,7 @@ </template> <script setup lang="ts"> -// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll) + +// 仪表盘页壳 — 仅保留:Header(refresh) + 错误条 + 布局栅格 + 加载编排(loadAll) + // 底部统计概要行(原独立统计卡 StatCardRow 已下沉为底部一行小字,空间让给项目/灵感面板)。 // 项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。 import { onMounted, ref } from 'vue' @@ -101,10 +97,6 @@ async function refresh() { await loadAll() } -function quickCapture() { - router.push('/ideas') -} - // 底部统计概要项点击跳转(可点击项:ideas/projects/tasks;drafts 不可点) function go(link: string) { router.push(link) diff --git a/src/views/Projects.vue b/src/views/Projects.vue index a52612f..e59baf6 100644 --- a/src/views/Projects.vue +++ b/src/views/Projects.vue @@ -485,7 +485,7 @@ async function runImport() { } .view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); } .view-toggle-btn.active { - background: rgba(108, 99, 255, 0.12); + background: var(--df-accent-soft); color: var(--df-accent); }