优化: UI/UX遗留批(审计/AI命令层/前端组件,会话前基线收尾)
- 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 测试
This commit is contained in:
@@ -5,9 +5,16 @@
|
|||||||
//! 2. soft_delete / list_active / list_deleted / restore — 回收站生命周期。
|
//! 2. soft_delete / list_active / list_deleted / restore — 回收站生命周期。
|
||||||
//! 3. allowed_columns_for 按表隔离 — update_field 误传跨表列名在白名单阶段被拒。
|
//! 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::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};
|
use df_types::types::{ProjectStatus, TaskStatus};
|
||||||
|
|
||||||
// ---------- fixtures ----------
|
// ---------- 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());
|
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
|
// 2. soft_delete / list_active / list_deleted / restore
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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 状态机提示前置 |
|
||||||
@@ -12,6 +12,7 @@ use df_storage::crud::AiToolExecutionRepo;
|
|||||||
use df_storage::db::Database;
|
use df_storage::db::Database;
|
||||||
|
|
||||||
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||||||
|
use super::tools::entity_resolve;
|
||||||
|
|
||||||
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
|
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
|
||||||
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
|
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
|
||||||
@@ -256,6 +257,43 @@ pub(crate) async fn process_tool_calls(
|
|||||||
})
|
})
|
||||||
.collect();
|
.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::<crate::state::AppState>().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)
|
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ pub async fn list_tool_executions(
|
|||||||
query: Option<ToolExecQuery>,
|
query: Option<ToolExecQuery>,
|
||||||
) -> Result<ToolExecutionPage, String> {
|
) -> Result<ToolExecutionPage, String> {
|
||||||
let q = query.unwrap_or_default();
|
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 offset = q.offset.unwrap_or(0);
|
||||||
let audit_q = AuditQuery::from(q);
|
let audit_q = AuditQuery::from(q);
|
||||||
|
|
||||||
|
|||||||
@@ -568,7 +568,9 @@ pub async fn ai_conversation_delete(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
) -> Result<(), 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;
|
let mut session = state.ai_session.lock().await;
|
||||||
// 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
|
// 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ pub async fn ai_save_provider(
|
|||||||
provider_type: String,
|
provider_type: String,
|
||||||
model_configs: Vec<ModelConfig>,
|
model_configs: Vec<ModelConfig>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
// F-260618-06:接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
|
// 接收前端传入的 model_configs 落库(含用户在 Settings 调的 weight/enabled/label),
|
||||||
// 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。
|
// 不再硬塞 Vec::new() 丢弃用户配置。新建传 []、编辑传回填+改动后的 providerForm.models。
|
||||||
// 编辑已有提供商时保留原 created_at,避免被覆盖
|
// 编辑已有提供商时保留原 created_at,避免被覆盖
|
||||||
// F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。
|
// F-260614-04c: 编辑路径同时保留原 enabled/weight(负载均衡池可编辑层)。
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ fn extract_results(html: &str) -> Vec<Value> {
|
|||||||
"snippet": snip.cloned().unwrap_or_default(),
|
"snippet": snip.cloned().unwrap_or_default(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
(None, Some(snip)) => {
|
(None, Some(_snip)) => {
|
||||||
// 残留 snippet 无对应标题(罕见,DDG 结构异常时兜底):跳过,无标题无 URL 无意义。
|
// 残留 snippet 无对应标题(罕见,DDG 结构异常时兜底):跳过,无标题无 URL 无意义。
|
||||||
// 不构造半残条目,保持每条都有 title+url(让 LLM 能 fetch_url)。
|
// 不构造半残条目,保持每条都有 title+url(让 LLM 能 fetch_url)。
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -566,8 +566,18 @@ fn find_obscura() -> Option<String> {
|
|||||||
/// 从 markdown 启发式提取 title:首个 `# 一级标题` 或首个非空文本行(≤120 chars)。
|
/// 从 markdown 启发式提取 title:首个 `# 一级标题` 或首个非空文本行(≤120 chars)。
|
||||||
/// obscura dump markdown 无独立 title 字段,此为最佳近似(静态管线有 <title>,此函数仅 render 分支用)。
|
/// obscura dump markdown 无独立 title 字段,此为最佳近似(静态管线有 <title>,此函数仅 render 分支用)。
|
||||||
fn extract_title_from_markdown(md: &str) -> Option<String> {
|
fn extract_title_from_markdown(md: &str) -> Option<String> {
|
||||||
|
// 代码块围栏状态:true=当前在 ``` 代码块内,块内行全部跳过(不当作标题)。
|
||||||
|
// 修复:原实现只跳过 ``` 围栏行本身,块内容行(如 "code block")会被误当标题。
|
||||||
|
let mut in_code_block = false;
|
||||||
for line in md.lines() {
|
for line in md.lines() {
|
||||||
let t = line.trim();
|
let t = line.trim();
|
||||||
|
if t.starts_with("```") {
|
||||||
|
in_code_block = !in_code_block; // 切换围栏状态(``` 开或闭)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_code_block {
|
||||||
|
continue; // 代码块内:跳过
|
||||||
|
}
|
||||||
if t.is_empty() {
|
if t.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -579,10 +589,13 @@ fn extract_title_from_markdown(md: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 否则取首个非空、非表格/代码块的行(去掉 markdown 强调前缀)
|
// 否则取首个非空、非表格/代码块的行(去掉 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();
|
let cleaned: String = t.trim_start_matches(|c: char| c == '*' || c == '-').trim().to_string();
|
||||||
if !cleaned.is_empty() && cleaned.chars().count() <= 120 {
|
// 超长行(>120)截断到 120 作标题(innerText 纯文本无结构时首行即正文,
|
||||||
return Some(cleaned);
|
// 原实现整行 >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]
|
#[test]
|
||||||
fn test_substantial_content_real_markdown_passes() {
|
fn test_substantial_content_real_markdown_passes() {
|
||||||
// 真实正文:600 chars,无空壳标记,应判有实质内容
|
// 真实正文:>500 chars(达到 has_substantial_content 的 500 字符下限阈值),
|
||||||
let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。\n".repeat(15);
|
// 无空壳标记,应判有实质内容。原测试 md 仅 494 chars(<500)被前置阈值拦截误判空壳。
|
||||||
|
let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。异步与同步的区别在于并发模型。\n".repeat(15);
|
||||||
assert!(has_substantial_content(&md));
|
assert!(has_substantial_content(&md));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -475,6 +475,7 @@ fn body_snippet(v: &Value) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
// ── build_images_url:端点拼接规则 ──
|
// ── build_images_url:端点拼接规则 ──
|
||||||
|
|
||||||
@@ -641,8 +642,6 @@ mod tests {
|
|||||||
// 边界:估算值 = MAX_IMAGE_BYTES + 1 → 应被拒
|
// 边界:估算值 = MAX_IMAGE_BYTES + 1 → 应被拒
|
||||||
// 反推 b64 长度:要使 len*3/4 == MAX+1,len = (MAX+1)*4/3
|
// 反推 b64 长度:要使 len*3/4 == MAX+1,len = (MAX+1)*4/3
|
||||||
let target = MAX_IMAGE_BYTES + 1;
|
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
|
// 因整数除法,估算可能略小于 target,但应保证 > MAX_IMAGE_BYTES
|
||||||
// 用更精确的 b64_len:target*4/3 + 4(向上补一个 base64 块),保证估算严格 > MAX
|
// 用更精确的 b64_len:target*4/3 + 4(向上补一个 base64 块),保证估算严格 > MAX
|
||||||
let b64_len_safe = ((target as u128 * 4 + 2) / 3) as usize;
|
let b64_len_safe = ((target as u128 * 4 + 2) / 3) as usize;
|
||||||
@@ -795,4 +794,82 @@ mod tests {
|
|||||||
msg
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,11 +288,11 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
|
|||||||
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
|
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
|
||||||
/// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。
|
/// 懒初始化走双检锁:先读锁查 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,慢路径扫盘包
|
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
|
||||||
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
|
/// `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`
|
/// 中毒 → tracing::error! 记录 + 返 None(调用方 `skills_cached` 得空 Vec / `read_skill_content_stripped`
|
||||||
/// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建,
|
/// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建,
|
||||||
/// 返空后下次 `invalidate_skills` 或进程重启自愈。
|
/// 返空后下次 `invalidate_skills` 或进程重启自愈。
|
||||||
@@ -346,11 +346,11 @@ async fn skills_lock_async() -> Option<SkillsGuard> {
|
|||||||
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`(clone),
|
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`(clone),
|
||||||
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
|
/// 因 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。
|
/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。
|
||||||
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
|
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
|
||||||
let g = skills_lock_async().await;
|
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()
|
g.and_then(|g| g.clone()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ pub(crate) fn invalidate_skills() {
|
|||||||
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
|
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
|
||||||
/// 缓存未命中返 None;文件读失败返 None。
|
/// 缓存未命中返 None;文件读失败返 None。
|
||||||
///
|
///
|
||||||
/// P1-260617-3:改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。
|
/// 改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。
|
||||||
///
|
///
|
||||||
/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop,
|
/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop,
|
||||||
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
|
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
|
||||||
|
|||||||
@@ -1251,8 +1251,8 @@ mod tests {
|
|||||||
///
|
///
|
||||||
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
|
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
|
||||||
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
|
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
|
||||||
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> super::get_app_config::GetAppConfigCtx {
|
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||||||
super::get_app_config::GetAppConfigCtx {
|
crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||||||
db: db.clone(),
|
db: db.clone(),
|
||||||
agent_max_iterations: Arc::new(AtomicUsize::new(
|
agent_max_iterations: Arc::new(AtomicUsize::new(
|
||||||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
|
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 res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||||
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
||||||
assert_eq!(returned, 15, "无 offset + limit=15 应返回 15 行(旧 bug 固定返 500)");
|
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();
|
fs::remove_dir_all(&tmp).ok();
|
||||||
}
|
}
|
||||||
@@ -1613,7 +1613,7 @@ mod tests {
|
|||||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||||
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
||||||
assert_eq!(returned, 500, "无 offset 无 limit 应默认返回 500 行");
|
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();
|
fs::remove_dir_all(&tmp).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,13 +150,13 @@ pub fn register(
|
|||||||
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
|
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
|
||||||
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
|
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
|
||||||
let line_count = content.lines().count();
|
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 lines: Vec<&str> = content.lines().collect();
|
||||||
let skip = offset as usize;
|
let skip = offset as usize;
|
||||||
let limit = args["limit"].as_u64().unwrap_or(200).min(2000) 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 page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect();
|
||||||
let more = (skip + page.len()) < line_count;
|
let more = (skip + page.len()) < line_count;
|
||||||
(page.join("\n"), Some(skip), more)
|
(page.join("\n"), Some(skip), more, limit)
|
||||||
} else {
|
} else {
|
||||||
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
|
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
|
||||||
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
|
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
|
||||||
@@ -168,13 +168,26 @@ pub fn register(
|
|||||||
.min(2000) as usize;
|
.min(2000) as usize;
|
||||||
let page: Vec<&str> = content.lines().take(limit).collect();
|
let page: Vec<&str> = content.lines().take(limit).collect();
|
||||||
let more = line_count > page.len();
|
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!({
|
Ok(serde_json::json!({
|
||||||
"path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
|
"path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
|
||||||
"offset": offset_used,
|
"offset": offset_used,
|
||||||
"returned_lines": result.lines().count(),
|
"returned_lines": returned_lines,
|
||||||
"has_more": has_more,
|
"truncated": has_more,
|
||||||
|
"truncated_info": truncated_info,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1034,7 +1047,7 @@ pub fn register(
|
|||||||
registry,
|
registry,
|
||||||
dummy: Arc<()>,
|
dummy: Arc<()>,
|
||||||
"run_command",
|
"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,
|
RiskLevel::High,
|
||||||
schema: object_schema(vec![
|
schema: object_schema(vec![
|
||||||
("command", "string", true),
|
("command", "string", true),
|
||||||
@@ -1108,6 +1121,7 @@ pub fn register(
|
|||||||
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
|
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
|
"succeeded": result.exit_code == Some(0),
|
||||||
"command": command,
|
"command": command,
|
||||||
"working_dir": working_dir,
|
"working_dir": working_dir,
|
||||||
"exit_code": result.exit_code,
|
"exit_code": result.exit_code,
|
||||||
@@ -1289,11 +1303,15 @@ mod tests {
|
|||||||
let win_start = char_start.saturating_sub(context_chars);
|
let win_start = char_start.saturating_sub(context_chars);
|
||||||
let win_end = (char_start + context_chars).min(total_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();
|
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!(
|
format!(
|
||||||
"{}…(±{}字符窗口)…{}",
|
"{lead}{window}…(±{context_chars}字符窗口)…",
|
||||||
if win_start > 0 { "…" } else { "" },
|
lead = lead,
|
||||||
context_chars,
|
window = window,
|
||||||
window
|
context_chars = context_chars
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ pub mod idea;
|
|||||||
pub mod trash;
|
pub mod trash;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod download_file;
|
pub mod download_file;
|
||||||
|
pub mod entity_resolve;
|
||||||
|
|||||||
@@ -266,7 +266,6 @@ pub async fn evaluate_idea(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<IdeaRecord, String> {
|
) -> Result<IdeaRecord, String> {
|
||||||
// 取出灵感
|
|
||||||
let record = state
|
let record = state
|
||||||
.ideas
|
.ideas
|
||||||
.get_by_id(&id)
|
.get_by_id(&id)
|
||||||
@@ -274,13 +273,7 @@ pub async fn evaluate_idea(
|
|||||||
.map_err(err_str)?
|
.map_err(err_str)?
|
||||||
.ok_or_else(|| format!("灵感不存在: {id}"))?;
|
.ok_or_else(|| format!("灵感不存在: {id}"))?;
|
||||||
|
|
||||||
let idea = record_to_idea(&record);
|
// 构造 engine(单次评估)
|
||||||
|
|
||||||
// 多维评分(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)。
|
|
||||||
let provider = build_default_provider(&state).await;
|
let provider = build_default_provider(&state).await;
|
||||||
let engine = match provider {
|
let engine = match provider {
|
||||||
Some((p, pool)) => {
|
Some((p, pool)) => {
|
||||||
@@ -288,6 +281,92 @@ pub async fn evaluate_idea(
|
|||||||
}
|
}
|
||||||
None => df_ideas::adversarial::AdversarialEngine::heuristic(),
|
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)?;
|
let eval = engine.evaluate(&idea).await.map_err(err_str)?;
|
||||||
|
|
||||||
// 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐)
|
// 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐)
|
||||||
@@ -334,7 +413,6 @@ pub async fn evaluate_idea(
|
|||||||
let score_value = (scores.overall * 10.0).round() as i64;
|
let score_value = (scores.overall * 10.0).round() as i64;
|
||||||
|
|
||||||
// 构造完整记录后单次原子写回(update_full 保留 id 与 created_at)。
|
// 构造完整记录后单次原子写回(update_full 保留 id 与 created_at)。
|
||||||
// ai_analysis/scores_json 按值 move 进主表记录后,下方历史快照仍需复用 → 此处 clone 保留绑定。
|
|
||||||
let updated = IdeaRecord {
|
let updated = IdeaRecord {
|
||||||
scores: Some(scores_json.clone()),
|
scores: Some(scores_json.clone()),
|
||||||
ai_analysis: Some(ai_analysis.clone()),
|
ai_analysis: Some(ai_analysis.clone()),
|
||||||
@@ -349,15 +427,8 @@ pub async fn evaluate_idea(
|
|||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
|
|
||||||
// 追加一条评估历史快照(idea_evaluations 审计表,version 单调递增)。
|
// 追加评估历史快照(idea_evaluations 审计表,version 单调递增)。
|
||||||
// 主表 update_full 成功后再追加,保证主表先落;历史表为额外冗余列(evaluated_by
|
// version 并发重复兜底(V25 唯一约束 + 重试)。
|
||||||
// 独立冗余,ai_analysis JSON 内的 evaluated_by 字段保留不删)。
|
|
||||||
//
|
|
||||||
// version 并发重复兜底(V25 唯一约束 + 重试):version 此前由
|
|
||||||
// `list_by_idea().first().version + 1` 算出,读-改-写非原子,并发评估同一灵感
|
|
||||||
// 可能写出相同 version。V25 在 idea_evaluations(idea_id, version) 上加了唯一索引,
|
|
||||||
// 此处捕获唯一约束冲突 → 重新查最新 version 重算并重试(上限 3 次防死循环)。
|
|
||||||
// 单用户桌面应用并发概率极低,但唯一约束 + 重试是数据完整性兜底,值得做。
|
|
||||||
let mut attempt = 0;
|
let mut attempt = 0;
|
||||||
let max_attempts = 3;
|
let max_attempts = 3;
|
||||||
loop {
|
loop {
|
||||||
@@ -383,10 +454,6 @@ pub async fn evaluate_idea(
|
|||||||
match state.idea_evaluations.insert(eval_record).await {
|
match state.idea_evaluations.insert(eval_record).await {
|
||||||
Ok(_) => break,
|
Ok(_) => break,
|
||||||
Err(e) => {
|
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 {
|
if is_unique_constraint_err(&e) && attempt < max_attempts {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"灵感 {id} 评估历史 version 唯一约束冲突,重试 {}/{}",
|
"灵感 {id} 评估历史 version 唯一约束冲突,重试 {}/{}",
|
||||||
|
|||||||
+3
-2
@@ -109,11 +109,12 @@ function initTheme() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+I / Cmd+I 切换 AI 面板
|
// Ctrl+I / Cmd+I 三态循环:关闭 → 分栏打开 → 最大化 → 关闭(cyclePanelMode)
|
||||||
|
// UI 按钮/审批激活仍用 togglePanel 二态开关,语义互不干扰
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
aiStore.togglePanel()
|
aiStore.cyclePanelMode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ const projectsStore = useProjectStore()
|
|||||||
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
|
// 后端 run_agentic_loop 对 override 兜底校验池内才用,前端预选无副作用。
|
||||||
// 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至
|
// 第三批抽离: 默认 model 选中逻辑(modelOverride=enabledModels[0])随 enabledModels 迁移至
|
||||||
// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消。
|
// TopBar.vue 的 activeConversationId watch;本组件仅保留编辑态取消。
|
||||||
// 滚动边沿/跟随意图重置(SW-260618-18/B-260618-24)随 MessageList 迁移,由子组件自管。
|
// 滚动边沿/跟随意图重置(SW-260618-18)随 MessageList 迁移,由子组件自管。
|
||||||
watch(() => store.state.activeConversationId, () => {
|
watch(() => store.state.activeConversationId, () => {
|
||||||
if (editingMsgId.value) cancelEdit()
|
if (editingMsgId.value) cancelEdit()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ function onPageSizeChange(val: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-btn:hover:not(:disabled) {
|
.page-btn:hover:not(:disabled) {
|
||||||
background: rgba(108, 99, 255, 0.06);
|
background: var(--df-accent-bg);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ function onDismiss() {
|
|||||||
border: 0.5px solid var(--df-accent);
|
border: 0.5px solid var(--df-accent);
|
||||||
border-left: 3px solid var(--df-accent);
|
border-left: 3px solid var(--df-accent);
|
||||||
border-radius: var(--df-radius-md);
|
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;
|
cursor: pointer;
|
||||||
transition: all 0.18s var(--df-ease);
|
transition: all 0.18s var(--df-ease);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -155,7 +155,7 @@ function onDismiss() {
|
|||||||
min-width: 18px;
|
min-width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
border-radius: 9px;
|
border-radius: 8px;
|
||||||
background: var(--df-danger);
|
background: var(--df-danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@@ -118,6 +118,6 @@ onMounted(async () => {
|
|||||||
.ci-item--success { border-color: var(--df-success); }
|
.ci-item--success { border-color: var(--df-success); }
|
||||||
.ci-item--failure { border-color: var(--df-danger); }
|
.ci-item--failure { border-color: var(--df-danger); }
|
||||||
.ci-item--pending { border-color: var(--df-warning); }
|
.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; }
|
.ci-item:hover { opacity: 0.8; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ watch(
|
|||||||
.conflict-type-badge {
|
.conflict-type-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
|
.badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
|
||||||
.badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
|
.badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ body.ai-sidebar-resizing * {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1001,7 +1001,7 @@ onBeforeUnmount(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ai-goal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--df-success); flex-shrink: 0; }
|
.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 { 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 {
|
.ai-goal-remove:hover {
|
||||||
color: var(--df-danger, #e5484d);
|
color: var(--df-danger, #e5484d);
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ const displayProjects = computed(() =>
|
|||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
text-transform: uppercase;
|
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-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-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); }
|
.chip-planning { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
||||||
|
|||||||
@@ -180,9 +180,16 @@
|
|||||||
</template>
|
</template>
|
||||||
<!-- 编辑态 -->
|
<!-- 编辑态 -->
|
||||||
<template v-else>
|
<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
|
<label
|
||||||
v-for="idea in candidateIdeas"
|
v-for="idea in filteredCandidates"
|
||||||
:key="idea.id"
|
:key="idea.id"
|
||||||
class="related-edit-item"
|
class="related-edit-item"
|
||||||
>
|
>
|
||||||
@@ -202,10 +209,15 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 传统评分条(score-bar) -->
|
<!-- 传统评分条(score-bar,从旧评估体系保留,默认折叠以降低信息密度) -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
|
<h3>
|
||||||
<div class="score-bar-chart" v-if="parseScores(idea.scores).length > 0">
|
<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">
|
<div class="score-bar-row" v-for="dim in parseScores(idea.scores)" :key="dim.name">
|
||||||
<span class="score-bar-label">{{ dim.name }}</span>
|
<span class="score-bar-label">{{ dim.name }}</span>
|
||||||
<div class="score-bar-track">
|
<div class="score-bar-track">
|
||||||
@@ -220,10 +232,30 @@
|
|||||||
<!-- 标签 -->
|
<!-- 标签 -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>{{ $t('ideas.tagsTitle') }}</h3>
|
<h3>{{ $t('ideas.tagsTitle') }}</h3>
|
||||||
|
<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">
|
<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>
|
<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>
|
||||||
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.noTags') }}</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>
|
</div>
|
||||||
|
|
||||||
<!-- 状态管理:P1-④ select → badge 点击(当前态显色 badge,点击展开下拉选项) -->
|
<!-- 状态管理:P1-④ select → badge 点击(当前态显色 badge,点击展开下拉选项) -->
|
||||||
@@ -292,6 +324,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'status-change', value: IdeaStatus): void
|
(e: 'status-change', value: IdeaStatus): void
|
||||||
(e: 'update-desc', value: string): void
|
(e: 'update-desc', value: string): void
|
||||||
(e: 'update-related', ids: string[]): void
|
(e: 'update-related', ids: string[]): void
|
||||||
|
(e: 'update-tags', tags: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -336,6 +369,8 @@ const descExpanded = ref(false)
|
|||||||
|
|
||||||
// P0-① 详细论据(正反方/行动建议)折叠:默认收起,降低首屏信息密度,结论前置后用户按需展开
|
// P0-① 详细论据(正反方/行动建议)折叠:默认收起,降低首屏信息密度,结论前置后用户按需展开
|
||||||
const debateExpanded = ref(false)
|
const debateExpanded = ref(false)
|
||||||
|
// score-bar 默认折叠(旧评估体系,降低信息密度)
|
||||||
|
const scoreBarExpanded = ref(false)
|
||||||
|
|
||||||
function measureDesc() {
|
function measureDesc() {
|
||||||
const el = descRef.value
|
const el = descRef.value
|
||||||
@@ -348,6 +383,7 @@ function measureDesc() {
|
|||||||
watch(() => props.idea.id, () => {
|
watch(() => props.idea.id, () => {
|
||||||
descExpanded.value = false
|
descExpanded.value = false
|
||||||
debateExpanded.value = false
|
debateExpanded.value = false
|
||||||
|
scoreBarExpanded.value = false
|
||||||
void nextTick(measureDesc)
|
void nextTick(measureDesc)
|
||||||
})
|
})
|
||||||
watch(() => props.idea.description, () => void nextTick(measureDesc))
|
watch(() => props.idea.description, () => void nextTick(measureDesc))
|
||||||
@@ -386,6 +422,15 @@ const vClickOutside: Directive<HTMLElement, () => void> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 关联灵感(related_ids JSON 数组字符串)=====
|
// ===== 关联灵感(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/空/非法 → [])
|
// 解析 props.idea.related_ids(JSON 字符串数组,null/空/非法 → [])
|
||||||
const relatedIds = computed<string[]>(() => {
|
const relatedIds = computed<string[]>(() => {
|
||||||
const raw = props.idea.related_ids
|
const raw = props.idea.related_ids
|
||||||
@@ -426,6 +471,7 @@ function confirmRelate() {
|
|||||||
|
|
||||||
function cancelRelate() {
|
function cancelRelate() {
|
||||||
relatedEditing.value = false
|
relatedEditing.value = false
|
||||||
|
relatedSearch.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelect(id: string) {
|
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;未知状态回退到原值显示
|
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
|
||||||
function statusLabelKey(status: IdeaStatus): string {
|
function statusLabelKey(status: IdeaStatus): string {
|
||||||
return props.statusOptions.find(s => s.value === status)?.labelKey ?? status
|
return props.statusOptions.find(s => s.value === status)?.labelKey ?? status
|
||||||
@@ -639,7 +712,7 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
background: rgba(108, 99, 255, 0.06);
|
background: var(--df-accent-bg);
|
||||||
border-left: 3px solid var(--df-accent);
|
border-left: 3px solid var(--df-accent);
|
||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
}
|
}
|
||||||
@@ -884,7 +957,7 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: var(--df-radius-xs);
|
border-radius: var(--df-radius-xs);
|
||||||
background: rgba(108, 99, 255, 0.12);
|
background: var(--df-accent-soft);
|
||||||
color: var(--df-accent);
|
color: var(--df-accent);
|
||||||
}
|
}
|
||||||
.latest-tag {
|
.latest-tag {
|
||||||
@@ -937,7 +1010,7 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
.tag {
|
.tag {
|
||||||
font-size: 12px; padding: 4px 10px;
|
font-size: 12px; padding: 4px 10px;
|
||||||
border-radius: var(--df-radius-xs);
|
border-radius: var(--df-radius-xs);
|
||||||
background: rgba(108, 99, 255, 0.1);
|
background: var(--df-accent-bg);
|
||||||
color: var(--df-accent);
|
color: var(--df-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,13 +1019,13 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
.related-chip {
|
.related-chip {
|
||||||
font-size: 12px; padding: 4px 10px;
|
font-size: 12px; padding: 4px 10px;
|
||||||
border-radius: var(--df-radius-xs);
|
border-radius: var(--df-radius-xs);
|
||||||
background: rgba(108, 99, 255, 0.1);
|
background: var(--df-accent-bg);
|
||||||
color: var(--df-accent);
|
color: var(--df-accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
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-chip-missing { cursor: default; opacity: 0.6; }
|
||||||
|
|
||||||
.related-edit-list {
|
.related-edit-list {
|
||||||
@@ -971,6 +1044,52 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
}
|
}
|
||||||
.related-edit-item input { cursor: pointer; }
|
.related-edit-item input { cursor: pointer; }
|
||||||
.related-edit-actions { display: flex; gap: 8px; }
|
.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) ===== */
|
/* ===== 按钮已提取到 global.css 全局(.btn/.btn-primary/.btn-ghost/.btn-sm) ===== */
|
||||||
/* IdeaDetail 特有按钮定位 */
|
/* IdeaDetail 特有按钮定位 */
|
||||||
@@ -1057,8 +1176,8 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.status-menu-item:hover { background: rgba(108, 99, 255, 0.08); }
|
.status-menu-item:hover { background: var(--df-accent-bg); }
|
||||||
.status-menu-item.active { background: rgba(108, 99, 255, 0.12); font-weight: 500; }
|
.status-menu-item.active { background: var(--df-accent-soft); font-weight: 500; }
|
||||||
/* menu 内 badge 取消 emoji 之外的全局 hover 等副作用,仅作色标 */
|
/* menu 内 badge 取消 emoji 之外的全局 hover 等副作用,仅作色标 */
|
||||||
.status-menu-item .status-tag { cursor: pointer; }
|
.status-menu-item .status-tag { cursor: pointer; }
|
||||||
|
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ watch(renderedContent, () => {
|
|||||||
/* ===== 知识内容 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css)===== */
|
/* ===== 知识内容 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css)===== */
|
||||||
.detail-content.ai-md { white-space: normal; }
|
.detail-content.ai-md { white-space: normal; }
|
||||||
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
.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; }
|
.muted { color: var(--df-text-dim); font-size: 12px; }
|
||||||
|
|
||||||
/* 状态徽章 / 置信度 */
|
/* 状态徽章 / 置信度 */
|
||||||
|
|||||||
@@ -68,8 +68,8 @@
|
|||||||
<!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) -->
|
<!-- 7b:收缩/展开切换按钮(最底部,快捷键 Ctrl+B 同效) -->
|
||||||
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
|
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
|
||||||
<span class="nav-link-icon">
|
<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-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="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 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>
|
||||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
|
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
|
||||||
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
|
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
|
||||||
@@ -150,7 +150,7 @@ const secondaryNav = [
|
|||||||
{ path: '/knowledge', label: 'nav.knowledge',
|
{ 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>' },
|
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',
|
{ 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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,22 @@
|
|||||||
<div>{{ $t('dependencyGraph.empty') }}</div>
|
<div>{{ $t('dependencyGraph.empty') }}</div>
|
||||||
</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 v-if="showAddDep" class="dep-graph__modal-overlay" @click.self="showAddDep = false">
|
||||||
<div class="dep-graph__modal">
|
<div class="dep-graph__modal">
|
||||||
@@ -60,12 +76,15 @@
|
|||||||
*/
|
*/
|
||||||
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue'
|
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
|
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
|
||||||
import dagre from 'dagre'
|
import dagre from 'dagre'
|
||||||
import '@antv/x6-vue-shape'
|
import '@antv/x6-vue-shape'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
|
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import ModuleNode from './ModuleNode.vue'
|
import ModuleNode from './ModuleNode.vue'
|
||||||
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -81,8 +100,15 @@ const containerRef = ref<HTMLElement | null>(null)
|
|||||||
const modules = ref<ProjectModuleRecord[]>([])
|
const modules = ref<ProjectModuleRecord[]>([])
|
||||||
const dependencies = ref<ModuleDependencyRecord[]>([])
|
const dependencies = ref<ModuleDependencyRecord[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { confirmDialog, confirmState, answerConfirm } = useConfirm()
|
||||||
let graph: Graph | null = null
|
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 showAddDep = ref(false)
|
||||||
const depFrom = ref('')
|
const depFrom = ref('')
|
||||||
@@ -165,8 +191,9 @@ function renderGraph() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 边数据(module_dependencies 表)
|
// 边数据(module_dependencies 表);edge id 用 dep.id,删除时按 id 反查源/目标
|
||||||
const edges = dependencies.value.map(d => ({
|
const edges = dependencies.value.map(d => ({
|
||||||
|
id: d.id,
|
||||||
source: d.from_module_id,
|
source: d.from_module_id,
|
||||||
target: d.to_module_id,
|
target: d.to_module_id,
|
||||||
labels: d.label ? [{ text: d.label }] : [{ text: d.dep_type }],
|
labels: d.label ? [{ text: d.label }] : [{ text: d.dep_type }],
|
||||||
@@ -206,6 +233,7 @@ function buildGraph() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
renderGraph()
|
renderGraph()
|
||||||
|
bindEdgeDeleteButtons()
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitContent() {
|
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 {
|
function depTypeColor(depType: string): string {
|
||||||
switch (depType) {
|
switch (depType) {
|
||||||
@@ -378,4 +465,26 @@ onBeforeUnmount(() => {
|
|||||||
.dep-graph__modal-actions {
|
.dep-graph__modal-actions {
|
||||||
display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
|
|||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -725,7 +725,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
|
|||||||
.gc-commit-diff-toggle {
|
.gc-commit-diff-toggle {
|
||||||
background: none; border: 0.5px solid var(--df-border);
|
background: none; border: 0.5px solid var(--df-border);
|
||||||
color: var(--df-text-dim); cursor: pointer; font-size: 11px;
|
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-toggle:hover { color: var(--df-text); border-color: var(--df-accent); }
|
||||||
.gc-commit-diff .gc-diff-content { flex: 1; min-height: 0; }
|
.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;
|
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-modified { color: #f0a020; }
|
||||||
.gc-st-added { color: #4caf50; }
|
.gc-st-added { color: #4caf50; }
|
||||||
.gc-st-deleted { color: #e05050; }
|
.gc-st-deleted { color: #e05050; }
|
||||||
@@ -806,8 +806,8 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
|
|||||||
.gc-branch-tag {
|
.gc-branch-tag {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
background: rgba(108,99,255,0.15);
|
background: var(--df-accent-soft);
|
||||||
color: var(--df-accent);
|
color: var(--df-accent);
|
||||||
}
|
}
|
||||||
.gc-branch-picker-hint {
|
.gc-branch-picker-hint {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
|
|||||||
.module-node__tag {
|
.module-node__tag {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
background: rgba(83, 120, 232, 0.15);
|
background: rgba(83, 120, 232, 0.15);
|
||||||
color: #7c9aff;
|
color: #7c9aff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ watch(() => props.view, () => nextTick(measureDesc))
|
|||||||
border-radius: var(--df-radius-sm);
|
border-radius: var(--df-radius-sm);
|
||||||
}
|
}
|
||||||
.stage-design { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
.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-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); }
|
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||||
|
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ onMounted(loadConnections)
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
.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-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); }
|
.type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ function cancelEdit() {
|
|||||||
.wf-dag-node-name { font-weight: 500; white-space: nowrap; }
|
.wf-dag-node-name { font-weight: 500; white-space: nowrap; }
|
||||||
.wf-dag-node-type {
|
.wf-dag-node-type {
|
||||||
font-size: 10px; color: var(--df-text-dim, #888);
|
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 { font-size: 11px; }
|
||||||
.wf-dag-node-status-dot {
|
.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--uncond { background: transparent; color: var(--df-text-dim, #aaa); }
|
||||||
.wf-dag-cond-label--editable {
|
.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;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.wf-dag-cond-label--editable:hover { background: rgba(74,144,226,0.18); }
|
.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-arrow { color: var(--df-text-primary); white-space: nowrap; }
|
||||||
.wf-dag-edge-input {
|
.wf-dag-edge-input {
|
||||||
flex: 1; min-width: 0; font-family: var(--df-font-mono); font-size: 11px;
|
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;
|
background: var(--df-bg); color: var(--df-text); outline: none;
|
||||||
}
|
}
|
||||||
.wf-dag-edge-input:focus { border-color: var(--df-accent); }
|
.wf-dag-edge-input:focus { border-color: var(--df-accent); }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! 面板与持久化 — togglePanel/toggleMaximize + UI 持久化(restoreUiState/persistUiState)
|
//! 面板与持久化 — togglePanel/toggleMaximize/cyclePanelMode + UI 持久化(restoreUiState/persistUiState)
|
||||||
//! + token 开关 + providers/skills/clearChat
|
//! + token 开关 + providers/skills/clearChat
|
||||||
//!
|
//!
|
||||||
//! 模块级私有:
|
//! 模块级私有:
|
||||||
@@ -13,7 +13,9 @@ import { aiApi } from '@/api'
|
|||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { setStreaming } from './streamingGuard'
|
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()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
@@ -46,16 +48,25 @@ function applyUiState(s: any) {
|
|||||||
state.sidebarWidth = clampSidebarWidth(s.sidebarWidth)
|
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() {
|
function restoreUiState() {
|
||||||
applyUiState(appSettings.get<any>('df-ai-ui', null))
|
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 模式)。
|
// 循环依赖兜底:stores/ai ↔ useAiPanel 互 import,顶层 restoreUiState 执行时 state 可能尚未
|
||||||
// loadProviders 后还会二次校验 DB is_default(双保险:localStorage 可能与 DB 不同步时以 DB 为准)。
|
// 初始化(TDZ)。冷启动靠 appSettings 缓存为空 → applyUiState(null) 提前 return 绕过访问;
|
||||||
const savedProvider = appSettings.get<string | null>('df-ai-active-provider', null)
|
// 但 HMR 热重载 stores/ai.ts 时 appSettings 已有缓存,restoreUiState 真正访问 state 触发
|
||||||
if (savedProvider) state.activeProvider = savedProvider
|
// ReferenceError: Cannot access 'state' before initialization。对齐 stores/ai.ts __bindMessages
|
||||||
|
// 既存模式:try 失败则微任务延迟重试,待 state 就绪后落库恢复。
|
||||||
|
try {
|
||||||
|
restoreUiState()
|
||||||
|
} catch {
|
||||||
|
Promise.resolve().then(restoreUiState)
|
||||||
|
}
|
||||||
|
|
||||||
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
|
// loadAll() 异步填充缓存后,再次应用 UI 布局(模块级 state,watch 仅触发一次)
|
||||||
watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any))
|
watch(() => appSettings.cache['df-ai-ui'], (v) => applyUiState(v as any))
|
||||||
@@ -96,6 +107,34 @@ function toggleMaximize() {
|
|||||||
persistUiState()
|
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 / 清屏 ──
|
// ── Providers / Skills / 清屏 ──
|
||||||
|
|
||||||
/** 拉取 provider 列表(用于顶部 bar 展示与切换) + 同步 DB 默认 provider 到 activeProvider */
|
/** 拉取 provider 列表(用于顶部 bar 展示与切换) + 同步 DB 默认 provider 到 activeProvider */
|
||||||
@@ -129,17 +168,35 @@ async function setProvider(providerId: string) {
|
|||||||
|
|
||||||
/** 清空当前会话消息(后端 + 本地 state) */
|
/** 清空当前会话消息(后端 + 本地 state) */
|
||||||
async function clearChat() {
|
async function clearChat() {
|
||||||
|
try {
|
||||||
await aiApi.clearChat()
|
await aiApi.clearChat()
|
||||||
state.messages = []
|
state.messages = []
|
||||||
state.currentText = ''
|
state.currentText = ''
|
||||||
state.pendingApprovals = []
|
state.pendingApprovals = []
|
||||||
setStreaming(false, { reason: 'clearChat' })
|
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() {
|
export function useAiPanel() {
|
||||||
return {
|
return {
|
||||||
togglePanel,
|
togglePanel,
|
||||||
toggleMaximize,
|
toggleMaximize,
|
||||||
|
cyclePanelMode,
|
||||||
loadProviders,
|
loadProviders,
|
||||||
loadSkills,
|
loadSkills,
|
||||||
setProvider,
|
setProvider,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function useMessageScroll(containerRef: Ref<HTMLDivElement | undefined>)
|
|||||||
// 回到底部按钮:内容溢出且不在底部时显示
|
// 回到底部按钮:内容溢出且不在底部时显示
|
||||||
const showBackToBottom = ref(false)
|
const showBackToBottom = ref(false)
|
||||||
|
|
||||||
// B-260618-24: 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。
|
// 跟随意图锁存。点回到底部/滚到底/发新消息→true;用户上滑→false。
|
||||||
const isFollowingBottom = ref(true)
|
const isFollowingBottom = ref(true)
|
||||||
// 边沿检测:上一帧是否在底部(用于触发 onNearBottom 回调)
|
// 边沿检测:上一帧是否在底部(用于触发 onNearBottom 回调)
|
||||||
let wasNearBottom = true
|
let wasNearBottom = true
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface UseStreamRendererOptions {
|
|||||||
saveSelection: () => void
|
saveSelection: () => void
|
||||||
/** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */
|
/** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */
|
||||||
restoreSelection: () => void
|
restoreSelection: () => void
|
||||||
/** B-260618-24:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
|
/** 跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
|
||||||
isFollowingBottom: Ref<boolean> | ComputedRef<boolean>
|
isFollowingBottom: Ref<boolean> | ComputedRef<boolean>
|
||||||
/** 回到底部操作 */
|
/** 回到底部操作 */
|
||||||
scrollToBottom: () => void
|
scrollToBottom: () => void
|
||||||
@@ -185,7 +185,7 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
|||||||
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
|
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
opts.restoreSelection()
|
opts.restoreSelection()
|
||||||
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
|
// rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
|
||||||
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
|
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
|
||||||
// 滚到渲染前高度)致"差一截"。
|
// 滚到渲染前高度)致"差一截"。
|
||||||
if (opts.isFollowingBottom.value) opts.scrollToBottom()
|
if (opts.isFollowingBottom.value) opts.scrollToBottom()
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import type { AiToolCallInfo } from '@/api/types'
|
|||||||
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
/** 工具结果 JSON 已知字段(全可选;list_* 运行时返回数组,靠 Array.isArray 区分) */
|
||||||
export interface ToolResult {
|
export interface ToolResult {
|
||||||
path?: string
|
path?: string
|
||||||
|
// A1-B5: 只读工具(git status/diff/log)失败路径后端返 {success:false,error} 合法 JSON 对象(非 Err),
|
||||||
|
// 前端 isToolFailure 靠此字段判失败(防绿框假成功)
|
||||||
|
success?: boolean
|
||||||
|
error?: string
|
||||||
pattern?: string
|
pattern?: string
|
||||||
from?: string
|
from?: string
|
||||||
to?: string
|
to?: string
|
||||||
@@ -131,9 +135,11 @@ export function shouldKeepOpen(tc: AiToolCallInfo): boolean {
|
|||||||
*
|
*
|
||||||
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
|
* 后端 AR-6:Low 风险工具失败不 emit AiError,改 emit AiToolCallCompleted(result=错误),
|
||||||
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
|
* 故失败工具 status 仍为 'completed',与成功工具视觉同态(原绿色边框+绿色结果框)。
|
||||||
* 此函数区分两类失败:
|
* 此函数区分三类失败:
|
||||||
* - run_command exit_code≠0:结果 JSON 合法,parseResult 命中,看 exit_code 判失败
|
* - 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 文本兜底,无结构信号
|
* 前端 parseResult 返回 null(非 JSON 对象)→ 走 raw 文本兜底,无结构信号
|
||||||
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
|
* 此时按行首前缀 "执行失败"/"Error:"/"Failed:" 启发式判定(对齐后端 err_msg 格式,
|
||||||
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。
|
* 行首锚定避免误伤含这些子串的正常产出,见 UX-260617-18)。
|
||||||
@@ -150,8 +156,12 @@ export function isToolFailure(tc: AiToolCallInfo): boolean {
|
|||||||
// run_command 但无 exit_code(异常)→ 保守判失败
|
// run_command 但无 exit_code(异常)→ 保守判失败
|
||||||
if (r) return true
|
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 错误字符串路径)→ 看文本前缀
|
// 通用工具:parseResult 返回 null(非 JSON,AR-6 错误字符串路径)→ 看文本前缀
|
||||||
if (!parseResult(tc.result)) {
|
if (!r) {
|
||||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
const raw = typeof tc.result === 'string' ? tc.result : ''
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
|
// UX-260617-18:收窄为行首锚定(原 /执行失败|failed|error[:\s]/i 匹配任意位置的子串,
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ function highlightCode(code: string, infostring: string): string {
|
|||||||
// infostring 形如 "ts" / "javascript" / "rust,norun" —— 取首个 token 去空白小写
|
// infostring 形如 "ts" / "javascript" / "rust,norun" —— 取首个 token 去空白小写
|
||||||
const langRaw = (infostring || '').split(/[\s,]/)[0]?.trim().toLowerCase() ?? ''
|
const langRaw = (infostring || '').split(/[\s,]/)[0]?.trim().toLowerCase() ?? ''
|
||||||
const lang = _LANG_ALIASES[langRaw] ?? langRaw
|
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
|
let highlighted: string
|
||||||
try {
|
try {
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
|||||||
@@ -50,5 +50,14 @@ export default {
|
|||||||
human: 'Human',
|
human: 'Human',
|
||||||
auto: 'Auto',
|
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)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export default {
|
|||||||
relatedManage: 'Manage relations',
|
relatedManage: 'Manage relations',
|
||||||
relatedConfirm: 'Confirm',
|
relatedConfirm: 'Confirm',
|
||||||
relatedCancel: 'Cancel',
|
relatedCancel: 'Cancel',
|
||||||
|
relatedSearchPlaceholder: 'Search idea title or description...',
|
||||||
|
|
||||||
// Idea stats panel (used by IdeasPanel)
|
// Idea stats panel (used by IdeasPanel)
|
||||||
statsPending: 'Pending',
|
statsPending: 'Pending',
|
||||||
@@ -97,7 +98,8 @@ export default {
|
|||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
tagsTitle: '🏷️ Tags',
|
tagsTitle: '🏷️ Tags',
|
||||||
noTags: 'No tags yet',
|
noTags: 'No tags',
|
||||||
|
editTags: 'Edit tags',
|
||||||
|
|
||||||
// Status management
|
// Status management
|
||||||
statusTitle: '📋 Status',
|
statusTitle: '📋 Status',
|
||||||
@@ -139,5 +141,10 @@ export default {
|
|||||||
confirmStatus: 'Change idea "{title}" status from {from} to {to}?',
|
confirmStatus: 'Change idea "{title}" status from {from} to {to}?',
|
||||||
promoteFailed: 'Promotion failed',
|
promoteFailed: 'Promotion failed',
|
||||||
evalFailed: 'Evaluation 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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ export default {
|
|||||||
projects: 'Projects',
|
projects: 'Projects',
|
||||||
tasks: 'Tasks',
|
tasks: 'Tasks',
|
||||||
knowledge: 'Knowledge',
|
knowledge: 'Knowledge',
|
||||||
audit: 'Audit Log',
|
audit: 'History',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
workspace: 'Workspace',
|
workspace: 'Workspace',
|
||||||
traceability: 'Traceability',
|
traceability: 'Traceability',
|
||||||
|
|||||||
@@ -50,5 +50,14 @@ export default {
|
|||||||
human: '人工',
|
human: '人工',
|
||||||
auto: '自动',
|
auto: '自动',
|
||||||
},
|
},
|
||||||
|
// Tab 导航
|
||||||
|
tabAudit: '审批历史',
|
||||||
|
tabConversations: '对话记录',
|
||||||
|
// 对话历史
|
||||||
|
convEmpty: '暂无对话记录',
|
||||||
|
convLoading: '加载对话列表…',
|
||||||
|
convTruncated: '内容过长已截断(展开查看完整对话)',
|
||||||
|
convSelectHint: '← 选择左侧一次对话查看完整交互日志',
|
||||||
|
convReasoning: '推理过程(thinking)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export default {
|
|||||||
relatedManage: '管理关联',
|
relatedManage: '管理关联',
|
||||||
relatedConfirm: '确定',
|
relatedConfirm: '确定',
|
||||||
relatedCancel: '取消',
|
relatedCancel: '取消',
|
||||||
|
relatedSearchPlaceholder: '搜索灵感标题或描述...',
|
||||||
|
|
||||||
// 灵感统计看板(IdeasPanel 引用)
|
// 灵感统计看板(IdeasPanel 引用)
|
||||||
statsPending: '待评估',
|
statsPending: '待评估',
|
||||||
@@ -89,6 +90,7 @@ export default {
|
|||||||
// 标签
|
// 标签
|
||||||
tagsTitle: '🏷️ 标签',
|
tagsTitle: '🏷️ 标签',
|
||||||
noTags: '暂无标签',
|
noTags: '暂无标签',
|
||||||
|
editTags: '编辑标签',
|
||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
statusTitle: '📋 状态管理',
|
statusTitle: '📋 状态管理',
|
||||||
@@ -139,5 +141,10 @@ export default {
|
|||||||
confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?',
|
confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?',
|
||||||
promoteFailed: '立项失败',
|
promoteFailed: '立项失败',
|
||||||
evalFailed: '评估失败',
|
evalFailed: '评估失败',
|
||||||
|
// 批量操作
|
||||||
|
batchDelete: '批量删除',
|
||||||
|
confirmBatchDelete: '确定删除选中的 {n} 个灵感?此操作不可撤销。',
|
||||||
|
batchDeleteSuccess: '已删除 {n} 个灵感',
|
||||||
|
selectedCount: '已选 {n} 项',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default {
|
|||||||
projects: '项目',
|
projects: '项目',
|
||||||
tasks: '任务',
|
tasks: '任务',
|
||||||
knowledge: '知识库',
|
knowledge: '知识库',
|
||||||
audit: '审批历史',
|
audit: '历史记录',
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
workspace: '工作区',
|
workspace: '工作区',
|
||||||
traceability: '追溯',
|
traceability: '追溯',
|
||||||
|
|||||||
+7
-1
@@ -16,4 +16,10 @@ app.config.errorHandler = (err, instance, info) => {
|
|||||||
console.error(`[Vue error] <${tag}> ${info}:`, err);
|
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"));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ═══════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════
|
||||||
DevFlow Design System — Claude 设计系统规范
|
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-pad-panel: 14px 16px; /* 面板/卡片内边距 */
|
||||||
--df-msg-max-width: 90%; /* AI 气泡/工具卡片最大宽度(对齐不撑满) */
|
--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-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
--df-font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace;
|
--df-font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||||
|
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ function onDragEnd() { isDragging.value = false }
|
|||||||
border: 0.5px solid var(--df-accent);
|
border: 0.5px solid var(--df-accent);
|
||||||
border-left: 3px solid var(--df-accent);
|
border-left: 3px solid var(--df-accent);
|
||||||
border-radius: var(--df-radius-md);
|
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;
|
overflow: hidden;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -554,7 +554,7 @@ function onDragEnd() { isDragging.value = false }
|
|||||||
background: var(--df-danger);
|
background: var(--df-danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1.5px solid var(--df-bg-card);
|
border: 1.5px solid var(--df-bg-card);
|
||||||
@@ -730,7 +730,7 @@ function onDragEnd() { isDragging.value = false }
|
|||||||
border-radius: var(--df-radius-xs, 3px);
|
border-radius: var(--df-radius-xs, 3px);
|
||||||
font-family: var(--df-font-mono);
|
font-family: var(--df-font-mono);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|||||||
+294
-2
@@ -3,13 +3,27 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>{{ t('auditLog.title') }}</h1>
|
<h1>{{ t('auditLog.title') }}</h1>
|
||||||
<div class="header-actions">
|
<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>
|
<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') }}
|
{{ t('auditLog.refresh') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 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>
|
<p class="page-desc">{{ t('auditLog.desc') }}</p>
|
||||||
|
|
||||||
<!-- 筛选栏 -->
|
<!-- 筛选栏 -->
|
||||||
@@ -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>
|
<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>
|
<button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -104,6 +174,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { formatRelative, formatDate } from '@/utils/time'
|
import { formatRelative, formatDate } from '@/utils/time'
|
||||||
import { usePersistedRef } from '@/composables/usePersistedRef'
|
import { usePersistedRef } from '@/composables/usePersistedRef'
|
||||||
|
import { aiApi } from '@/api/ai'
|
||||||
|
import type { AiConversationSummary } from '@/api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
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
|
if (loading.value) return
|
||||||
offset.value = 0
|
offset.value = 0
|
||||||
expandedId.value = null
|
expandedId.value = null
|
||||||
@@ -278,6 +421,7 @@ function decidedClass(d: string): string {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void load()
|
void load()
|
||||||
|
if (activeTab.value === 'conversations') void loadConversations()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -476,4 +620,152 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* empty-state 已提取到 global.css 全局 */
|
/* 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>
|
</style>
|
||||||
|
|||||||
@@ -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-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>
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -58,7 +54,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll) +
|
// 仪表盘页壳 — 仅保留:Header(refresh) + 错误条 + 布局栅格 + 加载编排(loadAll) +
|
||||||
// 底部统计概要行(原独立统计卡 StatCardRow 已下沉为底部一行小字,空间让给项目/灵感面板)。
|
// 底部统计概要行(原独立统计卡 StatCardRow 已下沉为底部一行小字,空间让给项目/灵感面板)。
|
||||||
// 项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
|
// 项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
@@ -101,10 +97,6 @@ async function refresh() {
|
|||||||
await loadAll()
|
await loadAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
function quickCapture() {
|
|
||||||
router.push('/ideas')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部统计概要项点击跳转(可点击项:ideas/projects/tasks;drafts 不可点)
|
// 底部统计概要项点击跳转(可点击项:ideas/projects/tasks;drafts 不可点)
|
||||||
function go(link: string) {
|
function go(link: string) {
|
||||||
router.push(link)
|
router.push(link)
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ async function runImport() {
|
|||||||
}
|
}
|
||||||
.view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
.view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||||
.view-toggle-btn.active {
|
.view-toggle-btn.active {
|
||||||
background: rgba(108, 99, 255, 0.12);
|
background: var(--df-accent-soft);
|
||||||
color: var(--df-accent);
|
color: var(--df-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user