Compare commits

..
19 Commits
Author SHA1 Message Date
lxy 256af4e736 优化: 工具调用标识类型安全加固 2026-08-10 08:04:30 +08:00
lxy 5a1b7a871a 重构: AI执行循环拆分降低复杂度 2026-08-10 08:04:30 +08:00
lxy 6141a67b0e 优化: 审批弹窗全局化与体验打磨 2026-08-10 08:04:30 +08:00
lxy dedb9a8862 修复: 工作流推进链路与执行入口优化 2026-08-10 08:04:29 +08:00
lxy 2473dcfe68 优化: 文件预览大纲提取 + 预览/图标/滚动条打磨 2026-08-10 02:23:53 +08:00
lxy 40224d603a 文档: 待办看板更新 + manifest appid 忽略规则 2026-08-10 00:00:56 +08:00
lxy cf00345c35 优化: 项目文件树/依赖图组件 + 灵感对抗校验 + 小程序脚本 + 模块管理 2026-08-10 00:00:31 +08:00
lxy 6bafdcd5a5 修复: token 汇总正确性(会话级双计/is_estimated跨端/流式usage传递) + is_estimated SELECT 缺失修复 2026-08-09 23:59:55 +08:00
lxy 11f4978ec1 优化: aichat效率剩余(压缩后台化防阻塞/审计批量事务/只读缓存轮内去重/流式增量渲染/AiCommandOutput合批/双渲染合并) + 跨端加固(df-project路径保留大小写/tunnel文档更正supervisor重连/relay固定时间比较与帧上限/启动校验) + 销账 2026-08-09 21:35:59 +08:00
lxy fbd8fae44b 修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账 2026-08-09 21:35:58 +08:00
lxy 8bc5380ecb 文档: 全系统走查登记(阶段②: 任务/仪表盘/灵感知识59项 + Settings/审计/布局/后端命令95项 + df-mcp/工具crate 35项) + API Key 401 已解决销账 2026-08-09 21:35:46 +08:00
lxy 7eb2e25ed5 优化: aichat效率批(save无变化捷径/断路器尾窗/system缓存指纹/会话列表刷新收敛) + 新增 LLM prompt caching(cache_control开关默认关/system稳定段易变段分离治缓存命中率) + 销账 2026-08-09 21:35:13 +08:00
lxy 4df91ed155 修复: 项目详情批量(任务列表联动刷新/工作流降级标注/中文git乱码/子目录错误行内/零工程空态/切tab保活) + 依赖图批量(节点渲染register/环高亮/防环防重/点击选中) + 销账 2026-08-09 21:35:13 +08:00
lxy 4a9f77872e 文档: 项目详情页走查(60项) + aichat效率走查(74项,含LLM缓存命中率核心发现)登记 2026-08-09 21:35:03 +08:00
lxy 5a92e6056c 优化: AI治理与性能(tool_failure_stats失败率统计命令 + git status仓库级缓存治cmd闪烁N²) + 销账 2026-08-09 21:35:02 +08:00
lxy e099eff4cb 优化: MCP 收尾(update原子CAS治TOCTOU/advance_task数据变更映射/list_trash分页) + miniapp渲染收尾(mention chip可视化/代码块语言标签) + 销账 2026-08-09 21:35:02 +08:00
lxy 37040616bd 优化: aichat 工具体验补强(切生成会话占位气泡治丢首响应/read_symbol符号级提示/失败引导/审批失败toast) + 修复 WorkflowDagDisplay 崩页与 aiChat i18n 缺key 2026-08-09 21:34:53 +08:00
lxy cad76ffdf2 修复: @任务联想源隔离 store.tasks 污染 + DirAuthDialog 拒绝二次确认 + 看板销账(AC弱模型机制 3/5 已落地核对) 2026-08-09 21:34:52 +08:00
lxy 180d8e339f 修复: TaskDetail 操作按钮缺陷(推进竞态/菜单状态机过滤/工作流残留重置/子任务校验/描述编辑) + 看板销账(aichat切换缺陷 14/16 已修核对) 2026-08-09 21:34:52 +08:00
125 changed files with 7347 additions and 1511 deletions
+4
View File
@@ -89,3 +89,7 @@ workflows/
devflow-dev.db devflow-dev.db
devflow-dev.db-shm devflow-dev.db-shm
devflow-dev.db-wal devflow-dev.db-wal
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
apps/df-miniapp/src/manifest.json
# 微信小程序 appid(真实 appid 本地保留,不提交仓库)
+18
View File
@@ -22,6 +22,7 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0", "@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",
@@ -5056,6 +5057,16 @@
"@sinonjs/commons": "^1.7.0" "@sinonjs/commons": "^1.7.0"
} }
}, },
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tootallnate/once": { "node_modules/@tootallnate/once": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz",
@@ -12995,6 +13006,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-detect": { "node_modules/type-detect": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz",
+2 -1
View File
@@ -7,7 +7,7 @@
"dev:h5": "uni", "dev:h5": "uni",
"dev:mp-weixin": "uni -p mp-weixin", "dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build", "build:h5": "uni build",
"build:mp-weixin": "uni build -p mp-weixin", "build:mp-weixin": "uni build -p mp-weixin && node scripts/fix-mp-build.cjs",
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
@@ -25,6 +25,7 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001", "@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001", "@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0", "@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",
+69
View File
@@ -0,0 +1,69 @@
//! post-build 修复脚本:处理微信小程序构建产物的兼容性问题
//!
//! 背景:marked v18+ 含 lookbehind 检测 `new RegExp("(?<=1)(?<!1)")`,
//! 微信开发者工具转译器静态检测到 `(?<=` 字符串 → 误判需 @swc/helpers/_wrap_reg_exp
//! helper → 注入 require → 产物无此模块 → 小程序白屏。
//!
//! 微信基础库运行时不支持 lookbehind,marked 本就走降级路径(Te=false)。
//! 本脚本把产物里的 lookbehind 检测替换为 Te=false,绕过微信工具静态误判,
//! 同时保留 marked 完整功能(运行时本就降级)。
//!
//! 用法:build 后运行 `node scripts/fix-mp-build.cjs`
const fs = require('fs')
const path = require('path')
const BUILD_DIR = path.join(__dirname, '..', 'dist', 'build', 'mp-weixin')
const VENDOR = path.join(BUILD_DIR, 'common', 'vendor.js')
function fixLookbehind() {
if (!fs.existsSync(VENDOR)) {
console.warn('[fix-mp-build] vendor.js 不存在,跳过:', VENDOR)
return
}
let src = fs.readFileSync(VENDOR, 'utf8')
const before = src
// marked v18 lookbehind 检测 `var Te=((l="")=>{try{return!!new RegExp("(?<=1)(?<!1)"+l)}catch{return!1}})()`
// 替换为 `var Te=false`(强制降级,微信基础库不支持 lookbehind)
src = src.replace(
/var Te=\(\([^)]*\)=>\{try\{return!!new RegExp\("\(\?<=1\)\(\?<!1\)"[^}]*\}\}\)\)\(\),?/,
'var Te=false,'
)
if (src === before) {
// 未命中(压缩名可能不是 Te),退而按字符串替换 lookbehind 检测
src = src.replace(/new RegExp\("\(\?<=1\)\(\?<!1\)"[^)]*\)/g, 'false')
}
// 关键:marked 条件分支里还有 `(?<!`)` lookbehind 字面量(Te 为 true 时的分支)。
// 微信工具静态检测到任何 `(?<=` / `(?<!` 字符都会注入 swc helper 误判。
// 把所有 lookbehind 字面量替换为非 lookbehind 等价形式。
// (?:x) 合法不 throw;但该分支仅 Te=true 时用,运行时 Te=false 恒走降级分支,
// 替换后行为不变,同时消除微信工具的静态触发器。
src = src.replace(/\(\?<!`\)/g, '(?:x)')
src = src.replace(/\(\?<=1\)\(\?<!1\)/g, '(?:1)')
// 命名捕获组(ES2018)也触发 _wrap_reg_exp。marked v18 用 `(?<a>`+)[^`]+\k<a>` 匹配代码围栏。
// 转为编号捕获组 `(`+)\1`(ES5 兼容,无命名捕获组,行为等价)。
// 通用: (?<name>...) → (...), \k<name> → \N(按捕获组序编号,这里只有 a/b 两组)
src = src.replace(/\(\?<a>\`\+\)\[^\`\]\+\\k<a>/g, '(`+)\\1')
src = src.replace(/\(\?<b>\`\+\)\[^\`\]\+\\k<b>/g, '(`+)\\1')
// 兜底:任何残留命名捕获组 (?<name> 或 \k<name> 转为无命名
src = src.replace(/\(\?<[a-z][a-z0-9_]*>/g, '(')
src = src.replace(/\\k<[a-z]>/g, '\\1')
if (src !== before) {
fs.writeFileSync(VENDOR, src)
console.log('[fix-mp-build] vendor.js lookbehind 已全部替换(检测+条件分支)')
} else {
console.warn('[fix-mp-build] 未找到 lookbehind(可能已处理或格式不同)')
}
}
// 清理手动补的 @swc/helpers(不再需要,避免污染)
function cleanupSwcHelpers() {
const swcDir = path.join(BUILD_DIR, 'common', '@swc', 'helpers')
if (fs.existsSync(swcDir)) {
fs.rmSync(swcDir, { recursive: true, force: true })
console.log('[fix-mp-build] 已清理手动补的 @swc/helpers')
}
}
fixLookbehind()
cleanupSwcHelpers()
@@ -812,6 +812,7 @@ export function handleEvent(event: AiChatEvent): void {
cache_hit: event.prompt_cache_hit_tokens, cache_hit: event.prompt_cache_hit_tokens,
cache_miss: event.prompt_cache_miss_tokens, cache_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens, reasoning: event.reasoning_tokens,
is_estimated: event.is_estimated,
} }
// 不完整标记(网络中断保文) // 不完整标记(网络中断保文)
if (event.incomplete) { if (event.incomplete) {
-53
View File
@@ -1,53 +0,0 @@
{
"name": "DevFlow",
"appid": "PLACEHOLDER",
"description": "DevFlow 远程 AI Chat — 跨端操作桌面端开发助手",
"versionName": "0.1.0",
"versionCode": 100,
"transformPx": false,
"app-plus": {
"usingComponents": true,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.INTERNET\"/>"
]
},
"ios": {},
"sdkConfigs": {}
}
},
"quickapp": {},
"mp-weixin": {
"appid": "PLACEHOLDER",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true,
"postcss": true
},
"usingComponents": true,
"permission": {},
"requiredBackgroundModes": [],
"requiredPrivateInfos": []
},
"h5": {
"title": "DevFlow Mini",
"router": {
"mode": "hash",
"base": "./"
},
"devServer": {
"port": 8081,
"https": false
}
},
"vueVersion": "3"
}
+72 -2
View File
@@ -350,6 +350,50 @@ function shouldRenderMsg(m: ChatMessage): boolean {
return true return true
} }
// ────────────────────────────────────────────────────────────────
// MR-P2-2 用户消息 mention chip:解析 `[项目:名]`/`[任务:名]`/`[灵感:名]` 标记分段渲染。
//
// 对齐桌面 MessageItem.segmentUserContent:命中已知实体的标记渲染为 chip(浅底圆角徽标),
// 未命中保持字面(防误伤正常方括号文本)。
// 标记格式与 @ 选中插入一致(onEntitySelect):冒号后是实体展示名 —— project 用 name,
// task/idea 用 title。miniapp 消息内容仅存原始文本(AiUserMessage 只回 message,无 span 元数据),
// 故「已解析成功」以实体名是否命中当前 entities 列表判定(与插入时同字段)。
// ────────────────────────────────────────────────────────────────
type UserSegment =
| { type: 'text'; text: string }
| { type: 'chip'; kind: 'project' | 'task' | 'idea'; label: string }
/** 实体名是否命中已知实体(项目 name / 任务·灵感 title,与 @ 选中插入同字段) */
function isKnownEntity(kind: 'project' | 'task' | 'idea', name: string): boolean {
const e = entities.value
if (kind === 'project') return e.projects.some((p) => p.name === name)
if (kind === 'task') return e.tasks.some((t) => t.title === name)
return e.ideas.some((i) => i.title === name)
}
/** 用户消息内容 → 分段(文本段 + chip 段),纯文本返回单文本段(零回归) */
function segmentUserContent(content: string): UserSegment[] {
const segs: UserSegment[] = []
const re = /\[(项目|任务|灵感):([^\]]+)\]/g
let last = 0
let m: RegExpExecArray | null
while ((m = re.exec(content)) !== null) {
if (m.index > last) segs.push({ type: 'text', text: content.slice(last, m.index) })
const kind = m[1] === '项目' ? 'project' : m[1] === '任务' ? 'task' : 'idea'
const name = m[2].trim()
// 仅命中已知实体渲染 chip;未命中(正常方括号文本/无效引用)保持字面
segs.push(isKnownEntity(kind, name) ? { type: 'chip', kind, label: name } : { type: 'text', text: m[0] })
last = m.index + m[0].length
}
if (last < content.length) segs.push({ type: 'text', text: content.slice(last) })
return segs.length ? segs : [{ type: 'text', text: content }]
}
/** 用户消息内容是否含 mention 标记段(决定是否走分段渲染;纯文本走原单一 text 零回归) */
function hasMentionMarker(content: string): boolean {
return /\[(项目|任务|灵感):[^\]]+\]/.test(content)
}
/** /**
* 输入变化时检测联想触发条件。 * 输入变化时检测联想触发条件。
* *
@@ -771,8 +815,17 @@ function tokenInOf(t: TokenUsage): number {
<view v-if="m.role === 'user'"> <view v-if="m.role === 'user'">
<!-- 图片预览(消息带图时) --> <!-- 图片预览(消息带图时) -->
<image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" /> <image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" />
<!-- 文本(有文本才显,图片+文本共存;纯图片则只有图,不空白) --> <!-- MR-P2-2 文本: mention 标记时分段渲染 chip(命中已知实体的 `[项目:名]`/`[任务:名]`/`[灵感:名]`
<text v-if="m.content" user-select>{{ m.content }}</text> 渲染为 chip,未命中保持字面);纯文本走原单一 `<text>` 渲染零回归 -->
<template v-if="m.content && hasMentionMarker(m.content)">
<text user-select>
<template v-for="(seg, idx) in segmentUserContent(m.content)" :key="m.id + '-seg-' + idx">
<text v-if="seg.type === 'text'">{{ seg.text }}</text>
<text v-else class="user-chip" :class="'user-chip--' + seg.kind">{{ seg.label }}</text>
</template>
</text>
</template>
<text v-else-if="m.content" user-select>{{ m.content }}</text>
</view> </view>
<template v-else> <template v-else>
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) --> <!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
@@ -1054,6 +1107,23 @@ function tokenInOf(t: TokenUsage): number {
.msg.user text { .msg.user text {
color: #ffffff; color: #ffffff;
} }
/* MR-P2-2 用户消息 mention chip(对齐桌面 ai-msg-chip:浅底圆角徽标)。
用户气泡底 = #4a9eff,chip 用半透明白底 + 深蓝字保证对比度(对齐桌面 rgba(255,255,255,.32));
按 kind 区分颜色变体(project/task/idea),与 @ mention 浮层分组色呼应。
mp-weixin <text> 组件天然 inline,嵌套于外层 <text> 内联排布;背景/圆角/padding 真机可渲染。
选择器用 .msg.user text.user-chip(≥ .msg.user text 的 (0,2,1))盖过白字,保证深蓝字生效。 */
.msg.user text.user-chip {
padding: 1px 6px;
margin: 0 1px;
border-radius: 4px;
font-size: 13px;
line-height: 1.4;
color: #14407a;
background-color: rgba(255, 255, 255, 0.32);
}
.msg.user text.user-chip--project { background-color: rgba(255, 255, 255, 0.38); }
.msg.user text.user-chip--task { background-color: rgba(255, 255, 255, 0.3); }
.msg.user text.user-chip--idea { background-color: rgba(255, 255, 255, 0.24); }
/* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */ /* 用户消息图片预览(2026-08-06):120px 宽 + 圆角,与气泡风格一致;mode=widthFix 高自适应 */
.msg-user-img { .msg-user-img {
display: block; display: block;
+4
View File
@@ -53,6 +53,8 @@ export type AiChatEvent =
prompt_cache_hit_tokens: number prompt_cache_hit_tokens: number
prompt_cache_miss_tokens: number prompt_cache_miss_tokens: number
reasoning_tokens: number reasoning_tokens: number
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
is_estimated: boolean
incomplete?: boolean | null incomplete?: boolean | null
conversation_id?: string | null conversation_id?: string | null
} }
@@ -170,6 +172,8 @@ export interface TokenUsage {
cache_hit?: number cache_hit?: number
cache_miss?: number cache_miss?: number
reasoning?: number reasoning?: number
/** 该轮 token 是否估算值(AiCompleted.is_estimated 透传,reload 历史消息可能无) */
is_estimated?: boolean
} }
// ============================================================ // ============================================================
+14 -3
View File
@@ -130,23 +130,34 @@ function inlineHljs(html: string): string {
}) })
} }
/** 代码块语言徽标(rich-text 内联 style;块代码顶部展示 fence lang,无 lang 不调用)。 */
function codeLangBadge(lang: string): string {
// 块级 div(pre 深底上浅灰标签 + 圆角,pre 自身已有内边距),放 <code> 前占据顶部一行
return `<div style="font-size:10px;color:#9e9e9e;background-color:#2e2e2e;padding:1px 8px;border-radius:4px;margin-bottom:6px">${lang}</div>`
}
/** /**
* 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。 * 块代码语法高亮:仅处理已知语言,未知/无语言兜底原样(确定性强,不做自动猜测)。
* marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。 * marked 已对代码内容做 HTML 转义,先反转还原再交给 hljs(hljs 输出自带转义)。
* 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。
*/ */
function highlightCodeBlocks(html: string): string { function highlightCodeBlocks(html: string): string {
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => { return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
const langMatch = /class="language-([^"]+)"/i.exec(attrs) const langMatch = /class="language-([^"]+)"/i.exec(attrs)
const langRaw = langMatch ? langMatch[1] : '' const langRaw = langMatch ? langMatch[1] : ''
const lang = LANG_ALIASES[langRaw] ?? langRaw const lang = LANG_ALIASES[langRaw] ?? langRaw
if (!lang || !hljs.getLanguage(lang)) return m const badge = langRaw ? codeLangBadge(langRaw) : ''
if (!lang || !hljs.getLanguage(lang)) {
// 未注册语言:不高亮但保留徽标(fence 有 lang 时),避免 ` ```未知lang ` 丢语言标签
return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
}
let value: string let value: string
try { try {
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
} catch { } catch {
return m return badge ? `<pre><code${attrs}>${badge}${inner}</code></pre>` : m
} }
return `<pre><code${attrs}>${inlineHljs(value)}</code></pre>` return `<pre><code${attrs}>${badge}${inlineHljs(value)}</code></pre>`
}) })
} }
+9 -6
View File
@@ -42,25 +42,25 @@ impl ContentPart {
impl ChatMessage { impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self { pub fn system(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn user(content: impl Into<String>) -> Self { pub fn user(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant(content: impl Into<String>) -> Self { pub fn assistant(content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self { pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self { pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 多模态 user 消息:content 文本 + parts(含 Image 片)。 /// 多模态 user 消息:content 文本 + parts(含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。 /// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self { pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, timestamp: Some(now_millis_i64()) } Self { id: Some(new_message_id()), role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, prompt_tokens: None, completion_tokens: None, prompt_cache_hit_tokens: None, prompt_cache_miss_tokens: None, reasoning_tokens: None, is_estimated: None, timestamp: Some(now_millis_i64()) }
} }
/// 是否含图片片(供 provider 判定走多模态分支)。 /// 是否含图片片(供 provider 判定走多模态分支)。
@@ -320,6 +320,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
assert_eq!(m.content, "字面量构造"); assert_eq!(m.content, "字面量构造");
@@ -377,6 +378,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
@@ -437,6 +439,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
+4
View File
@@ -138,6 +138,10 @@ pub struct ChatMessage {
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。 /// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>, pub reasoning_tokens: Option<u32>,
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// 供 reload 逐条回显「估算」标注,对齐 live 态 AiCompleted.is_estimated;老消息 None(向前兼容)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_estimated: Option<bool>,
} }
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。 /// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
+86 -4
View File
@@ -29,10 +29,38 @@ use crate::retry::{
// 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers // 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers
// 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。 // 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。
use crate::anthropic_helpers::{ use crate::anthropic_helpers::{
apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef, apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef, SystemBlock,
ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS, ANTHROPIC_VERSION, DEFAULT_MAX_TOKENS,
}; };
// ============================================================
// Anthropic prompt caching 开关(进程级全局,受 AppState settings 同步)
// ============================================================
/// Anthropic prompt caching 总开关(默认关)。
///
/// 该分支同时服务 Claude 官方 / GLM 订阅端点 (open.bigmodel.cn/api/anthropic) / 任意 Messages
/// API 网关——**cache_control 字段可能被非官方网关拒收**,故必须默认关、用户对支持的端点显式开启。
///
/// 开关开:`convert_request` 把 system 包成 blocks 数组并附加 `cache_control:{type:ephemeral}`
/// (配合上层把易变段挪出 system,让 system 稳定段真正命中缓存)。
/// 开关关:system 保持纯字符串(现状行为,零风险兼容网关)。
///
/// 进程级全局而非 per-provider:上层(AppState settings)按「用户对当前启用的 anthropic 端点
/// 是否支持缓存」一次性开启,所有 anthropic 请求一致生效。默认关。
static ANTHROPIC_CACHE_ENABLED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// 设置 anthropic prompt caching 开关(上层读取 AppState settings 后同步)。
pub fn set_anthropic_cache_enabled(enabled: bool) {
ANTHROPIC_CACHE_ENABLED.store(enabled, std::sync::atomic::Ordering::SeqCst);
}
/// 读取 anthropic prompt caching 开关(`convert_request` 组装 system 形态用)。
pub fn anthropic_cache_enabled() -> bool {
ANTHROPIC_CACHE_ENABLED.load(std::sync::atomic::Ordering::SeqCst)
}
// ============================================================ // ============================================================
// Provider 实现 // Provider 实现
// ============================================================ // ============================================================
@@ -92,8 +120,10 @@ impl AnthropicCompatProvider {
req.model req.model
}; };
// 抽离 system 消息 // 抽离 system 消息
let system: Option<String> = { // 开关开 → blocks 数组 + cache_control(Anthropic prompt caching,让 system 稳定段在
// 支持缓存的端点上命中);开关关 → 纯字符串(兼容非官方网关,零风险)。
let system: Option<SystemBlock> = {
let sys: Vec<String> = req let sys: Vec<String> = req
.messages .messages
.iter() .iter()
@@ -102,8 +132,14 @@ impl AnthropicCompatProvider {
.collect(); .collect();
if sys.is_empty() { if sys.is_empty() {
None None
} else if anthropic_cache_enabled() {
Some(SystemBlock::Cached(vec![serde_json::json!({
"type": "text",
"text": sys.join("\n\n"),
"cache_control": { "type": "ephemeral" },
})]))
} else { } else {
Some(sys.join("\n\n")) Some(SystemBlock::Plain(sys.join("\n\n")))
} }
}; };
@@ -1126,4 +1162,50 @@ mod tests {
"补占位后 precheck 应通过" "补占位后 precheck 应通过"
); );
} }
// ---------- prompt caching:开关驱动 system 形态 ----------
/// 开关控制 system 形态:开 → blocks 数组含 cache_control:ephemeral;关 → 纯字符串。
/// (两态共用进程级全局开关,拆两个用例并行跑会互相污染——单测内顺序切换断言,末尾复位。)
#[test]
fn anthropic_cache_switch_controls_system_shape() {
set_anthropic_cache_enabled(false); // 兜底复位,防污染其他并行用例
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
let mk = || CompletionRequest {
model: "claude-3-5-sonnet".into(),
messages: vec![
ChatMessage::system("a"),
ChatMessage::system("b"),
ChatMessage::user("hi"),
],
temperature: None,
max_tokens: None,
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
// 开:数组形态 + cache_control(Anthropic prompt caching)
set_anthropic_cache_enabled(true);
let body = provider.convert_request(mk());
match body.system.expect("开关开时应有 system") {
SystemBlock::Cached(blocks) => {
assert_eq!(blocks.len(), 1, "开关开应包成单 blocks 数组");
assert_eq!(blocks[0]["type"], "text");
assert_eq!(blocks[0]["text"], "a\n\nb", "多条 system 应以 \\n\\n join 进单个 text 块");
assert_eq!(blocks[0]["cache_control"]["type"], "ephemeral");
}
SystemBlock::Plain(s) => panic!("开关开应为数组形态,实际 Plain: {}", s),
}
// 关:纯字符串(兼容非官方网关,零行为变更)
set_anthropic_cache_enabled(false);
let body = provider.convert_request(mk());
match body.system {
Some(SystemBlock::Plain(s)) => assert_eq!(s, "a\n\nb", "开关关多条 system 应以 \\n\\n join 纯字符串"),
other => panic!("开关关应为纯字符串,实际: {:?}", other),
}
}
} }
+20 -4
View File
@@ -18,6 +18,20 @@ use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCal
// Anthropic API 请求/响应结构体 // Anthropic API 请求/响应结构体
// ============================================================ // ============================================================
/// Anthropic `system` 字段形态(受 prompt caching 开关 `ANTHROPIC_CACHE_ENABLED` 控制):
/// - `Plain`:纯字符串。开关关时保持原形态,兼容非官方网关(GLM 订阅端点 / 任意 Messages API
/// 代理——部分网关会拒收 cache_control 字段)。
/// - `Cached`:blocks 数组 `[{type:text, text, cache_control:{type:ephemeral}}]`。开关开时启用
/// Anthropic prompt caching,让 system 稳定段在支持缓存的端点上命中。
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum SystemBlock {
/// 纯字符串形态(兼容网关,开关关)
Plain(String),
/// blocks 数组形态(含 cache_control,开关开)
Cached(Vec<serde_json::Value>),
}
/// Anthropic 请求体 /// Anthropic 请求体
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicRequest { pub(crate) struct AnthropicRequest {
@@ -25,7 +39,7 @@ pub(crate) struct AnthropicRequest {
pub messages: Vec<serde_json::Value>, pub messages: Vec<serde_json::Value>,
pub max_tokens: u32, pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>, pub system: Option<SystemBlock>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>, pub temperature: Option<f32>,
pub stream: bool, pub stream: bool,
@@ -151,7 +165,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
reasoning_tokens: 0, reasoning_tokens: 0,
}); });
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None }
} }
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total // 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
"message_delta" => { "message_delta" => {
@@ -159,9 +173,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let acc = usage_accum let acc = usage_accum
.get_or_insert(TokenUsage::default()); .get_or_insert(TokenUsage::default());
acc.completion_tokens = out as u32; acc.completion_tokens = out as u32;
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens; acc.total_tokens = acc.prompt_tokens.saturating_add(acc.completion_tokens);
} }
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None, error: None, reasoning_content: None } // 加固:usage 挂到本帧(而非仅 message_stop 带出),中途断连/端点不发 message_stop
// 时仍能拿到真实 usage,对称 openai_helpers 的修复。
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: usage_accum.clone(), error: None, reasoning_content: None }
} }
// 文本增量 // 文本增量
"content_block_delta" => { "content_block_delta" => {
+11
View File
@@ -275,6 +275,17 @@ impl ContextManager {
self.messages.iter().map(|t| t.message.clone()).collect() self.messages.iter().map(|t| t.message.clone()).collect()
} }
/// 只读最近 N 条消息(尾部切片 clone)。BE2(AC-EFF-R2-1):断路器/探索熔断检查等只关心
/// 最近消息的读路径,用本方法避免 all_messages_clone 每轮全量 clone(长对话每轮 O(n) 深克隆浪费)。
/// 尾部顺序保持时间正序(与 all_messages_clone 一致,调用方从尾部反向扫即可)。
pub fn recent_messages(&self, n: usize) -> Vec<ChatMessage> {
let skip = self.messages.len().saturating_sub(n);
self.messages[skip..]
.iter()
.map(|t| t.message.clone())
.collect()
}
/// 从 Vec 恢复(兼容从 DB 加载) /// 从 Vec 恢复(兼容从 DB 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) { pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载", // clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
+5 -3
View File
@@ -256,17 +256,19 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>)
delta: delta_text, delta: delta_text,
finished, finished,
tool_calls, tool_calls,
usage: None, // 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: choice.delta.reasoning_content, reasoning_content: choice.delta.reasoning_content,
} }
} else { } else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积) // choices 为空 = usage-only chunk,不输出文本(usage 已累积)usage 一并带出
StreamChunk { StreamChunk {
delta: String::new(), delta: String::new(),
finished: false, finished: false,
tool_calls: None, tool_calls: None,
usage: None, usage: usage_accum.clone(),
error: None, error: None,
reasoning_content: None, reasoning_content: None,
} }
+87 -25
View File
@@ -13,7 +13,7 @@
//! //!
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至 //! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口, //! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径( //! 含 ARC-260618-01-e 自洽性校验,已实施——解析后按 final_score 修正矛盾字段)。外部已用路径(
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。 //! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
use std::sync::Arc; use std::sync::Arc;
@@ -33,8 +33,8 @@ pub use crate::adversarial_helpers::{
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation, AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
}; };
use crate::adversarial_helpers::{ use crate::adversarial_helpers::{
SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval, SYSTEM_PROMPT, action_hint, assessment_desc, assessment_for_score, build_adversarial_prompt,
priority_label, enforce_score_consistency, parse_llm_eval, priority_label, recommendation_for_level,
}; };
/// 对抗评估引擎 /// 对抗评估引擎
@@ -97,8 +97,9 @@ impl AdversarialEngine {
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险, /// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。 /// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
/// ///
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与 /// ARC-260618-01-e(已实施): evaluate_with_llm 解析后经 [`enforce_score_consistency`]
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。 /// 做自洽性校验——final_score 与 recommendation / analyst.final_assessment 不一致时,
/// 按 final_score 统一重新判定两字段(LLM 论点等自由文本保留,不改 LLM 调用/输出结构)。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> { async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea); let prompt = build_adversarial_prompt(idea);
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。 // 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
@@ -134,7 +135,9 @@ impl AdversarialEngine {
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1] // 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,1]
// 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。 // 均在 parse 内对所有 Ok 路径完成),此处不再重复 clamp(CR-40-1 去冗余)。
parse_llm_eval(&resp.text, &idea.id) // ARC-260618-01-e: 解析后做自洽性校验(final_score 与 recommendation /
// analyst.final_assessment 矛盾时按 final_score 修正,见 enforce_score_consistency)。
Ok(enforce_score_consistency(parse_llm_eval(&resp.text, &idea.id)?))
} }
/// 启发式评估(基于评分与内容信号,稳定有区分度) /// 启发式评估(基于评分与内容信号,稳定有区分度)
@@ -145,7 +148,7 @@ impl AdversarialEngine {
let positive = self.generate_positive_argument(idea, &scores)?; let positive = self.generate_positive_argument(idea, &scores)?;
let negative = self.generate_negative_argument(idea, &scores)?; let negative = self.generate_negative_argument(idea, &scores)?;
let analyst = self.analyst_analysis(idea, &scores)?; let analyst = self.analyst_analysis(idea, &scores)?;
let recommendation = self.recommendation_for(&analyst.final_assessment); let recommendation = recommendation_for_level(&analyst.final_assessment);
Ok(AdversarialEval { Ok(AdversarialEval {
idea_id: idea.id.clone(), idea_id: idea.id.clone(),
@@ -240,13 +243,8 @@ impl AdversarialEngine {
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成 /// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> { fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
let final_assessment = match scores.overall { // 分档与 LLM 自洽性校验共用 assessment_for_score,单源防两处阈值漂移
x if x >= 7.5 => AssessmentLevel::StrongGo, let final_assessment = assessment_for_score(scores.overall);
x if x >= 6.0 => AssessmentLevel::Recommended,
x if x >= 4.5 => AssessmentLevel::Conditional,
x if x >= 3.0 => AssessmentLevel::Revised,
_ => AssessmentLevel::Defer,
};
let mut strengths = Vec::new(); let mut strengths = Vec::new();
if scores.impact >= 6.0 { if scores.impact >= 6.0 {
@@ -305,22 +303,12 @@ impl AdversarialEngine {
final_assessment, final_assessment,
}) })
} }
/// 评估等级 → 最终建议
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::adversarial_helpers::recommendation_for_score;
use crate::capture::Idea; use crate::capture::Idea;
use crate::scoring::ScoringEngine; use crate::scoring::ScoringEngine;
use df_types::types::{IdeaStatus, Priority}; use df_types::types::{IdeaStatus, Priority};
@@ -594,4 +582,78 @@ mod tests {
assert!((eval.final_score - 7.0).abs() < 1e-9); assert!((eval.final_score - 7.0).abs() < 1e-9);
assert_eq!(eval.recommendation, Recommendation::Soon); assert_eq!(eval.recommendation, Recommendation::Soon);
} }
/// 自洽性校验(ARC-260618-01-e 已实施): LLM 高分配 Monitor/Defer → 按 final_score 修正
/// 为 ImmediateAction/StrongGo,仅字段修正不整体降级启发式。
#[tokio::test]
async fn a13_llm_inconsistent_high_score_corrected() {
let idea = make_idea("矛盾测试", "高分却给 Monitor", Priority::High, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.8},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.3},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"Defer"},
"final_score": 8.5,
"recommendation": "Monitor"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a13] 高分配 Monitor/Defer → 修正");
println!(" final_score={:.2} (高分)", eval.final_score);
println!(" recommendation={:?} (期望 ImmediateAction)", eval.recommendation);
println!(" final_assessment={:?} (期望 StrongGo)", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm, "仅字段矛盾不整体降级");
assert!((eval.final_score - 8.5).abs() < 1e-9, "final_score 不被修正");
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::StrongGo);
}
/// 自洽性校验: LLM 低分配 ImmediateAction/StrongGo → 按 final_score 修正为 Monitor/Defer。
#[tokio::test]
async fn a14_llm_inconsistent_low_score_corrected() {
let idea = make_idea("低分矛盾", "低分却给 ImmediateAction", Priority::Medium, vec![]);
let llm_text = r#"{
"positive": {"thesis":"p","evidence":[],"reasoning":[],"confidence":0.9},
"negative": {"thesis":"n","evidence":[],"reasoning":[],"confidence":0.1},
"analyst": {"summary":"s","strengths":[],"weaknesses":[],"risks":[],"opportunities":[],"final_assessment":"StrongGo"},
"final_score": 2.0,
"recommendation": "ImmediateAction"
}"#;
let provider = Arc::new(MockProvider { text: llm_text.to_string() });
let engine = AdversarialEngine::new(provider);
let eval = engine.evaluate(&idea).await.unwrap();
println!("\n[a14] 低分配 ImmediateAction/StrongGo → 修正");
println!(" final_score={:.2} (低分)", eval.final_score);
println!(" recommendation={:?} (期望 Monitor)", eval.recommendation);
println!(" final_assessment={:?} (期望 Defer)", eval.analyst.final_assessment);
assert_eq!(eval.evaluated_by, EvaluatedBy::Llm);
assert!((eval.final_score - 2.0).abs() < 1e-9, "final_score 不被修正");
assert_eq!(eval.recommendation, Recommendation::Monitor);
assert_eq!(eval.analyst.final_assessment, AssessmentLevel::Defer);
}
/// 自洽性校验分档单测: 与启发式 analyst_analysis 同一分档(阈值边界逐一覆盖)。
#[test]
fn consistency_banding_matches_heuristic() {
assert_eq!(assessment_for_score(9.0), AssessmentLevel::StrongGo);
assert_eq!(assessment_for_score(7.5), AssessmentLevel::StrongGo); // 上界含
assert_eq!(assessment_for_score(7.0), AssessmentLevel::Recommended);
assert_eq!(assessment_for_score(6.0), AssessmentLevel::Recommended);
assert_eq!(assessment_for_score(5.0), AssessmentLevel::Conditional);
assert_eq!(assessment_for_score(4.5), AssessmentLevel::Conditional);
assert_eq!(assessment_for_score(4.0), AssessmentLevel::Revised);
assert_eq!(assessment_for_score(3.0), AssessmentLevel::Revised);
assert_eq!(assessment_for_score(2.9), AssessmentLevel::Defer);
assert_eq!(assessment_for_score(0.0), AssessmentLevel::Defer);
assert_eq!(recommendation_for_score(9.0), Recommendation::ImmediateAction);
assert_eq!(recommendation_for_score(6.5), Recommendation::Soon);
assert_eq!(recommendation_for_score(4.8), Recommendation::WithResources);
assert_eq!(recommendation_for_score(3.5), Recommendation::ResearchMore);
assert_eq!(recommendation_for_score(1.0), Recommendation::Monitor);
}
} }
+67 -3
View File
@@ -1,8 +1,9 @@
//! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。 //! 对抗评估 — 纯函数 / 类型 / 常量(自 adversarial.rs 抽离,纯搬迁,零行为变更)。
//! //!
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造JSON 解析等无副作用 //! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造JSON 解析与自洽性校验
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与 //! 等无副作用逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。 //! 主入口,经 [`enforce_score_consistency`] 落地 ARC-260618-01-e 自洽性校验)仍留在
//! `adversarial.rs`,二者经 [`use`] 互相引用。
//! //!
//! 抽离边界:类型定义(非 impl+ 常量 + 顶层 free function。impl 主体不动,外部 //! 抽离边界:类型定义(非 impl+ 常量 + 顶层 free function。impl 主体不动,外部
//! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等) //! 已用路径(`df_ideas::adversarial::{AdversarialEngine, AdversarialEval, Recommendation}` 等)
@@ -336,6 +337,69 @@ pub(crate) fn parse_recommendation(s: &str) -> Result<Recommendation> {
} }
} }
// ============================================================
// 自洽性校验(ARC-260618-01-e 已实施)— 综合评分分档 / 修正矛盾字段
// ============================================================
/// 评估等级 → 最终建议(与启发式 recommendation 映射同一套,收敛于此单点实现)。
pub(crate) fn recommendation_for_level(level: &AssessmentLevel) -> Recommendation {
match level {
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
AssessmentLevel::Recommended => Recommendation::Soon,
AssessmentLevel::Conditional => Recommendation::WithResources,
AssessmentLevel::Revised => Recommendation::ResearchMore,
AssessmentLevel::Defer => Recommendation::Monitor,
}
}
/// 综合评分 → 评估等级(分档与启发式 analyst_analysis 一致;自洽性校验复用,单源防两处漂移)。
pub(crate) fn assessment_for_score(score: f64) -> AssessmentLevel {
if score >= 7.5 {
AssessmentLevel::StrongGo
} else if score >= 6.0 {
AssessmentLevel::Recommended
} else if score >= 4.5 {
AssessmentLevel::Conditional
} else if score >= 3.0 {
AssessmentLevel::Revised
} else {
AssessmentLevel::Defer
}
}
/// 综合评分 → 最终建议(先分档再映射)。
pub(crate) fn recommendation_for_score(score: f64) -> Recommendation {
recommendation_for_level(&assessment_for_score(score))
}
/// 自洽性校验: LLM 解析结果可能 final_score 与 recommendation / analyst.final_assessment
/// 互相矛盾(如高分配 Monitor、低分配 ImmediateAction,parse 只校验枚举合法性不校验语义。
/// 以 final_score 为准重新判定这两个结构化字段消除矛盾;LLM 生成的论点/证据/summary 等
/// 自由文本原样保留。不改 LLM 调用/输出结构,只在解析后修正字段值(ARC-260618-01-e)。
pub(crate) fn enforce_score_consistency(mut eval: AdversarialEval) -> AdversarialEval {
let expected_rec = recommendation_for_score(eval.final_score);
let expected_level = assessment_for_score(eval.final_score);
if eval.recommendation != expected_rec {
tracing::warn!(
score = eval.final_score,
from = ?eval.recommendation,
to = ?expected_rec,
"LLM 评估自洽性修正: recommendation 与 final_score 矛盾,按 final_score 重新判定"
);
eval.recommendation = expected_rec;
}
if eval.analyst.final_assessment != expected_level {
tracing::warn!(
score = eval.final_score,
from = ?eval.analyst.final_assessment,
to = ?expected_level,
"LLM 评估自洽性修正: final_assessment 与 final_score 矛盾,按 final_score 重新判定"
);
eval.analyst.final_assessment = expected_level;
}
eval
}
pub(crate) fn priority_label(p: &Priority) -> &'static str { pub(crate) fn priority_label(p: &Priority) -> &'static str {
match p { match p {
Priority::Critical => "紧急", Priority::Critical => "紧急",
+50 -1
View File
@@ -152,13 +152,35 @@ where
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await; let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?; write_response(&mut writer, &resp).await?;
if let Some(name) = tool_name { if let Some(name) = tool_name {
fire_write_hook(config, &name); // MC-5(MCP-6):仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) {
fire_write_hook(config, &name);
}
} }
} }
Ok(()) Ok(())
} }
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
///
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
///
/// MC-5(MCP-6):stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
if resp.error.is_some() {
return false;
}
match &resp.result {
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
None => false,
}
}
/// 方法分发 → 构造 Response。 /// 方法分发 → 构造 Response。
/// ///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。 /// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
@@ -626,4 +648,31 @@ mod tests {
*calls.lock().unwrap() *calls.lock().unwrap()
); );
} }
#[tokio::test]
async fn main_loop_no_write_callback_on_business_failure() {
// MC-5(MCP-6):业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"业务失败(isError=true)不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
} }
+4 -2
View File
@@ -162,8 +162,10 @@ async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await; let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
// ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI) // ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
if resp.error.is_none() { // (桌面端据此刷新 GUI)。MC-5/MCP-6:业务失败(handler 返 isError=true)不触发,
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
if crate::server::is_success_tool_call(&resp) {
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) { if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
cb(&name); cb(&name);
} }
+145 -34
View File
@@ -140,7 +140,7 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
"confidence": opt_str_field("置信度(可空:high/medium/low)") "confidence": opt_str_field("置信度(可空:high/medium/low)")
}), &["kind", "title", "content"]), Medium, insert_knowledge), }), &["kind", "title", "content"]), Medium, insert_knowledge),
// ─── 回收站 ─── // ─── 回收站 ───
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash), spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务;分页 offset/limit,默认 limit=50 上限 100,projects/tasks 各自独立分页)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_trash),
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project), spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
] ]
}) })
@@ -211,14 +211,6 @@ fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
.to_owned() .to_owned()
} }
/// 取可选整数参数(默认值)
fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
args.get(key)
.and_then(|v| v.as_i64())
.map(|i| i as i32)
.unwrap_or(default)
}
/// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。 /// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。
/// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。 /// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。
fn pagination(args: &Value) -> (u32, u32) { fn pagination(args: &Value) -> (u32, u32) {
@@ -360,6 +352,14 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(v) => v, Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
// MC-6(MCP-4):name trim + 拒空(对齐 GUI create_with_binding / ProjectManager::create,
// 防空白项目名进库——required 只保证传参,不保证非空白)。
let name = name.trim().to_string();
if name.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error(
"项目名不能为空".to_string(),
)));
}
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "planning"); let status = arg_str_or(&args, "status", "planning");
medium_audit("create_project", &name); medium_audit("create_project", &name);
@@ -418,16 +418,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) { if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
return r; return r;
} }
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据 // 部分更新:name/description 缺省回退 existing,避免空默认清空数据
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone()); let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone()); let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned()); // MC-1(MCP-1):status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task
let status = match ProjectStatus::from_db_str(&status) { // 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。
Some(s) => s, // 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。
None => return CallToolResult::error( let status = existing.status;
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
),
};
let now = now_millis(); let now = now_millis();
let rec = ProjectRecord { let rec = ProjectRecord {
id: id.clone(), id: id.clone(),
@@ -440,12 +437,15 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 与 check_expected_updated_at(客户端显式 expected_updated_at 的提前拦截)不同,
// 这里封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated })) json_ok(json!({ "id": id, "project": updated }))
} }
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -517,8 +517,15 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")), Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e), Err(e) => return err_str(e),
} }
// MC-6(MCP-2):两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。
// tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await { if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
return err_str(e); tracing::warn!(
tool = "bind_directory",
project_id = %id,
error = %e,
"stack 写回失败(降级不中断,path 已绑定)"
);
} }
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated })) json_ok(json!({ "id": id, "project": updated }))
@@ -571,6 +578,13 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v, Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
// MC-6(MCP-4):title trim + 拒空(对齐 GUI create_task BE-CMD-1,防空白标题进库)。
let title = title.trim().to_string();
if title.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error(
"任务标题不能为空".to_string(),
)));
}
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
medium_audit("create_task", &format!("{project_id}/{title}")); medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move { Box::pin(async move {
@@ -694,12 +708,14 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "task": updated })) json_ok(json!({ "id": id, "task": updated }))
} }
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -779,7 +795,14 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(r) => return Box::pin(std::future::ready(r)), Err(r) => return Box::pin(std::future::ready(r)),
}; };
let description = arg_str_or(&args, "description", ""); let description = arg_str_or(&args, "description", "");
let priority = arg_int_or(&args, "priority", 1); // MC-6(MCP-3):priority 值域校验(复用 df-storage normalize_priority 与 GUI/AI 工具同源,
// 拦截 99 等越界值——原 arg_int_or 直落 99 被静默吞为前端 Critical,IPC 拒/MCP 放行漂移)。
let priority = match df_storage::crud::normalize_priority(
args.get("priority").and_then(|v| v.as_i64()),
) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(CallToolResult::error(e))),
};
medium_audit("create_idea", &title); medium_audit("create_idea", &title);
Box::pin(async move { Box::pin(async move {
let now = now_millis(); let now = now_millis();
@@ -854,12 +877,14 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };
match repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => { Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten(); let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "idea": updated })) json_ok(json!({ "id": id, "idea": updated }))
} }
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")), Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e), Err(e) => err_str(e),
} }
}) })
@@ -928,15 +953,29 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")), Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")),
}); });
rec.updated_at = now; rec.updated_at = now;
if let Err(e) = repo.update_full(&rec).await { // 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
return err_str(e); // 旧 update_full 忽略 Ok(false) 静默覆盖;改后并发端(GUI)已改则 affected==0,
// 报版本冲突,不再互相覆盖。
match repo.update_full_cas(&rec, &idea.updated_at).await {
Ok(true) => json_ok(json!({ "id": id, "idea": rec, "scores": scores })),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e),
} }
json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
}) })
} }
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。 /// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai /// 确定性纯函数。
///
/// **独立口径声明(MC-6/MCP-5 决策)**:本实现与 `df_ideas::scoring::ScoringEngine` 是两套
/// 并行算法,维度对齐(均为 0-10 的 feasibility/impact/urgency/overall)但权重/信号词/边界不同。
/// 保留独立实现而非复用 ScoringEngine 的取舍:
/// - df-mcp 是轻量独立 server,当前不依赖 df-ideas;而 df-ideas 传递依赖 df-ai(reqwest/
/// eventsource-stream 等重 HTTP 依赖),为消除 ~20 行评分函数把整棵重依赖树拉进 MCP 进程
/// 投入产出比不划算(编译/二进制/攻击面)。
/// - 复用还需在 df-mcp 内复制 IdeaRecord→df_ideas::Idea 转换(record_to_idea 同款),再次引入
/// 双实现;且会改变 MCP 侧既有评分输出值(行为变更)。
/// 故保持独立口径,在此显式声明;若未来 df-ideas 轻量化或 MCP 确需与 GUI 评分完全一致,再收敛。
fn heuristic_scores(title: &str, description: &str) -> Value { fn heuristic_scores(title: &str, description: &str) -> Value {
let desc_len = description.chars().count(); let desc_len = description.chars().count();
// feasibility:描述越详细越可行(评估前已有思考) // feasibility:描述越详细越可行(评估前已有思考)
@@ -973,8 +1012,9 @@ fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult>
// handler 实现 — 回收站 // handler 实现 — 回收站
// ============================================================ // ============================================================
fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { fn list_trash(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone(); let db = ctx.db.clone();
let (offset, limit) = pagination(&args);
Box::pin(async move { Box::pin(async move {
let projects = match ProjectRepo::new(&db).list_deleted().await { let projects = match ProjectRepo::new(&db).list_deleted().await {
Ok(v) => v, Ok(v) => v,
@@ -984,11 +1024,34 @@ fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v, Ok(v) => v,
Err(e) => return err_str(e), Err(e) => return err_str(e),
}; };
// 分页:与 list_projects/tasks/ideas 同契约(offset/limit/has_more)。
// projects 与 tasks 各自独立分页(同页语义:各自第 N 页),has_more 取两者并集。
// 回收站数据量小(仅软删项),内存分页足够,避免给共享 list_deleted 加 SQL 分页侵入。
// 探页:多取一条探测是否有下一页(对齐 list_* 的 limit+1 探页语义)。
let project_probe: Vec<_> = projects
.into_iter()
.skip(offset as usize)
.take(limit as usize + 1)
.collect();
let project_has_more = project_probe.len() > limit as usize;
let project_page: Vec<_> = project_probe.into_iter().take(limit as usize).collect();
let task_probe: Vec<_> = tasks
.into_iter()
.skip(offset as usize)
.take(limit as usize + 1)
.collect();
let task_has_more = task_probe.len() > limit as usize;
let task_page: Vec<_> = task_probe.into_iter().take(limit as usize).collect();
json_ok(json!({ json_ok(json!({
"projects": projects, "projects": project_page,
"tasks": tasks, "tasks": task_page,
"project_count": projects.len(), "project_count": project_page.len(),
"task_count": tasks.len() "task_count": task_page.len(),
"offset": offset,
"limit": limit,
"has_more": project_has_more || task_has_more,
"project_has_more": project_has_more,
"task_has_more": task_has_more
})) }))
}) })
} }
@@ -1820,4 +1883,52 @@ mod tests {
assert!(visible_for_test(false, "search_knowledge")); assert!(visible_for_test(false, "search_knowledge"));
assert!(visible_for_test(false, "insert_knowledge")); assert!(visible_for_test(false, "insert_knowledge"));
} }
// ── list_trash 分页(offset/limit/has_more,projects/tasks 各自独立分页)──
#[tokio::test]
async fn list_trash_pagination_and_has_more() {
let ctx = test_ctx().await;
// 3 项目软删进回收站 + 1 任务软删(宿主项目不删,仅任务进回收站)
let mut pids = Vec::new();
for i in 0..3 {
pids.push(seed_project(&ctx, &format!("回收项目{i}")).await);
}
for p in &pids {
ProjectRepo::new(&ctx.db).soft_delete(p).await.unwrap();
}
let tid = seed_task(&ctx, &pids[0], "回收任务").await;
TaskRepo::new(&ctx.db).soft_delete(&tid).await.unwrap();
// 首页 limit=2 → 项目 2 条(仍有下一页),任务 1 条(无下一页)
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 0 })).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
let v = json_of(&r);
assert_eq!(v["projects"].as_array().unwrap().len(), 2);
assert_eq!(v["tasks"].as_array().unwrap().len(), 1);
assert_eq!(v["project_count"], 2);
assert_eq!(v["task_count"], 1);
assert_eq!(v["project_has_more"], true, "3 项目取 2 还有下一页");
assert_eq!(v["task_has_more"], false);
assert_eq!(v["has_more"], true);
assert_eq!(v["limit"], 2);
assert_eq!(v["offset"], 0);
// 第二页 offset=2 → 项目 1 条,两边都无下一页
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 2 })).await;
let v = json_of(&r);
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
assert_eq!(v["project_has_more"], false);
assert_eq!(v["task_has_more"], false);
assert_eq!(v["has_more"], false);
}
/// limit 超上限钳到 100(对齐其他 list 工具的钳制)。
#[tokio::test]
async fn list_trash_caps_limit_to_100() {
let ctx = test_ctx().await;
let r = list_trash(&ctx, json!({ "limit": 999 })).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
assert_eq!(json_of(&r)["limit"], 100, "limit 超上限应钳到 100");
}
} }
+61 -2
View File
@@ -13,7 +13,7 @@ use df_ai::provider::LlmProvider;
// AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由 // AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。 // (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
use df_ai::router::{select_model_id, TaskRequirements}; use df_ai::router::{select_model_id, TaskRequirements};
use df_storage::crud::AiProviderRepo; use df_storage::crud::{AiProviderRepo, TaskRepo};
use df_storage::db::Database; use df_storage::db::Database;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use df_storage::secret::{ensure_resolved_key, resolve_provider_secret}; use df_storage::secret::{ensure_resolved_key, resolve_provider_secret};
@@ -72,7 +72,66 @@ pub(crate) async fn resolve_and_parse(
inputs: &HashMap<String, NodeOutput>, inputs: &HashMap<String, NodeOutput>,
) -> anyhow::Result<AiNodeParams> { ) -> anyhow::Result<AiNodeParams> {
let provider_cfg = resolve_provider(db, config).await?; let provider_cfg = resolve_provider(db, config).await?;
parse_params(config, inputs, provider_cfg) // P0-B: parse_params 缺 prompt 报错时兜底 —— 工作流推进链模板 AiNode config 无 prompt
// (in_progress/testing/done 三链首节点 AiNode 仅带 provider_id, prompt 留空),
// 原行为直接 Err 致整链必失败。兜底:从 config.task_id 读 TaskRecord 生成基于
// title/description 的执行 prompt;无 task_id 用通用默认。不改 LLM 调用结构,
// 仅 parse_params 失败时补 prompt 重解析(provider_cfg 已 resolve 不重复)。
// 实现:parse_params 按值吃 provider_cfg,失败需重建 → 先 clone 一份供兜底重解析用,
// 避免 ResolvedProvider(api_key 等)无法从 Err 取回(原签名为 by-value 消费)。
match parse_params(config, inputs, provider_cfg.clone()) {
Ok(p) => Ok(p),
Err(e) => {
let prompt = resolve_fallback_prompt(db, config)
.await
.unwrap_or_else(|| default_fallback_prompt());
// 把兜底 prompt 注入 config 后重解析(prompt 字段补齐后 parse_params 不再缺 prompt)。
// 用 mutable config copy 避免改原 config 借用(config: &serde_json::Value 不可变借)。
let mut cfg = config.clone();
if let Some(obj) = cfg.as_object_mut() {
obj.insert("prompt".to_string(), serde_json::Value::String(prompt));
} else {
// config 非 object(理论不会,防御):构造 {prompt: ...} 替代。
cfg = serde_json::json!({ "prompt": prompt });
}
// tracing 兜底可见性(用户/排查:看到 AiNode 用了默认 prompt 而非报错)。
tracing::warn!(
error = %e,
"AiNode 缺 prompt 兜底: 从 task_id/通用默认生成执行 prompt"
);
parse_params(&cfg, inputs, provider_cfg)
}
}
}
/// P0-B 兜底 prompt 构建:从 config.task_id 读 TaskRecord.title/description 生成执行 prompt。
///
/// 无 task_id / 查询失败 / task 不存在 → None,调用方降级到 `default_fallback_prompt`。
/// description 空只取 title;非空拼「标题(描述)」。truncate 防 token 滥用。
async fn resolve_fallback_prompt(db: &Arc<Database>, config: &serde_json::Value) -> Option<String> {
let task_id = config.get("task_id").and_then(|v| v.as_str())?;
if task_id.is_empty() {
return None;
}
let repo = TaskRepo::new(db);
let task = repo.get_by_id(task_id).await.ok()??;
let title = task.title.trim();
let desc = task.description.trim();
let body = if desc.is_empty() {
title.to_string()
} else {
format!("{}({})", title, truncate_for_summary(desc))
};
Some(format!(
"根据任务「{}」完成开发,产出可执行代码/方案并自检。要求:逐步推理、\
",
body
))
}
/// P0-B 通用兜底 prompt:无 task_id 时用(独立 AiNode 调用 / config 无 task 关联)。
fn default_fallback_prompt() -> String {
"请根据上下文完成当前任务,产出可执行结果并自检。若需更多信息请说明。".to_string()
} }
/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。 /// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。
+1 -1
View File
@@ -6,7 +6,7 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 脚本节点 /// 脚本节点
pub struct ScriptNode; pub struct ScriptNode;
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ── // ── 运行时白/黑名单(通过 set_script_safety_config 注入,优先于 env var;env var 作回退) ──
use std::sync::OnceLock; use std::sync::OnceLock;
/// 运行时白名单配置(前端设置页写入,优先于环境变量) /// 运行时白名单配置(前端设置页写入,优先于环境变量)
+45 -3
View File
@@ -61,10 +61,10 @@ pub(super) fn truncate_chars(s: &str, max: usize) -> String {
format!("{}…(已截断)", truncated) format!("{}…(已截断)", truncated)
} }
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级), /// 规范化路径用于比较/查重:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。 /// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径 /// 注:仅用于比较,存库请用 [`canonicalize_for_store`](保留大小写)
pub fn normalize_path(p: &str) -> String { pub fn normalize_for_compare(p: &str) -> String {
match Path::new(p).canonicalize() { match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(), Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p Err(_) => p
@@ -74,6 +74,22 @@ pub fn normalize_path(p: &str) -> String {
} }
} }
/// 规范化路径用于存库:canonicalize 解析绝对规范路径(失败降级为 trim + 正斜杠),
/// **保留大小写**(大小写敏感系统如 Linux/容器文件系统路径解析需原样大小写,
/// 小写化会导致路径解析失败)。比较/查重请用 [`normalize_for_compare`]。
pub fn canonicalize_for_store(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/"),
Err(_) => p.trim_end_matches(['\\', '/']).replace('\\', "/"),
}
}
/// 兼容别名:小写比较版(等价 [`normalize_for_compare`])。
/// 旧调用方沿用;新代码按用途选 `normalize_for_compare`(比较)/ `canonicalize_for_store`(存库)。
pub fn normalize_path(p: &str) -> String {
normalize_for_compare(p)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -111,4 +127,30 @@ mod tests {
assert!(!n.contains('\\'), "反斜杠未归一: {n}"); assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}"); assert_eq!(n, n.to_lowercase(), "未小写: {n}");
} }
#[test]
fn canonicalize_for_store_preserves_case() {
// 存库版保留大小写(降级分支:不存在的路径走 trim + replace,大小写原样保留)
let n = canonicalize_for_store(r"C:\Foo\Bar\");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
assert!(
n.contains("Foo") && n.contains("Bar"),
"存库版不应小写化(大小写敏感系统路径解析依赖): {n}"
);
}
#[test]
fn compare_lowercases_but_store_keeps_case() {
// compare 版与 store 版语义分离:比较小写、存库保留大小写
let p = r"C:\Foo\Bar";
let comp = normalize_for_compare(p);
let store = canonicalize_for_store(p);
assert_eq!(comp, comp.to_lowercase(), "比较版应小写: {comp}");
assert_ne!(comp, store, "小写化仅限比较版,存库版应保留大小写: {store}");
assert!(!store.contains('\\'), "存库版应正斜杠: {store}");
}
} }
+10 -3
View File
@@ -4,15 +4,22 @@
//! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权) //! - `/ws/device`:桌面端 df-tunnel 连入(Hello device 鉴权)
//! - `/ws/miniapp`:小程序连入(按 device_id 配对路由) //! - `/ws/miniapp`:小程序连入(按 device_id 配对路由)
//! //!
//! 纯转发中继,无业务逻辑。token 校验对齐 `DEFAULT_TOKEN`(relay.rs,可 `DF_RELAY_TOKEN` env 覆盖)。 //! 纯转发中继,无业务逻辑。token 校验对齐 `DF_RELAY_TOKEN`(relay.rs,必须设置)。
//! //!
//! 联调启动:`cargo run -p df-relay`(或 `DF_RELAY_ADDR=0.0.0.0:8080 cargo run -p df-relay`)。 //! 联调启动:`DF_RELAY_TOKEN=<token> cargo run -p df-relay`(未设 token 时启动即失败,exit 1)。
use df_relay::{DefaultRelayServer, RelayServer}; use df_relay::{DefaultRelayServer, RelayError, RelayServer};
#[tokio::main] #[tokio::main]
async fn main() -> df_relay::Result<()> { async fn main() -> df_relay::Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
// RLY-5:token 未设置 → 启动即返回错误(exit code 1 + 明确消息),而非运行期 panic。
// relay.rs expected_token() 已改为返回 Option 兜底,但 fail-fast 比慢速拒连更早暴露配置错误。
if std::env::var("DF_RELAY_TOKEN").is_err() {
return Err(RelayError::Start(
"必须设置环境变量 DF_RELAY_TOKEN(未设置时拒绝启动)".into(),
));
}
// 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。 // 监听地址:env 覆盖,默认 0.0.0.0:8080(对齐 df-miniapp config.ts relayHost + df-tunnel src-tauri 集成)。
let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); let addr = std::env::var("DF_RELAY_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)"); tracing::info!(%addr, "[df-relay] 启动跨端中继 axum WS Server(/ws/device + /ws/miniapp)");
+112 -6
View File
@@ -10,6 +10,14 @@
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。 //! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
//! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。 //! 4. 后续消息按 kind 路由:Event(device→miniapp)/ Command(miniapp→device)/ Control。
//! //!
//! ## 鉴权模型(MC-4 现状与风险)
//! MVP 单共享 token(`DF_RELAY_TOKEN`),无 per-device 绑定:**任何持 token 客户端可声明任意
//! `device_id` 并冒充该设备收发指令**。生产级 per-device token(配对时按 device 颁发独立 token
//! 并存储)涉及配对流程改造(需 df-miniapp/df-tunnel 配合),当前批次不实施,仅做最小加固:
//! - 连接建立时校验 `device_id` 格式(非空 + 长度上限,见 `validate_device_id`)。
//! - token 比较用常量时间比较(RLY-4),防时序侧信道。
//! 完整 per-device 鉴权留设计文档 Phase3。
//!
//! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。 //! AiChatEvent JSON 透传:relay 不解析 payload,只按 device_id + 方向转发。
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -32,12 +40,39 @@ use crate::broadcast::{BroadcastMessage, ClientKind, MessageKind};
use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState}; use crate::conn::{next_conn_id, ConnHandle, ConnId, RelayState};
use crate::error::{RelayError, Result}; use crate::error::{RelayError, Result};
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。 /// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。
/// RLY-5:不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。 /// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
fn expected_token() -> String { fn expected_token() -> Option<String> {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| { std::env::var("DF_RELAY_TOKEN").ok()
panic!("必须设置环境变量 DF_RELAY_TOKEN") }
})
/// RLY-3:入站文本帧大小上限(1 MiB)。
/// 防恶意/异常客户端发超大帧耗尽内存与转发带宽;超限直接断开连接。
const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024;
/// 常量时间字符串比较(RLY-4,防时序侧信道)。
///
/// 没有引入 subtle 依赖;用等长逐字节异或累加 + 尾随等量循环掩蔽长度差,
/// 比较时长不随首字节差异提前返回。长度本身不保密(可被包长度观测),故长度不等直接返回。
fn constant_time_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
// 用最大长度循环,短者补 0 参与异或,时长恒等于较长者,不泄露首字节命中点
let len = a.len().max(b.len());
let mut diff: u8 = 0;
for i in 0..len {
let av = a.get(i).copied().unwrap_or(0);
let bv = b.get(i).copied().unwrap_or(0);
diff |= av ^ bv;
}
diff == 0
}
/// MC-4 最小加固:校验 device_id 格式。
/// 拒绝空串与超长(防内存/日志注入),仅做基础防御;per-device token 绑定留 Phase3。
fn validate_device_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
} }
/// 客户端首消息:身份宣告(简单协议) /// 客户端首消息:身份宣告(简单协议)
@@ -222,7 +257,34 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
let _ = socket_tx.close().await; let _ = socket_tx.close().await;
return; return;
} }
if hello.token != expected_token() { // MC-4 最小加固:device_id 格式校验(空/超长/含控制字符一律拒)。
// 单共享 token 下 device_id 无强绑定,但格式校验可挡最基础的注入/异常输入。
if !validate_device_id(&hello.device_id) {
tracing::warn!(
device_id = %hello.device_id,
"握手失败:device_id 格式非法"
);
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"invalid_device_id"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
}
// RLY-5:token 未配置(服务启动时应已由 main 校验)→ 拒绝握手而非 panic
let Some(expected) = expected_token() else {
tracing::error!("DF_RELAY_TOKEN 未设置,拒绝握手");
let _ = send_text(
&mut socket_tx,
r#"{"kind":"control","error":"server_misconfigured"}"#,
)
.await;
let _ = socket_tx.close().await;
return;
};
// RLY-4:常量时间比较防时序侧信道
if !constant_time_eq(&hello.token, &expected) {
tracing::warn!( tracing::warn!(
device_id = %hello.device_id, device_id = %hello.device_id,
"握手失败:token 校验不通过" "握手失败:token 校验不通过"
@@ -270,6 +332,15 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
maybe_msg = socket_rx.next() => { maybe_msg = socket_rx.next() => {
match maybe_msg { match maybe_msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
// RLY-3:入站帧超限(>1MiB)直接断开,防大帧耗尽内存/带宽
if text.len() > MAX_FRAME_BYTES {
tracing::warn!(
conn_id = conn_id.0,
len = text.len(),
"入站帧超限,断开连接"
);
break;
}
if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await { if let Err(e) = handle_inbound_text(&state, conn_id, kind, &device_id, &text).await {
tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略"); tracing::warn!(conn_id = conn_id.0, error = %e, "入站消息处理失败,忽略");
} }
@@ -331,6 +402,13 @@ async fn recv_hello(rx: &mut futures_util::stream::SplitStream<WebSocket>) -> Re
} }
_ => return Err(RelayError::Client("握手首帧类型非法".into())), _ => return Err(RelayError::Client("握手首帧类型非法".into())),
}; };
// RLY-3:Hello 帧同样限长(Hello 结构很小,超限视为异常/恶意)
if text.len() > MAX_FRAME_BYTES {
return Err(RelayError::Client(format!(
"Hello 帧超限({} B > {MAX_FRAME_BYTES} B)",
text.len()
)));
}
let hello: Hello = let hello: Hello =
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?; serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
Ok(hello) Ok(hello)
@@ -440,3 +518,31 @@ fn now_ms() -> i64 {
.map(|d| d.as_millis() as i64) .map(|d| d.as_millis() as i64)
.unwrap_or(0) .unwrap_or(0)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_eq_equality_and_inequality() {
assert!(constant_time_eq("abc", "abc"));
assert!(!constant_time_eq("abc", "abd"));
assert!(!constant_time_eq("abc", "ab"));
assert!(!constant_time_eq("", "a"));
assert!(constant_time_eq("", ""));
}
#[test]
fn validate_device_id_basic() {
assert!(validate_device_id("dev-1"));
assert!(validate_device_id("a".repeat(128).as_str()), "上限 128 应放行");
assert!(!validate_device_id(""), "空串拒绝");
assert!(!validate_device_id("a".repeat(129).as_str()), "超长拒绝");
assert!(!validate_device_id("dev\u{0001}"), "控制字符拒绝");
}
#[test]
fn max_frame_is_one_mib() {
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
}
}
+104 -3
View File
@@ -98,7 +98,9 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
// message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底: // message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
// 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。 // 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。
message_id: row.get("message_id").unwrap_or(None), message_id: row.get("message_id").unwrap_or(None),
tool_call_id: row.get("tool_call_id")?, // row.get::<String> 后 From<String> → ToolCallId(newtype 不直接 impl rusqlite::FromSql,
// 避免 df-types 反向依赖 rusqlite;Deref<Target=str> 不自动给 ToSql)
tool_call_id: row.get::<_, String>("tool_call_id")?.into(),
tool_name: row.get("tool_name")?, tool_name: row.get("tool_name")?,
arguments: row.get("arguments")?, arguments: row.get("arguments")?,
result: row.get("result")?, result: row.get("result")?,
@@ -221,7 +223,7 @@ impl_repo!(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![ params![
rec.id, rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name, rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by rec.requested_at, rec.executed_at, rec.decided_by
], ],
@@ -231,7 +233,7 @@ impl_repo!(
conn.execute( conn.execute(
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12", "UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12",
params![ params![
rec.conversation_id, rec.message_id, rec.tool_call_id, rec.tool_name, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by, rec.id rec.requested_at, rec.executed_at, rec.decided_by, rec.id
], ],
@@ -243,6 +245,56 @@ impl_repo!(
// 通用 query 宏硬编码 ORDER BY created_at 会触发 "no such column" → 调用方 unwrap_or_default 吞错。 // 通用 query 宏硬编码 ORDER BY created_at 会触发 "no such column" → 调用方 unwrap_or_default 吞错。
// 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。 // 故为此表提供专用查询,绕过通用 query。详见 ai.rs audit_finalize。
impl AiToolExecutionRepo { impl AiToolExecutionRepo {
/// 批量插入审计记录(单事务多行 INSERT,砍 N 次串行 INSERT 尾巴)。
///
/// 对比 [`insert`](`impl_repo!` 生成,每次 spawn_blocking + 单行 execute):
/// 本方法单次 `spawn_blocking` + 单事务,`prepare` 一次 INSERT stmt 循环 bind N 行,
/// 一次 `COMMIT`(原子性:全插或全不插,审计留痕可追溯)。空 `records` 直接返回(无操作)。
///
/// **用途**:audit/mod.rs `process_tool_calls` 低风险工具 join_all 并行执行后的回填循环
/// (每工具一条审计),把 N 次串行 INSERT 合并为一次事务批量(治 aichat 效率 AC-EFF-T1-1)。
///
/// 安全:全部值走参数绑定(同 `insert` 宏体),无 SQL 拼接注入面;单连接 Mutex 持锁整段,
/// 与单行 insert 的锁粒度相同(一次持锁换 N 次持锁)。
pub async fn insert_batch(&self, records: Vec<AiToolExecutionRecord>) -> Result<()> {
if records.is_empty() {
return Ok(());
}
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard.execute_batch("BEGIN").map_err(storage_err)?;
let result = (|| -> std::result::Result<(), rusqlite::Error> {
let mut stmt = guard.prepare(
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
)?;
for rec in &records {
stmt.execute(params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level,
rec.requested_at, rec.executed_at, rec.decided_by
])?;
}
Ok(())
})();
match result {
Ok(()) => {
guard.execute_batch("COMMIT").map_err(storage_err)?;
}
Err(e) => {
// 回滚失败静默(尽量保一致性;rollback 失败通常是连接已坏,交给上层)
let _ = guard.execute_batch("ROLLBACK");
return Err(storage_err(e));
}
}
Ok(())
})
.await
.map_err(storage_err)??;
Ok(())
}
/// 按 tool_call_id 查最新一条审计记录(审批回填定位用)。 /// 按 tool_call_id 查最新一条审计记录(审批回填定位用)。
pub async fn find_by_tool_call_id( pub async fn find_by_tool_call_id(
&self, &self,
@@ -463,6 +515,55 @@ impl AiToolExecutionRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 按工具聚合执行统计(status 分布计数,AC-5 运行时失败率画像数据源)。
///
/// 单条 GROUP BY 取 `(tool_name, status, count)` 三元组;内存聚合与失败率口径
/// (failed_rate = failed / (completed + failed))在命令层完成(record.rs
/// `tool_failure_stats`),本层只负责取数,不掺展示逻辑。
/// `from`:可选时间下限(millis,`requested_at >= from`),None = 全量。
///
/// 与本表其他查询同理走专用 SELECT(通用 query 宏硬编码 ORDER BY created_at,
/// 本表无该列)。`requested_at` 存毫秒数字符串,`CAST AS INTEGER` 数值比较
/// (对齐 `cleanup_stale_pending` 同口径)。参数化绑定防注入。
pub async fn stats_by_tool(&self, from: Option<i64>) -> Result<Vec<(String, String, i64)>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 条件分支仅差 WHERE + 参数,一条 GROUP BY 复用(match 分支互斥,stmt 借用安全)
let (sql, param): (&str, Option<i64>) = match from {
Some(f) => (
"SELECT tool_name, status, COUNT(*) FROM ai_tool_executions \
WHERE CAST(requested_at AS INTEGER) >= ?1 GROUP BY tool_name, status",
Some(f),
),
None => (
"SELECT tool_name, status, COUNT(*) FROM ai_tool_executions \
GROUP BY tool_name, status",
None,
),
};
let row_map = |row: &rusqlite::Row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
};
let mut stmt = guard.prepare(sql).map_err(storage_err)?;
let mut rows = match param {
Some(f) => stmt.query_map(params![f], row_map).map_err(storage_err)?,
None => stmt.query_map([], row_map).map_err(storage_err)?,
};
let mut out = Vec::new();
for r in &mut rows {
out.push(r.map_err(storage_err)?);
}
Ok(out)
})
.await
.map_err(storage_err)?
}
} }
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。 // AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
+183
View File
@@ -401,6 +401,61 @@ impl IdeaRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段),
/// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "ideas")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
///
/// LW-8(BE-CMD-7):promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
/// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用
/// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0,
/// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。
///
/// - `deleted_at IS NULL` 收口软删(回收站灵感不可立项认领)。
/// - 返回 false = 已立项(并发) / 软删 / 不存在,调用方须回滚其副作用。
pub async fn claim_promotion(&self, id: &str, promoted_to: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let promoted_to = promoted_to.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET status = 'promoted', promoted_to = ?1, updated_at = ?2 \
WHERE id = ?3 AND promoted_to IS NULL AND deleted_at IS NULL",
params![promoted_to, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。 /// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
/// ///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换); /// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
@@ -538,6 +593,43 @@ impl IdeaRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
/// `Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &IdeaRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14",
params![
rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
} }
impl KnowledgeRepo { impl KnowledgeRepo {
@@ -1479,6 +1571,51 @@ mod tests {
assert!(!repo.restore("i1").await.unwrap()); assert!(!repo.restore("i1").await.unwrap());
} }
// ── LW-8 立项认领 CAS(claim_promotion)──────────────────────────
// 锁定:① 首次认领成功(写入 status=promoted + promoted_to);② 二次认领 CAS 失败
// (promoted_to IS NULL 前置)且不覆盖已有立项;③ 软删灵感不可认领。
#[tokio::test]
async fn idea_claim_promotion_claims_once_only() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "立项")).await.unwrap();
// 首次认领:promoted_to IS NULL → true,status=promoted + promoted_to 落库
assert!(repo.claim_promotion("i1", "p-1").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.status.as_str(), "promoted");
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"));
// 二次认领:promoted_to 已非空(CAS)→ false(双击/并发重复立项防护)
assert!(!repo.claim_promotion("i1", "p-2").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"), "CAS 失败不得覆盖已有立项");
}
#[tokio::test]
async fn idea_claim_promotion_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "回收站")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
assert!(
!repo.claim_promotion("i1", "p-1").await.unwrap(),
"软删灵感不可立项认领(deleted_at IS NULL 收口)"
);
}
// ── LW-6 update_field_active(软删过滤)─────────────────────────
#[tokio::test]
async fn idea_update_field_active_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("i1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("i1").await.unwrap();
assert!(!repo.update_field_active("i1", "title", "回收站改").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.title, "新标题", "软删后字段不应被改动");
}
#[tokio::test] #[tokio::test]
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() { async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
let repo = setup_idea_repo().await; let repo = setup_idea_repo().await;
@@ -1497,4 +1634,50 @@ mod tests {
// i1 活跃不出现;i3 删除最晚在前 // i1 活跃不出现;i3 删除最晚在前
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC"); assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
} }
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn idea_update_full_cas_success_when_expected_matches() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
let current = repo.get_by_id("i1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn idea_update_full_cas_conflict_when_expected_stale() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("i1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("i1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
} }
+16 -9
View File
@@ -46,6 +46,7 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
prompt_cache_hit_tokens: row.get("prompt_cache_hit_tokens")?, prompt_cache_hit_tokens: row.get("prompt_cache_hit_tokens")?,
prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?, prompt_cache_miss_tokens: row.get("prompt_cache_miss_tokens")?,
reasoning_tokens: row.get("reasoning_tokens")?, reasoning_tokens: row.get("reasoning_tokens")?,
is_estimated: row.get("is_estimated")?,
}) })
} }
@@ -86,8 +87,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -96,7 +97,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -122,7 +123,8 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens,
is_estimated
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC", FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq ASC",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
@@ -164,13 +166,13 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3" FROM ai_messages WHERE conversation_id = ?1 AND seq < ?2 ORDER BY seq DESC LIMIT ?3"
} else { } else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id, "SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated
FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2" FROM ai_messages WHERE conversation_id = ?1 ORDER BY seq DESC LIMIT ?2"
}; };
let mut stmt = guard.prepare(sql).map_err(storage_err)?; let mut stmt = guard.prepare(sql).map_err(storage_err)?;
@@ -289,8 +291,8 @@ impl AiMessageRepo {
(id, conversation_id, seq, role, content, parts, tool_call_id, (id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at, tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens, prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens) prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens, is_estimated)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
@@ -299,7 +301,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status, rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at, rec.reasoning_content, rec.timestamp, rec.created_at,
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens rec.prompt_cache_hit_tokens, rec.prompt_cache_miss_tokens, rec.reasoning_tokens, rec.is_estimated
]) ])
.map_err(storage_err)?; .map_err(storage_err)?;
} }
@@ -373,6 +375,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
} }
} }
@@ -535,6 +538,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}, },
AiMessageRecord { AiMessageRecord {
id: "new_1".into(), id: "new_1".into(),
@@ -555,6 +559,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}, },
]; ];
repo.replace_conversation("conv", records).await.expect("replace"); repo.replace_conversation("conv", records).await.expect("replace");
@@ -627,6 +632,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}], }],
) )
.await .await
@@ -668,6 +674,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
}; };
repo.replace_conversation("c", vec![rec()]).await.expect("1st"); repo.replace_conversation("c", vec![rec()]).await.expect("1st");
repo.replace_conversation("c", vec![rec()]).await.expect("2nd"); repo.replace_conversation("c", vec![rec()]).await.expect("2nd");
+148 -4
View File
@@ -43,10 +43,17 @@ pub struct ProjectQuery {
pub offset: Option<u32>, pub offset: Option<u32>,
} }
/// order_by 白名单(独立于 update_field 白名单,对齐 ideas 的 validate_idea_order_by 模式)。
///
/// BE-CMD-3:projects update_field 白名单已剔除 id/created_at(主键与创建时间不可经通用
/// update_field 改写),但 `created_at` 作为**排序字段**仍合法——故排序白名单单独定义,
/// 不依赖 update_field 白名单(否则 order_by=created_at 会被误拒,破坏 list_by_query 默认排序)。
const PROJECT_ORDER_BY_ALLOWED: &[&str] = &["created_at", "updated_at", "name", "status"];
/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。 /// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。
/// ///
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name` /// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `PROJECT_ORDER_BY_ALLOWED`
/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。 /// 白名单校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。 /// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
fn build_order_clause(order_by: Option<&str>) -> Result<String> { fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let Some(raw) = order_by else { let Some(raw) = order_by else {
@@ -60,8 +67,13 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let parts: Vec<&str> = raw.split_whitespace().collect(); let parts: Vec<&str> = raw.split_whitespace().collect();
let col = parts[0]; let col = parts[0];
let dir = parts.get(1).map(|s| s.to_ascii_uppercase()); let dir = parts.get(1).map(|s| s.to_ascii_uppercase());
// 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。 // 排序白名单校验列名(防 SQL 注入:列名拼字符串前必须校验;独立于 update_field 白名单)。
validate_column_name(col, "projects")?; if !PROJECT_ORDER_BY_ALLOWED.contains(&col) {
return Err(df_types::error::Error::Storage(format!(
"非法 order_by 字段名: {col},合法值: {:?}",
PROJECT_ORDER_BY_ALLOWED
)));
}
match dir.as_deref() { match dir.as_deref() {
None | Some("DESC") => Ok(format!("{col} DESC")), None | Some("DESC") => Ok(format!("{col} DESC")),
Some("ASC") => Ok(format!("{col} ASC")), Some("ASC") => Ok(format!("{col} ASC")),
@@ -414,6 +426,67 @@ impl ProjectRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &ProjectRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8 AND updated_at = ?9",
params![
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站项目仍可改字段),
/// 本方法收口软删防护,供命令层 `update_project` 使用——软删项目(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "projects")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE projects SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复) /// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
/// ///
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束, /// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
@@ -611,3 +684,74 @@ impl_repo!(
) )
} }
); );
// ============================================================
// 单元测试 — update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn prec(id: &str, name: &str) -> ProjectRecord {
ProjectRecord {
id: id.to_string(),
name: name.to_string(),
description: String::new(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
async fn setup() -> ProjectRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
ProjectRepo::new(&db)
}
#[tokio::test]
async fn update_full_cas_success_when_expected_matches() {
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
let current = repo.get_by_id("p1").await.unwrap().unwrap();
// 本地构建新版本(name + updated_at 递增)
let mut rec = current.clone();
rec.name = "新项目".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "新项目");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_conflict_when_expected_stale() {
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("p1").await.unwrap().unwrap();
other.name = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("p1").await.unwrap().unwrap();
mine.name = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
}
+5 -1
View File
@@ -125,7 +125,11 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at", "promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
], ],
"projects" => &[ "projects" => &[
"id", "name", "description", "status", "idea_id", "path", "stack", "created_at", // id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
// (BE-CMD-3,对标 ideas/tasks 白名单同款防护,防篡改主键/伪造创建时间致子表悬空)。
// 注:projects 排序用 created_at 不受影响 — build_order_clause 走独立排序白名单
// (PROJECT_ORDER_BY_ALLOWED,update_field 白名单与排序白名单解耦,同 ideas 模式)。
"name", "description", "status", "idea_id", "path", "stack",
"updated_at", "updated_at",
], ],
"tasks" => &[ "tasks" => &[
+202 -4
View File
@@ -455,12 +455,20 @@ impl TaskRepo {
/// ///
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT), /// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。 /// 返回满足条件的总行数(忽略分页裁剪)。
///
/// LW-5(BE-CMD-2):补齐 assignee/queue/parent_id/module_id 维度,与 list_by_query
/// 全维度对齐——此前 count 缺四维导致「count 超算、list 空页」翻页不一致
/// (前端分页 total 与页数据对不上)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> { pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
let conn = self.conn.clone(); let conn = self.conn.clone();
let project_id = query.project_id.clone(); let project_id = query.project_id.clone();
let status = query.status.clone(); let status = query.status.clone();
let priority = query.priority; let priority = query.priority;
let assignee = query.assignee.clone();
let keyword = query.keyword.clone(); let keyword = query.keyword.clone();
let queue = query.queue.clone();
let parent_id = query.parent_id.clone();
let module_id = query.module_id.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
@@ -480,6 +488,11 @@ impl TaskRepo {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1)); where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p)); params_vec.push(Box::new(p));
} }
// LW-5: assignee 维度(与 list_by_query 同 WHERE 构造,防 count/list 漂移)
if let Some(ref a) = assignee {
where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(a.clone()));
}
if let Some(ref kw) = keyword { if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_"); let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%"); let pat = format!("%{escaped}%");
@@ -489,6 +502,19 @@ impl TaskRepo {
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat)); params_vec.push(Box::new(pat));
} }
// LW-5: queue / parent_id / module_id 维度(知识图谱 V29 + 工程 V41)
if let Some(ref q) = queue {
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(q.clone()));
}
if let Some(ref pid) = parent_id {
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(ref mid) = module_id {
where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(mid.clone()));
}
let sql = format!( let sql = format!(
"SELECT COUNT(*) FROM tasks WHERE {}", "SELECT COUNT(*) FROM tasks WHERE {}",
@@ -743,11 +769,72 @@ impl TaskRepo {
.await .await
.map_err(storage_err)? .map_err(storage_err)?
} }
}
// ============================================================ /// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB) /// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
// ============================================================ ///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖。对齐 [`advance_status_atomic`](同款 `WHERE ... = ?expected` 原子条件写)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &TaskRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19",
params![
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站任务仍可改字段),
/// 本方法收口软删防护,供命令层 `update_task` 使用——软删任务(回收站)返回 `false`,
/// 调用方据此报「已删除」,杜绝回收站任务被字段更新复活/改动。
/// 字段名走同款 [`validate_column_name`] 白名单(防注入 + 按表隔离)。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "tasks")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -1160,4 +1247,115 @@ mod tests {
assert_eq!(updated.queue, "todo"); assert_eq!(updated.queue, "todo");
assert_eq!(updated.status.as_str(), "todo"); assert_eq!(updated.status.as_str(), "todo");
} }
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn update_full_cas_success_when_expected_matches() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
let current = repo.get_by_id("t1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_conflict_when_expected_stale() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("t1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("t1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
// ============================================================
// LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页)
// ============================================================
#[tokio::test]
async fn count_by_query_matches_list_by_query_dimensions() {
let repo = setup().await;
// 父任务 + 3 子/叶任务,覆盖 queue/assignee/parent_id 三维(module_id 需 FK 另测)
repo.insert(trec("parent", "todo", None)).await.unwrap();
let mut c1 = trec("c1", "backlog", Some("parent"));
c1.assignee = Some("alice".to_string());
let mut c2 = trec("c2", "todo", Some("parent"));
c2.assignee = Some("bob".to_string());
let mut c3 = trec("c3", "active", None);
c3.assignee = Some("alice".to_string());
repo.insert(c1).await.unwrap();
repo.insert(c2).await.unwrap();
repo.insert(c3).await.unwrap();
// 单维度:queue=backlog → 1(c1)
let q = TaskQuery { queue: Some("backlog".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 1);
// assignee=alice → 2(c1 + c3)
let q = TaskQuery { assignee: Some("alice".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 2);
// parent_id=parent → 2(c1 + c2)
let q = TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 2);
// 组合:queue=backlog AND parent_id=parent → 1(c1)
let q = TaskQuery {
queue: Some("backlog".to_string()),
parent_id: Some("parent".to_string()),
..Default::default()
};
assert_eq!(repo.count_by_query(&q).await.unwrap(), 1);
// 关键契约:count 与 list_by_query 对同一 query 结果数一致(翻页 total 与页数据对齐)
for q in [
TaskQuery { queue: Some("backlog".to_string()), ..Default::default() },
TaskQuery { assignee: Some("alice".to_string()), ..Default::default() },
TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() },
] {
let count = repo.count_by_query(&q).await.unwrap();
let list_len = repo.list_by_query(&q).await.unwrap().len() as i64;
assert_eq!(count, list_len, "count 与 list 维度必须一致,query={q:?}");
}
}
// ============================================================
// LW-6 update_field_active 软删过滤(回收站任务不可改字段)
// ============================================================
#[tokio::test]
async fn update_field_active_skips_soft_deleted() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("t1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("t1").await.unwrap();
assert!(!repo.update_field_active("t1", "title", "又改").await.unwrap());
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "新标题", "软删后字段不应被改动");
}
} }
+31 -6
View File
@@ -46,7 +46,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
// V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。 // V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。
let steps: [(i32, fn(&Connection) -> Result<()>); 41] = [ // V42 = ai_messages 加 is_estimated 列(消息级估算标记,reload 逐条回显「估算」标注)。
let steps: [(i32, fn(&Connection) -> Result<()>); 42] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -88,6 +89,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(39, migrate_v39), (39, migrate_v39),
(40, migrate_v40), (40, migrate_v40),
(41, migrate_v41), (41, migrate_v41),
(42, migrate_v42),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1257,6 +1259,22 @@ fn migrate_v41(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V42: ai_messages 加 is_estimated 列(消息级估算标记)
///
/// 消息级 token 持久化(V38/V39)已落 prompt/completion/cache/reasoning,但「该轮 prompt 是否
/// estimated 兜底」未存——reload 逐条回显时无法对齐 live 态 AiCompleted.is_estimated 标注。
/// 本迁移补 INTEGER 列(NULL=老消息未标记,向前兼容;0=false 真实,1=true 估算)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v38/v39 模式),对新库/老库均安全。
fn migrate_v42(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_messages", "is_estimated") {
conn.execute("ALTER TABLE ai_messages ADD COLUMN is_estimated INTEGER", [])?;
tracing::info!("v42: ai_messages 加 is_estimated 列(消息级估算标记)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [42])?;
tracing::info!("迁移 v42 完成: ai_messages 加 is_estimated 列");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -1281,6 +1299,7 @@ CREATE TABLE IF NOT EXISTS ai_messages (
prompt_cache_hit_tokens INTEGER, prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER, prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
is_estimated INTEGER,
UNIQUE(conversation_id, seq) UNIQUE(conversation_id, seq)
); );
@@ -1532,6 +1551,7 @@ CREATE TABLE IF NOT EXISTS ai_messages (
prompt_cache_hit_tokens INTEGER, prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER, prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER, reasoning_tokens INTEGER,
is_estimated INTEGER,
UNIQUE(conversation_id, seq) UNIQUE(conversation_id, seq)
); );
@@ -2023,7 +2043,7 @@ mod tests {
"tasks.module_id 列缺失(V41 加)" "tasks.module_id 列缺失(V41 加)"
); );
// 3. schema_version 应推进到 41(全量迁移成功落版本号) // 3. schema_version 应推进到 42(全量迁移成功落版本号)
let max_version: i64 = conn let max_version: i64 = conn
.query_row( .query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version", "SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -2032,8 +2052,8 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 41, max_version, 42,
"全量迁移后 schema_version 应为 41(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为 42(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version max_version
); );
@@ -2046,6 +2066,11 @@ mod tests {
column_exists(&conn, "project_modules", "status"), column_exists(&conn, "project_modules", "status"),
"project_modules.status 列缺失(V40 加)" "project_modules.status 列缺失(V40 加)"
); );
// 5. V42 抽查:ai_messages.is_estimated 列存在(消息级估算标记)
assert!(
column_exists(&conn, "ai_messages", "is_estimated"),
"ai_messages.is_estimated 列缺失(V42 加)"
);
} }
// ============================================================ // ============================================================
@@ -2105,7 +2130,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 41, "首轮应推进到 41"); assert_eq!(max_v, 42, "首轮应推进到 42");
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2117,7 +2142,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v2, 41, "重跑后应重新推进到 41"); assert_eq!(max_v2, 42, "重跑后应重新推进到 42");
} }
// ============================================================ // ============================================================
+7 -2
View File
@@ -1,7 +1,7 @@
//! 数据模型定义 — 与数据库表对应的 Rust 结构体 //! 数据模型定义 — 与数据库表对应的 Rust 结构体
use df_ai_core::model::{deserialize_model_configs, ModelConfig}; use df_ai_core::model::{deserialize_model_configs, ModelConfig};
use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus}; use df_types::types::{IdeaStatus, LinkType, NodeType, ProjectStatus, TaskStatus, ToolCallId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// ============================================================ // ============================================================
@@ -383,7 +383,9 @@ pub struct AiToolExecutionRecord {
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入。 /// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>, pub message_id: Option<String>,
pub tool_call_id: String, /// 工具调用关联键(跨 tool_calls / pending_approvals / ai_tool_executions)。
/// branded newtype 防把 execution_id / conversation_id 等同形 String 误传。
pub tool_call_id: ToolCallId,
pub tool_name: String, pub tool_name: String,
pub arguments: String, pub arguments: String,
pub result: Option<String>, pub result: Option<String>,
@@ -440,6 +442,9 @@ pub struct AiMessageRecord {
pub prompt_cache_miss_tokens: Option<u32>, pub prompt_cache_miss_tokens: Option<u32>,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。 /// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
pub reasoning_tokens: Option<u32>, pub reasoning_tokens: Option<u32>,
/// 本轮 prompt 是否估算值(round_usage.prompt_tokens==0 → estimated_prompt 兜底打标)。
/// reload 逐条回显「估算」标注;老消息 NULL → None(向前兼容)。
pub is_estimated: Option<bool>,
} }
// ============================================================ // ============================================================
+3 -1
View File
@@ -9,7 +9,9 @@
//! //!
//! ## 边界 //! ## 边界
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制) //! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由) //! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/收发循环;断线**自动重连由调用方 supervisor
//! 负责**,src-tauri 已实现指数退避重连,本 crate 提供 connect/disconnect/is_connected
//! 原语 + `backoff_delay` 退避工具)
//! //!
//! ## 模块 //! ## 模块
//! - [`events`]:隧道透传事件/指令/消息包装类型 //! - [`events`]:隧道透传事件/指令/消息包装类型
+15 -10
View File
@@ -1,7 +1,9 @@
//! df-tunnel 隧道客户端(Phase2 完整实现) //! df-tunnel 隧道客户端(Phase2 完整实现)
//! //!
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`, //! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://host/ws/device`,
//! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环 + 指数退避重连 //! 穿 NAT 无需端口映射。tokio-tungstenite WS + Hello 握手 + 收发循环。
//! 断线重连由**调用方 supervisor** 负责(src-tauri 已实现指数退避重连,见其 tunnel 初始化处):
//! 本 crate 提供 connect/disconnect/is_connected 原语 + [`reconnect_params`] 参数复用 + [`backoff_delay`] 退避工具。
//! //!
//! ## 协议对齐(df-relay Phase2 commit 2b8b30e) //! ## 协议对齐(df-relay Phase2 commit 2b8b30e)
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }` //! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
@@ -130,9 +132,9 @@ enum OutFrame {
/// 默认 WS 隧道客户端(完整实现) /// 默认 WS 隧道客户端(完整实现)
pub struct WsTunnelClient { pub struct WsTunnelClient {
/// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读) /// 目标服务器 URL(connect 后保存,供调用方 supervisor 经 [`reconnect_params`] 复用)。
/// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连), /// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
/// 该字段保留以备 Phase3「断线后台自动重连」消费 /// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)
server_url: Mutex<Option<String>>, server_url: Mutex<Option<String>>,
device_id: Mutex<Option<String>>, device_id: Mutex<Option<String>>,
token: Mutex<Option<String>>, token: Mutex<Option<String>>,
@@ -155,7 +157,7 @@ impl WsTunnelClient {
} }
} }
/// 当前已连接时取一份重连参数(未来自动重连用) /// 当前已连接时取一份重连参数(URL/device_id/token,供调用方 supervisor 断线重连用)
pub async fn reconnect_params(&self) -> Option<(String, String, String)> { pub async fn reconnect_params(&self) -> Option<(String, String, String)> {
let url = self.server_url.lock().await.clone()?; let url = self.server_url.lock().await.clone()?;
let device_id = self.device_id.lock().await.clone()?; let device_id = self.device_id.lock().await.clone()?;
@@ -344,8 +346,9 @@ impl WsTunnelClient {
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping /// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
/// 4. 任一端断开 → 退出 task,置 connected=false /// 4. 任一端断开 → 退出 task,置 connected=false
/// ///
/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层 /// 注:本任务范围不含「自动后台重连」(断开即退出)。自动重连由**调用方 supervisor** 承担:
/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。 /// 轮询 is_connected → 用 [`reconnect_params`](WsTunnelClient::reconnect_params)参数
/// + [`backoff_delay`] 退避 → 再 connect。src-tauri 已实现该 supervisor。
async fn run_loop( async fn run_loop(
mut sink: futures_util::stream::SplitSink< mut sink: futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
@@ -497,9 +500,11 @@ fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
} }
} }
/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分) /// 计算指数退避延迟(1s, 2s, 4s, 8s, 16s, 30s, 30s, ...)
#[allow(dead_code)] ///
fn backoff_delay(attempt: u32) -> Duration { /// 供调用方 supervisor 断线重连使用(本 crate 不内置自动重连,见模块文档)。
/// 暴露为 pub 使外部(supervisor / src-tauri)可直接复用,不再属死代码。
pub fn backoff_delay(attempt: u32) -> Duration {
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... // 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
let secs = RECONNECT_BASE let secs = RECONNECT_BASE
.as_secs() .as_secs()
+113 -1
View File
@@ -84,6 +84,73 @@ impl PartialEq<String> for ExecutionId {
} }
} }
/// 工具调用 ID(跨 ai_tool_executions / tool_calls 关联键,IPC 边界透明序列化为字符串)
///
/// branded newtype:编译器拒绝把任意 String 当 ToolCallId 传入,防误传(如把
/// execution_id 当 tool_call_id 落库)。serde `transparent` 保证 JSON 形态为纯字符串,
/// 前端零感知。设计同 [`ExecutionId`] / [`ToolCallType`],手写不抽宏(全仓仅 3 个
/// 字符串 newtype,抽 macro_rules 增心智成本无收益)。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
#[serde(transparent)]
pub struct ToolCallId(String);
impl ToolCallId {
/// 构造新工具调用 ID
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
impl std::fmt::Display for ToolCallId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::ops::Deref for ToolCallId {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl From<String> for ToolCallId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for ToolCallId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<ToolCallId> for String {
fn from(id: ToolCallId) -> Self {
id.0
}
}
// ToolCallId 与 &str/String 的比较(测试/匹配中大量使用,与 ExecutionId 对齐)
impl PartialEq<&str> for ToolCallId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
impl PartialEq<str> for ToolCallId {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<String> for ToolCallId {
fn eq(&self, other: &String) -> bool {
self.0 == *other
}
}
/// 决策 ID /// 决策 ID
pub type DecisionId = String; pub type DecisionId = String;
/// 节点类型(如 ai / human / ai_self_review) /// 节点类型(如 ai / human / ai_self_review)
@@ -484,6 +551,22 @@ impl Priority {
Priority::Critical => "critical", Priority::Critical => "critical",
} }
} }
/// 从 i32 解析优先级(对齐**前端约定** 0=Critical/1=High/2=Medium/3=Low
/// 与 src-tauri commands::idea::priority_from_i32 兜底语义一致,统一调用入口)。
///
/// 注意:enum discriminant 与前端相反(Low=0/Critical=3,历史定义),
/// 勿用 `p as Priority` 转换——本函数手动映射前端语义,与 `as_str` 序列化
/// ("low"/"critical") 互不干扰。越界/负值(含 4+、-1 等)统一归并 Low。
pub fn from_i32(p: i32) -> Priority {
match p {
0 => Priority::Critical,
1 => Priority::High,
2 => Priority::Medium,
// 3(低)与越界/负值统一归 Low,对齐现有 priority_from_i32 读侧语义
_ => Priority::Low,
}
}
} }
impl Default for Priority { impl Default for Priority {
@@ -503,7 +586,7 @@ pub fn new_id() -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::TaskStatus; use super::{Priority, TaskStatus};
// 合法值:覆盖全部枚举变体(与 as_str 一致) // 合法值:覆盖全部枚举变体(与 as_str 一致)
#[test] #[test]
@@ -592,4 +675,33 @@ mod tests {
// 数量应等于枚举变体数 // 数量应等于枚举变体数
assert_eq!(TaskStatus::valid_values().len(), 7); assert_eq!(TaskStatus::valid_values().len(), 7);
} }
// ── Priority::from_i32(对齐前端约定 0=Critical/1=High/2=Medium/3=Low)──
#[test]
fn priority_from_i32_maps_frontend_semantics() {
assert_eq!(Priority::from_i32(0), Priority::Critical);
assert_eq!(Priority::from_i32(1), Priority::High);
assert_eq!(Priority::from_i32(2), Priority::Medium);
assert_eq!(Priority::from_i32(3), Priority::Low);
}
// 越界/负值统一归并 Low(对齐 src-tauri priority_from_i32 的 `_` 兜底)
#[test]
fn priority_from_i32_out_of_range_falls_back_to_low() {
assert_eq!(Priority::from_i32(4), Priority::Low);
assert_eq!(Priority::from_i32(99), Priority::Low);
assert_eq!(Priority::from_i32(-1), Priority::Low);
assert_eq!(Priority::from_i32(i32::MIN), Priority::Low);
assert_eq!(Priority::from_i32(i32::MAX), Priority::Low);
}
// 与 as_str 对偶自洽:from_i32 的每个前端值 round-trip 后 as_str 不变
#[test]
fn priority_from_i32_roundtrip_consistent_with_as_str() {
assert_eq!(Priority::from_i32(0).as_str(), "critical");
assert_eq!(Priority::from_i32(1).as_str(), "high");
assert_eq!(Priority::from_i32(2).as_str(), "medium");
assert_eq!(Priority::from_i32(3).as_str(), "low");
}
} }
@@ -0,0 +1,156 @@
# aichat 运行效率走查报告(多角度)
> 走查日期:2026-08-09
> 范围:aichat 前后端全链路(后端 agentic 2910 行 + audit + tools + 流式;前端 composables/ai + MessageList/ChatInput/TopBar
> 方法:6 路并行 Explore agent(单轮成本/工具执行/流式渲染/前端交互/会话生命周期/LLM成本)
> 性质:**只走查登记,不实施代码**
> 触发:用户反馈「aichat 运行的效率很低」+「成本走查,比如有的 LLM 要求的缓存命中率规则」
---
## 一、单轮执行成本(13 项)
### P1
- **R1-1 每轮全量历史重放,无增量**context/mod.rs:184-253 + agentic:1492-1527)——第 k 轮 prompt≈Σ历史,R 轮总 input≈O(R²);追加尾部的每轮新 tool_result 是必然 cache miss
- **R1-2 上一轮已见工具结果本轮仍全量重放**summarize_tool_results view-only:1540-1551)——grep/search 的 `matches:[]` 每轮重复
- **R1-3 自动压缩阈值过惰性**context_lifecycle.rs:88-95>61K token 才触发)——普通会话永不压缩,全量重放无兜底
### P2
- **R2-1 每轮 ≥4 次全历史深克隆**build_for_request + save + count_recent_failures + check_stall_breaker2 次持 session 锁)
- **R2-2 每轮必 save_conversation**(全量 clone + truncate_for_persist + 序列化 + DB 元数据重写)
- **R2-3 estimated_prompt 每轮全量重估 token**(与 history_tokens 增量维护重复)
### P3
R3-1 重试/候选切换全量 to_vec clone · R3-2 summarize 对未变化历史每轮重算 · R3-3 system_prompt 每轮整体克隆(未打 cache_control 断点)· R3-4 build_system_prompt 每次 loop/resume 查库(list_active×2)· R3-5 每轮重建 provider + keyring 解析 · R3-6 process_tool_calls 每工具全量扫历史 · R3-7 无 token 预算型终止(空转到 10 轮)
---
## 二、工具执行开销(9 项)
### P1
- **T1-1 审计每工具 ≥1 次串行 INSERT**audit/mod.rs:684-721,全经单全局 DB 锁,无批量)——单轮 N 工具 = N 次 INSERTjoin_all 后的串行尾巴
- **T1-2 只读缓存是"假缓存"**cache.rs:251-330)——每工具全量扫历史 + 1 次 DB SELECT,非 O(1) 索引;miss 代价>收益
- **T1-3 git 工具不进只读缓存**git.rs:99-133,每轮跑子进程;module.rs 5s git 缓存不共享)——LLM 失忆重调 git 完全没被治
- **T1-4 每轮对完整历史重处理**build_for_request + summarize + estimated_prompt + 2 次全量 clone
### P2
- **T2-1 审批/授权每 Med/High 工具最多 3 次 DB 往返 + 全量历史扫**auto_exec_mode 等低频配置每轮查库)
- **T2-2 read_file/read_symbol 每次全量读文件(≤1MB)**(分段读也全读,offset 不同缓存 miss
- **T2-3 缓存命中把全量内容直接 push messages(不 namespace+ 审计落全量** → 缓存反放大历史
- **T2-4 entity_resolve 每工具 clone args + 每工具一次 list_active 全表查询**
### P3
T3-1 工具事件双写 + 每工具一个心跳 task
---
## 三、流式渲染效率(12 项)
### P1
- **S1-1 每帧全量 lexer + 单块回复整段重 parse,块级 memo 退化**useStreamRenderer.ts:77-97/130-154)——无 `\n\n` 的长文回复每帧全文 lexer+parse+sanitize,整轮 O(n²),长文卡顿主因
- **S1-2 长尾代码块/末块每帧重跑 hljs 高亮**:146-149 + useMarkdown:87-111
### P2
- **S2-1 每次 flush 两次整组件重渲染**currentText 触发 + streamingBlocks rAF 触发,:219-236~40 次/s 整树求值
- **S2-2 renderMd 缓存命中前先跑 wrapNakedDiff 全文扫描**useMarkdown.ts:191-197
- **S2-3 isToolResultJson 对每条 JSON 形消息每帧 JSON.parse**MessageList:698-708
- **S2-4 handleEvent 每活跃事件重复 setConvState('generating')**useAiEvents.ts:122-124,同值无短路)
- **S2-5 AiCommandOutputrun_command 逐行)未合批,事件风暴**command_stream.rs:39-48,几百上千行命令→每秒上百 IPC)
- **S2-6 已完成块每帧重算 simpleHash + 新子串做 Map key 哈希**
### P3
S3-1 每 delta 文本空闲计时器/看门狗重建 · S3-2 切会话缓冲双清 no-op · S3-3 tail-${tailSeq} 恒等于 tail-1(注释误导)· S3-4 messages deep watch 每工具翻转 JSON.stringify 全量
### 已核查无问题(Info
后端 50ms 合批真生效(stream_recv.rs:346-352)· rAF 节流真生效 · 消息侧无 O(n²)(isLastAi O(1))· 内存缓存界内(_blockCache≤300/_mdCache≤200/currentText 完成即清)
---
## 四、前端交互效率(12 项)
### P1
- **F1-1 回合收尾双拉会话列表**useAiLifecycleEvents.ts:119 + notify→listener 再拉,每回合 2 次全量 list IPC)
- **F1-2 notifyConversationChanged 同窗口自触发冗余重拉**aiShared.ts:21 emit 回传同窗口 listener;删除活跃会话可达 5 IPC)
- **F1-3 loadConversations 无防抖**(8+ 处调用,整体替换数组→侧栏全量重渲染)
### P2
- **F2-1 switch 无条件多 1 次 pendingToolCalls IPC**(纯文本会话也白拉)
- **F2-2 switch 主线程同步全量 parse**JSON.parse + parseConvMessages 每条 toolCall 再 parse
- **F2-3 MessageList deep watch 每次 messages 变更全量 JSON.stringify 快照**
- **F2-4 输入联想每键全量过滤 + 首次 @ 全量拉 tasks**
- **F2-5 TopBar 多个 computed 每次 messages 变更全量扫描**historyMsgs/summaryMsgs/contextInfo
### P3
F3-1 滚动边沿 buildActiveToolIds 全量遍历 · F3-2 每 delta 定时器重建(看门狗+文本空闲)· F3-3 AiChat 启动串行 IPC 链(约 5 次)· F3-4 persistUiState 每次写整包 df-ai-ui
---
## 五、会话生命周期(16 项)
### P0
- **L0-1 save_conversation 每轮 O(N) 全量克隆+截断+记录构建,CPU 在主线程**conversation.rs:254-343,单线程 runtime)——"无变化捷径"在 records 构建后才判定,每轮白付全部 CPU;N 轮工具对话 ≈N+1~N+2 次 save
- **L0-2 system prompt 每次用户操作全量重建 + 串行 6-8 次 DB 查询**chat.rs:527-651 + prompt.rs:280-343)——全部落在「点击→首 token」关键路径,project/task 清单可缓存
- **L0-3 自动压缩在 loop 内同步阻塞整轮**context_lifecycle.rs:159-167,非流式 LLM 最长 60s+ 压缩后必全量 DELETE+INSERT 重写
### P1
- **L1-1 最终轮重复 save**agentic:1918 + :2111 收敛时再 spawn
- **L1-2 每轮 build_for_request + sanitize + pairing + estimated_prompt 共 5-6 次全量遍历**
- **L1-3 会话切换全量读库 + 全量重估 token + 全量重跑意图识别**restore 时对每条 re-estimate + re-recognize
- **L1-4 每次生成结束在所有窗口触发会话列表全量刷新 + 事件双写**55 处 emit + publish_event 双写)
- **L1-5 知识注入每次发送全量克隆消息 + 两次 DB 检索**keyword + vector,关键路径)
### P2
L2-1 Checkpoint 每 20 条消息全量序列化 · L2-2 单次 save 对 session 锁加锁 3 次 · L2-3 active provider 在 send→loop 各查一次库 · L2-4 estimated_prompt 每轮全量重算 · L2-5 空标题会话切换触发额外 LLM 标题生成 · L2-6 心跳每 20s 无条件广播
### P3
L3-1 所有重生成路径同样重建 system promptregenerate/edit/force_send)· L3-2 事件总线双写在热点仍存在(app.emit 全窗口广播)
### 关键前提
Tauri Windows 单线程 tokio runtime——所有 CPU 密集操作主线程串行,任一长任务阻塞全部 IPC。最高优先级:L0-1 save 提前判定 + L0-2 缓存 + L0-3 压缩后台化。
---
## 六、LLM 成本 / 缓存命中率(12 项)
> **核心结论**DevFlow **从未向任何 provider 发送 `cache_control`/`cache_breakpoint`**,且 system prompt 每轮用实时 DB 数据重建 → 前缀缓存既没开、前缀也不稳定,**多轮对话几乎 100% 缓存不命中,每轮全价支付全量历史 + 全量 system**。代码只做了「读回 cache_read/cache_creation 计费展示」,从未「请求缓存」。
### P0
- **C0-1 全链路无 cache_control 注解,Anthropic 提示缓存根本没启用**anthropic_helpers.rs:22-36 无字段;system 是纯 String 无法表达 breakpoint,需改 text-block 数组)
- **C0-2 system prompt 每轮用实时 DB 数据重建,前缀字节不稳定 → 跨轮缓存全废**prompt.rs:295/315 list_active 20+20 注入,agent 改数据即变;建议挪到消息流只留静态指令)
### P1
- **C1-1 日期在 system prompt 位置 0,跨天失效整条前缀**prompt.rs:45 当前日期)
- **C1-2 每轮全量重发全部历史,成本 O(n²),无缓存兜底**(10 轮实测单次请求可达 ~12 万 token 全价)
- **C1-3 augmentation/知识注入拼进 system,随轮变化再添失效源**chat.rs:639-652
### P2
- **C2-1 模型路由从不因简单意图降档,贵模型低用**router.rs weight 主键,tier 仅 tiebreakChat 意图也应降档)
- **C2-2 重试发送字节完全相同请求,无缓存 → 每次重试重复全价**
- **C2-3 system prompt 本身膨胀(文档探索段 140-180 行常驻)+ 重复注入技能/工作流/目标**
- **C2-4 标题生成/规划/压缩是独立 LLM 调用,不共享前缀缓存**
### P3
C3-1 tools 每轮全量序列化无 cache_control 与 system 一起缓存 · C3-2 AnthropicRequest.system 纯 String 需结构改造 · C3-3 采样参数合理(确认项)
### 修复优先级(成本 agent 建议)
1. **C0-1 + C0-2 一起修**(加 cache_control + 稳定 system 前缀)才能产生真实命中率
2. C1-1/C1-2/C1-3(日期与易变段挪出稳定前缀、augmentation 改注入消息流)→ 多轮 loop 输入成本砍 ~1/5~1/10
3. C2-1 模型降档(收益独立于缓存,可并行)
---
## 七、优先级建议(合并 6 路)
**成本大头(后端)**:R1-1 全量历史重放 + R2-1 每轮 4 次深克隆 → 增量/窗口化
**卡顿主因(渲染)**:S1-1 单块回复每帧全量渲染 → 增量 lex/parse
**无谓开销(前端)**:F1-1/2/3 列表刷新收敛(合并一个 debounced scheduleConversationsRefresh
**DB 瓶颈**T1-1 审计批量事务 + T1-2 只读缓存 O(1) 索引
**成本(LLM 缓存)**C0-1 + C0-2cache_control + 稳定前缀)→ 命中率从 ~0% 提升到可复用
---
## 八、登记去向
- 本报告:`docs/05-代码审查/aichat效率走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目
- memory 指针:待补齐
@@ -0,0 +1,53 @@
# df-mcp + 工具 crate 走查报告(2026-08-09
> 走查日期:2026-08-09
> 范围:df-mcpserver/tools/protocol/server_http+ df-execute + df-project + df-ideas + df-types + df-tunnel + df-relay
> 方法:Explore agent 独立核验 + 对齐 08-02 走查
> 性质:**只走查登记,不实施代码**(守 session-role-diagnose-only
> 关联:全系统模块走查(阶段②)最后一批
---
## 一、df-mcp16 项)
### P1
- **MCP-1 update_project 直接写 status 绕过状态机**tools.rs:424-430vs update_task 收口,可制造 GUI 不可能非法跳态)——建议对齐任务侧收口/校验
### P2
MCP-2 bind_directory 两步非原子写 path+stack · MCP-3 create_idea priority 无值域校验(IPC 拒/MCP 放行漂移 Critical)· MCP-4 create_project/task 不校验 name 非空 · MCP-5 MCP evaluate/score 用独立算法与 df-ideas 漂移 · MCP-6 写操作回调在业务失败时仍触发(假 df-data-changed
### P3
MCP-7 jsonrpc 字段不校验 2.0 · MCP-8 tools/call 缺 name 回错语义 · MCP-9 空串清空字段与文档不符 · MCP-10 pagination 超大 i64 截断 · MCP-11 list_trash 全量内存分页 · MCP-12 create_task FK 错误模糊 · MCP-13 search_knowledge 非数字项静默丢 · MCP-14 insert_knowledge confidence 不校验 · MCP-15 60s 空闲退出误杀长会话(需确认)· MCP-16 HTTP read_only 硬编码 false 无鉴权(需确认)
## 二、df-execute3 项)
EX-1 ShellRequest None 超时无兜底(调用方显式设,共享 crate 风险低)· EX-2 execute_streaming 输出格式与 execute 不一致 · EX-3 env_snapshot fallback 永久冻结 · EX-4 probe_version 单命令无独立超时
## 三、df-project4 项)
- **PRJ-1 【P1】normalize_path 无条件 to_lowercase 被 bind_directory 存库**(大小写敏感系统路径解析失败,需确认目标平台)
PRJ-2 read_readme_raw 整读无上限 · PRJ-3 discover_projects 同步阻塞调用方需 spawn_blocking · PRJ-4 collect_manifests 整读截断
## 四、df-ideas2 项)
IDEA-1 LLM 评估 final_score/recommendation 自洽性未校验(ARC-260618-01-e 已登记,需确认)· IDEA-2 否定前缀检测局限
## 五、df-types2 项)
TYPES-1 Priority 无统一 from_i32 越界处理(散落调用方,配 MCP-3)· TYPES-2 未知状态值静默归 planning(建议 warn 审计)
## 六、df-tunnel4 项)
TUN-1 lib 文档声称指数退避重连但实现无自动重连(断线永久断开,需确认排期)· TUN-2 token 明文 WS · TUN-3 unbounded_channel 无背压 · TUN-4 connected 先置 true 假已连接窗口
## 七、df-relay5 项)
RLY-1 单共享 token 无 per-device 绑定(冒充设备收指令,需确认)· RLY-2 默认 0.0.0.0 无 TLS · RLY-3 入站帧无大小限制 + 无背压 · RLY-4 token 非常量时间比较 · RLY-5 未设 token panic
---
## 优先级建议
**P1**MCP-1 update_project 状态机旁路 → PRJ-1 normalize_path 存库
**P2(跨端/一致性)**:TUN-1 自动重连(断线永久断开,影响跨端)→ RLY-1 per-device 鉴权 → MCP-6 假数据变更
**其余 P2/P3**:分批
## 登记去向
- 本报告:`docs/05-代码审查/df-mcp+工具crate走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目
- 全系统索引:**阶段②全部完成** ✅
@@ -0,0 +1,59 @@
# Settings + 审计 + 布局 + 后端命令走查报告(2026-08-09)
> 走查日期:2026-08-09
> 范围:Settings.vue + settings 组件 / AuditLog.vue / layout + AppSidebar + App.vue + 全局样式 / 后端 src-tauri/src/commands 全模块
> 方法:3 路并行 Explore agentSettings+AuditLog / layout+sidebar+全局 / 后端命令)
> 性质:**只走查登记,不实施代码**(守 session-role-diagnose-only
> 关联:全系统模块走查(阶段②)第二批
---
## 一、Settings + AuditLog29 项)
### P28
SA-1 AuditLog 决策者列 i18n 缺键(decided_by 有 auto_trust/auto_dedup/cache_hit 等 6 值,i18n 只 human/auto → 渲染 key 串)· SA-2 对话 tab 角色标签硬编码中文不随 locale · SA-3 审计列表 load 无 seq 守卫(筛选竞态)· SA-4 toggleConv 竞态 · SA-5 ProviderPanel 脏表单切导航静默丢改动 · SA-6 单模型冗余标签全隐藏 · SA-7 池权重 debounce 跨 provider 冲突 · SA-8 连接密码明文存 localStorage
### P321
SA-9 对话 spinner 类名未定义 · SA-10 切回 audit tab 不刷新 · SA-11 对话 tab 无刷新入口 · SA-12 加载中整表替换闪烁 · SA-13 空态文案误导 · SA-14 对话详情全量渲染无分页 · SA-15 对话列表固定 200 条 · SA-16 对话详情无空态 · SA-17 样式 token 混用 · SA-18 maskKey 8 字符泄露 · SA-19 测试连接后 savedSnapshot 未刷新 · SA-20 模型表格 weight 无前端 clamp · SA-21 设为默认无 in-flight 禁用 · SA-22 ConnectionPanel 无脏确认 · SA-23 ConnectionPanel 保存成功无 toast · SA-24 AdvancedSection 假保存反馈 · SA-25 PerformanceSection 并发 0 弹成 3 · SA-26 KnowledgePanel 加载后冗余 save · SA-27 KnowledgePanel min_messages 无 clamp · SA-28 AllowedDirsPanel 删除无确认 · SA-29 AppearanceSection 不监听 OS 主题 · SA-30 .btn/.empty-hint 双源定义(settings.css 覆盖 global.css)· SA-31 搜索滚动部分索引项失效
## 二、layout + sidebar + 全局(21 项)
### P11
- **LS-1 ConfirmDialog Enter 误触危险操作**(焦点在取消按钮按 Enter → emit result(true) 执行删除,preventDefault 抑制取消分支,安全隐患)
### P29
LS-2 AppSidebar 收缩偏好每次重启丢失(子组件 onMounted 早于父组件 loadAll)· LS-3 AppLayout 小屏 media query 被内联样式覆盖(100vw 失效)· LS-4 nav-link 活跃指示条被 overflow 裁剪· LS-5 App.vue startApprovalPopup 未排除 ai-detached 窗口(重复审批浮窗)· LS-6 Paginator 越界无自愈(删除后 page 超界空白)· LS-7 路由切换无 scrollBehavior 滚动残留 · LS-8 亮色主题白色透明底不可见(version 徽章/滚动条)· LS-9 ConfirmDialog 无 aria/focus trap · LS-10 --df-primary 与 --df-accent 重复 token
### P311
LS-11 AiHome 关闭面板后空白 · LS-12 主题启动 FOUC · LS-13 toast 无 aria-live · LS-14 df-data-changed 监听无窗口守卫(分离窗口无用全量 load)· LS-15 data-changed 无 knowledge/workflow 分支 · LS-16 Paginator 无 aria-label + 页码爆炸 · LS-17 纯图标按钮无 aria-label · LS-18 AppLayout resize 监听未清理 · LS-19 router.replace 未 catch · LS-20 退出最大化不持久化 · LS-21 primaryNav badge 死代码 + v-html XSS 面 + Ctrl+I 大小写
## 三、后端命令(45 项)
### P18
- **BE-CMD-1 task update_task 漏 queue 校验**(可写非法 queue/queue-status 不一致)
- **BE-CMD-2 task count_tasks 与 list_tasks 过滤维度不一致**count 超算致翻页空页)
- **BE-CMD-3 project update_project 通用 update_field 裸奔**(白名单含 id/created_at/status/path 零校验,可改主键致子表悬空)
- **BE-CMD-4 idea update_idea 无校验**status 可写任意/绕过 promote_idea 得半立项)
- **BE-CMD-5 task update_field 可更新已软删任务**get_by_id 不过滤 deleted_at
- **BE-CMD-6 idea evaluate_idea 无条件覆盖 pending_review**(打回终态)
- **BE-CMD-7 idea promote_idea 读-改-写竞态**(双击重复立项 + 补偿非原子)
- **BE-CMD-8 task create/reparent/move 不重算父聚合**(父 status 陈旧)
### P215
BE-CMD-9 update_task content_json 无 JSON 校验 · BE-CMD-10 create_task 未校验 priority 值域 · BE-CMD-11 delete_task 级联不清理 task_links · BE-CMD-12 relocate_project_path 存非规范化路径+无 `..` 拦截 · BE-CMD-13 relocate 两步写非原子 · BE-CMD-14 find_binding_conflict 全表扫 N+1 · BE-CMD-15 update_idea 返回 bool 不存在静默假成功 · BE-CMD-16 save_template 可覆盖内置模板 · BE-CMD-17 update_project stack 无 JSON 校验 · BE-CMD-18 create_task 空 title · BE-CMD-19 get_task_by_id 返软删任务 · BE-CMD-20 update_task 合法 status 落白名单错误 · BE-CMD-21 import_projects_batch 同路径重复绑定 · BE-CMD-22 update_idea status 可写 promoted 半立项 · BE-CMD-23 idea related_ids/scores 非法 JSON
### P322
BE-CMD-24~45 见详单(create_idea 空 title / list_ideas status 不校验 / knowledge kind/confidence 不校验 / knowledge_save_config 先改内存 / workflow list_workflow_executions 全表拉 / approve_human_approval 不校验存活 / run_workflow 不校验 task_id / services environment 不校验 / ci_status 静默返空 / commit_sha 未校验 / events total 窗口封顶 等)
---
## 四、优先级建议
**P1(确定性/安全,优先修)**LS-1 ConfirmDialog Enter 误触 → BE-CMD-3/4 update_project/idea 裸奔 → BE-CMD-1 update_task queue → BE-CMD-2 count/list 不一致 → BE-CMD-5 软删可改 → BE-CMD-8 父聚合陈旧
**P2(体验/数据)**SA-1 决策者 i18n → LS-2 sidebar 偏好丢失 → LS-3 小屏布局 → SA-5 脏表单丢改动
## 五、登记去向
- 本报告:`docs/05-代码审查/settings+审计+布局后端走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目
- 全系统索引:已更新(前端+后端全走查完成)
@@ -0,0 +1,66 @@
# 任务 + 仪表盘 + 灵感知识走查报告(2026-08-09)
> 走查日期:2026-08-09
> 范围:Tasks.vue / TaskDetail.vue(已修项不重复)、Dashboard.vue / Projects.vue / ProjectCard.vue、Ideas.vue / IdeaDetail.vue、Knowledge.vue / KnowledgeDetail.vue
> 方法:3 路并行 Explore agent(任务 / Dashboard+Projects / Ideas+Knowledge
> 性质:**只走查登记,不实施代码**(守 session-role-diagnose-only
> 关联:全系统模块走查(阶段②)第一批
---
## 一、任务模块(19 项)
### P11
- **TSK-1 任务列表状态徽章无配色**Tasks.vue:784-790 无 status-* 色类,仅 TaskDetail/ProjectDetail scoped 有 → 列表页裸文本)——建议提取全局 components.css
### P28
TSK-2 status-tag 尺寸/圆角三视图不一致 · TSK-3 任务产出「产出」标题重复 · TSK-4 迷你进度条宽度策略不一致(72px vs flex:1)· TSK-5 状态色硬编码与 token 漂移 · TSK-6 列表快捷菜单不做状态机过滤且缺 cancelled · TSK-7 快捷操作失败无反馈(advance 静默/priority 换整表错误态)· TSK-8 快捷菜单被 .task-group overflow:hidden 裁切 · TSK-9 新建弹窗 Esc 不关 + 空项目确认静默
### P310
TSK-10 删除确认未传 dangerLabel · TSK-11 推进中所有按钮同文「推进中」refresh 未禁用 · TSK-12 列表行/快捷按钮键盘不可达 · TSK-13 详情页无删除入口(需确认设计)· TSK-14 并发 loadTasks 无 seq 守卫(搜索被自动刷新冲掉,buildActiveTaskQuery 不含 keyword)· TSK-15 搜索态树形进度失真/子任务浮为顶层 · TSK-16 TaskDetail 路由快速切换竞态(需确认)· TSK-17 limit=500 截断致进度/删除计数不准 · TSK-18 persisted 筛选值过期无校验 · TSK-19 TaskDetail 对任意 task 变更全量重载
---
## 二、Dashboard + Projects17 项)
### P12,同根)
- **DBP-1 【需确认】Dashboard 活跃项目面板恒为空**(后端状态机无 'active'filter status==='active' 永不命中)
- **DBP-2 状态映射只覆盖 planning/active/completed**in_progress 显示 0%「规划中」,badge/stage 与真实生命周期错位)
### P27
DBP-3 Projects 分页 page 越界空白无自愈 · DBP-4 Projects 缺加载态 · DBP-5 Dashboard loadAll 短路 loadStats 被跳过 · DBP-6 IdeasPanel 渲染 archived 裸 i18n key · DBP-7 ideas 统计被 Ideas 筛选污染(需确认,task 侧已修未对称)· DBP-8 ProjectCard 工程数 N+1 拉取 · DBP-9 删除/恢复/彻底删除无成功反馈
### P38
DBP-10 StatCardRow.vue 死代码 · DBP-11 Dashboard 头部与 page-header 不一致(绿点硬编码)· DBP-12 ActiveProjectsPanel 死 CSS 变体 · DBP-13 新建弹窗状态管理不完整(重置/聚焦/Esc/pathWarning)· DBP-14 卡片删除按钮仅 hover + 键盘不可达 · DBP-15 描述展开态跨视图残留 · DBP-16 Dashboard 统计行加载前显示 0 · DBP-17 硬编码间距 + 过时注释
### 核对通过
count_tasks 独立通道正确(P1-b A0 成立)、回收站 updated_at=deleted_at 移入时间正确
---
## 三、Ideas + Knowledge23 项)
### P14
- **IDEA-1 确定:relate/promote/数据变更监听用全量列表冲掉当前筛选视图**loadIdeas() 无参覆盖 store.ideasfilteredIdeas 无二次过滤)
- **IDEA-2 确定【需确认后端校验】:状态菜单可直接置 promoted 绕过立项**promoted_to=null 死状态)
- **KNOW-1 确定:publish/reject/archive 失败仍显示成功反馈并推进详情**runWithCatch 吞错,调用方不查结果)
- **KNOW-2 确定:「自动选中下一条」完全失效**(快照在 filter 后读,findIndex 恒 -1
### P210
IDEA-3 翻页越界空白 · IDEA-4 列表卡片点击不写 URL 路由同步失效(需确认)· IDEA-5 失败操作整块替换列表+删除失败清详情 · IDEA-6 捕捉成功无反馈 · IDEA-7 updateIdea 无错误包装静默丢改动 · IDEA-8 对抗评估渲染无防御(缺字段崩/NaN)· IDEA-9 评估历史不刷新 · KNOW-3 切 Tab 搜索框残留旧关键字 · KNOW-4 首次进入长内容不出现展开(渲染器已预热时,需确认)· KNOW-5 编辑保存失败丢改动
### P39
IDEA-10 archived 状态悬空(无徽章色)· IDEA-11 按评分排序评估后不重排 · IDEA-12 反馈通道混用(arco Message vs 自建 toast)· KNOW-6 onMounted 重复拉收件箱 · KNOW-7 加载中与空态同时渲染 · KNOW-8 relativeTime 用 Number() 直解时间戳(需确认)· KNOW-9 详情切换无 loading/错误 UI · KNOW-10 知识徽章 CSS 双份 scoped 重复 · C-1~3 跨模块(toast 位置/侧栏宽度/错误条不一致)
---
## 四、优先级建议
**P1(确定性,优先修)**:KNOW-1 假成功反馈 → KNOW-2 下一条失效 → IDEA-1 筛选被冲掉 → IDEA-2 死状态立项 → DBP-1/2 Dashboard 状态映射 → TSK-1 状态徽章无配色
**P2(体验)**IDEA-5/7/8Ideas 失败反馈与防御)、TSK-8 快捷菜单裁切、DBP-3 分页越界
## 五、登记去向
- 本报告:`docs/05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目
- 全系统索引:已更新
@@ -0,0 +1,55 @@
# 全系统模块走查索引
> 创建:2026-08-09
> 用途:阶段②「全系统逐一模块走查(样式/交互/缺陷)」的走查清单与进度跟踪。
> 流程:每模块走查(样式/交互/缺陷)→ 记录到 `docs/05-代码审查/` → 全部完成后再批量打包 /advance。
## 前端视图(13
| # | 模块 | 状态 | 走查报告 |
|---|---|---|---|
| 1 | Dashboard.vue | ✅ 已走查 2026-08-09 | 任务+仪表盘+灵感知识走查-2026-08-09.md17 项) |
| 2 | Projects.vue | ✅ 已走查 2026-08-09 | 同上(并入 Dashboard 批) |
| 3 | ProjectDetail.vue(概览/文件/依赖图) | ✅ 已走查 2026-08-09 | 项目详情页走查-2026-08-09.md60 项) |
| 4 | Tasks.vue | ✅ 已走查 2026-08-09 | 任务+仪表盘+灵感知识走查-2026-08-09.md19 项) |
| 5 | TaskDetail.vue | ✅ 已走查 2026-08-09 | todo.md 2026-08-0910 项已修) |
| 6 | Ideas.vue / IdeaDetail | ✅ 已走查 2026-08-09 | 任务+仪表盘+灵感知识走查-2026-08-09.md23 项 Ideas+Knowledge |
| 7 | Knowledge.vue / KnowledgeDetail | ✅ 已走查 2026-08-09 | 同上(23 项 Ideas+Knowledge |
| 8 | AuditLog.vue | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查-2026-08-09.md29 项) |
| 9 | Settings.vue | ✅ 已走查 2026-08-09 | 同上(并入 Settings 批) |
| 10 | AiChat.vue + ai/ 组件 | ✅ 已走查 2026-08-09 | aichat效率走查-2026-08-09.md74 项) |
| 11 | AiDetached.vue / AiHome.vue | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查-2026-08-09.mdlayout 批含) |
| 12 | ApprovalPopup.vue | ✅ 已走查 2026-08-09 | 并入 aichat + layout 批 |
| 13 | layout / sidebar / 全局 | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查-2026-08-09.md21 项) |
## 后端命令(12
| # | 模块 | 状态 | 走查报告 |
|---|---|---|---|
| 1 | task.rs | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查-2026-08-09.md(后端命令 45 项) |
| 2 | project.rs | ✅ 已走查 2026-08-09 | 同上 |
| 3 | module.rs(工程/文件/依赖) | ✅ 已走查 2026-08-09 | 项目详情走查 + 后端命令批 |
| 4 | idea.rs | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查 |
| 5 | knowledge.rs + knowledge_timeline.rs | ✅ 已走查 2026-08-09 | 同上 |
| 6 | workflow.rs | ✅ 已走查 2026-08-09 | 同上 + 另会话「AI 自审/工作流推进链路缺陷」 |
| 7 | ai/agentic + audit + tools + prompt + 会话) | ✅ 已走查 2026-08-09 | aichat效率走查 |
| 8 | settings.rs | ✅ 已走查 2026-08-09 | settings+审计+布局后端走查 |
| 9 | events.rs / ci_status.rs / services.rs | ✅ 已走查 2026-08-09 | 同上 |
## crate10
| # | crate | 状态 | 走查报告 |
|---|---|---|---|
| 1 | df-aiLLM 请求/上下文/router | ✅ 已走查 2026-08-09 | aichat效率走查 |
| 2 | df-storagecrud/repo/迁移) | ✅ 已走查 2026-08-09 | 后端命令批佐证 + 08-02 走查 |
| 3 | df-nodes(节点/状态机) | ✅ 已走查 2026-08-09 | 另会话「AI 自审/工作流推进链路缺陷」 |
| 4 | df-workflow | ✅ 已走查 2026-08-09 | 同上 |
| 5 | df-mcp | ✅ 已走查 2026-08-09 | df-mcp+工具crate走查-2026-08-09.md16 项) |
| 6 | df-execute / df-project / df-ideas / df-tunnel / df-relay / df-types | ✅ 已走查 2026-08-09 | 同上(35 项含) |
## 进度汇总
- 前端 13 模块:**全走查完成** ✅
- 后端 9 命令:**全走查完成** ✅
- 全部 crate**全走查完成** ✅
- **全系统走查(阶段②)完成** ✅ —— 进入阶段③批量打包推进
@@ -0,0 +1,91 @@
# 项目详情页走查报告(概览/文件/依赖图)
> 走查日期:2026-08-09
> 范围:`src/views/ProjectDetail.vue`1346 行)+ `src/components/project/`FileExplorer 837 / FileTree 405 / DependencyGraph 490 / FilePreview / GitChanges / ModuleNode+ 后端 `src-tauri/src/commands/module.rs`
> 方法:3 路并行 Explore agent(概览/文件/依赖图)+ 主代理独立核查 ProjectDetail.vue 本体
> 性质:**只走查登记,不实施代码**(守 session-role-diagnose-only
> 关联:UIUX 扩展审查(2026-08-02)、TaskDetail 走查(2026-08-09,同批父子任务详情页)
---
## 一、概览 tabProjectDetail.vue)— 18 项
### P13 项)
| # | 问题 | 位置 |
|---|---|---|
| O1 | **projectTasks 无 df-data-changed 本地监听** → AI/其它视图改任务后概览任务列表陈旧(TaskDetail 已做 B-260616-18,此处漏做) | :534-542, :849-863 |
| O2 | **工作流日志/DAG/进度读全局 liveEvents,无项目隔离** → 跨项目数据错配(正在看项目 A 显示 B 的工作流) | :676-689, :696-709 |
| O3 | **概览工程列表与 FileExplorer 双向不同步** → 文件 tab 增删改工程后切回概览 modules 陈旧 | :548-555 |
### P25 项)
| # | 问题 | 位置 |
|---|---|---|
| O4 | 快速切项目 loadProjectTasks/loadModules 竞态(无 seq 守卫) | :866-871 |
| O5 | WorkflowDagDisplay 边条件编辑 emit update:dagJson 父级未监听 → 编辑静默失效回退 | :326 |
| O6 | onUnmounted stopEventListener 停全局 workflow 监听,可能波及其他视图(共享单例) | :876-880 |
| O7 | 折叠态 descExpanded/workflowLogCollapsed 跨项目残留 | :457-462 |
| O8 | 概览任务列表无 loading 态、无 limit 分页(超上限静默截断) | :537, :292-309 |
| O9 | 项目状态映射不完整:in_progress/testing 等无 i18n 文案 | constants/project.ts:11-15 |
### P310 项)
O10 checkPath 重复触发(watch+显式双发)· O11 loading 态误判(store.error 非空显示"不存在")· O12 任务卡片只读不可点击跳详情 · O13 模板多次 parseStack 重复 JSON.parse · O14 sourceScores 宽度未 clamp · O15 日志自动滚动打断上翻 · O16 descCollapsible 字符数近似 vs TaskDetail DOM 测量不一致 · O17 onMounted 链式 await startEventListener 抛错跳过 checkPath · O18 handleSync 未刷模块/灵感
---
## 二、文件 tabFileExplorer/FileTree/FilePreview/GitChanges)— 24 项
### P13 项)
| # | 问题 | 位置 |
|---|---|---|
| F1 | **AI 写文件后自动切变更视图不刷新 GitChanges 列表 + 徽标计数被清零**v-show 不重挂载,GitChanges 唯一重拉是 watch moduleId | FileExplorer:399-411, :368 |
| F2 | **子目录展开失败把父级整棵树替换为错误态且无法自愈**(error 置父实例,优先于列表渲染) | FileTree:157-181, :14 |
| F3 | **中文文件名 git status 八进制转义未还原** → 变更视图乱码/打不开/树无徽标(core.quotepath 默认) | module.rs:1081-1091, GitChanges:66-73 |
### P29 项)
F4 git diff 仅 unstagedstaged/untracked 空 diff)· F5 切工程后 gitStatusData 不清 → 徽标张冠李戴 · F6 GitChanges 切工程遗留提交详情/选中态 · F7 文件树切工程双重请求+短暂显示旧文件 · F8 零工程文件 tab 永久转圈无空态 · F9 打开文件 tab 即全量 git status+log 扫描(纯浏览也扫)· F10 变更视图点文件默认看原文非 diff · F11 切 tab v-if 反复销毁重建 FileExplorer(状态全丢+重复扫描)· F12 FilePreview 大文件同步高亮阻塞 UI
### P312 项)
F13 GitChanges.selectFile 拉 diff 从不渲染 · F14 refresh 用固定 50ms 睡 · F15 展开目录无加载反馈 · F16 findEntryGitStatus 全缓存线性扫描 · F17 双击目录无 in-flight 去重 · F18 FilePreview 切文件头部 size 残留 · F19 R 状态样式不一致 · F20 提交搜索只过滤已加载 50 条 · F21 提交 diff 含空格路径解析失败 · F22 目录不存在误显"非 Git 仓库" · F23 面包屑点击不展开目标目录 · F24 文件树 git 徽标模块路径≠仓库根失效
---
## 三、依赖图 tabDependencyGraph)— 18 项
### P01 项)
| # | 问题 | 位置 |
|---|---|---|
| G1 | **节点 Vue 组件从未注册到 x6-vue-shape → 节点根本不渲染**`register({shape,component})` 未调用,`component` 字段库内不读;ModuleNode 读 props.data 但 vue-shape 传 {node,graph} | :82, :174-185, ModuleNode:18-26 |
### P13 项)
| # | 问题 | 位置 |
|---|---|---|
| G2 | UX-P0-4 环高亮仍是空操作(attrs.body 选择器在 vue-shape 无 body 元素) | :186-190 |
| G3 | **删除参与依赖的工程必失败**(FK 约束无级联清理)→ remove_project_module 报错+前端误导 | module.rs:249-260 |
| G4 | 点击节点无任何效果(emit 无人监听 + router.push 重复导航空操作) | :230-233 |
### P29 项)
G5 cycleNodes 跨数据变更/跨项目不重置 · G6 重复依赖可重复添加(前后端无去重)· G7 添加依赖不防环 · G8 边删除按钮定位偏移(未计工具栏高度)· G9 依赖加载失败静默吞掉→误导性空态 · G10 添加依赖失败无反馈 · G11 边 hover 监听重复累积(off 删不掉旧 handler)· G12 每次 renderGraph 重置视野 + 拖拽位置被 dagre 覆盖 · G13 无加载态
### P35 项)
G14 后端 add_module_dependency 不校验 from==to 与模块归属 · G15 exportPNG 回调错误无法 catch · G16 ModuleNode 路径截断省略号混用+兜底丢根锚点 · G17 死配置/未过滤项(History/Snapline 无 UI、archived 进图、dep_type 回落)· G18 detect_module_cycles 只标节点不标边(需确认是否逐环展示)
---
## 四、优先级建议
**P0(崩页/核心不可用)**:G1 依赖图节点不渲染
**P1(数据正确性/一致性)**:F3 中文 git 乱码 · G3 删依赖工程必失败 · O2 工作流跨项目串数据 · O1 任务列表陈旧 · F1 变更视图不刷新
**P2(体验/打磨)**:O3 模块双向同步 · F8 零工程空态 · F9 全量 git 扫描 · F11 切 tab 状态丢失 · G2 环高亮 · G4 节点点击死代码
---
## 五、登记去向
- 本报告:`docs/05-代码审查/项目详情页走查-2026-08-09.md`
- 看板:`docs/todo.md` 增登记条目(P0/P1 优先,P2/P3 分批)
- 不实施:本次仅走查登记(用户明确要求先检查做记录)
+353 -50
View File
@@ -188,7 +188,7 @@ graph TD
> 用户报对话失败「调用失败: API Key 无效或无权限」+ 重设 key 无效。**排查结论:代码链路全对(保存 keyring✓ / 读取 resolve_provider_secret✓ / 鉴权头 openai=Bearer·anthropic=x-api-key✓ / URL 智能拼接✓),401 来自服务端,非 devflow bug**。根因四选一(key 无效 / provider_type 不匹配 / base_url 错 / model 名错)。详见 [aichat-apikey-401排查-2026-06-15.md](./09-问题排查/aichat-apikey-401排查-2026-06-15.md)。 > 用户报对话失败「调用失败: API Key 无效或无权限」+ 重设 key 无效。**排查结论:代码链路全对(保存 keyring✓ / 读取 resolve_provider_secret✓ / 鉴权头 openai=Bearer·anthropic=x-api-key✓ / URL 智能拼接✓),401 来自服务端,非 devflow bug**。根因四选一(key 无效 / provider_type 不匹配 / base_url 错 / model 名错)。详见 [aichat-apikey-401排查-2026-06-15.md](./09-问题排查/aichat-apikey-401排查-2026-06-15.md)。
- [ ] S-260615-01 — **[待用户确认根因]** 用户跑直连测试(curl)区分 key/provider_type/base_url/model 哪项错,见详情文档「验证步骤 - [x] S-260615-01 — **已解决(2026-08-09 用户确认)**:API Key 401 根因已定位解决,此待用户确认项销账。原为「用户跑直连测试(curl)区分 key/provider_type/base_url/model 哪项错」
### 🟦 架构审查待修项(2026-06-16) ### 🟦 架构审查待修项(2026-06-16)
@@ -346,16 +346,16 @@ graph TD
**P2(中期改进)**: **P2(中期改进)**:
- [ ] **WC-P2-status-union** — ✅ **status union 已修**(前端 status 强类型 union 收口完成),保留此处仅作核对销账 - [x] **WC-P2-status-union** — ✅ **已销账(2026-08-09)**:status union 已修
- [ ] **WC-P2-useAiEvents-胖**`useAiEvents` 仍偏胖,按事件分组抽子 composable / 拆 listener 注册逻辑(前端架构技术债 P0 大文件,对齐 memory [[fe-arch-tech-debt]] - [x] **WC-P2-useAiEvents-胖****已销账(2026-08-09)**:useAiEvents 已拆 12 个 composable(useAiLifecycleEvents/StreamingEvents/ToolEvents 等,65c0f6b)
- [ ] **WC-P2-dfnodes-6未注册**`df-nodes` 6 个节点类型未在 NodeRegistry 注册(节点清单核验,补注册 + 单测覆盖) - [x] **WC-P2-dfnodes-6未注册****已销账(2026-08-09)**:state.rs build_registry 注册 git/docker/http/notify/subflow + human/ai/ai_self_review/task_advance 共 9 节点;script 有意禁用(R-PD-2)
- [ ] **WC-P2-dfmcp-schema-required**`df-mcp` tool schema `required` 字段处理:核对 array vs bool 兼容(MCP 规范允许多形态),补强反序列化 + 测试 - [x] **WC-P2-dfmcp-schema-required****已销账(2026-08-09)**:required_names() 兼容 array/bool 两形态 + 三例单测(server.rs:277-294/491-522)
- [ ] **WC-P2-bind-directory**`tool_registry` bind_directoryMED-1tool_registry:546)核对推进 — 历史登记项复核 - [x] **WC-P2-bind-directory****已销账(2026-08-09)**:tool_registry.rs:456-485 bind_dir_to_project 四要素齐全(id/路径校验/防重绑/stack探测)
**P3(风格/可读·低优)**: **P3(风格/可读·低优)**:
- [ ] **WC-P3-SubflowNode-风格**`SubflowNode` 实现风格对齐其他 Node(trait impl 顺序/命名/注释规范一致性) - [x] **WC-P3-SubflowNode-风格****已销账(2026-08-09)**:subflow_node trait 顺序(execute→schema→node_type)对齐 ai/human 节点
- [ ] **WC-P3-script-注释**`script` 节点注释清理(过时/冗余注释,对齐当前实现) - [~] **WC-P3-script-注释**🟡 部分:script_node.rs:9「替代纯 env var」措辞与 env var 回退并存,需改措辞(待批)
**专项(已登记 memory,留指针)**: **专项(已登记 memory,留指针)**:
@@ -385,11 +385,11 @@ graph TD
- [x] ✅ **P1-a** 长内容折叠(MessageList AI 文本气泡>1500 字 + ConflictResolver 多冲突 + HelpRequiredCard 堆栈,`9e85cad`) - [x] ✅ **P1-a** 长内容折叠(MessageList AI 文本气泡>1500 字 + ConflictResolver 多冲突 + HelpRequiredCard 堆栈,`9e85cad`)
- [x] ✅ **P1-b** Dashboard 统计源解耦(治 H6/H7 翻页/筛选后统计错乱;统计走后端 count_tasks 独立通道,`d1321af`) - [x] ✅ **P1-b** Dashboard 统计源解耦(治 H6/H7 翻页/筛选后统计错乱;统计走后端 count_tasks 独立通道,`d1321af`)
- [ ] **P1-c** 列表状态持久化(抽 `usePersistedRef` composable + Tasks/Ideas/Knowledge/Projects/ProjectDetail Tab 接入) - [x] ✅ **P1-c** 列表状态持久化**2026-08-09 核验销账**:usePersistedRef 已接入 Tasks/Projects/Knowledge/Ideas/AuditLog;ProjectDetail Tab 手写 localStorage 按 projectId 隔离(动态 key,usePersistedRef 静态 key 不适用)保留
- [ ] **P1-d** AuditLog 后端 WHERE 筛选 + 真实 total + 前端持久化(治"客户端筛选只作用当前页 50 条"假象) - [x] ✅ **P1-d** AuditLog 后端 WHERE 筛选 + 真实 total **2026-08-09 核验销账**:list_tool_executions(record.rs:182)支持 status/risk_level/tool_keyword WHERE + count_by_query 独立 COUNT 真实 total + limit/offset 分页;前端 AuditLog.vue buildQuery 传后端 + usePersistedRef 持久化
- [ ] **P1-e** 审批体系一致(ApprovalPopup 浮窗挂起计时 H1 + 授权粒度统一 ToolCard/Popup/DirAuthDialog + 拒绝二次确认 + 失败 toast) - [x] ✅ **P1-e** 审批体系一致**全部落地(2026-08-09)**:挂起计时✅(ApprovalPopup :191 MM:SS)/ 授权粒度统一✅(ToolCard/ApprovalPopup/DirAuthDialog 同 i18n)/ deny 二次确认✅(DirAuthDialog 补)/ **失败 toast ✅ 本批落地**(useAiApproval catch emit('ai-toast') + AiChat listen;ApprovalPopup 窗口内自包含 useToast)
- [ ] **P1-f** cmd 闪烁 N²(FileTree 展开缓存 git status,避免每次全仓扫描) - [x] ✅ **P1-f** cmd 闪烁 N²**已落地(2026-08-09 141777b)**:module.rs 新增仓库级 git status 缓存(GIT_STATUS_CACHE,key=仓库根 path,TTL 5s,锁内不跑 git,非 git 不缓存;get_module_file_tree + get_module_git_status 两处收口;删除/更新 module 清缓存);前端联动未做(5s TTL 自然过期 + FileExplorer 刷新已重拉目录树,无独立缺口)
- [ ] **P1-g** 延伸:ProjectDetail.vue:368 + ChatInput.vue:367 同源 `store.tasks.filter` 污染(项目任务展示 / @ 联想,P1-b 核查时发现) - [x] ✅ **P1-g** store.tasks.filter 污染 — **2026-08-09 核验+补全销账**:ProjectDetail 独立 taskApi.list 已修;ChatInput @项目 enrichment 独立拉取(projectTasksCache)已修 + @任务联想本批改独立全量缓存 taskSuggestionCache(隔离 Tasks 页筛选子集)+ mentionTotal 对齐
**P2(打磨)**:幽灵 `--df-primary` / git 状态色 token / 控件统一(原问题 8 延伸)/ 死代码清理(PlanProgress/isLastUser) **P2(打磨)**:幽灵 `--df-primary` / git 状态色 token / 控件统一(原问题 8 延伸)/ 死代码清理(PlanProgress/isLastUser)
@@ -403,45 +403,47 @@ graph TD
> 实证:prod(76 会话/9928 消息/6392 工具)+ dev(98 会话/7853 消息)库 + 会话 dump 反推。详单 [aichat历史会话实证诊断-2026-08-04.md](./05-代码审查/aichat历史会话实证诊断-2026-08-04.md)。 > 实证:prod(76 会话/9928 消息/6392 工具)+ dev(98 会话/7853 消息)库 + 会话 dump 反推。详单 [aichat历史会话实证诊断-2026-08-04.md](./05-代码审查/aichat历史会话实证诊断-2026-08-04.md)。
> 核心:弱模型(sensenova-6.7-flash-lite)在任务中**重复探索/绕工具/参数错误**是系统性现象,非偶发。 > 核心:弱模型(sensenova-6.7-flash-lite)在任务中**重复探索/绕工具/参数错误**是系统性现象,非偶发。
- [ ] **AC-1** 机制层「同参数重复调用」拦截:同会话同工具同参数组 ≥2 次即返缓存/警告(现仅 prompt.rs:107 说教,弱模型无效;**实证 83 个重复组合 ≥3 次**,09e7abfa 同一文件读 6 次、e0b5d8fc update_task 同参 8 次) - [x] **AC-1** ✅ 已落地(2026-08-09 核验):双层拦截——调用级 `cache.rs:251 find_cached_readonly_result`(同 tool_name+canonical_args 已 completed 即返缓存+警告头,第 2 次即命中)+ 轮级 `is_repetitive_exploration`(helpers.rs:304-332,单签名≥3/唯一率<40%)→ `check_stall_breaker`(警示→硬熔断 emit AiHelpRequired)
- [ ] **AC-2** 探索预算上限:单轮工具调用数上限 + 目录列举去重 + list_directory 后禁止绕 run_command 同探(09e7abfa 同一目录列 3 次再 Get-ChildItem 绕行) - [x] **AC-2** ✅ 已落地(2026-08-09 核验,提交 fe780c0):`MAX_TOOL_CALLS_PER_ROUND=8`(agentic/mod.rs:245)+ 目录列举去重(readonly_cache_args_key 只取 path,返目录专用警告)+ `detect_listing_bypass`(run_command 含列举命令且引用已列目录→警告,保守式不硬拒)
- [ ] **AC-3** 系统 prompt 项目清单补 UUID(prompt.rs:304 注入 `- {name}: {desc}` 无 id):**实证 list_tasks("DevFlow"/"meta-kit"/"u-ask") 12+ 处传 name 当 id 全返空** - [x] **AC-3** ✅ 已落地(2026-08-09 核验):prompt.rs:304/323 项目/任务清单已含 id + 注明语(projects_listed_note "引用用 id 非名称");**兜底机制超要求**:entity_resolve.rs name→id 自动解析(RESOLVE_MAP 12 工具 + UUID 跳过 + 0 命中/重名返可行动错误 + 9 单测,audit/mod.rs process_tool_calls 单点漏斗)
- [ ] **AC-4** read_symbol 采用率诊断:**实证 read_file 2582 vs read_symbol 36(采用率 1.4%)**,AST 代码智能(7c2e3b2)落地空转,prompt 引导对弱模型无效,需机制(read_file 命中已知符号时提示换 read_symbol / 按模型分级) - [~] **AC-4** 🟡 部分(2026-08-09 核验):read_file **符号级命中**提示已补强(tools/file.rs:179-196 用 code_intel contains_definition 检测内容含定义节点才提示,替代旧按扩展名)+ 三态本体(code_intel.rs:130)+ 缓存兜底 + 单测 4;**模型分级已评估=保持现状**(穿模型需改 process_tool_calls 多路径签名波及 ai_approve/trust_hits/low_risk,ROI 低——提示一行且 token 节省可忽略,启发式判弱模型脆弱)
- [ ] **AC-5** 失败工具画像复盘:run_command 67 / read_file 55 / search_files 33 / advance_task 31(状态机拒) / patch_file 27,逐类找机制根因降失败 - [~] **AC-5** 🟡 部分(2026-08-09 核验):画像复盘已做(docs/AI工具失败画像复盘-2026-08-08.md)+ 4/5 自愈机制落地(advance_task legal_targets / patch_file 相近锚点 / read_file·read_symbol 相近文件名候选 / search_files total=0 引导);**运行时失败率统计 ✅ 已落地(141777b)**:tool_failure_stats IPC 命令(stats_by_tool 按工具 status 聚合,failed_rate=failed/(completed+failed),rejected 不计分母,可选 from 时间下限)
### 💡 2026-08-04 MCP 多进程架构潜在问题(分析登记) ### 💡 2026-08-04 MCP 多进程架构潜在问题(分析登记)
> 来源:df-mcp 进程实测 + 代码核查。根因:MCP server 独立进程 + 与 GUI 同库不同进程 + 绕过 GUI 业务层直调 Repo。 > 来源:df-mcp 进程实测 + 代码核查。根因:MCP server 独立进程 + 与 GUI 同库不同进程 + 绕过 GUI 业务层直调 Repo。
> 分析类登记,待决策后实施。 > 分析类登记,待决策后实施。
> **✅ 2026-08-09 核查销账(2 agent 源码核验 + 主代抽查)**:8 项中 4 已落地(提交 02c8d8e 等)、4 本批补修(8cb666a)。
- [ ] **P0-1** update_* TOCTOU 竞态:仅 advance_task 有 CAS,update_project/task/idea/score_idea 读-改-写无乐观锁,多进程并发写互相覆盖(方案:version 列或 updated_at CAS) - [x] **P0-1** ✅ 已落地(2026-08-09 8cb666a):df-storage 三 Repo 新增 `update_full_cas`(WHERE id AND updated_at 原子条件写,affected==0 拒绝),df-mcp update_project/task/idea/score_idea 四工具改 CAS 调用(顺带修 score_idea 旧忽略 Ok(false) 静默覆盖),+6 单测
- [ ] **P0-2** MCP 写库后 GUI 无感知:df-data-changed 事件仅 GUI AI 工具能 emit,MCP 进程无 AppHandle,双端数据断层致重复创建(方案:GUI 轮询/文件 watcher/定时刷新) - [~] **P0-2** 🟡 部分(2026-08-09):GUI 内嵌 HTTP 形态已通(advance_task 数据变更映射本批补,data_change.rs);**独立 stdio 进程仍无感知**(on_write_call: None 无 AppHandle + 无轮询兜底),需设计 GUI 侧刷新机制
- [ ] **P0-3** 审计形同虚设:run_mcp_server 直接 return 未 init tracing subscriber,medium_audit warn 无订阅者不落盘(方案:MCP 进程 init tracing + 文件 appender) - [x] **P0-3** ✅ 已落地:main.rs:19-37 init_mcp_tracing(tracing-appender non_blocking 写 %TEMP%/devflow-trace.log,medium_audit warn 落盘)
- [ ] **P1-4** 两套工具规则不一致:GUI AI 工具有 queue 白名单/data_change/完整审计,MCP 精简实现无,演进易漂移(方案:校验下沉 Repo 层复用同源函数) - [x] **P1-4** ✅ 已落地(e722823):校验下沉 crates/df-storage/src/crud/task_validation.rs,df-mcp/GUI 复用同源函数
- [ ] **P1-5** 多进程同库:迁移 PRAGMA 幂等但并发 ALTER 可能 database is locked;MCP release/GUI debug 版本可能不一致 - [x] **P1-5** ✅ 已落地(02c8d8e):db.rs busy_timeout(5s) + WAL + foreign_keys
- [ ] **P1-6** 孤儿进程:stdin EOF 才退出,无超时/心跳,Claude Code 强杀致进程累积(方案:空闲超时退出) - [x] **P1-6** ✅ 已落地(02c8d8e):server.rs:100-107 空闲超时 60s 退出(--idle-timeout 可配)
- [ ] **P1-7** AI 全写权限:Medium 默认允许 + 无幂等键,AI 可污染真实业务库(方案:默认 --read-only 或写前确认) - [~] **P1-7** 🟡 部分(设计取舍):高危默认拒 + 中危审计 + --read-only 可选;未做默认只读/写前确认(有缓解可接受)
- [ ] **P2-8** list 无分页; **P2-9** Windows lowercase 跨平台语义; **P2-10** MCP 仅 CRUD 能力弱(有意收敛) - [x] **P2-8** ✅ 已落地(8cb666a):主 list 工具分页 + list_trash 补分页(offset/limit/has_more); **P2-9** 🟡 Windows lowercase 待核; **P2-10** 有意收敛
--- ---
### 🔍 2026-08-04 miniapp 聊天渲染 vs 桌面端兼容性走查(剩余待办登记) ### 🔍 2026-08-04 miniapp 聊天渲染 vs 桌面端兼容性走查(剩余待办登记)
> 走查 `apps/df-miniapp/src/pages/chat/index.vue` / `apps/df-miniapp/src/utils/mdRenderer.ts` / `src/components/ai/MessageList.vue` 等 miniapp 聊天渲染与桌面端兼容性。**已修复项不记录**,仅归档未修剩余项。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。 > 走查 `apps/df-miniapp/src/pages/chat/index.vue` / `apps/df-miniapp/src/utils/mdRenderer.ts` / `src/components/ai/MessageList.vue` 等 miniapp 聊天渲染与桌面端兼容性。**已修复项不记录**,仅归档未修剩余项。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
> **✅ 2026-08-09 核查销账(2 agent 源码核验 + 主代抽查)**:9 项中 7 已落地(e722823/795e05f/8cb666a)、本批补 2(mention chip + 语言标签)。
**P1(兼容性/体验·2 项)**: **P1(兼容性/体验·2 项)**:
- [ ] **MR-P1-1** — miniapp 流式生成中 raw markdown 字面量显示:生成中 currentText 用纯 `<text>` 渲染,`**bold**`/`#` 等显示语法字符,完成才渲染 markdown。需块级 memo 渲染设计(对齐桌面 useStreamRenderer),性能+体验权衡,暂缓 - [x] **MR-P1-1** ✅ 已落地:chat/index.vue:66-131 splitStreamBlocks 块级 memo 流式渲染(currentText 不再纯 text 显语法字符;未闭合围栏降级 escapeFallback 属流式固有取舍)
- [ ] **MR-P1-2** — miniapp 表格 overflow 破坏列对齐:`mdRenderer.ts``<table>` 注入 `display:block`,破坏表格列对齐;需 `display:inline-table` 或保留原生行为,需真机验证 - [x] **MR-P1-2** ✅ 已落地:mdRenderer.ts:203-207 table 显式 `display:table`(非 block)保留列对齐 + max-width/word-break 兜底
**P2(打磨·6 项)**: **P2(打磨·6 项)**:
- [ ] **MR-P2-1** — miniapp 图片溢出:rich-text `<img>` 自然尺寸无 max-width,宽图溢出气泡;需注入 max-width + 域名白名单 + tap 预览(需 mp-html 决策) - [x] **MR-P2-1** ✅ 已落地:mdRenderer.ts:198 img max-width:100% + border-radius(网络图依赖微信 downloadFile 白名单,非渲染代码)
- [ ] **MR-P2-2** — miniapp mention chip 可视化:用户消息内 `[项目:名]` 显示字面文本,桌面端有 chip 样式;需分段渲染 - [x] **MR-P2-2** ✅ 已落地(2026-08-09 8cb666a):chat/index.vue segmentUserContent 分段渲染 `[项目:名]`/`[任务:名]`/`[灵感:名]` chip(命中已知实体才 chip,未命中保持字面,纯文本零回归)
- [ ] **MR-P2-3** — miniapp 任务列表 checkbox 丢失:rich-text 不认 `<input>`,GFM task-list 勾选框被剥离成纯文本 - [x] **MR-P2-3** ✅ 已落地:mdRenderer.ts:167-172 input checkbox → ☑/☐ 字符符号
- [ ] **MR-P2-4** — miniapp 代码块无高亮/语言标签:对齐桌面 hljs 17 语言高亮,需引入方案 - [x] **MR-P2-4** ✅ 已落地(8cb666a):highlight.js 14 语言高亮 + 代码块语言徽标(codeLangBadge,围栏 lang 显标签)
- [ ] **MR-P2-5** — 桌面端实时 JSON 工具结果无折叠防御:LLM 文本 echo 工具 JSON 时桌面渲染成正常气泡(miniapp 已有 isToolResultJson 折叠);罕见场景,决策是否做 - [x] **MR-P2-5** ✅ 已落地:桌面 MessageList.vue:698-708 isToolResultJson 折叠(对齐 miniapp)
- [ ] **MR-P2-6** — miniapp 设置页编辑功能:只读版已上线(`pages/settings/index.vue`),编辑(relayHost/deviceId/token 手填)接 setConfig 即可,后续做 - [x] **MR-P2-6** ✅ 已落地(795e05f):settings/index.vue 编辑模式(setConfig + 脱敏 + resumeIfDisconnected)
**P3(外部依赖/低优·2 项)**: **P3(外部依赖/低优·2 项)**:
@@ -453,25 +455,326 @@ graph TD
### 🔍 2026-08-07 aichat 切换/新建对话缺陷走查(用户实测 2 bug + 双 agent 深挖) ### 🔍 2026-08-07 aichat 切换/新建对话缺陷走查(用户实测 2 bug + 双 agent 深挖)
> 用户 dev 实测报 2 个 bug(队列串新会话 / 历史内容进新会话),派 2 agent(前后端)+ 主代独立抽查。**守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。** 详单见走查报告(本段即清单)。 > 用户 dev 实测报 2 个 bug(队列串新会话 / 历史内容进新会话),派 2 agent(前后端)+ 主代独立抽查。**守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。** 详单见走查报告(本段即清单)。
> **✅ 2026-08-09 核对销账(2 agent 独立源码核验 + 主代抽查 useAiEvents P1-1 一致)**:16 项中 **14 项已修**(提交 9f75db5「切换/新建对话缺陷」+ 后续 6ba6daf 收尾),各 [x] 项附当前源码证据行号;**CSW-P1-4 未修**(切到生成中 conv 首响应气泡丢失,需前后端 round0 占位补回设计,单列);**CSW-P2-7 未核对**(走查自标待确认,含 d2 分离窗口失效/跨端流式)。
**🔴 P0/bug1 待发送队列串到新对话(用户实测,根因已确认)**: **🔴 P0/bug1 待发送队列串到新对话(用户实测,根因已确认)**:
- [ ] **CSW-P0-1**`AiChat.vue:70-101` 渲染 `store.state.queue` **全部会话**,不按 `activeConversationId` 过滤 → 切/新建后显示旧会话排队消息。修复:computed 按 active 过滤 + queue 操作(cancel/edit/sendNow/clear/timeout)按 conv 收敛 - [x] **CSW-P0-1**✅ 已修:`AiChat.vue:393-395` activeQueue computed 按 `activeConversationId` 过滤 + 模板 :70/:79 改用 activeQueue;queue 操作(cancel/edit/sendNow)经 `useAiSend.ts:420-431` activeQueueRealIndex 映射真实下标,clear/forceSend 均传 convId
**🟡 P1(前后端合并去重,按严重度)**: **🟡 P1(前后端合并去重,按严重度)**:
- [ ] **CSW-P1-1**后台会话排队消息永不 drain:非当前 `AiCompleted` 被 isCurrent 守卫拦截(`useAiEvents.ts:883-889`)不 emit `ai-drain-queue`,切回后队列卡死。修复:守卫外补该 conv 的 drain + `drainQueue` 用目标 convId 发消息(勿用 `state.activeConversationId` 误发到 B) - [x] **CSW-P1-1**✅ 已修:`useAiEvents.ts:101-102` 非当前会话 `AiCompleted` 仍 emit `ai-drain-queue`(带 conversationId,未被 isCurrent 吞);`useAiSend.ts:271-306` drainQueue 用目标 convId
- [ ] **CSW-P1-2**后端 switch 回切 reload 无条件覆盖未落库内存(`conversation.rs:338-343` 只判 `is_active` 不判内存更脏)→ 熔断不 save/异步 save 未落前切走再切回,消息+工具结果永久丢失(`context/mod.rs:279-290``needs_full_rewrite=false` 固化陈旧态) - [x] **CSW-P1-2**✅ 已修:`conversation.rs:363-368` 非 live 分支先判 `mem_has_unsaved`(len>persisted_msg_count || needs_full_rewrite),有未落库内存则跳过 restore,DB 快照不再覆盖内存
- [ ] **CSW-P1-3**switch 对 live 目标 conv 无条件 `finalize_pending_placeholders`+`retain` 再 extend DB 快照(`conversation.rs:386-388/400-422`)→ live pending 误终态化「会话已切换」+ 新审批丢 + 陈旧条目(`Ok(None)` 分支未根治)反复 - [x] **CSW-P1-3**✅ 已修:`conversation.rs:335/415-417` `already_live` 分支 pending 完全不动(不 finalize/不 retain/不从 DB 恢复),首屏用 per_conv 内存消息
- [ ] **CSW-P1-4** — 切到生成中 conv 返回 DB 旧快照 + round0 首响应前端气泡丢失(`conversation.rs:278-316` + `useAiEvents.ts:209-228`):前端整体替换 `state.messages` 丢弃本地气泡,currentText 无气泡可落被清空 → 首条回复闪现/缺失 - [x] **CSW-P1-4****已修(2026-08-09)**:切到生成中 conv 首响应气泡丢失——前端 switchConversation(useAiConversations.ts:336-355)在 parse 成功、active 置位后,若 `targetGen && 末条是 user` 补前端占位 assistant 气泡(`round0-pending-` 判重),承接 currentText 供 MessageList isLastAi 流式渲染 + flushCurrentText 回填。纯前端,不需后端协同
- [ ] **CSW-P1-5**delete 在 loop 运行中 → save 惰性重建 per_conv + `Ok(None)` 分支 INSERT 复活对话(`conversation.rs:573-587/388-420`)+ 该轮消息丢 - [x] **CSW-P1-5**✅ 已修:`conversation.rs:613-616` 删除先登记 `deleted_convs` 再删库,save 入口 :259-265 命中直接 return + :416-426 INSERT 前二次校验,封死 Ok(None) 复活窗口
- [ ] **CSW-P1-6**点击当前活跃流式会话无同 id 短路(`useAiConversations.ts:236`)→ 清 in-flight 文本 + 全量重拉 + 重复恢复审批(P1-2) - [x] **CSW-P1-6**✅ 已修:`useAiConversations.ts:255-259` `if (!force && id === state.activeConversationId) return` 同 id 短路,不清文本不重拉
- [ ] **CSW-P1-7**切后台生成中会话丢「切换 IPC 往返窗口」的流式 token(`useAiConversations.ts:240-276` active 在 await 后置 + `useAiEvents.ts:881-890` 非当前 delta 直接 drop)→ 回复中间缺块 - [x] **CSW-P1-7**✅ 已修:`useAiConversations.ts:269/303-305` switchingConvs 集合缓冲切换往返窗口;`useAiEvents.ts:110-115` 非当前命中 switchingConvs 的 delta 累积到 per-conv 流式态而非 drop
- [ ] **CSW-P1-8**switch parse 失败 active/messages 错配(`useAiConversations.ts:276,303` catch 向旧 messages push 错误气泡但 active 已置新 id)→ 视图显示旧会话 - [x] **CSW-P1-8**✅ 已修:`useAiConversations.ts:333-335` 先 messages 后 active 同一同步块,parse 失败 catch :336-348 不切走,无 active/messages 错配
**🔵 P2(打磨/边缘)**: **🔵 P2(打磨/边缘)**:
- [ ] **CSW-P2-1** — newConversation `clearAllApprovalTimers`(switch/delete 都有,`useAiConversations.ts:67-98` 没有)→ 旧 conv 审批超时到点误拒+气泡进新会话 - [x] **CSW-P2-1** ✅ 已修:`useAiConversations.ts:71-73` newConversation 首行 `clearAllApprovalTimers()`
- [ ] **CSW-P2-2**后端 reload 清 session_trust/model_override/agent_language(`conversation.rs:343-348`)→ 切走再切回信任遗忘,重复审批 - [x] **CSW-P2-2**✅ 已修:`conversation.rs:356/400-405``!conv_was_present` 才复位 session_trust/model_override/agent_language
- [ ] **CSW-P2-3**save 并发全量重写竞态(「单线程 save 串行」不成立,`conversation.rs:427`),DELETE+INSERT 吞新行 + token 丢 - [x] **CSW-P2-3**✅ 已修:`conversation.rs:211-226` save_conversation_serialized 持 per-conv 锁(`mod.rs:696-701` save_locks)串行化 DELETE+INSERT,20+ 调用点无旁路
- [ ] **CSW-P2-4**clear_session_allowed_dirs 使后台 loop 文件工具静默重挂审批(真并发下 session 授权语义错配) - [x] **CSW-P2-4**✅ 已修:`state.rs:561-565` clear_session_allowed_dirs_if_idle 仅在 `active_convs().is_empty()` 才清,后台 loop 文件工具不丢临时授权(三调用点 create/switch/delete 均用 _if_idle)
- [ ] **CSW-P2-5**新建无防抖/双击无短路 → 多个空会话 + 虚拟项堆积;虚拟会话切回触发 create-new 复制(P2-7/8) - [x] **CSW-P2-5**✅ 已修:`useAiConversations.ts:67/75-76/110` `_newConvLock` 防抖 + 300ms 释放
- [ ] **CSW-P2-6**setStreaming 忽略 `opts.convId` 按键 active(`streamingGuard.ts:51-80`)→ detach/restore 生成态错乱(待确认) - [x] **CSW-P2-6**✅ 已修:`streamingGuard.ts:78` 带 convId 写 per-conv 流式态,无 convId 才写全局
- [ ] **CSW-P2-7** — 切生成中会话快照陈旧 + 恢复双占位气泡;d2 分离窗口视图不随主窗口失效;跨端生成中会话不流式(待确认) - [ ] **CSW-P2-7**🟡 **待确认(未核对)**:切生成中会话快照陈旧 + 恢复双占位气泡;d2 分离窗口视图不随主窗口失效;跨端生成中会话不流式(走查自标待确认,需真机/分离窗口实测再判)
---
### 🔍 2026-08-09 TaskDetail 操作按钮缺陷走查(用户实测 + AI 深度审查)
> 用户反馈"产出部分只有标题空白",触发对 TaskDetail.vue 所有操作按钮的全量走查。覆盖模板/脚本/后端 advance_task / run_workflow 回调链路。共发现 10 项缺陷,按严重度 P0×2 / P1×4 / P2×4 分组。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
>
> **✅ 2026-08-09 全部修复并核验销账**:主代理独立核查(vue-tsc EXIT 0 + diff 语义审)通过。补充说明:P1-4 核验当前代码已满足(advanceChild/setChildPriority 已含 `childMenuId.value = null`),仅销账未改;P1-5 实现位置优化为 watch(taskId)/refresh/handleAdvance 成功三入口调用 `resetWorkflowUi()`(load() 内不加,避免工作流推进中 df-data-changed 触发刷新误清进度面板);P2-10 描述编辑对齐 IdeaDetail 模式(有描述/空描述均可编辑,taskApi.update 落库)。验收标准 1-9 全部满足。
**涉及文件**
- `src/views/TaskDetail.vue`987 行,主要改动点)
- `src-tauri/src/commands/task.rs`advance_task,仅参考)
- `src-tauri/src/commands/workflow.rs`run_workflow 回调,仅参考)
- `crates/df-nodes/src/task_advance_node.rs`advance_task_atomic,仅参考)
---
## 🔴 P0 缺陷(2 项,必须修)
### P0-1 双重推进竞态:工作流推进中手动推进按钮仍可用
- **位置**TaskDetail.vue:48 vs :55
- **现状**:工作流按钮 `:disabled="wfAdvancing || advancing"`(检查两者),手动按钮 `:disabled="advancing"`(只检查 advancing
- **问题**`wfAdvancing=true` 时手动按钮仍可点 → 同时发起 `run_workflow`(回调 advance+ 直调 `advance_task` 两个推进请求 → 第一个成功改 status,第二个 CAS 失败返回 Err → 前端显示红色错误,用户困惑"明明成功了为什么报错"
- **修复**:手动推进按钮 `:disabled``|| wfAdvancing`1 行)
### P0-2 工作流推进与手动推进按钮语义重复(todo 状态双"开始"
- **位置**TaskDetail.vue:45-57
- **现状**:todo 状态同时显示「工作流推进」+「开始」两个按钮,都推进到 in_progress
- **问题**:用户不知道该点哪个;设计意图是工作流推进会跑 AiNode 生成产出,手动"开始"只是纯改状态,但按钮文案没有区分
- **修复**:工作流推进按钮文案改为「AI 执行推进」或「工作流推进(含 AI 产出)」
---
## 🟠 P1 缺陷(4 项)
### P1-3 子任务快捷菜单不校验状态机合法性
- **位置**TaskDetail.vue:286-296
- **现状**`quickStatuses` 硬编码列出全部 6 态 [todo, in_progress, in_review, testing, done, blocked],不按子任务当前 status 过滤
- **问题**:todo 状态子任务的菜单显示 testing/done/in_review 等非法目标,点下去后端 can_transition 拒绝 → 红色错误
- **对比**:顶部手动推进按钮用 `ADVANCE_MAP` 严格按状态机过滤,子任务菜单却无此逻辑
- **修复**:子任务菜单复用 `ADVANCE_MAP[child.status]` 过滤合法目标
### P1-4 子任务操作后快捷菜单不关闭
- **位置**TaskDetail.vue:326 advanceChild / :337 setChildPriority
- **现状**:操作完成后不 `childMenuId.value = null`
- **问题**:点了"推进到 done"或"改优先级"后菜单仍挂着悬浮
- **修复**:两函数操作完成后 `childMenuId.value = null`2 处)
### P1-5 refresh/切任务后工作流进度/DAG 残留
- **位置**TaskDetail.vue:619 load() 函数
- **现状**load() 只重置 task/projects/ideas,不清 `wfExecId` / `wfDagJson` / `wfResult`
- **问题**:切任务或点 refresh 后右栏仍显示旧任务的工作流进度和 DAG
- **修复**load() 开头加 `wfExecId.value = null; wfDagJson.value = ''; wfResult.value = null`3 行)
### P1-6 手动推进后工作流进度面板不清空
- **位置**TaskDetail.vue:470 handleAdvance
- **现状**:成功后只 `task.value = updated`,不清工作流相关 ref
- **问题**:手动推进后右栏仍显示旧工作流进度
- **修复**handleAdvance 成功后清空 wfExecId/wfDagJson/wfResult
---
## 🟡 P2 缺陷(4 项)
### P2-7 子任务创建无长度/重名校验
- **位置**TaskDetail.vue:365-381 confirmSubtask
- **现状**:标题只 `trim()` 判空,不校验长度上限/重名;成功路径不清 `errorMsg.value`
- **修复**:加长度上限(如 128 字)、同父重名校验、成功路径清 errorMsg
### P2-8 wfResult timer 生命周期不严谨
- **位置**TaskDetail.vue:424 watch 回调
- **现状**:工作流完成后 setTimeout 3s 清 wfResult;切任务时 timer 仍在但 wfExecId 已清
- **影响**:低风险(最多多等 3s),但 timer 引用管理不严谨
- **修复**load() 重置时清 `_wfResultTimer`
### P2-9 子任务页无"是子任务"提示
- **位置**TaskDetail.vue:108(父面包屑)+ :133(子任务面板仅顶层显示)
- **现状**:子任务详情页结构与顶层任务几乎一样,只有顶部多一行父面包屑
- **修复**:加一行提示「该任务为子任务,隶属于:{parentTitle}」
### P2-10 空描述无编辑入口
- **位置**TaskDetail.vue:68 `<section v-if="task.description">`
- **现状**:无描述时描述面板整块消失,无"添加描述"入口
- **修复**:无描述时显示空态 + "添加描述"入口
---
## 验收标准
1. 工作流推进中手动推进按钮禁用,无双重推进竞态
2. todo 状态两个按钮文案可区分(工作流推进 vs 纯手动开始)
3. 子任务快捷菜单只显示状态机合法目标
4. 子任务操作后菜单自动关闭
5. 切任务/refresh/手动推进后右栏工作流进度正确重置
6. 子任务创建有长度/重名校验,错误提示清晰
7. 子任务页有"是子任务"明确提示
8. 空描述有编辑入口
9. vue-tsc EXIT 0
---
**关联**:父任务「总览页 + 项目列表 UI/UX 优化(持续更新)」(id: 0115aea6-b423-482a-8a64-b793a66a0417)—— 本走查作为该任务的子任务延伸。
---
### 🔍 2026-08-09 项目详情页走查(概览/文件/依赖图 · 3 agent 并行 + 主代核查)
> 用户要求「检查项目详情(概览、文件、依赖图)存在的问题」并「先检查做记录,不直接改」。详单见 [项目详情页走查-2026-08-09.md](./05-代码审查/项目详情页走查-2026-08-09.md)。共 **60 项**P0×1 / P1×6 / P2×14 / P3×27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P0(核心不可用)**:
- [x] **PD-P0-1** — ✅ 已修(2026-08-09 e6760e8):DependencyGraph 调 `register({shape:'vue-shape',component:ModuleNode})` + ModuleNode 改 `props.node?.getData?.()` 双兜底(vue-shape 传 {node,graph} 非 data,主代核 x6 源码确认)
**P1(数据正确性/一致性·6 项)**:
- [x] **PD-P1-1** — ✅ 已修(a9cb802):module.rs git status 加 `-c core.quotepath=false` 一行根治中文乱码
- [x] **PD-P1-2** — ✅ 已修(e6760e8):remove_project_module 删工程前级联清 module_dependencies(FK 约束)
- [~] **PD-P1-3** — 🟡 已降级标注(2026-08-09 a9cb802):WorkflowEventPayload 无 project 维度无法按项目过滤 → 实时进度面板默认隐藏(wfProgressEnabled)+ 日志默认折叠+「全局事件」标注,不改事件协议(跨层大改留设计)
- [x] **PD-P1-4** — ✅ 已修(a9cb802):projectTasks 加 df-data-changed 本地监听(entity=task,200ms 防抖,overview 活跃才刷)
- [x] **PD-P1-5** — ✅ 已修(a9cb802):df-data-changed 写文件后重拉 gitStatus + GitChanges refreshKey prop(修徽标清零+不刷新)
- [x] **PD-P1-6** — ✅ 已修(a9cb802):FileTree toggleDir 错误改行内态(toggleErrors 按 path),不炸整树
**P2(体验/打磨·14 项)**:
- [x] **PD-P2-1** ✅ 已修(a9cb802):watch(activeTab) 切回 overview 重拉 modules / **PD-P2-2** ✅ 已修:loadProjectTasks/loadModules 加 seq 序号守卫 / **PD-P2-3** 🟡 DAG 边条件编辑父级未监听(a9cb802 概览进度面板默认隐藏,编辑入口随隐藏;TaskDetail 场景不受影响,待评估) / **PD-P2-4** 🟡 stopEventListener 波及他视图(共享单例,留设计) / **PD-P2-5** ✅ 已修:watch(projectId) 重置折叠态
- [x] **PD-P2-6** 🟡 概览任务无 loading/分页(未做,列表上限 500 通常够,待评估) / **PD-P2-7** ✅ 已修:constants/project.ts 补全 5 状态 + i18n / **PD-P2-8** ✅ 已修(e6760e8):环高亮改 isCycle 注入 data + ModuleNode 红框 / **PD-P2-9** ✅ 已修:节点点击改组件内 selectedId 选中高亮(移除死代码 emit+push) / **PD-P2-10** ✅ 已修(a9cb802):零工程空态引导 CTA
- [x] **PD-P2-11** 🟡 打开文件 tab 全量 git 扫描(后端 5s 缓存已缓解,懒加载留设计) / **PD-P2-12** ✅ 已修:FileExplorer/DependencyGraph 用 KeepAlive 保活(onActivated 守卫) / **PD-P2-13** ✅ 已修(e6760e8):loadModules 重置 cycleNodes / **PD-P2-14** ✅ 已修:前端禁用已存在组合 + 后端幂等
**P3(边缘/打磨·27 项)**: 见详单文档(概览 O10-18 / 文件 F13-24 / 依赖图 G14-18) — 部分已随批修(见下)
> 已随批修 P3O7 sourceScores clamp / O8 descCollapsible DOM 测量 / O9 handleSync 补模块+灵感 / O10 parseStack 缓存 / 依赖图 G10 防环文案 cycleAddError / G14 后端 from!=to 校验 / G16 省略号统一。其余待后续批。
> **优先级建议**P0 G1 节点不渲染 → P1 F3 中文乱码 + G3 删依赖必失败 + O2 跨项目串数据 + O1 列表陈旧 + F1 变更不刷新 → P2 分批。
> **memory 指针**[[devflow-project-detail-walkthrough]]
---
### 🔍 2026-08-09 aichat 运行效率走查(6 路并行 · 单轮成本/工具/流式/前端/会话/LLM成本)
> 用户反馈「aichat 运行的效率很低」+「成本走查,比如有的 LLM 要求的缓存命中率规则」。详单见 [aichat效率走查-2026-08-09.md](./05-代码审查/aichat效率走查-2026-08-09.md)。共 **74 项**(单轮13/工具9/流式12/前端12/会话16/LLM成本12)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**LLM 成本/缓存命中(用户重点 · P0×2)**:
- [x] **AC-EFF-C0-1** — ✅ 已修(2026-08-09 d09d136):AnthropicRequest.system 改 untagged SystemBlock 枚举(Plain/Cached),cache_control:{type:ephemeral},开关 ANTHROPIC_CACHE_ENABLED 默认关
- [x] **AC-EFF-C0-2** — ✅ 已修(d09d136):system 稳定段/易变段分离——日期+项目/任务清单+augmentation+知识注入挪消息流末尾 merge_volatile_tail(治跨天失效+前缀稳定),开关关逐字等价现状;df-ai 492 测试通过
- [~] **AC-EFF-C1-1** ✅ 日期挪易变段已修 / **C1-2** 🟡 每轮全量重发 O(n²) 部分缓解(cache_control+稳定前缀后第2轮起命中,增量滑窗留待后续批) / **C1-3** ✅ augmentation+知识注入挪消息流已修
**后端效率(单轮成本 + 会话 + 工具 · P0×4)**:
- [x] **AC-EFF-R1-1** 🟡 全量历史重放(cache_control 命中后 O(n²) 实际降为 cache_read,增量机制待后续) / **AC-EFF-R2-1** ✅ 已修(0300764):count_recent_failures+check_stall_breaker 改 recent_messages 尾窗口 40 条
- [x] **AC-EFF-L0-1** — ✅ 已修(0300764):save_conversation 无变化捷径提前判定(无变化不 clone/truncate/构建 records)
- [x] **AC-EFF-L0-2** — ✅ 已修(0300764):system prompt 数据指纹缓存(OnceLock+entity_fingerprint,省每轮 DB 查询+拼接)
- [x] **AC-EFF-L0-3** — ✅ 已修(c41f4ad):压缩后台化 tokio::spawn fire-and-forget + is_compressing 防重入,当前轮正常走不阻塞
- [x] **AC-EFF-T1-1** ✅ 已修(c41f4ad):AiToolExecutionRepo.insert_batch 单事务批量,审计砍串行尾巴 / **T1-2** ✅ 已修:只读缓存轮内去重(同轮重复 (tool,args_key) 只真执行一次) / **T1-3** 🟡 git 工具不进缓存(留待后续,module.rs git 缓存未共享)
**前端效率(流式 + 交互 · P1×4)**:
- [x] **AC-EFF-S1-1** ✅ 已修(c41f4ad):流式增量渲染(已完成块跨帧缓存 + 只重 lexer 末块增长段,单块>12000 降级纯文本) / **S1-2** 🟡 长尾代码块 hljs(随 S1-1 缓解,高亮缓存留待)
- [x] **AC-EFF-F1-1~3** — ✅ 已修(0300764):scheduleConversationsRefresh 250ms trailing debounce 收敛回合双拉+notify 自触发+无防抖
- [x] **AC-EFF-S2-1** ✅ 已修(c41f4ad):MessageList 双渲染合并(currentText 条件改 streamingBlocks.length>0,每 delta 只 rAF 一次) / **S2-5** ✅ 已修:AiCommandOutput 50ms+4KB 合批(砍事件风暴)
**P2/P3 其余 50+ 项**: 见详单文档(R2/R3/T2/T3/S2/S3/F2/F3/L1/L2/L3/C2/C3 全量)
> **优先级建议**:① C0-1+C0-2 缓存命中率 → ② L0-1 save + L0-2 system 缓存 + L0-3 压缩后台化 → ③ R1-1 全量重放 + S1-1 流式增量 → ④ T1-1 审计批量 + F1-1/2/3 列表刷新收敛。
> **memory 指针**[[aichat-efficiency-walkthrough-2026-08-09]]
### 🔍 2026-08-09 任务/仪表盘/灵感知识走查(3 路并行 · 阶段②第一批)
> 详单见 [任务+仪表盘+灵感知识走查-2026-08-09.md](./05-代码审查/任务+仪表盘+灵感知识走查-2026-08-09.md)。共 **59 项**(任务19/Dashboard+Projects17/Ideas+Knowledge23P0=0/P1=7/P2=25/P3=27)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性,优先修)**:
- [x] **WK-1** ✅ 已修(2026-08-09 0554cc1):ActiveProjectsPanel 改 status !== completed/cancelled + 状态映射对齐 7 态(in_progress→coding/testing→testing/releasing→release),删 active 键
- [x] **WK-2** ✅ 已修:Knowledge 操作前快照 index,selectNextCandidate 直接按同位置取后续
- [x] **WK-3** ✅ 已修:updateStatus/archive 返布尔,失败 toast + 中止推进
- [x] **WK-4** ✅ 已修:promoteIdea/relateIdeas 去内部 loadIdeas + _activeIdeaQuery + Ideas.vue loadCurrentView()
- [x] **WK-5** ✅ 已修:Ideas/IdeaDetail 状态菜单排除 promoted
- [x] **WK-6** ✅ 已修:7 态 status-* 色类提取全局 components.css(列表+详情+项目统一)
- [x] **WK-7** ✅ 已修:Ideas 改 error-banner,失败不清理选中态,deleteIdea 返布尔
**P2(体验/数据)**: WK-8~13 ✅ 已修(0554cc1):快捷菜单状态机+补cancelled / overflow裁切根治(顶底角裁剪下放) / loadTasks seq守卫+keyword / 搜索态隐藏进度徽章 / 分页越界钳制 / 失败统一toast。剩余 WK-14~18 🟡 待后续
**P327 项)**: 见详单文档(TSK-10~19 / DBP-10~17 / IDEA-10~12 / KNOW-6~10 / C-1~3
> **优先级**WK-1 Dashboard 状态映射 → WK-2/3 Knowledge 确定性 → WK-4/5 Ideas → WK-6 状态徽章 → WK-7 错误条统一。
### 🔍 2026-08-09 Settings/审计/布局/后端命令走查(3 路并行 · 阶段②第二批)
> 详单见 [settings+审计+布局后端走查-2026-08-09.md](./05-代码审查/settings+审计+布局后端走查-2026-08-09.md)。共 **95 项**Settings+AuditLog29 / layout+sidebar21 / 后端命令45P0=0/P1=9/P2=32/P3=54)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1(确定性/安全,优先修)**:
- [x] **LW-1** ✅ 已修(0554cc1):ConfirmDialog Enter 分支加 BUTTON 排除(焦点在取消不再误触删除)
- [x] **LW-2** ✅ 已修(7795fe6):update_project 白名单剔除 id/created_at + status 值校验 + path relocate 同款校验 + stack JSON 校验
- [x] **LW-3** ✅ 已修(7795fe6):update_idea status 值校验 + 拒绝 status=promoted + related_ids/scores JSON 校验
- [x] **LW-4** ✅ 已修(7795fe6):update_task queue 补 validate_queue + queue/status 一致性校验
- [x] **LW-5** ✅ 已修(7795fe6):count_by_query 补齐 assignee/queue/parent_id/module_id 维度
- [x] **LW-6** ✅ 已修(7795fe6):update_field_active(WHERE id AND deleted_at IS NULL) 软删拒改
- [x] **LW-7** ✅ 已修(7795fe6):evaluate_idea 起始 guard 终态返 Err
- [x] **LW-8** ✅ 已修(7795fe6):claim_promotion CAS(WHERE id AND promoted_to IS NULL) 防双击重复立项
- [x] **LW-9** ✅ 已修(7795fe6):create_task/update_task parent_id/move_task_queue 后 recompute_parent_status
**P2(体验/数据·32 项)**: 见详单文档(SA-1~8 审计 i18n/脏表单/竞态、LS-2~10 侧栏偏好/小屏布局/审批浮窗/Paginator、BE-CMD-9~23 后端校验/聚合)
**P354 项)**: 见详单文档(SA-9~31 / LS-11~21 / BE-CMD-24~45
> **优先级**LW-1 ConfirmDialog 安全 → LW-2/3 update 裸奔 → LW-4 queue → LW-5 count/list → LW-6 软删 → LW-8 父聚合。
> **全系统索引**:前端 13 + 后端 9 命令全走查完成 ✅,剩 crate df-mcp/df-execute/df-project/df-ideas/df-tunnel/df-relay/df-types。
### 🔍 2026-08-09 df-mcp + 工具 crate 走查(阶段②最后一批)
> 详单见 [df-mcp+工具crate走查-2026-08-09.md](./05-代码审查/df-mcp+工具crate走查-2026-08-09.md)。共 **35 项**P0=0/P1=2/P2=8/P3=25)。守 session-role-diagnose-only:仅走查登记,未实施代码。
**P1**:
- [x] **MC-1** ✅ 已修(7795fe6):MCP update_project status 保留原值(对齐 update_task 收口)
- [x] **MC-2** ✅ 已修(4387695):df-project normalize_path 拆 normalize_for_compare/canonicalize_for_store,存库保留大小写(调用点迁移留后续)
- [x] **MC-3** ✅ 已修(4387695):df-tunnel 文档更正——自动重连已在 src-tauri supervisor 实现(lib.rs:292 轮询+指数退避),避免双重竞争;backoff_delay 改 pub 供 supervisor 复用
- [x] **MC-4** ✅ 已修(4387695):df-relay 成本评估选文档明示风险 + validate_device_id 最小加固(per-device token 留 Phase3)
- [x] **MC-5** ✅ 已修(7795fe6):is_success_tool_call 按 result.isError 判定,业务失败不触发写回调
- [x] **MC-6** ✅ 已修(7795fe6):create_idea priority normalize + create_project/task 拒空名 + bind_directory 降级;evaluate 复用 ScoringEngine 未做(独立口径声明,df-mcp 不依赖 df-ideas 避免重 HTTP 依赖树)
- [x] **MC-7** ✅ 已修(2026-08-09):df-ideas 自洽性校验(enforce_score_consistency 分档一致性,ARC-260618-01-e 已实施) + df-types Priority::from_i32(0-3/Low 兜底对齐读侧) + script 措辞修正
- [x] **RLY 附加** ✅ 已修(4387695):token 固定时间比较 + 入站帧 1MB 上限 + 未设 token 改错误退出(不再 panic)
**P325 项)**: 见详单文档(MCP-7~16 / EX-1~4 / PRJ-2~4 / TUN-2~4 / RLY-2~5
> **全系统走查(阶段②)完成 ✅**——前端 13 模块 + 后端 9 命令 + 全部 crate 已走查登记。进入**阶段③批量打包推进 /advance**。
### 🔴 2026-08-09 AI 自审/工作流推进链路缺陷(task_id 断裂 + AiNode 缺 prompt + 审批无 UI
> 用户「检查 AI self-review 遇到的问题」触发对工作流推进链路的深度审查。**核心结论:工作流推进按钮从 TaskDetail 触发时,三条推进链(in_progress/testing/done)全部不可用**。守 session-role-diagnose-only:本会话仅走查+登记,未实施代码。
**涉及文件**
- `src-tauri/src/commands/workflow.rs`run_workflow_inner 核心)
- `crates/df-nodes/src/task_workflow_templates.rs`(三模板)
- `crates/df-nodes/src/ai_node.rs` / `ai_self_review_node.rs` / `ai_node_helpers.rs` / `human_node.rs`
- `crates/df-workflow/src/executor.rs` / `dag.rs` / `registry.rs` / `dag_def.rs`
- `src/views/TaskDetail.vue` / `src/stores/project/workflow.ts` / `src/views/ProjectDetail.vue`
---
## 🔴 P0 缺陷(3 项,工作流推进 100% 失败)
### P0-A task_id 断裂:AiSelfReviewNode 必定失败
- **位置**`workflow.rs:295` `executor.run(&runtime_dag, config)` + `executor.rs:153`
- **现状**:前端传 `config = {}`TaskDetail.vue:590),`run_workflow_inner` 拿到 `task_id` 但**只存进 WorkflowRecord + 回调闭包 cb_task_id,从未注入传给 executor 的 config**。DagExecutor 构造 NodeContext 时 `deep_merge(config={}, node_configs[id])`testing 模板节点 config 只含 `{"gate":true}`。最终 `ctx.config` **无 task_id**
- **结果**`ai_self_review_node.rs:100` `ctx.config.get("task_id")` → None → `Err("AiSelfReviewNode 缺少必填参数: task_id")` → 节点失败 → 工作流 failed。
- **修复**`run_workflow_inner``executor.run` 前把 `task_id` 注入 config`config["task_id"] = task_id`1 行)。
### P0-B AiNode 缺 promptin_progress 模板必定失败
- **位置**`task_workflow_templates.rs:45-47` `dag.add_node("ai_execute", "ai", json!({}))`
- **现状**in_progress 模板 AiNode config 为空对象。注释称「prompt / provider / task_id 均由 run_workflow 全局 config 注入」,但前端传空 config、后端不注入。AiNode 的 `parse_params`ai_node_helpers.rs:189-200)取 `inputs["prompt"]` > `config.prompt`,两者皆无 → `Err("AiNode 缺少必填参数: prompt")`
- **结果**todo→in_progress 推进链 AiNode 必定报错,任务原地不动,无 AI 产出。
- **修复**AiNode 加默认 prompt 兜底(基于 task.description 生成执行方案),或 run_workflow 注入。
### P0-C TaskDetail 无审批 UItesting/done 模板的 HumanNode 审批无法操作
- **位置**`TaskDetail.vue`(无 ApprovalDialog 组件)+ `ProjectDetail.vue:353-356`(唯一渲染处)
- **现状**:工作流 store 的 `startEventListener`workflow.ts:111-144)收到 `human_approval_request` 会设 `state.pendingApproval`。但**只有 ProjectDetail.vue 有 `watch(() => store.pendingApproval)` 打开 ApprovalDialog**ProjectDetail.vue:838-841)。TaskDetail.vue 只调 `store.startEventListener()`TaskDetail.vue:674),**不渲染 ApprovalDialog、不 watch pendingApproval**。
- **结果**:用户从 TaskDetail 点「工作流推进」进入 testingin_review→testing 模板含 ai_self_review + human_review)或 donetesting→done 模板含 human)模板,HumanNode 发出审批请求 → `state.pendingApproval` 被设但**无弹窗** → 用户无法批准/拒绝 → HumanNode 等满 30min 默认超时(human_node.rs:34 DEFAULT_TIMEOUT_SECS=1800)→ 工作流 failed。
- **修复**TaskDetail 渲染 ApprovalDialog + watch pendingApproval,或将审批弹窗做成全局(独立于 ProjectDetail/TaskDetail)。
---
## 🟠 P1 缺陷(3 项)
### P1-D in_progress 失败无退回信号
- **位置**`task_state_machine.rs:156` `regression_target(IN_PROGRESS) => None`
- **现状**in_progress 模板失败(AiNode 缺 promptP0-B)时,`regression_target("in_progress")` 返回 None → 回调跳过推进。任务**原地留在 todo**,无错误提示、无退回态、无任何信号(回调只 `tracing::warn!`)。
- **结果**:用户点「工作流推进」→ 后台静默失败 → 任务不动,前端 wfResult=failed 提示但无具体原因。
- **修复**:AiNode 失败时把错误信息透出到前端(WorkflowFailed.error 已含,但前端 wfFailedHint 只显通用文案)。
### P1-E done 模板失败后任务卡 testing 无退回
- **位置**`task_state_machine.rs` `regression_target(DONE) => None`
- **现状**testing→done 模板(human 节点)若用户拒绝(返回 Err)→ 工作流 failed → `regression_target("done")` → None → 任务**留在 testing**,未退回 in_review。
- **问题**:done 是终态,无可退态合理,但用户拒绝后应停留 testing 让人工处理,当前无明确信号提示「推进未完成,任务仍在 testing」。
### P1-F AiSelfReview 审查空产出
- **位置**`ai_self_review_node.rs:129-134`
- **现状**:若 in_progress 阶段 AiNode 失败(P0-B),output_json 为空 → 自审节点审查占位文本 `"(无产出,仅依据需求审查)"`
- **结果**:即使 task_id 修好,无产出的自审也只是空洞审查,无意义。
---
## 🟡 P2 缺陷(3 项)
### P2-G 前端注释误导:「运行时注入」实际无注入
- **位置**`TaskDetail.vue:590` 注释「全局 config 留空(节点级 config 由模板/运行时注入)」
- **现状**:模板只注入 gate:true,运行时(run_workflow_inner)不注入 task_id/prompt。注释承诺与实际不符,误导后续维护者。
### P2-H 三模板归一度差:in_progress 纯 AI / testing AI+Human / done 纯 Human
- **位置**`task_workflow_templates.rs`
- **现状**:三模板职责不统一——in_progress 无人工确认直接改状态,testing 有 AI 自审 + 人工,done 纯人工。用户对「工作流推进」在三个状态的体验差异巨大但不透明。
- **建议**:至少统一「推进是否有人工确认」的预期,或在按钮文案/进度提示中说明当前推进链内容。
### P2-I WorkflowRecord 未存 project_id
- **位置**`workflow.rs:170` `project_id: None`
- **现状**:即使 task_id 关联任务(任务有关联 project_id),执行记录 `project_id` 恒为 None。按项目过滤工作流执行记录时无法直接从 workflow 关联项目。
- **修复**task_id 有值时反查任务 project_id 填入。
---
## 验收标准
1. task_id 注入 AiNode/AiSelfReviewNode config,自审节点能读到任务
2. in_progress 模板 AiNode 有默认 prompt,能生成产出
3. TaskDetail 触发工作流后,HumanNode 审批弹窗正常弹出可操作
4. 工作流失败时前端显示具体错误原因(非通用文案)
5. 老工作流(无 task_id/target_status)零行为破坏
6. cargo check --workspace EXIT 0 + vue-tsc EXIT 0
---
**关联**:本走查与「2026-08-09 TaskDetail 操作按钮缺陷走查」同属 TaskDetail 交互审查线,但本段聚焦 AI 工作流推进链路(后端 + 审批 UI),按钮缺陷聚焦 TaskDetail 自身交互。修复时建议合并处理(都在 TaskDetail + workflow 链路)。
@@ -1,11 +1,11 @@
//! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。 //! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。
//! //!
//! 从 `mod.rs::run_agentic_loop` 机械抽取(L1017-1193),行为零变更 //! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)
//! //!
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。 //! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
//! //!
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式): //! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要; //! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system); //! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。 //! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
//! //!
@@ -14,14 +14,21 @@
//! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens, //! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
//! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。 //! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
//! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。 //! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发 //! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁;L0-3 后台化后窗口拉长
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。 //! (loop 继续推进,可能 push 新消息),但 loop 只 push 增长、protect_start 基于触发时 len,
//! 压缩区间始终落在当前轮之前,不误伤当前轮(见 maybe_auto_compress L0-3 注释)。
//! //!
//! 安全:复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve //! 安全(L0-3 后台化后):provider 在后台 task 内经 `secret::build_provider_for`
//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。 //! (不再复用 loop 顶部已 build 的 provider——后者是 Box 非 Clone 无法移入 task;多一次
//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。 //! keyring resolve 在后台线程不阻塞 loop),api_key 经 df_storage::secret 闭环;
//! summary/error payload/日志均不含 api_key。
//! is_compressing 防重入:set_compressing(true) 在触发时置位,后台 task 各出口
//! (成功/失败/conv 已删/provider 重建失败)均复位,防重入不泄漏。
//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。 //! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
//! messages 操作改 per_conv。 //! messages 操作改 per_conv。
//!
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
use std::sync::Arc; use std::sync::Arc;
@@ -29,7 +36,7 @@ use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::{ChatMessage, LlmProvider}; use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::state::{AppState, LlmConcurrency}; use crate::state::{AppState, LlmConcurrency};
@@ -53,19 +60,26 @@ const KEYWORD_FALLBACK_ENABLED: bool = true;
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。 /// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
/// ///
/// 从 `run_agentic_loop` 抽取,行为零变更。返回值: /// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
/// 触发时仅做零成本准备(读触发条件 / 读 active 克隆 / 算关键词兜底)后立即 `tokio::spawn`
/// 独立 task fire-and-forget 执行压缩,本函数返回 `false` 让当前轮**用未压缩上下文正常走**
/// (build_for_request 不受影响,当前轮回复不丢);压缩结果由后台 task 在完成时更新
/// per_conv.messages(标 compressed + 插摘要),**下一轮** build_for_request 生效。
/// `is_compressing` 防重入:触发时置 true,后台 task 各出口(成功/失败/conv 已删)均复位。
///
/// 返回值:
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。 /// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
/// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。 /// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。
/// ///
/// 参数沿用原内联代码引用的局部: /// 参数沿用原内联代码引用的局部:
/// - `provider`:loop 顶部 build 的 provider(F-04b 切换 candidate 后由调用方传入最新)。 /// - `provider_config`:compress 沿用主 candidate(非 fallback 范围)。后台 task 内经
/// - `provider_config`:同上,compress 沿用主 candidate(非 fallback 范围)。 /// `secret::build_provider_for` 重建 provider(复用 loop 顶部同款三步打包,仅多一次
/// keyring resolve,在后台线程不阻塞 loop)。
/// - `iteration`:当前轮次(仅用于日志)。 /// - `iteration`:当前轮次(仅用于日志)。
pub(super) async fn maybe_auto_compress( pub(super) async fn maybe_auto_compress(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
app_handle: &AppHandle, app_handle: &AppHandle,
provider: &Box<dyn LlmProvider>,
provider_config: &AiProviderRecord, provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency, llm_concurrency: &LlmConcurrency,
iteration: usize, iteration: usize,
@@ -142,7 +156,7 @@ pub(super) async fn maybe_auto_compress(
} }
} }
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。 // 改进3 B:在 active_msgs move 进压缩前,先算关键词摘要兜底文本。
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。 // LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。 // KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED { let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
@@ -151,101 +165,197 @@ pub(super) async fn maybe_auto_compress(
String::new() String::new()
}; };
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。 // L0-3:保护区外无 active 可压缩(active_msgs 空)→ noop,同步复位 is_compressing
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop // 后返回(不 spawn 空任务)。对齐原 Ok(None) 分支语义
let compress_outcome: Result<Option<CompressedSummary>, String> = if active_msgs.is_empty() { if active_msgs.is_empty() {
Ok(None) session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
} else { return false;
compress_via_llm( }
provider.as_ref(),
provider_config,
active_msgs,
&lang,
conv_id,
llm_concurrency,
).await.map(Some)
};
match compress_outcome { // ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
Ok(Some(summary)) => { //
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。 // 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
{ // 期间 loop 无法推进 build_for_request / stream,当前轮回复整体延迟。
let mut session = session_arc.lock().await; //
let conv = session.conv(&conv_id); // 现改为 spawn 独立 task 后台执行压缩,本函数立即返回 false:
let _compressed = conv.messages.compress_old_messages(protect_start); // - 当前轮 build_for_request 用**未压缩上下文**,回复不受压缩阻塞影响(关键:不丢当前轮);
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用 // - 后台 task 完成后更新 per_conv.messages(compress_old_messages + insert 摘要),
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); // 下一轮 build_for_request 生效;
conv.messages.set_compressing(false); // - is_compressing 防重入:触发时已置 true(上方),后台 task 各出口(成功/失败/conv 已删)
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn) // 均复位,下一轮 maybe_auto_compress 检测到 true 即跳过,不会并发二次压缩。
} //
tracing::info!( // 选型理由(后台化 vs 降级独立线程):并发安全成立——所有 per_conv 访问经
conv_id = %conv_id, // session_arc 锁(数据无竞争),且压缩只读「触发时刻快照的 active_msgs」+ 完成后
iteration, // 一次性 mutate,与 loop 的 push 均为「锁内短段」,protect_start 基于触发时 len
pre_tokens = pre_compress_tokens, // (loop 只 push 增长,压缩区间始终落在当前轮之前),故选 tokio::spawn 后台化,
"[ai] 自动压缩成功,摘要已插首位" // 无需降级独立线程。provider 在 task 内重建(secret::build_provider_for),避免
); // 与 loop 共享 Box<dyn LlmProvider>(后者非 Clone,无法移入 task)。
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体), //
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。 // 无感知降级:provider 重建失败 / LLM 失败 → 仅复位 is_compressing + 关键词兜底,
// miniapp 仍插摘要气泡(对端发生压缩告知用户) // 不发 AiError(前端 AiError 会误判生成结束致"压缩后停止"),loop 继续 stream
let ev = AiChatEvent::AiAutoCompressed { let session_arc = session_arc.clone();
conversation_id: Some(conv_id.to_string()), let app_handle = app_handle.clone();
summary: summary.nl_summary, let conv_id = conv_id.to_string();
}; let provider_config = provider_config.clone();
let _ = app_handle.emit("ai-chat-event", ev.clone()); let llm_concurrency = llm_concurrency.clone();
// L3 emit 双写:tunnel subscriber透传 miniapp tokio::spawn(async move {
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev); // 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
} // 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
Ok(None) => { // token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing // 下轮 maybe_auto_compress 会重复触发(每次仅 spawn 后台任务,不阻塞 loop,但浪费)
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false); let provider = match super::super::secret::build_provider_for(&provider_config) {
} Ok(p) => p,
Err(e) => { Err(e) => {
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。 tracing::warn!(
// conv_id = %conv_id,
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。 error = %e,
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认): keyword_fallback_len = keyword_fallback.len(),
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token, "[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文); KEYWORD_FALLBACK_ENABLED,
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点; );
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。 // 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。 {
// KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) +
// 消息状态不变,降级 build_for_request 裁剪)。
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
tracing::warn!(
conv_id = %conv_id,
error = %e,
keyword_fallback_len = keyword_fallback.len(),
"[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
if KEYWORD_FALLBACK_ENABLED {
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
if KEYWORD_FALLBACK_ENABLED {
// 同 LLM 失败兜底:标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
return;
}
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
true
} else {
false
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩 provider 重建失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
);
}
}
return;
}
};
// 压缩调用(复用主 candidate 的 provider_config,api_key 经 secret 闭环)。
// 成功 → Some(summary);失败 → Err。
let compress_outcome: Result<Option<CompressedSummary>, String> =
compress_via_llm(
provider.as_ref(),
&provider_config,
active_msgs,
&lang,
&conv_id,
&llm_concurrency,
).await.map(Some);
match compress_outcome {
Ok(Some(summary)) => {
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
// conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv,
// is_compressing 标志随 conv 销毁无意义,直接 return。
{
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
return;
}
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start); let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() { // T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback)); conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
true conv.messages.set_compressing(false);
} else { // TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
false }
} tracing::info!(
conv_id = %conv_id,
iteration,
pre_tokens = pre_compress_tokens,
"[ai] 自动压缩成功(后台),摘要已插首位"
);
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
let ev = AiChatEvent::AiAutoCompressed {
conversation_id: Some(conv_id.clone()),
summary: summary.nl_summary,
}; };
if inserted { let _ = app_handle.emit("ai-chat-event", ev.clone());
tracing::info!( // L3 emit 双写:tunnel subscriber透传 miniapp
conv_id = %conv_id, let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
pre_tokens = pre_compress_tokens, }
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)" Ok(None) => {
); // 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
Err(e) => {
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
//
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn + 兜底释放
// token + build_for_request 裁剪,loop 继续 stream_llm,用户自然看到后续回复。
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
{
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
}
}
tracing::warn!(
conv_id = %conv_id,
error = %e,
keyword_fallback_len = keyword_fallback.len(),
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
if KEYWORD_FALLBACK_ENABLED {
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
let inserted = {
let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(&conv_id) {
return;
}
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
if !keyword_fallback.is_empty() {
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
true
} else {
false
}
};
if inserted {
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩失败兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)"
);
}
} }
} }
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
} }
} });
} }
} }
false false
+26 -5
View File
@@ -4,8 +4,8 @@
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use crate::state::AppState; use crate::state::AppState;
use super::approval_timeout; use super::approval_timeout;
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider}; use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider};
use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt; use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry}; use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState; use super::conv_state::ConvState;
@@ -94,7 +94,12 @@ pub async fn try_continue_agent_loop(
}; };
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string()); let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
let conv_id_owned = conv_id.to_string(); let conv_id_owned = conv_id.to_string();
let system_prompt = build_system_prompt(state, &lang).await; // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分,
// OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
let cache_on = cache_enabled(state).await
&& matches!(provider_config.provider_type.as_str(), "anthropic" | "anthropic_compat");
let (system_prompt, base_volatile) =
build_system_prompt_parts(state, &lang, &[], &[], cache_on).await;
let session_arc = state.ai_session.clone(); let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone(); let tools_arc = state.ai_tools.clone();
@@ -103,7 +108,23 @@ pub async fn try_continue_agent_loop(
let knowledge_config = state.knowledge_config.lock().await.clone(); let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone(); let llm_concurrency = state.llm_concurrency.clone();
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await; // 知识注入:缓存模式拼到易变段末尾(追加消息流,不失效 system 缓存),否则拼 system
// (与旧路径逐字等价)。
let volatile_tail = if cache_on {
let knowledge = build_knowledge_context_for_conv(state, conv_id, &knowledge_config).await;
let mut tail = base_volatile;
if !knowledge.is_empty() {
tail = if tail.is_empty() { knowledge } else { format!("{}\n\n---\n{}", tail, knowledge) };
}
if tail.is_empty() { None } else { Some(tail) }
} else {
None
};
let system_prompt = if cache_on {
system_prompt
} else {
inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await
};
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst); let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst); let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
@@ -141,7 +162,7 @@ pub async fn try_continue_agent_loop(
}; };
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await; super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await;
}); });
} }
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -215,7 +215,10 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存, /// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。 /// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。 /// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String { ///
/// pub(crate):供 audit/mod.rs 轮内去重(AC-EFF-T1-2)复用同一 key 口径(同源同 key,
/// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。
pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String {
if matches!(tool_name, "list_directory" | "list_dir") { if matches!(tool_name, "list_directory" | "list_dir") {
args.get("path") args.get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -25,6 +25,10 @@ pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static
"update_project" => ("project", "update"), "update_project" => ("project", "update"),
"update_task" => ("task", "update"), "update_task" => ("task", "update"),
"update_idea" => ("idea", "update"), "update_idea" => ("idea", "update"),
// P0-2:advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端
// listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。
// 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。
"advance_task" => ("task", "update"),
"delete_project" | "purge_project" => ("project", "delete"), "delete_project" | "purge_project" => ("project", "delete"),
"delete_task" => ("task", "delete"), "delete_task" => ("task", "delete"),
"delete_idea" => ("idea", "delete"), "delete_idea" => ("idea", "delete"),
+118 -7
View File
@@ -41,9 +41,12 @@ use approval::{detect_retry_count, handle_approval_tool};
// list_tool_executions 是 #[tauri::command]ToolExecutionDto 供前端 DTO 序列化)。 // list_tool_executions 是 #[tauri::command]ToolExecutionDto 供前端 DTO 序列化)。
pub mod record; pub mod record;
#[allow(unused_imports)] #[allow(unused_imports)]
pub(crate) use record::{audit_tool_call, query_audit_history, record_audit}; pub(crate) use record::{audit_tool_call, build_audit_record, query_audit_history, record_audit};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use record::{list_tool_executions, ToolExecutionDto, ToolExecutionPage, ToolExecQuery}; pub use record::{
list_tool_executions, tool_failure_stats, ToolExecutionDto, ToolExecutionPage, ToolExecQuery,
ToolFailureStat, ToolFailureStats, ToolFailureStatsQuery,
};
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason // reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。 // 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
@@ -67,7 +70,7 @@ pub(crate) use finalize::audit_finalize;
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
// 第三批从本文件抽离,行为零变更。 // 第三批从本文件抽离,行为零变更。
mod cache; mod cache;
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for}; pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for, readonly_cache_args_key};
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
@@ -591,16 +594,37 @@ pub(crate) async fn process_tool_calls(
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。 // 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
// 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。 // 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。
// //
// AC-EFF-T1-2(2026-08-09)轮内去重:同轮重复 (tool, args_key) 只真执行一次。
// LLM 单轮可能发多条同参只读调用(失忆重调),原实现首查未命中(本轮首个尚未落 messages)
// 致重复全部真执行(重复 I/O + 重复 tool_result 回灌)。现进程内暂存首个结果
// (in_round_cache),后续同 key 复用——语义同跨轮缓存,仅因首个在本轮未落库故内存暂存。
// 命中计数递增威慑(cache_hit_warning 同口径);key 与跨轮缓存同源(readonly_cache_args_key),
// 防两处 key 口径漂移。跨轮重复仍走 find_cached_readonly_result(历史扫描,未变)。
//
// find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。 // find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
// in_round_cache:同轮 (tool, args_key) -> (首个结果内容, 已见同参次数)。首个出现:
// 跨轮缓存命中 → 存 (content, hit_count);未命中 → 真执行,backfill 后存 (content, 1)。
// in_round_dupes:同轮重复的 draft(延后到 backfill 后复用 in_round_cache,防双执行)。
let mut in_round_cache: HashMap<(String, String), (String, u32)> = HashMap::new();
let mut seen_round_keys: HashSet<(String, String)> = HashSet::new();
let mut in_round_dupes: Vec<(ToolCallDraft, RiskLevel, String)> = Vec::new();
let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len()); let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len());
for (draft, args, risk_level) in low_risk { for (draft, args, risk_level) in low_risk {
let args_key = readonly_cache_args_key(&draft.name, &args);
let key = (draft.name.clone(), args_key.clone());
if !seen_round_keys.insert(key) {
// 轮内重复:延后复用首个结果(首个已在 seen/uncached/backfill),不重复历史扫描+DB 查。
in_round_dupes.push((draft, risk_level, args_key));
continue;
}
let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await; let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
if let Some((cached_content, hit_count)) = cached { if let Some((cached_content, hit_count)) = cached {
// 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源), // 缓存命中:存轮内缓存(供后续同 key 复用)+ 直接 push tool_result + 审计
// 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次) // (decided_by=cache_hit 标记缓存来源),不走真执行 + 不重emit Started/Completed。
// AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的 // AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的
// tool_result 前置「重复调用拦截」警告头(机制化提示,LLM 能看到 tool_result), // tool_result 前置「重复调用拦截」警告头(机制化提示,LLM 能看到 tool_result),
// 命中次数递增威慑,告知勿再重复调用相同参数的工具。 // 命中次数递增威慑,告知勿再重复调用相同参数的工具。
in_round_cache.insert((draft.name.clone(), args_key), (cached_content.clone(), hit_count));
let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached_content); let warned_content = format!("{}{}", cache_hit_warning(&draft.name, hit_count), cached_content);
// emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。 // emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。
let ev = AiChatEvent::AiToolCallCompleted { let ev = AiChatEvent::AiToolCallCompleted {
@@ -677,7 +701,12 @@ pub(crate) async fn process_tool_calls(
} }
})).await; })).await;
// 串行回填 tool_result + 审计短 lock push + 锁外 audit // 串行回填 tool_result + 审计收集(短 lock push + 锁外收集),审计改单事务批量插入。
// AC-EFF-T1-1(2026-08-09):原每结果 audit_tool_call(每次 spawn_blocking + 单行 INSERT,
// 单连接 Mutex 串行)合并为 build_audit_record 收集 + insert_batch 一次事务批量,
// 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。
let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
Vec::with_capacity(results.len());
for (draft, raw_result, risk_level, outcome) in results { for (draft, raw_result, risk_level, outcome) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
@@ -714,7 +743,89 @@ pub(crate) async fn process_tool_calls(
RiskLevel::Medium => "auto_takeover_medium", RiskLevel::Medium => "auto_takeover_medium",
RiskLevel::High => "auto_takeover_all", RiskLevel::High => "auto_takeover_all",
}; };
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some(decided_by), current_message_id).await; // AC-EFF-T1-2 轮内去重回填:真执行结果存入 in_round_cache,供同轮后续同 key 复用
// (首个执行 → count=1,轮内重复命中时递增;key 与跨轮缓存同源)。
{
let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null);
let exec_key = (draft.name.clone(), readonly_cache_args_key(&draft.name, &args_val));
in_round_cache.entry(exec_key).or_insert_with(|| (content.clone(), 1));
}
audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, status, risk_level,
Some(content), Some(decided_by), current_message_id,
));
}
// 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。
if !audit_records.is_empty() {
let batch_len = audit_records.len();
if let Err(e) = audit_repo.insert_batch(audit_records).await {
tracing::error!(
conv_id = %conv_id,
batch = batch_len,
"[ai] insert_batch 批量写审计记录失败(已回滚,共 {} 条): {}",
batch_len,
e
);
}
}
// AC-EFF-T1-2 轮内重复处理:复用首个结果(已回填 in_round_cache),跳过重复真执行。
// 语义同跨轮缓存命中:emit Completed + push tool_result + 审计(decided_by=cache_hit),
// 命中计数递增威慑(cache_hit_warning 同口径)。首个结果来自跨轮命中(前段已存)或
// 本轮真执行 backfill(上段已存),故必命中;防御性缺失则丢弃(不重复执行,LLM 下轮可重调)。
let mut dupe_audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
Vec::with_capacity(in_round_dupes.len());
for (draft, risk_level, args_key) in in_round_dupes {
let key = (draft.name.clone(), args_key);
let Some((content, count)) = in_round_cache.get(&key).cloned() else {
tracing::warn!(
conv_id = %conv_id,
tool = %draft.name,
tc_id = %draft.id,
"[ai] 轮内去重:首个结果缺失(理论不可达),丢弃重复调用(防御降级)"
);
continue;
};
let new_count = count + 1;
in_round_cache.insert(key, (content.clone(), new_count));
let warned_content = format!("{}{}", cache_hit_warning(&draft.name, new_count), content);
// emit Completed 携带缓存结果供前端折叠卡片展示。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(warned_content.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);
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content));
}
dupe_audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level,
Some(warned_content), Some("cache_hit"), current_message_id,
));
tracing::info!(
conv_id = %conv_id,
tool = %draft.name,
tc_id = %draft.id,
hit_count = new_count,
"[ai] 只读工具轮内去重命中(第 {} 次同参,本轮首个已执行),跳过重复真执行",
new_count
);
}
if !dupe_audit_records.is_empty() {
let dup_len = dupe_audit_records.len();
if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await {
tracing::error!(
conv_id = %conv_id,
batch = dup_len,
"[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}",
dup_len,
e
);
}
} }
} }
+255 -26
View File
@@ -10,6 +10,7 @@
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。 //! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::State; use tauri::State;
use df_ai::ai_tools::RiskLevel; use df_ai::ai_tools::RiskLevel;
@@ -23,10 +24,51 @@ use crate::state::AppState;
use super::risk_str; use super::risk_str;
/// 一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) /// 构造一条工具执行审计记录(纯函数,单条 [`audit_tool_call`] / 批量插入路径共用)。
/// ///
/// `decided_by` 有值auto/human= 已决策执行 → 记 executed_at /// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
/// `None`pending 待审批→ executed_at 留空待 audit_finalize 回填。 /// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
pub(crate) fn build_audit_record(
conv_id: &str,
tool_call_id: &str,
tool_name: &str,
arguments: &str,
status: &str,
risk_level: RiskLevel,
result: Option<String>,
decided_by: Option<&str>,
message_id: Option<&str>,
) -> AiToolExecutionRecord {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv_id.to_string()),
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
message_id: message_id.map(|s| s.to_string()),
// &str → ToolCallId(From<&str>),branded newtype 防外部 String 误传
tool_call_id: tool_call_id.into(),
tool_name: tool_name.to_string(),
arguments: arguments.to_string(),
result,
status: status.to_string(),
risk_level: risk_str(risk_level).to_string(),
requested_at: now_millis(),
executed_at,
decided_by: decided_by.map(|s| s.to_string()),
}
}
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
///
/// 单条写入路径。批量路径(audit/mod.rs process_tool_calls 回填循环)经
/// [`build_audit_record`] 收集记录后调 `AiToolExecutionRepo::insert_batch`
/// 单事务批量插入(治 aichat 效率 AC-EFF-T1-1,N 次串行 INSERT → 一次事务)。
///
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
pub(crate) async fn audit_tool_call( pub(crate) async fn audit_tool_call(
repo: &AiToolExecutionRepo, repo: &AiToolExecutionRepo,
conv_id: &str, conv_id: &str,
@@ -39,28 +81,11 @@ pub(crate) async fn audit_tool_call(
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: Option<&str>, message_id: Option<&str>,
) { ) {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; let record = build_audit_record(
if let Err(e) = repo conv_id, tool_call_id, tool_name, arguments,
.insert(AiToolExecutionRecord { status, risk_level, result, decided_by, message_id,
id: new_id(), );
conversation_id: Some(conv_id.to_string()), if let Err(e) = repo.insert(record).await {
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
message_id: message_id.map(|s| s.to_string()),
tool_call_id: tool_call_id.to_string(),
tool_name: tool_name.to_string(),
arguments: arguments.to_string(),
result,
status: status.to_string(),
risk_level: risk_str(risk_level).to_string(),
requested_at: now_millis(),
executed_at,
decided_by: decided_by.map(|s| s.to_string()),
})
.await
{
tracing::error!( tracing::error!(
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}", "audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
conv_id, conv_id,
@@ -204,7 +229,8 @@ pub async fn list_tool_executions(
.map(|r| ToolExecutionDto { .map(|r| ToolExecutionDto {
id: r.id, id: r.id,
conversation_id: r.conversation_id, conversation_id: r.conversation_id,
tool_call_id: r.tool_call_id, // ToolCallId → String(DTO 边界,前端按字符串消费)
tool_call_id: r.tool_call_id.into(),
tool_name: r.tool_name, tool_name: r.tool_name,
arguments_brief: super::truncate_chars(&r.arguments, 120), arguments_brief: super::truncate_chars(&r.arguments, 120),
result_brief: r.result.map(|s| super::truncate_chars(&s, 160)), result_brief: r.result.map(|s| super::truncate_chars(&s, 160)),
@@ -223,3 +249,206 @@ pub async fn list_tool_executions(
has_more, has_more,
}) })
} }
// ============================================================
// AC-5 按工具失败率统计(诊断 IPC,替代 ad-hoc 查库)
// ============================================================
/// 按工具失败率统计查询入参(前端透传,空值=全量)。
///
/// `from`:可选时间下限(millis,`requested_at >= from`),None = 全量。
/// AC-5 诊断期 ad-hoc 查库无运行时统计机制,本命令提供聚合统计供审计面板/诊断查询。
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ToolFailureStatsQuery {
pub from: Option<i64>,
}
/// 单工具执行统计(AC-5 失败率画像)。
///
/// `failed_rate` = failed / (completed + failed)——仅"真正执行过"的算成功率分母;
/// rejected/skipped_retry 是用户/AI 决策非执行失败,不计分母,但单独计数展示。
/// completed+failed=0(从未真正执行,如纯决策挂起)时 failed_rate=0。
#[derive(Debug, Clone, Serialize)]
pub struct ToolFailureStat {
pub tool_name: String,
pub total: i64,
pub completed: i64,
pub failed: i64,
pub rejected: i64,
pub interrupted: i64,
pub skipped_retry: i64,
pub failed_rate: f64,
}
/// 失败率统计聚合结果(跨工具汇总)。
#[derive(Debug, Clone, Serialize)]
pub struct ToolFailureStats {
/// 按 total 降序(使用最多的工具在前,面板聚焦高流量工具)
pub stats: Vec<ToolFailureStat>,
pub total_executions: i64,
pub total_failed: i64,
}
/// 把 `stats_by_tool` 的 (tool_name, status, count) 三元组聚合为 DTO(纯函数,命令 + 单测共用)。
///
/// 内存聚合(单条 GROUP BY 已按 tool,status 分组):HashMap 二次归并 → 每工具 status 分布,
/// 计算 failed_rate(分母 completed+failed,四舍五入到 4 位小数防浮点长尾)。排序按 total 降序、
/// tool_name 升序破平。`total_executions` = 全工具 total 之和,`total_failed` = failed 之和。
fn aggregate_tool_stats(rows: Vec<(String, String, i64)>) -> ToolFailureStats {
let mut by_tool: HashMap<String, HashMap<String, i64>> = HashMap::new();
for (tool, status, cnt) in rows {
*by_tool.entry(tool).or_default().entry(status).or_insert(0) += cnt;
}
let mut total_executions = 0i64;
let mut total_failed = 0i64;
let mut stats: Vec<ToolFailureStat> = by_tool
.into_iter()
.map(|(tool_name, m)| {
let completed = m.get("completed").copied().unwrap_or(0);
let failed = m.get("failed").copied().unwrap_or(0);
let rejected = m.get("rejected").copied().unwrap_or(0);
let interrupted = m.get("interrupted").copied().unwrap_or(0);
let skipped_retry = m.get("skipped_retry").copied().unwrap_or(0);
let total: i64 = m.values().sum();
total_executions += total;
total_failed += failed;
let failed_rate = if completed + failed > 0 {
((failed as f64 / (completed + failed) as f64) * 10_000.0).round() / 10_000.0
} else {
0.0
};
ToolFailureStat {
tool_name,
total,
completed,
failed,
rejected,
interrupted,
skipped_retry,
failed_rate,
}
})
.collect();
stats.sort_by(|a, b| b.total.cmp(&a.total).then_with(|| a.tool_name.cmp(&b.tool_name)));
ToolFailureStats { stats, total_executions, total_failed }
}
/// AC-5 运行时失败率统计:按工具聚合 ai_tool_executions 的 status 分布与失败率。
///
/// 只读诊断 IPC:`query.from` 可选时间下限(millis),默认全量。数据源 ai_tool_executions 表
/// (GUI audit 模块写,已完成记录落盘)。口径:failed_rate = failed / (completed + failed);
/// rejected/skipped_retry/interrupted 单独计数展示(不计失败率分母,属用户/AI 决策非执行失败)。
#[tauri::command]
pub async fn tool_failure_stats(
state: State<'_, AppState>,
query: Option<ToolFailureStatsQuery>,
) -> Result<ToolFailureStats, String> {
let from = query.and_then(|q| q.from);
let rows = state
.ai_tool_executions
.stats_by_tool(from)
.await
.map_err(err_str)?;
Ok(aggregate_tool_stats(rows))
}
#[cfg(test)]
mod tests {
use super::*;
use df_storage::db::Database;
/// 插入一条指定 tool/status/requested_at 的审计记录(测试构造数据用)。
/// requested_at 传毫秒(与生产 `audit_tool_call` 落 now_millis() 同口径)。
async fn insert_record(repo: &AiToolExecutionRepo, tool: &str, status: &str, t: i64) {
repo.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: None,
message_id: None,
tool_call_id: new_id().into(),
tool_name: tool.to_string(),
arguments: "{}".to_string(),
result: None,
status: status.to_string(),
risk_level: "low".to_string(),
requested_at: t.to_string(),
executed_at: None,
decided_by: None,
})
.await
.expect("测试数据插入应成功");
}
/// failed_rate 分母口径:rejected/skipped_retry/interrupted 不计分母,单独计数。
///
/// read_file: 8 completed + 2 failed + 3 rejected → rate=2/10=0.2,rejected=3
/// patch_file: 1 completed + 4 failed → rate=4/5=0.8
/// run_command: 2 skipped_retry + 1 rejected + 1 interrupted(无 completed/failed)→ rate=0
#[tokio::test]
async fn tool_failure_stats_denominator_and_counts() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let repo = AiToolExecutionRepo::new(&db);
let t = 2_000_000_000_000i64;
for _ in 0..8 { insert_record(&repo, "read_file", "completed", t).await; }
for _ in 0..2 { insert_record(&repo, "read_file", "failed", t).await; }
for _ in 0..3 { insert_record(&repo, "read_file", "rejected", t).await; }
insert_record(&repo, "patch_file", "completed", t).await;
for _ in 0..4 { insert_record(&repo, "patch_file", "failed", t).await; }
for _ in 0..2 { insert_record(&repo, "run_command", "skipped_retry", t).await; }
insert_record(&repo, "run_command", "rejected", t).await;
insert_record(&repo, "run_command", "interrupted", t).await;
let out = aggregate_tool_stats(repo.stats_by_tool(None).await.unwrap());
assert_eq!(out.total_executions, 8 + 2 + 3 + 1 + 4 + 2 + 1 + 1);
assert_eq!(out.total_failed, 6);
let rf = out.stats.iter().find(|s| s.tool_name == "read_file").unwrap();
assert_eq!(rf.total, 13);
assert_eq!(rf.completed, 8);
assert_eq!(rf.failed, 2);
assert_eq!(rf.rejected, 3);
assert_eq!(rf.failed_rate, 0.2, "rejected 不计分母,failed/(completed+failed)=2/10");
let pf = out.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
assert_eq!(pf.failed_rate, 0.8, "4/(1+4)=0.8");
let rc = out.stats.iter().find(|s| s.tool_name == "run_command").unwrap();
assert_eq!(rc.completed + rc.failed, 0, "无真正执行记录");
assert_eq!(rc.failed_rate, 0.0, "分母为 0 应归零");
assert_eq!(rc.skipped_retry, 2);
assert_eq!(rc.interrupted, 1);
// 排序:total 降序 → read_file(13) 应在 patch_file(5) 之前
assert_eq!(out.stats[0].tool_name, "read_file");
}
/// from 时间过滤:仅统计 requested_at >= from 的记录。
///
/// patch_file 追加一条更早(early)的 completed → from=late 时其不计入,
/// completed 由 2 降为 1,failed_rate 由 4/6≈0.6667 变为 4/5=0.8。
#[tokio::test]
async fn tool_failure_stats_from_time_filter() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let repo = AiToolExecutionRepo::new(&db);
let t_late = 2_000_000_000_000i64;
let t_early = 1_000_000_000_000i64;
for _ in 0..8 { insert_record(&repo, "read_file", "completed", t_late).await; }
for _ in 0..2 { insert_record(&repo, "read_file", "failed", t_late).await; }
insert_record(&repo, "patch_file", "completed", t_late).await;
for _ in 0..4 { insert_record(&repo, "patch_file", "failed", t_late).await; }
insert_record(&repo, "patch_file", "completed", t_early).await;
// 全量:patch_file completed=2(early+late)
let all = aggregate_tool_stats(repo.stats_by_tool(None).await.unwrap());
let pf_all = all.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
assert_eq!(pf_all.completed, 2);
assert_eq!(pf_all.failed_rate, 0.6667, "4/(2+4)≈0.6667");
// from=t_late:early 不计,patch_file completed=1
let late = aggregate_tool_stats(repo.stats_by_tool(Some(t_late)).await.unwrap());
let pf_late = late.stats.iter().find(|s| s.tool_name == "patch_file").unwrap();
assert_eq!(pf_late.completed, 1, "early 记录应被 from 过滤");
assert_eq!(pf_late.failed_rate, 0.8, "4/(1+4)=0.8");
assert_eq!(late.total_executions, 8 + 2 + 1 + 4, "不含 early 记录");
}
}
+4 -2
View File
@@ -75,9 +75,11 @@ pub async fn restore_pending_approvals(state: &AppState) {
} }
} }
session.pending_approvals.insert( session.pending_approvals.insert(
rec.tool_call_id.clone(), // ToolCallId → String(AiSession pending_approvals 是 String HashMap,
// 与 LLM provider draft.id 同形直接比较;DB 边界已 newtype 防误传)
rec.tool_call_id.to_string(),
PendingApproval { PendingApproval {
tool_call_id: rec.tool_call_id, tool_call_id: rec.tool_call_id.to_string(),
tool_name: rec.tool_name, tool_name: rec.tool_name,
arguments: args, arguments: args,
conversation_id: rec.conversation_id, conversation_id: rec.conversation_id,
+305 -5
View File
@@ -83,15 +83,61 @@ pub fn grammar_for(ext: &str) -> Option<Language> {
} }
} }
/// 判断 ext 是否为支持语言(含 Vue 借 TS)。 /// 判断 ext 是否为支持语言(含 Vue 借 TS)。file_outline 据此提前判 ext(不支持不读文件)。
///
/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来
/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。
#[allow(dead_code)]
pub fn is_supported_ext(ext: &str) -> bool { pub fn is_supported_ext(ext: &str) -> bool {
matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py") matches!(ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "vue" | "go" | "java" | "py")
} }
/// 判断 source 内容是否真的含可提取的函数/类/结构体定义(AC-4 read_file 符号级命中提示)。
///
/// read_file 读取成功后据此决定是否追加「改用 read_symbol」提示:仅当内容里真的存在
/// DEFINITION_KINDS 定义节点才提示——比旧按扩展名判断更准,无定义的文件不瞎提示。
/// 与 read_symbol 同语法树逻辑(grammar_for + DEFINITION_KINDS + Vue 切 <script> 段借 ts),
/// 但不提取符号只探测存在性,开销低。**复用现有函数,不新写正则**。
/// 无 grammar / 解析失败 / Vue 无 script / 无定义节点 → false(不提示)。
pub(crate) fn contains_definition(source: &str, ext: &str) -> bool {
let ext_lower = ext.to_lowercase();
let (parse_source, parse_ext): (String, &str) = if ext_lower == "vue" {
// Vue 逻辑在 <script> 段,切段借 ts(与 read_symbol 的 extract_vue_script 同源)
match extract_vue_script(source) {
Some((script, _)) => (script, "ts"),
None => return false, // 纯 template/style 无可提取符号
}
} else {
(source.to_string(), ext_lower.as_str())
};
let grammar = match grammar_for(parse_ext) {
Some(g) => g,
None => return false, // 不支持的语言不提示
};
let mut parser = Parser::new();
if parser.set_language(&grammar).is_err() {
return false; // grammar 加载失败(ABI 不兼容)不提示
}
let tree = match parser.parse(&parse_source, None) {
Some(t) => t,
None => return false, // 解析失败不提示
};
tree_has_definition(&tree.root_node())
}
/// 递归遍历语法树,判断是否含任一 DEFINITION_KINDS 定义节点(contains_definition 的遍历内核)。
fn tree_has_definition(node: &Node<'_>) -> bool {
if DEFINITION_KINDS.contains(&node.kind()) {
return true;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if tree_has_definition(&child) {
return true;
}
}
false
}
/// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。 /// 提取 Vue SFC 的 `<script>` 段内容(借 TS 解析)。
/// ///
/// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。 /// Vue 单文件组件逻辑在 `<script setup lang="ts">` 内,template/style 不做符号提取。
@@ -357,6 +403,118 @@ fn collect_calls_inner(
} }
} }
/// 文件符号概览(outline)— 提取**顶层**定义符号列表,供前端预览面板做符号树。
///
/// 与 read_symbol 复用同一套解析(grammar_for + DEFINITION_KINDS + Vue 切 script 段),
/// 但只返精简元数据 `{ name, kind, line, signature }`(不含全文/调用,轻量)。
/// 遍历语义:命中 DEFINITION_KINDS 的节点记入并**不下钻其子树** —— impl 内方法、
/// 函数/类内嵌套定义不算顶层(与 IDE 符号树顶层视图一致)。
///
/// **不报错底线**:无 grammar / 解析失败 / 无符号 / Vue 无 script → 空 Vec,不 Err 不 panic。
pub fn extract_outline(source: &str, ext: &str) -> Vec<serde_json::Value> {
let ext_lower = ext.to_lowercase();
// Vue 借 TS:切 script 段,行号偏移映射回原文件(与 read_symbol 同源)。
let (parse_source, parse_ext, line_offset): (String, &str, usize) = if ext_lower == "vue" {
match extract_vue_script(source) {
Some((script, byte_off)) => {
let off_lines = source[..byte_off].matches('\n').count();
(script, "ts", off_lines)
}
None => return Vec::new(), // 纯 template/style 无可提取符号
}
} else {
(source.to_string(), ext_lower.as_str(), 0)
};
let grammar = match grammar_for(parse_ext) {
Some(g) => g,
None => return Vec::new(), // 不支持的语言
};
let mut parser = Parser::new();
if parser.set_language(&grammar).is_err() {
return Vec::new(); // grammar 加载失败(ABI 不兼容)
}
let tree = match parser.parse(&parse_source, None) {
Some(t) => t,
None => return Vec::new(), // 解析失败
};
let bytes = parse_source.as_bytes();
let mut symbols = Vec::new();
collect_top_level_symbols(tree.root_node(), bytes, line_offset, &mut symbols);
symbols
}
/// 收集顶层定义符号(记入 + 不下钻:命中定义节点后不再进其子树)。
fn collect_top_level_symbols(
node: Node<'_>,
source: &[u8],
line_offset: usize,
out: &mut Vec<serde_json::Value>,
) {
if DEFINITION_KINDS.contains(&node.kind()) {
let name = symbol_name(node, source);
let kind = node.kind().to_string();
let line = node.start_position().row + 1 + line_offset;
// Python 等无 `{`/`;` 终结符的语言,extract_signature 会含函数体:outline 只取首行(头行即签名)。
let signature = extract_signature(&node_text(node, source))
.lines()
.next()
.unwrap_or("")
.trim()
.to_string();
out.push(serde_json::json!({
"name": name,
"kind": kind,
"line": line,
"signature": signature,
}));
return; // 不下钻(顶层符号语义)
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_top_level_symbols(child, source, line_offset, out);
}
}
/// 取定义节点展示名:name 字段 → type 字段(Rust impl_item)→ 首个 identifier 子节点
/// (Go type_declaration 等 name 藏在一层子节点内)。全空返空串(前端显示签名兜底)。
fn symbol_name(node: Node<'_>, source: &[u8]) -> String {
if let Some(n) = node.child_by_field_name("name") {
let t = node_text(n, source);
if !t.is_empty() {
return t;
}
}
if let Some(n) = node.child_by_field_name("type") {
let t = node_text(n, source);
if !t.is_empty() {
return t;
}
}
find_first_identifier(node, source).unwrap_or_default()
}
/// DFS 找首个 identifier 类子节点(Go type_declaration → type_spec → type_identifier 隔两层)。
fn find_first_identifier(node: Node<'_>, source: &[u8]) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
let kind = child.kind();
if kind == "identifier" || kind.ends_with("_identifier") {
let t = node_text(child, source);
if !t.is_empty() {
return Some(t);
}
}
}
for child in node.children(&mut cursor) {
if let Some(t) = find_first_identifier(child, source) {
return Some(t);
}
}
None
}
/// 兜底返回(不报错底线):任何失败路径统一返此结构,LLM 据 fallback/suggestion 回退 grep/read_file。 /// 兜底返回(不报错底线):任何失败路径统一返此结构,LLM 据 fallback/suggestion 回退 grep/read_file。
fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value { fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
@@ -633,4 +791,146 @@ fn outer() {
let calls = skeleton["calls"].as_array().expect("骨架应含 calls"); let calls = skeleton["calls"].as_array().expect("骨架应含 calls");
assert!(!calls.is_empty(), "应提取出内部调用"); assert!(!calls.is_empty(), "应提取出内部调用");
} }
// ============================================================
// contains_definition — AC-4 read_file 符号级命中检测
// ============================================================
#[test]
fn test_contains_definition_hits_definitions() {
// 含 fn/struct 定义 → true(应提示改用 read_symbol)
assert!(contains_definition(RUST_FIXTURE, "rs"));
assert!(contains_definition("pub fn main() {}\n", "rs"));
assert!(contains_definition("struct Config { name: String }\n", "rs"));
// TS/JS
assert!(contains_definition("export function greet() { return 1; }", "ts"));
assert!(contains_definition("const handler = () => {};", "js"));
// Go / Java / Python
assert!(contains_definition("package main\nfunc main() {}\n", "go"));
assert!(contains_definition("class Foo { void bar() {} }", "java"));
assert!(contains_definition("def foo():\n pass\n", "py"));
}
#[test]
fn test_contains_definition_plain_text_false() {
// 无定义节点的纯文本/纯注释 → false(符号级命中,不瞎提示)
assert!(!contains_definition("just some text\nwith no definitions\n", "rs"));
assert!(!contains_definition("// 只有注释\n/* 多行注释 */\n", "rs"));
// 字符串字面量里有 fn 字样但不是定义
assert!(!contains_definition("let msg = \"fn main() {}\";\nconsole.log(msg);", "js"));
}
#[test]
fn test_contains_definition_unsupported_ext_or_parse_fail() {
// Ruby 无 grammar → false
assert!(!contains_definition("def foo()\n puts 'hi'\nend\n", "rb"));
// 无扩展名 → false
assert!(!contains_definition("any content", ""));
// 大小写不敏感("RS" 等价 "rs")
assert!(contains_definition("pub fn main() {}\n", "RS"));
}
#[test]
fn test_contains_definition_vue() {
// Vue 含 <script> 函数 → true
let vue = "<template><div>{{ msg }}</div></template>\n\n<script setup lang=\"ts\">\nfunction greet() {}\n</script>\n";
assert!(contains_definition(vue, "vue"));
// 无 <script> 段(纯 template/style)→ false
let no_script = "<template><div>hi</div></template>\n<style></style>";
assert!(!contains_definition(no_script, "vue"));
}
// ============================================================
// extract_outline — 文件符号概览(顶层符号列表)
// ============================================================
#[test]
fn test_extract_outline_rust_top_level() {
let src = "use std::io;\n\npub fn read_symbol(path: &str) {\n helper(path);\n}\n\nfn helper(_x: &str) {}\n\nstruct Config {\n name: String,\n}\n\nimpl Config {\n fn new() -> Self { Config { name: String::new() } }\n}\n";
let symbols = extract_outline(src, "rs");
// 顶层:read_symbol / helper / Config(struct) / Config(impl);impl 内 new 不下钻
let names: Vec<&str> = symbols.iter().map(|s| s["name"].as_str().unwrap()).collect();
assert_eq!(names, vec!["read_symbol", "helper", "Config", "Config"]);
assert_eq!(symbols[0]["kind"], "function_item");
assert_eq!(symbols[2]["kind"], "struct_item");
// impl Config 的 name 来自 type 字段
assert_eq!(symbols[3]["kind"], "impl_item");
assert_eq!(symbols[3]["name"], "Config");
// 行号 1-based
assert_eq!(symbols[0]["line"].as_u64().unwrap(), 3);
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 7);
// signature 只到首个 {,不含函数体
assert_eq!(symbols[0]["signature"].as_str().unwrap(), "pub fn read_symbol(path: &str)");
assert!(!symbols[0]["signature"].as_str().unwrap().contains("helper(path)"));
}
#[test]
fn test_extract_outline_vue_line_offset() {
let vue = "<template><div>{{ msg }}</div></template>\n\n<script setup lang=\"ts\">\nfunction greet() {\n helper();\n}\n\nfunction helper() {}\n</script>\n";
let symbols = extract_outline(vue, "vue");
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0]["name"], "greet");
assert_eq!(symbols[0]["kind"], "function_declaration");
// script 内容以 \n 开头(第 3 行 <script> 标签后),greet 在原文件第 4 行、helper 第 8 行
assert_eq!(symbols[0]["line"].as_u64().unwrap(), 4);
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 8);
}
#[test]
fn test_extract_outline_ts_export_and_arrow() {
let src = "export function greet() { return 1; }\nexport const handler = () => {};\ninterface Foo { bar: string }\n";
let symbols = extract_outline(src, "ts");
let kinds: Vec<&str> = symbols.iter().map(|s| s["kind"].as_str().unwrap()).collect();
// export 包装函数仍命中(export_statement 非定义,下钻到 function_declaration)
assert_eq!(kinds, vec!["function_declaration", "variable_declarator", "interface_declaration"]);
assert_eq!(symbols[0]["name"], "greet");
assert_eq!(symbols[1]["name"], "handler");
assert_eq!(symbols[1]["signature"].as_str().unwrap(), "handler = () =>");
}
#[test]
fn test_extract_outline_go_type_declaration() {
let src = "package main\n\nfunc main() {}\n\ntype User struct {\n Name string\n}\n";
let symbols = extract_outline(src, "go");
assert_eq!(symbols[0]["name"], "main");
assert_eq!(symbols[0]["kind"], "function_declaration");
// Go type_declaration 的 name 藏在一层子节点(type_spec)内,走 identifier 回退
assert_eq!(symbols[1]["name"], "User");
assert_eq!(symbols[1]["kind"], "type_declaration");
assert_eq!(symbols[1]["line"].as_u64().unwrap(), 5);
}
#[test]
fn test_extract_outline_java_class_only_top() {
let src = "public class Foo {\n private int x;\n public void bar() {}\n}\n";
let symbols = extract_outline(src, "java");
// 类内 method 不下钻:仅 class Foo 一个顶层符号
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0]["name"], "Foo");
assert_eq!(symbols[0]["kind"], "class_declaration");
}
#[test]
fn test_extract_outline_python() {
let src = "import os\n\ndef foo(a):\n return a\n\nclass Bar:\n def method(self):\n pass\n";
let symbols = extract_outline(src, "py");
// class 内 method 不下钻
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0]["name"], "foo");
assert_eq!(symbols[0]["kind"], "function_definition");
assert_eq!(symbols[1]["name"], "Bar");
assert_eq!(symbols[1]["kind"], "class_definition");
}
#[test]
fn test_extract_outline_unsupported_or_fail_returns_empty() {
// 不支持语言
assert!(extract_outline("# Title\nplain text", "md").is_empty());
// 无 script 的 Vue
assert!(extract_outline("<template><div>hi</div></template>", "vue").is_empty());
// 纯文本(无定义节点)
assert!(extract_outline("just some text\nno definitions", "rs").is_empty());
// 残缺语法(tree-sitter 容错仍产出节点,不 panic 即可,不强行断言空)
let _ = extract_outline("fn broken(", "rs");
}
} }
+149 -8
View File
@@ -13,38 +13,179 @@
//! 读 task-local(同一 task,因 tools.execute 不 spawn 直接 await handler), //! 读 task-local(同一 task,因 tools.execute 不 spawn 直接 await handler),
//! 命中则改走 shell `execute_streaming`,每行回调 [`emit_output`] → AiCommandOutput。 //! 命中则改走 shell `execute_streaming`,每行回调 [`emit_output`] → AiCommandOutput。
//! //!
//! AC-EFF-S2-5(2026-08-09):AiCommandOutput 合批。run_command 逐行回调不再每行单独 emit +
//! publish_event(大输出风暴时 IPC 事件风暴),改为累积进 [`CommandSink`] 共享缓冲(stdout/stderr
//! 分桶),由「后台 50ms 定时 flush + 体积阈值(4KB)立即 flush + Drop 兜底 flush」三路输出,
//! 每个 stream 字段累积的字符串(多行 '\n' 连接)作为单条 AiCommandOutput 输出(对齐
//! stream_recv DELTA_FLUSH_INTERVAL=50ms 的 delta 合批;消费方按文本渲染,多行输出等价逐行)。
//!
//! 未注入 sink(非 run_command / 调用方未配 scope)时 [`emit_output`] 静默 noop, //! 未注入 sink(非 run_command / 调用方未配 scope)时 [`emit_output`] 静默 noop,
//! 兜底不报错不阻断。 //! 兜底不报错不阻断。
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use super::AiChatEvent; use super::AiChatEvent;
use df_execute::shell::StreamKind; use df_execute::shell::StreamKind;
/// AiCommandOutput 合批参数(AC-EFF-S2-5,对齐 stream_recv DELTA_FLUSH_INTERVAL=50ms)。
///
/// 时间维度:后台 flush task 每 50ms 兜底输出一次(与 AR-8 delta 合批同 cadence);
/// 体积维度:单次累积超 [`FLUSH_BYTE_THRESHOLD`] 立即 flush(防大输出风暴 50ms 窗口内积压过多)。
/// 语义:stdout/stderr 各累积的字符串(多行以 '\n' 连接)作为**单条** AiCommandOutput 事件输出,
/// 不再每行单独 emit + publish_event——砍 IPC 事件风暴(与 delta 合批「合并后前端逐条累加,最终一致」
/// 同思路,消费方按文本渲染,多行输出等价于逐行)。
const FLUSH_INTERVAL: Duration = Duration::from_millis(50);
const FLUSH_BYTE_THRESHOLD: usize = 4096;
/// 一次 run_command 调用的输出下沉目标(emit 事件所需上下文)。 /// 一次 run_command 调用的输出下沉目标(emit 事件所需上下文)。
#[derive(Clone)] ///
/// AC-EFF-S2-5(2026-08-09):合批。run_command 逐行回调 → 累积进共享缓冲(stdout/stderr 分桶),
/// 由「后台 50ms 定时 flush + 体积阈值立即 flush + Drop 兜底 flush」三路输出,不再每行单独
/// emit(治 aichat 效率 S2-5)。缓冲用 `std::sync::Mutex`(短临界区无 await,emit 是同步回调)。
pub struct CommandSink { pub struct CommandSink {
app: AppHandle, app: AppHandle,
tool_call_id: String, tool_call_id: String,
conversation_id: Option<String>, conversation_id: Option<String>,
/// 合批共享缓冲(emit 线程写 / flush 定时读)。
buffer: Arc<StdMutex<CommandBuffer>>,
/// 后台 flush task 守卫(Drop 时停止 + abort,并兜底 flush 尾部)。
_flusher: FlushGuard,
}
/// 合批缓冲:stdout/stderr 分开累积(按 stream 字段 emit),bytes 供体积阈值判定。
#[derive(Default)]
struct CommandBuffer {
stdout: String,
stderr: String,
bytes: usize,
}
/// 后台 flush task 守卫:stop 标志 + JoinHandle。Drop 时置停 + abort(任务 50ms 循环读到 stop 退出),
/// 尾部输出由 CommandSink::drop 兜底 flush,不丢命令结尾。
struct FlushGuard {
stop: Arc<AtomicBool>,
handle: Option<tokio::task::JoinHandle<()>>,
} }
impl CommandSink { impl CommandSink {
pub fn new(app: AppHandle, tool_call_id: String, conversation_id: Option<String>) -> Self { pub fn new(app: AppHandle, tool_call_id: String, conversation_id: Option<String>) -> Self {
Self { app, tool_call_id, conversation_id } let buffer = Arc::new(StdMutex::new(CommandBuffer::default()));
let stop = Arc::new(AtomicBool::new(false));
// 后台 50ms flush task。仅当处于 tokio runtime 上下文时 spawn(tokio::spawn 需 runtime)。
// CommandSink::new 由 execute_with_heartbeat / ai_approve(async)调用,正常在 runtime 内;
// 非 runtime(如单测直接 new)跳过定时 task,退化为「体积阈值 + Drop 兜底」flush(仍不丢尾部)。
let handle = if tokio::runtime::Handle::try_current().is_ok() {
let buf = buffer.clone();
let stop_f = stop.clone();
let app_f = app.clone();
let id_f = tool_call_id.clone();
let conv_f = conversation_id.clone();
Some(tokio::spawn(async move {
let mut interval = tokio::time::interval(FLUSH_INTERVAL);
interval.tick().await; // 弃首 tick(tokio interval 首 tick 立即返回,对齐心跳弃首模式)
loop {
interval.tick().await;
if stop_f.load(Ordering::SeqCst) {
break;
}
Self::flush_inner(&app_f, &id_f, &conv_f, &buf);
}
}))
} else {
None
};
Self {
app,
tool_call_id,
conversation_id,
buffer,
_flusher: FlushGuard { stop, handle },
}
} }
/// emit 一行 stdout/stderr(AiCommandOutput,双写 app.emit + ai_event_bus)。 /// 累积一行 + 触发 flush 判定(行加入 stdout/stderr 分桶)。
/// emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。 ///
/// AC-EFF-S2-5:不立即 emit,先累积入缓冲;体积超阈值立即 flush,否则等 50ms 定时 flush
/// 或 Drop 兜底 flush。emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。
fn emit(&self, kind: StreamKind, line: &str) { fn emit(&self, kind: StreamKind, line: &str) {
let over_threshold = {
let mut buf = self.buffer.lock().unwrap();
let target = if kind == StreamKind::Stdout { &mut buf.stdout } else { &mut buf.stderr };
if !target.is_empty() {
target.push('\n');
}
target.push_str(line);
buf.bytes += line.len();
buf.bytes >= FLUSH_BYTE_THRESHOLD
};
if over_threshold {
Self::flush_inner(&self.app, &self.tool_call_id, &self.conversation_id, &self.buffer);
}
}
/// 兜底 flush(把当前缓冲整体取出并 emit)。Drop 时调用,保证尾部输出不丢。
fn flush(&self) {
Self::flush_inner(&self.app, &self.tool_call_id, &self.conversation_id, &self.buffer);
}
/// 内部 flush:单次锁内 take 缓冲(不持锁 emit),stdout/stderr 各输出一条(多行拼接)。
/// 与后台 task / Drop 并发调用安全:take 语义下同一批只被 drain 一次(无重复 emit)。
fn flush_inner(
app: &AppHandle,
tool_call_id: &str,
conversation_id: &Option<String>,
buffer: &Arc<StdMutex<CommandBuffer>>,
) {
let drained = {
let mut buf = buffer.lock().unwrap();
if buf.stdout.is_empty() && buf.stderr.is_empty() {
return;
}
Some(std::mem::take(&mut *buf))
};
if let Some(buf) = drained {
if !buf.stdout.is_empty() {
Self::emit_line(app, tool_call_id, conversation_id, StreamKind::Stdout, &buf.stdout);
}
if !buf.stderr.is_empty() {
Self::emit_line(app, tool_call_id, conversation_id, StreamKind::Stderr, &buf.stderr);
}
}
}
/// emit 一条 AiCommandOutput(stdout/stderr 累积字符串,双写 app.emit + ai_event_bus)。
fn emit_line(
app: &AppHandle,
tool_call_id: &str,
conversation_id: &Option<String>,
kind: StreamKind,
line: &str,
) {
let ev = AiChatEvent::AiCommandOutput { let ev = AiChatEvent::AiCommandOutput {
id: self.tool_call_id.clone(), id: tool_call_id.to_string(),
stream: kind.as_str().to_string(), stream: kind.as_str().to_string(),
line: line.to_string(), line: line.to_string(),
conversation_id: self.conversation_id.clone(), conversation_id: conversation_id.clone(),
}; };
let _ = self.app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
let _ = self.app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
}
impl Drop for CommandSink {
fn drop(&mut self) {
// 停后台 flush task + abort(任务在 stop 后下次 tick 退出),再兜底 flush 剩余缓冲
// (命令结束尾部输出不丢)。并发安全:flush_inner 的 take 语义保证不重复 emit。
self._flusher.stop.store(true, Ordering::SeqCst);
if let Some(h) = self._flusher.handle.take() {
h.abort();
}
self.flush();
} }
} }
+96 -23
View File
@@ -37,8 +37,8 @@ use super::super::audit::{audit_finalize, emit_data_changed};
use super::super::augmentation::build_augmentation_segment; use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize; use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation; use super::super::conversation::save_conversation;
use super::super::knowledge_inject::inject_knowledge_into_prompt; use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded}; use super::super::prompt::{build_system_prompt_parts, cache_enabled};
use super::super::{AiChatEvent, ApprovalKind, SessionState}; use super::super::{AiChatEvent, ApprovalKind, SessionState};
@@ -240,6 +240,20 @@ fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<Strin
(proj, task) (proj, task)
} }
/// 当前 provider 是否 anthropic 协议(仅此类型启用 prompt caching 拆分,OpenAI 路径零影响)。
fn is_anthropic_type(provider_type: &str) -> bool {
matches!(provider_type, "anthropic" | "anthropic_compat")
}
/// 把段追加到易变尾段(以 `\n\n---\n` 分隔,对齐旧 system 拼段风格)。首段直接赋。
fn append_volatile(volatile: String, seg: &str) -> String {
if volatile.is_empty() {
seg.to_string()
} else {
format!("{}\n\n---\n{}", volatile, seg)
}
}
// ============================================================ // ============================================================
// 发送 / 审批 / 控制 // 发送 / 审批 / 控制
// ============================================================ // ============================================================
@@ -301,24 +315,38 @@ pub async fn ai_regenerate(
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。 // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone(); let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。 // 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复: // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
// 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active, // 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
// 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。 // 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。
let mut system_prompt = { {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await if cache_on {
}; let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
// Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 // Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。 // 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() { if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
} }
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖) // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
@@ -352,7 +380,7 @@ pub async fn ai_regenerate(
}; };
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -633,12 +661,19 @@ pub async fn ai_chat_send(
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。 // (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。 // mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans); let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await; // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。 // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。 // 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() { if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
} }
// conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。 // conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。
@@ -648,8 +683,16 @@ pub async fn ai_chat_send(
// 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。 // 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{ {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; if cache_on {
let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
} }
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。 // 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。
// ai_chat_send 是 miniapp send_message 路由的入口,override 持久语义对跨端设定模型关键: // ai_chat_send 是 miniapp send_message 路由的入口,override 持久语义对跨端设定模型关键:
@@ -679,7 +722,7 @@ pub async fn ai_chat_send(
}; };
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -1718,23 +1761,37 @@ pub async fn ai_chat_edit(
let _tool_defs = state.ai_tools.tool_definitions(); let _tool_defs = state.ai_tools.tool_definitions();
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
let system_prompt = build_system_prompt(&state, &lang).await;
// conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。 // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
let conv_id = conversation_id.clone(); let conv_id = conversation_id.clone();
// Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。 // 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user, // 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
// helper 读到的末条 active user 即编辑后的新文本,语义等价。 // helper 读到的末条 active user 即编辑后的新文本,语义等价。
let mut system_prompt = { {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await if cache_on {
}; let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
}
// Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。 // Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
if !aug_seg.is_empty() { if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
} }
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复) // 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复)
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
@@ -1761,6 +1818,7 @@ pub async fn ai_chat_edit(
app_handle, app_handle,
provider_config, provider_config,
system_prompt, system_prompt,
volatile_tail,
conv_id, conv_id,
knowledge_config, knowledge_config,
llm_concurrency, llm_concurrency,
@@ -1960,19 +2018,34 @@ pub async fn ai_chat_force_send(
let lang = language.unwrap_or_else(|| "zh-CN".to_string()); let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。 // 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans); let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await; // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
let cache_on = cache_enabled(&state).await && is_anthropic_type(&provider_config.provider_type);
let (mut system_prompt, mut volatile) =
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。 // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await; let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
if !aug_seg.is_empty() { if !aug_seg.is_empty() {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg); if cache_on {
volatile = append_volatile(volatile, &aug_seg);
} else {
system_prompt = format!("{}\n\n---\n{}", system_prompt, aug_seg);
}
} }
// conv_id 已在上方状态占用块得出。 // conv_id 已在上方状态占用块得出。
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。 // 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。 // 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{ {
let config = state.knowledge_config.lock().await.clone(); let config = state.knowledge_config.lock().await.clone();
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; if cache_on {
let knowledge = build_knowledge_context_for_conv(&state, &conv_id, &config).await;
if !knowledge.is_empty() {
volatile = append_volatile(volatile, &knowledge);
}
} else {
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
}
} }
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
// 每会话独立模型(与 ai_chat_send/regenerate 路径一致,持久读 per_conv)。 // 每会话独立模型(与 ai_chat_send/regenerate 路径一致,持久读 per_conv)。
// force_send 语义 = 强制重发,尊重 per_conv 已有模型(override 持久)。 // force_send 语义 = 强制重发,尊重 per_conv 已有模型(override 持久)。
@@ -1992,7 +2065,7 @@ pub async fn ai_chat_force_send(
let max_retries = state.agent_max_retries.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await; run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await;
}); });
Ok("ok".to_string()) Ok("ok".to_string())
@@ -106,6 +106,7 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens, prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens, prompt_cache_miss_tokens: rec.prompt_cache_miss_tokens,
reasoning_tokens: rec.reasoning_tokens, reasoning_tokens: rec.reasoning_tokens,
is_estimated: rec.is_estimated,
} }
} }
@@ -155,6 +156,7 @@ pub fn message_to_record(
prompt_cache_hit_tokens: msg.prompt_cache_hit_tokens, prompt_cache_hit_tokens: msg.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens, prompt_cache_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens, reasoning_tokens: msg.reasoning_tokens,
is_estimated: msg.is_estimated,
} }
} }
@@ -836,6 +838,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
} }
} }
+63 -37
View File
@@ -251,7 +251,9 @@ async fn save_conversation_inner(
// 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。 // 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。
// 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow, // 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。 // 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = { // BE1(AC-EFF-L0-1):无变化捷径提前到克隆之前——同一 lock 段内先判「消息数未增且非 dirty」,
// 无变化则不 clone(每轮无变化 save 省一次全量 clone + 后续 truncate/records 构建)。
let (provider_id, created_at, pinned_goals, msg_len, persisted_count, needs_full_rewrite, mut persist_msgs) = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治): // 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。 // 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -264,50 +266,66 @@ async fn save_conversation_inner(
return; return;
} }
let __wait = __lock_start.elapsed(); let __wait = __lock_start.elapsed();
// 先取 session 顶层 owned 字段(immutable borrow 即刻结束),再取 conv mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let provider_id = session.active_provider_id.clone(); let provider_id = session.active_provider_id.clone();
let created_at = session.active_conv_created_at.clone(); let created_at = session.active_conv_created_at.clone();
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
let cloned = ( let msg_len = conv.messages.len();
conv.messages.all_messages_clone(), let persisted_count = conv.messages.persisted_msg_count();
let needs_full_rewrite = conv.messages.needs_full_rewrite();
// 无变化捷径提前判定:消息数未增且非 dirty → DB ai_messages 与内存完全一致,
// 免 clone(下方直接走元数据 update_full,消息写入整段跳过)。
let cloned = if !needs_full_rewrite && msg_len == persisted_count {
Vec::new()
} else {
conv.messages.all_messages_clone()
};
let pinned_goals = conv.pinned_goals.clone();
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
(
provider_id, provider_id,
created_at, created_at,
conv.pinned_goals.clone(), pinned_goals,
conv.messages.persisted_msg_count(), msg_len,
conv.messages.needs_full_rewrite(), persisted_count,
); needs_full_rewrite,
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) cloned,
cloned )
}; };
let __lock_total = __lock_start.elapsed(); let __lock_total = __lock_start.elapsed();
if __lock_total > std::time::Duration::from_millis(50) { if __lock_total > std::time::Duration::from_millis(50) {
eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total); eprintln!("[LOCK-DIAG] save 快照段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
} }
// truncate 在锁外(clone 副本上操作,不影响 session 真相源) // BE1:仅当消息确有可能变化(非无变化捷径)才对 clone 副本做 namespace 展开 + truncate。
// T2: 展开 namespace 引用为原文再落库 let no_message_change = !needs_full_rewrite && msg_len == persisted_count;
{ if !no_message_change {
let session = session_arc.lock().await; // truncate 在锁外(clone 副本上操作,不影响 session 真相源)
for m in &mut msgs { // T2: 展开 namespace 引用为原文再落库
if df_ai::namespace_store::is_namespace_ref(&m.content) { {
if let Some(original) = session.namespace_store.read_only(&m.content) { let session = session_arc.lock().await;
m.content = original.to_string(); for m in &mut persist_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
if let Some(original) = session.namespace_store.read_only(&m.content) {
m.content = original.to_string();
}
} }
} }
} }
} for m in &mut persist_msgs {
for m in &mut msgs { // P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样), // 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL // tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。 if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
if !matches!(m.role, df_ai::provider::MessageRole::Tool) { m.content = truncate_for_persist(&m.content);
m.content = truncate_for_persist(&m.content); }
} // F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片), // 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。 if let Some(parts) = m.parts.as_ref() {
if let Some(parts) = m.parts.as_ref() { m.parts = truncate_parts_for_persist(parts);
m.parts = truncate_parts_for_persist(parts); }
} }
} }
let persist_msgs = msgs;
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目, // 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
@@ -335,12 +353,19 @@ async fn save_conversation_inner(
// 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。 // 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。
let now = now_millis(); let now = now_millis();
let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone()); let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone());
let total_len = persist_msgs.len(); // BE1:no_message_change 时 persist_msgs 为空,records 也空(下方无变化捷径直接跳过消息写入,
let records: Vec<df_storage::models::AiMessageRecord> = persist_msgs // 仅元数据 update_full);非无变化时 records 由全量 persist_msgs 构建(append 切片仍按 persisted_count)。
.iter() let records: Vec<df_storage::models::AiMessageRecord> = if no_message_change {
.enumerate() Vec::new()
.map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at)) } else {
.collect(); persist_msgs
.iter()
.enumerate()
.map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at))
.collect()
};
// BE1:total_len 用 clone 后权威条数(no_message_change 时用 lock 读的 msg_len,=persisted_count)。
let total_len = if no_message_change { msg_len } else { persist_msgs.len() };
let conv_repo = AiConversationRepo::new(db); let conv_repo = AiConversationRepo::new(db);
let msg_repo = df_storage::crud::AiMessageRepo::new(db); let msg_repo = df_storage::crud::AiMessageRepo::new(db);
@@ -681,6 +706,7 @@ mod tests {
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: None, timestamp: None,
} }
} }
+34 -24
View File
@@ -351,6 +351,39 @@ pub(crate) async fn build_knowledge_context(
out out
} }
/// 取末条 active user 消息文本 + id,构建知识库上下文段(DRY:注入 [`inject_knowledge_into_prompt`]
/// 与 Anthropic prompt caching 易变段共用)。
///
/// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤
/// 导致两值取自不同消息。auto_inject 关 / 无命中返回空串。
pub(crate) async fn build_knowledge_context_for_conv(
state: &AppState,
conv_id: &str,
config: &crate::state::KnowledgeConfig,
) -> String {
let (last_user_text, user_message_id) = {
let __lock_t = std::time::Instant::now();
let session = state.ai_session.lock().await;
let msgs = session
.conv_read(conv_id)
.map(|c| c.messages.all_messages_clone())
.unwrap_or_default();
let found = msgs
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] build_knowledge_context_for_conv 持锁 {:?} (含 lock 等待)", __hold);
}
match found {
Some(m) => (m.content.clone(), m.id.clone()),
None => (String::new(), None),
}
};
build_knowledge_context(state, conv_id, &last_user_text, config, user_message_id.as_deref()).await
}
/// 知识注入 system prompt 的单一入口(DRY:F-09 agentic + chat 五处合一)。 /// 知识注入 system prompt 的单一入口(DRY:F-09 agentic + chat 五处合一)。
/// ///
/// 把原本散落在 `try_continue_agent_loop`(agentic/mod.rs)+ `ai_chat_send` / /// 把原本散落在 `try_continue_agent_loop`(agentic/mod.rs)+ `ai_chat_send` /
@@ -378,30 +411,7 @@ pub(crate) async fn inject_knowledge_into_prompt(
system_prompt: String, system_prompt: String,
config: &crate::state::KnowledgeConfig, config: &crate::state::KnowledgeConfig,
) -> String { ) -> String {
// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤 let knowledge_context = build_knowledge_context_for_conv(state, conv_id, config).await;
// 导致两值取自不同消息。
let (last_user_text, user_message_id) = {
let __lock_t = std::time::Instant::now();
let session = state.ai_session.lock().await;
let msgs = session
.conv_read(conv_id)
.map(|c| c.messages.all_messages_clone())
.unwrap_or_default();
let found = msgs
.iter()
.rev()
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] build_system_prompt_with_knowledge:383 持锁 {:?} (含 lock 等待)", __hold);
}
match found {
Some(m) => (m.content.clone(), m.id.clone()),
None => (String::new(), None),
}
};
let knowledge_context =
build_knowledge_context(state, conv_id, &last_user_text, config, user_message_id.as_deref()).await;
if knowledge_context.is_empty() { if knowledge_context.is_empty() {
system_prompt system_prompt
} else { } else {
+234 -19
View File
@@ -1,5 +1,9 @@
//! 系统提示词构建 + 活跃提供商获取 //! 系统提示词构建 + 活跃提供商获取
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use df_storage::db::Database;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
use crate::commands::err_str; use crate::commands::err_str;
@@ -43,7 +47,24 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。 /// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
fn env_profile_line() -> String { fn env_profile_line() -> String {
let today = chrono::Local::now().format("%Y-%m-%d"); let today = chrono::Local::now().format("%Y-%m-%d");
let (os, shell, interp) = if cfg!(target_os = "windows") { format!("当前日期: {today} | {}", env_profile_stable_body())
}
/// env_profile_line 的静态稳定段(OS + shell + 执行姿势,不含日期)。
///
/// Anthropic prompt caching 模式把日期挪到易变段(env_profile_date_volatile,拼首条 user 消息),
/// 本稳定段留在 system 供缓存命中——日期每日变,留 system 会每日失效整段缓存。
fn env_profile_stable_body() -> String {
let (os, shell, interp) = env_os_shell_interp();
format!(
"运行环境: {os} | shell: {shell}\n\
姿: (//$) .ps1 .py , `powershell -File x.ps1` `python x.py` , -c (): {interp}\n\n"
)
}
/// 平台对应 (os, shell, 可用解释器) 三元组(cfg! 分支,见 env_profile_line 文档)。
fn env_os_shell_interp() -> (&'static str, &'static str, &'static str) {
if cfg!(target_os = "windows") {
("Windows", "PowerShell", "python(无 python3)、node") ("Windows", "PowerShell", "python(无 python3)、node")
} else if cfg!(target_os = "macos") { } else if cfg!(target_os = "macos") {
("macOS", "bash/zsh", "python3、node") ("macOS", "bash/zsh", "python3、node")
@@ -51,11 +72,13 @@ fn env_profile_line() -> String {
("Linux", "bash", "python3、node") ("Linux", "bash", "python3、node")
} else { } else {
("Unknown", "sh", "python3、node") ("Unknown", "sh", "python3、node")
}; }
format!( }
"当前日期: {today} | 运行环境: {os} | shell: {shell}\n\
姿: (//$) .ps1 .py , `powershell -File x.ps1` `python x.py` , -c (): {interp}\n\n" /// Anthropic prompt caching 模式:日期易变段(拼到首条 user 消息,不进 system)。
) fn env_profile_date_volatile() -> String {
let today = chrono::Local::now().format("%Y-%m-%d");
format!("今天是 {today}")
} }
/// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题) /// 按语言返回系统提示词的 (固定前缀, 项目上下文标题, 任务上下文标题)
@@ -247,6 +270,98 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
} }
} }
// ── BE3(AC-EFF-L0-2): system prompt 会话内缓存 ──
//
// 背景:同一会话连续轮次(每次 send)都全量重建 system prompt——查活跃 project/task 清单 +
// 逐条 format 拼多 KB 字符串,对高频轮次是纯重复成本。
//
// 方案:按「数据指纹」缓存已拼好的 system 串(指纹 = 日期 + lang + custom_prompt + 活跃
// project/task 的 id+updated_at + 去重排除集)。任何依赖数据变更 → 指纹变 → 重建;未变直接
// 复用缓存串(省 2 次全行物化查询 + 字符串拼接)。
//
// 指纹键而非 conv_id:system prompt 内容本就与 conv_id 无关,同数据跨会话共享同一缓存串,
// 免 per-conv 生命周期清理(换会话/删会话零维护)。模块级静态(OnceLock+Mutex),不新增
// AppState 字段,进程内单例,懒初始化。
//
// 指纹查询只取轻量列(id+updated_at 两列,省 list_active 的全行物化);查询失败回退「强制重建」,
// 正确性不受损(只是多一次重建)。缓存按指纹封顶,超上限清空防长跑进程内存膨胀。
static SYSTEM_PROMPT_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
/// 缓存上限(条目)。system prompt 内容按指纹去重后通常仅 1~2 条;上限防御性封顶。
const SYSTEM_PROMPT_CACHE_MAX: usize = 32;
fn system_prompt_cache() -> &'static Mutex<HashMap<String, String>> {
SYSTEM_PROMPT_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 活跃实体(project/task)数据指纹:轻量查 id+updated_at 两列,拼成指纹串。
/// 返回 None = 查询失败(调用方按「已变更」处理强制重建,正确性不受损)。
async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<String> {
let conn = db.conn().clone();
let result = tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard.prepare(sql).ok()?;
let rows = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let updated: String = row.get(1)?;
Ok(format!("{}@{};", id, updated))
})
.ok()?;
let mut out = String::new();
for r in rows {
out.push_str(&r.ok()?);
}
Some(out)
})
.await;
result.ok().flatten()
}
/// 计算 system prompt 缓存指纹(返回 None 表示指纹依赖查询失败,调用方按 miss 处理强制重建)。
///
/// 指纹覆盖全部依赖输入:
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
async fn system_prompt_fingerprint(
state: &AppState,
lang: &str,
excl_proj: &[String],
excl_task: &[String],
) -> Option<String> {
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let custom = state
.settings
.get("custom_prompt")
.await
.ok()
.flatten()
.unwrap_or_default();
let proj_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL",
)
.await?;
let task_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM tasks WHERE deleted_at IS NULL",
)
.await?;
let mut key = format!("{date}|{lang}|{custom}|{proj_fp}|{task_fp}");
for id in excl_proj {
key.push_str("|p:");
key.push_str(id);
}
for id in excl_task {
key.push_str("|t:");
key.push_str(id);
}
Some(key)
}
/// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**) /// 构建系统提示词(环境信息 + 固定前缀 + 当前项目/任务**全局清单**)
/// ///
/// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path), /// 本函数注入"全貌"清单:最近 20 项目 + 20 任务的 id/name/status/description(无 path),
@@ -260,6 +375,11 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
/// ///
/// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给 /// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给
/// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。 /// [`build_system_prompt_with_excluded`] 传两个空排除列表(无去重)。
///
/// 注:caching 拆分后主链(send/regenerate/edit/force_send/续跑)已改走
/// [`build_system_prompt_parts`](开关关逐字等价),本 wrapper 暂零调用方——保留作预留 API
/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。
#[allow(dead_code)]
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String { pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
build_system_prompt_with_excluded(state, lang, &[], &[]).await build_system_prompt_with_excluded(state, lang, &[], &[]).await
} }
@@ -283,7 +403,15 @@ pub(crate) async fn build_system_prompt_with_excluded(
excluded_project_ids: &[String], excluded_project_ids: &[String],
excluded_task_ids: &[String], excluded_task_ids: &[String],
) -> String { ) -> String {
let (prefix, proj_label, task_label) = system_prompt_parts(lang); // BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).await;
if let Some(key) = &fp_key {
let cache = system_prompt_cache();
if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) {
return cached.clone();
}
}
let (prefix, _, _) = system_prompt_parts(lang);
let mut prompt = env_profile_line(); let mut prompt = env_profile_line();
prompt.push_str(prefix); prompt.push_str(prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试) // 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
@@ -291,55 +419,142 @@ pub(crate) async fn build_system_prompt_with_excluded(
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败) // 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
prompt.push_str(app_config_query_guidance_section(lang)); prompt.push_str(app_config_query_guidance_section(lang));
// 当前数据上下文(项目/任务全局清单)+ 自定义提示词 —— 拆出共用 helper,行为逐字等价。
prompt.push_str(&build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await);
prompt.push_str(&build_custom_section(state).await);
// BE3:未命中 → 存入缓存供下轮复用(按指纹封顶,超上限清空防长跑进程内存膨胀)。
if let Some(key) = fp_key {
let mut cache = system_prompt_cache().lock().unwrap_or_else(|p| p.into_inner());
if cache.len() >= SYSTEM_PROMPT_CACHE_MAX {
cache.clear();
}
cache.insert(key, prompt.clone());
}
prompt
}
/// 项目/任务全局清单段(DRY:主构建 [`build_system_prompt_with_excluded`] 与 caching 易变段
/// [`build_system_prompt_parts`] 共用)。
///
/// 拼接项目段 + 任务段(含机制层注明语);无项目/任务返回空串。
/// 语义与旧 build_system_prompt_with_excluded 内联块逐字一致(纯提取,零行为变更)。
async fn build_context_lists(
state: &AppState,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
lang: &str,
) -> String {
let (_, proj_label, task_label) = system_prompt_parts(lang);
let mut out = String::new();
// 附加当前数据上下文 // 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await { if let Ok(projects) = state.projects.list_active().await {
if !projects.is_empty() { if !projects.is_empty() {
prompt.push_str(proj_label); out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀 // system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt) // 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) { for p in projects.iter().take(20) {
if excluded_project_ids.iter().any(|id| id == &p.id) { if excluded_project_ids.iter().any(|id| id == &p.id) {
continue; continue;
} }
prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description)); out.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description));
if let Some(ref dir) = p.path { if let Some(ref dir) = p.path {
prompt.push_str(&format!(" 目录: {}\n", dir)); out.push_str(&format!(" 目录: {}\n", dir));
} }
} }
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用 // 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
prompt.push_str(&projects_listed_note(lang, 20)); out.push_str(&projects_listed_note(lang, 20));
} }
} }
// 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层) // 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层)
// 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。 // 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。
if let Ok(tasks) = state.tasks.list_active().await { if let Ok(tasks) = state.tasks.list_active().await {
if !tasks.is_empty() { if !tasks.is_empty() {
prompt.push_str(task_label); out.push_str(task_label);
// 去重:被 @ 的任务跳过(同项目去重机理) // 去重:被 @ 的任务跳过(同项目去重机理)
for tk in tasks.iter().take(20) { for tk in tasks.iter().take(20) {
if excluded_task_ids.iter().any(|id| id == &tk.id) { if excluded_task_ids.iter().any(|id| id == &tk.id) {
continue; continue;
} }
prompt.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description)); out.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description));
} }
// 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks
prompt.push_str(&tasks_listed_note(lang)); out.push_str(&tasks_listed_note(lang));
} }
} }
out
}
// 自定义提示词(设置中配置,追加到系统 prompt 末尾) /// 自定义提示词(设置 custom_prompt,追加到 system prompt 末尾)。无/空返回空串。
async fn build_custom_section(state: &AppState) -> String {
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await { if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
if !custom.is_empty() { if !custom.is_empty() {
let clean = custom.trim().trim_matches('"'); let clean = custom.trim().trim_matches('"');
if !clean.is_empty() { if !clean.is_empty() {
prompt.push_str("\n## 自定义指令\n"); return format!("\n## 自定义指令\n{}\n", clean);
prompt.push_str(clean);
prompt.push('\n');
} }
} }
} }
String::new()
}
prompt /// 读取 Anthropic prompt caching 开关(AppState settings KV,默认关)并同步 df-ai 进程级开关。
///
/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的 anthropic 端点显式开启。
/// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态
/// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。
pub(crate) async fn cache_enabled(state: &AppState) -> bool {
let v = state
.settings
.get("df-ai-anthropic-cache")
.await
.ok()
.flatten()
.unwrap_or_default();
let enabled = v == "true" || v == "1";
df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled);
enabled
}
/// 构建 system prompt(Anthropic prompt caching 模式)。
///
/// 返回 `(system 稳定段, 易变尾段)`:
/// - 开关关(`cache_enabled=false`)→ `(完整 system, 空串)`:与 [`build_system_prompt_with_excluded`]
/// 逐字等价(零行为变更,兼容非官方网关)。
/// - 开关开 → `(静态稳定段, 易变段)`:日期/项目/任务清单从 system 挪出,由上层把易变段
/// **追加到消息流末尾(user 消息,不进 system → 不失效 system 缓存)**。放消息流末尾而非
/// 首条 user:任何 system 之后的消息级缓存断点都在易变段之前,每日/每请求变化的易变内容
/// 不会失效已缓存前缀(跨天缓存仍命中)。
///
/// 注意:调用方须先按「开关开 AND 当前 provider 为 anthropic 类型」gate 再传 `cache_enabled`,
/// 保证 OpenAI 路径零影响(开关开也不改动 openai 请求结构)。
pub(crate) async fn build_system_prompt_parts(
state: &AppState,
lang: &str,
excluded_project_ids: &[String],
excluded_task_ids: &[String],
cache_enabled: bool,
) -> (String, String) {
if !cache_enabled {
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await;
return (full, String::new());
}
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索 + 查配置引导 + 自定义),
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
let (prefix, _, _) = system_prompt_parts(lang);
let mut stable = env_profile_stable_body();
stable.push_str(prefix);
stable.push_str(doc_exploration_strategy_section(lang));
stable.push_str(app_config_query_guidance_section(lang));
stable.push_str(&build_custom_section(state).await);
let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await;
let volatile = if lists.is_empty() {
env_profile_date_volatile()
} else {
format!("{}\n\n{}", env_profile_date_volatile(), lists)
};
(stable, volatile)
} }
/// 项目清单尾部注明语(中/英)。 /// 项目清单尾部注明语(中/英)。
+18 -1
View File
@@ -328,6 +328,11 @@ pub(crate) async fn stream_llm(
), ),
}; };
} }
// 已收到 finish_reason 仅剩等 usage 帧:慢 provider 拖过 idle 窗口不降 Partial,
// 按正常完成收尾(usage 缺失用默认 0,文本/tool_calls 已完整)。
if finished_received {
break;
}
warn!( warn!(
provider = %provider.name(), provider = %provider.name(),
conv_id = %conv_id, conv_id = %conv_id,
@@ -379,6 +384,11 @@ pub(crate) async fn stream_llm(
), ),
}; };
} }
// 已收到 finish_reason 仅剩等 usage 帧:慢/坏 provider 拖过 15s 不降 Partial,
// 按正常完成收尾(与 idle_deadline 分支同口径,usage 缺失用默认 0)。
if finished_received {
break;
}
warn!( warn!(
provider = %provider.name(), provider = %provider.name(),
conv_id = %conv_id, conv_id = %conv_id,
@@ -461,7 +471,14 @@ pub(crate) async fn stream_llm(
} }
if chunk.finished { if chunk.finished {
finished_received = true; finished_received = true;
break; // usage 携带点:OpenAI 兼容流中 usage 挂在 [DONE] 帧(或 usage-only 帧),
// 而 finish_reason 帧(finished=true, usage=None)在其之前到达。
// 若此刻 break 会错过 [DONE] 帧的 usage → 真实 completion_tokens 丢失,
// 前端 token 显示 0。拿到真实 usage 才停;usage 仍 None 则继续读到
// usage-only/[DONE] 帧或通道关闭(Ok(None) 兜底退出,不断连误判)。
if chunk.usage.is_some() {
break;
}
} }
} }
Ok(Some(Err(err_str))) => { Ok(Some(Err(err_str))) => {
+1
View File
@@ -71,6 +71,7 @@ pub(crate) async fn ensure_conversation_title(
prompt_cache_hit_tokens: None, prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None, prompt_cache_miss_tokens: None,
reasoning_tokens: None, reasoning_tokens: None,
is_estimated: None,
timestamp: m.timestamp, timestamp: m.timestamp,
}) })
.collect(); .collect();
+147 -13
View File
@@ -87,10 +87,7 @@ pub fn register(
let mut file = File::open(path).await let mut file = File::open(path).await
.map_err(|e| { .map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!( file_not_found_error(path)
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
path
)
} else { } else {
anyhow::anyhow!("无法访问文件 {}: {}", path, e) anyhow::anyhow!("无法访问文件 {}: {}", path, e)
} }
@@ -182,14 +179,13 @@ pub fn register(
consumed, consumed,
)) ))
} else { None }; } else { None };
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且扩展名在 read_symbol // 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且**符号级命中**检测通过
// 支持语言集合内,则末尾追加一句引导提示改用 read_symbol 精确提取符号,避免读全文件 // (内容里真的含可提取的函数/类/结构体定义)才追加 read_symbol 引导——比旧按扩展名
// 浪费上下文。复用 code_intel::is_supported_ext 单真相源(与 read_symbol handler 语言 // 判断更准,无定义的文件不瞎提示。用全量 content(非分页 result)检测,符号可能在
// 集合一致,不重复定义)。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。 // offset 范围外。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
let content_field = if offset_used.is_none() let content_field = if offset_used.is_none()
&& crate::commands::ai::code_intel::is_supported_ext( && crate::commands::ai::code_intel::contains_definition(&content, &ext)
path.rsplit('.').next().unwrap_or("").to_lowercase().as_str(),
)
{ {
format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。") format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。")
} else { } else {
@@ -227,7 +223,13 @@ pub fn register(
use tokio::fs::File; use tokio::fs::File;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
let mut file = File::open(path).await let mut file = File::open(path).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; .map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
file_not_found_error(path)
} else {
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
}
})?;
let metadata = file.metadata().await let metadata = file.metadata().await
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?; .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
if metadata.len() > 1_048_576 { if metadata.len() > 1_048_576 {
@@ -1098,7 +1100,15 @@ pub fn register(
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?; search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect(); let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_results.len()) < total as usize; let has_more = (offset + page_results.len()) < total as usize;
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more })) let mut out = serde_json::json!({
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
});
// AC-5:total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
if total == 0 {
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
}
Ok(out)
} }
); );
@@ -1380,6 +1390,83 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
scored.into_iter().map(|(_, s)| s).collect() scored.into_iter().map(|(_, s)| s).collect()
} }
// ============================================================
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选(AC-5)
// ============================================================
/// 父目录下与 needle 文件名最相近的 max 个文件名(Dice 字符多重集,思路同 similar_line_fragments)。
///
/// 用途:read_file/read_symbol 打开文件 NotFound 时,把父目录下最可能的文件名候选附进错误提示,
/// 治 LLM 拼错文件名/少写扩展名后盲目重试(AC-5)。低频错误路径(文件不存在),同步 std::fs::read_dir
/// 枚举可接受(目录通常不大)。阈值 40% 防误导(完全无关文件名不提示);父目录不可读/非目录返空。
fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<String> {
if needle.is_empty() {
return Vec::new();
}
// 预计算 needle 字符多重集计数(各文件名评分复用,同 similar_line_fragments 的探针计数)
use std::collections::HashMap;
let mut needle_counts: HashMap<char, u32> = HashMap::new();
for c in needle.chars() {
*needle_counts.entry(c).or_insert(0) += 1;
}
let needle_len = needle.chars().count() as u32;
let mut scored: Vec<(u32, String)> = Vec::new();
let entries = match std::fs::read_dir(dir) {
Ok(iter) => iter,
Err(_) => return Vec::new(), // 父目录不可读/非目录 → 无候选,退回原提示
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let name_len = name.chars().count() as u32;
if name == needle || name_len == 0 {
continue;
}
// 公共字符计数(needle 计数约束下扫描文件名)
let mut counts = needle_counts.clone();
let mut common = 0u32;
for c in name.chars() {
if let Some(cnt) = counts.get_mut(&c) {
if *cnt > 0 {
*cnt -= 1;
common += 1;
}
}
}
// Dice = 2*common / (needle_len + name_len),乘 100 转百分比整数(同 similar_line_fragments)
let dice = (common * 200) / (needle_len + name_len).max(1);
if dice >= 40 {
scored.push((dice, name));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0));
scored.truncate(max);
scored.into_iter().map(|(_, s)| s).collect()
}
/// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选(AC-5)。
///
/// read_file / read_symbol 共用(DRY)。保留原「建议用 list_directory」引导;候选为空不追加。
fn file_not_found_error(path: &str) -> anyhow::Error {
let mut msg = format!(
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
path
);
let p = std::path::Path::new(path);
if let (Some(parent), Some(file_name)) = (p.parent(), p.file_name()) {
let needle = file_name.to_string_lossy().into_owned();
let candidates = similar_file_names(parent, &needle, 3);
if !candidates.is_empty() {
msg.push_str(&format!(
"。相近文件候选:\n{}\n(可核对实际文件名)",
candidates.join("\n")
));
}
}
anyhow::anyhow!(msg)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1571,6 +1658,53 @@ mod tests {
"多行 old_text 应命中首行所在行,实际: {hits:?}" "多行 old_text 应命中首行所在行,实际: {hits:?}"
); );
} }
// ============================================================
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
// ============================================================
#[test]
fn similar_file_names_finds_close_name() {
// 临时目录放真实文件名,needle 少写一个字符(uilts.rs)应命中 utils.rs
let dir = std::env::temp_dir().join(format!("df-similar-files-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for name in ["main.rs", "mod.rs", "utils.rs", "README.md"] {
std::fs::write(dir.join(name), "").unwrap();
}
let hits = similar_file_names(&dir, "uilts.rs", 3);
std::fs::remove_dir_all(&dir).ok();
assert!(
hits.iter().any(|h| h == "utils.rs"),
"相近文件名应命中 utils.rs,实际: {hits:?}"
);
// max 截断
assert!(hits.len() <= 3, "候选数应不超过 max,实际: {}", hits.len());
}
#[test]
fn similar_file_names_empty_when_no_relation() {
let dir = std::env::temp_dir().join(format!("df-similar-empty-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("readme.md"), "").unwrap();
let hits = similar_file_names(&dir, "config.yaml", 3);
std::fs::remove_dir_all(&dir).ok();
assert!(hits.is_empty(), "无关文件名不应给候选,实际: {hits:?}");
}
#[test]
fn file_not_found_error_appends_candidates() {
// 少写一个字符的拼写 → 错误文案应含 target.rs 候选 + 保留 list_directory 引导
let dir = std::env::temp_dir().join(format!("df-notfound-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("target.rs"), "fn main() {}\n").unwrap();
let missing = dir.join("targett.rs");
let msg = file_not_found_error(&missing.to_string_lossy()).to_string();
std::fs::remove_dir_all(&dir).ok();
assert!(msg.contains("路径不存在"), "应保留基础提示,实际: {msg}");
assert!(msg.contains("list_directory"), "应保留 list_directory 引导,实际: {msg}");
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
}
} }
+50 -17
View File
@@ -121,11 +121,37 @@ pub async fn update_idea(
field: String, field: String,
value: String, value: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
state // BE-CMD-4:status 值合法性校验(防任意值进库)+ 拒绝经 update_field 直达 promoted
// (半立项:绕过 promote_idea 不建项目不写 promoted_to,须走立项流程)。
if field == "status" {
if IdeaStatus::from_db_str(value.trim()).is_none() {
return Err(format!(
"非法 status 值 {:?},合法值: draft/pending_review/approved/rejected/promoted/archived",
value
));
}
if value.trim() == "promoted" {
return Err(
"status 不能直接置为 promoted:立项须走 promote_idea(会创建项目并回写 promoted_to)"
.to_string(),
);
}
}
// BE-CMD-4(含 BE-CMD-23):related_ids/scores 是 JSON 字段,补合法性校验(防脏 JSON 落库)。
if field == "related_ids" || field == "scores" {
serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| format!("{field} 不是合法 JSON: {e}"))?;
}
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站灵感不可改字段。
let updated = state
.ideas .ideas
.update_field(&id, &field, &value) .update_field_active(&id, &field, &value)
.await .await
.map_err(err_str) .map_err(err_str)?;
if !updated {
return Err(format!("灵感 ID {id} 不存在或已删除"));
}
Ok(true)
} }
/// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。 /// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。
@@ -204,23 +230,21 @@ pub async fn promote_idea(
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// 回写灵感status=promoted + promoted_toupdate_full 单事务覆盖可变字段) // LW-8(BE-CMD-7):CAS 回写灵感(status=promoted + promoted_to,WHERE id AND promoted_to IS NULL)。
// 补偿删除:第二步失败时回滚第一步已建的 project,保证最终一致性(非原子,但防项目存留而 // 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;本方法原子「立项认领」,
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿 // 仅首个 affected=1 成功,第二个 affected=0 → 判定「已立项」并补偿软删刚建项目(回滚)。
// 删除而非真事务(改动最小,工程投入产出比最高) // 替代原 update_full(无条件覆盖):并发下两个项目都保留、灵感只指向一个,留悬空项目
let updated = IdeaRecord { if !state
status: IdeaStatus::Promoted, .ideas
promoted_to: Some(project_id.clone()), .claim_promotion(&id, &project_id)
updated_at: now, .await
..record .map_err(err_str)?
}; {
if let Err(e) = state.ideas.update_full(&updated).await { tracing::warn!("灵感 {id} 已被并发立项,回滚本次新建项目 {project_id}");
// 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
if let Err(del_err) = state.projects.soft_delete(&project_id).await { if let Err(del_err) = state.projects.soft_delete(&project_id).await {
tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}"); tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
} }
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e)); return Err(format!("灵感 {id} 已立项(并发双击),本次立项已回滚"));
} }
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。 // 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。
@@ -362,6 +386,15 @@ async fn evaluate_one(
engine: &df_ideas::adversarial::AdversarialEngine, engine: &df_ideas::adversarial::AdversarialEngine,
) -> Result<IdeaRecord, String> { ) -> Result<IdeaRecord, String> {
let id = record.id.clone(); let id = record.id.clone();
// LW-7(BE-CMD-6):终态灵感不可再评估(防无条件覆盖 pending_review 打回终态)。
// promoted(已立项)/archived(已归档)是终态,评估会把 status 覆盖回 pending_review,
// 破坏「已立项/已归档不可回退」语义。软删灵感由 evaluate_idea/batch 的存在性检查已过滤。
if matches!(record.status, IdeaStatus::Promoted | IdeaStatus::Archived) {
return Err(format!(
"灵感 {id} 已是终态({}),不可再评估",
record.status.as_str()
));
}
let idea = record_to_idea(&record); let idea = record_to_idea(&record);
// 多维评分(0-10IPC 层 *10 缩放为 0-100 // 多维评分(0-10IPC 层 *10 缩放为 0-100
+284 -29
View File
@@ -15,6 +15,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
@@ -23,6 +25,8 @@ use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord};
use df_types::types::new_id; use df_types::types::new_id;
// 工程扫描技术栈探测(scan_project_modules 自动填 stack 字段) // 工程扫描技术栈探测(scan_project_modules 自动填 stack 字段)
use df_project::scan::detect_stack; use df_project::scan::detect_stack;
// 文件符号概览(file_outline)复用 code_intel 的 AST 符号提取
use crate::commands::ai::code_intel;
use crate::state::AppState; use crate::state::AppState;
@@ -209,6 +213,10 @@ pub async fn update_project_module(
if path.is_empty() { if path.is_empty() {
return Err("path 不能为空".to_string()); return Err("path 不能为空".to_string());
} }
// path 变更 → 清旧路径的 git status 缓存(避免后续同路径复用脏状态)
if existing.path != path {
invalidate_git_status_cache(&existing.path);
}
existing.path = path; existing.path = path;
} }
existing.git_url = trim_opt(input.git_url); existing.git_url = trim_opt(input.git_url);
@@ -245,6 +253,29 @@ pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Re
if id.is_empty() { if id.is_empty() {
return Err("id 不能为空".to_string()); return Err("id 不能为空".to_string());
} }
// 删除前取 path,删除后清 git status 缓存(避免同路径复用脏状态)
if let Ok(Some(module)) = state.project_modules.get_by_id(&id).await {
invalidate_git_status_cache(&module.path);
}
// G3:FK 级联 — module_dependencies 的 from/to REFERENCES project_modules(id),参与依赖的
// 工程直接删必 FK 违例失败(foreign_keys=ON)。删工程前先清所有参与该工程的依赖边
// (从/到任一端都算参与),顺序参照 purge_with_descendants(子表先于父表删)。
for dep in state
.module_dependencies
.list_by_field("from_module_id", &id)
.await
.map_err(err_str)?
{
state.module_dependencies.delete(&dep.id).await.map_err(err_str)?;
}
for dep in state
.module_dependencies
.list_by_field("to_module_id", &id)
.await
.map_err(err_str)?
{
state.module_dependencies.delete(&dep.id).await.map_err(err_str)?;
}
state.project_modules.delete(&id).await.map_err(err_str)?; state.project_modules.delete(&id).await.map_err(err_str)?;
Ok(()) Ok(())
} }
@@ -301,6 +332,61 @@ pub async fn list_project_modules(
Ok(modules) Ok(modules)
} }
// ============================================================
// 仓库级 git status 缓存(P1-f:cmd 闪烁 N² 修复)
// ============================================================
/// git status 缓存 TTL(5 秒)。
///
/// 背景:`get_module_file_tree` 展开新目录 + `get_module_git_status` 切换变更视图都会在仓库根
/// 跑整仓 `git status --porcelain`;前端 `loadedChildren` 只对「同目录展开」去重,跨目录展开
/// N 次 = N 次整仓扫描(cmd 闪烁 N²)。git status 天然幂等(输出反映当前工作区),TTL 秒级失效
/// 可接受(用户改文件后刷新触发重扫兜底),不做实时 watcher(过度设计)。
const GIT_STATUS_CACHE_TTL: Duration = Duration::from_secs(5);
/// 仓库根路径 → (采集时间戳, git status --porcelain 输出 map)。
/// 键 = module.path(仓库根);值 = (Instant, {posix_rel: status})。
/// 进程级共享(spawn_blocking 内 std Mutex 访问),跨 IPC 请求复用。
static GIT_STATUS_CACHE: LazyLock<Mutex<HashMap<String, (Instant, HashMap<String, String>)>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// 判断缓存条目是否新鲜(TTL 内)。抽出纯函数便于单测。
fn is_git_status_cache_fresh(collected_at: Instant, now: Instant) -> bool {
now.duration_since(collected_at) < GIT_STATUS_CACHE_TTL
}
/// 取仓库 git status map:TTL 内命中直接复用缓存,未命中/过期才跑 git status 并更新缓存。
/// 非 git 仓库 → 空 map 且不入缓存(.git 判断廉价,且避免「git init 后 TTL 内误返空」)。
fn git_status_map_cached(dir: &str) -> HashMap<String, String> {
// 非 git 仓库快速返回(不缓存:git init 后 TTL 内重扫才拿得到真实状态)
if !Path::new(dir).join(".git").exists() {
return HashMap::new();
}
let now = Instant::now();
// 命中 TTL 内缓存 → 直接复用(锁内只查表,不跑 git,快速返回)
{
let cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((ts, map)) = cache.get(dir) {
if is_git_status_cache_fresh(*ts, now) {
return map.clone();
}
}
}
// 未命中/过期 → 跑 git(锁外执行,避免持锁阻塞其他目录的缓存命中;完成后更新缓存)
let map = collect_git_status_map(dir);
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
cache.insert(dir.to_string(), (now, map.clone()));
map
}
/// 清空指定仓库根的 git status 缓存条目(工程删除 / path 变更时防脏数据复用)。
fn invalidate_git_status_cache(dir: &str) {
GIT_STATUS_CACHE
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(dir);
}
// ============================================================ // ============================================================
// IPC 命令 — Git 状态查询(实时派生,不进表) // IPC 命令 — Git 状态查询(实时派生,不进表)
// ============================================================ // ============================================================
@@ -390,7 +476,7 @@ pub async fn get_module_git_status(
Ok(serde_json::to_value(status).map_err(err_str)?) Ok(serde_json::to_value(status).map_err(err_str)?)
} }
/// 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。 /// 列出工程分支(本地 + 远程跟踪,只读)。返回 { current, branches: [{ name, is_current }] }。
#[tauri::command] #[tauri::command]
pub async fn list_branches( pub async fn list_branches(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -413,10 +499,15 @@ pub async fn list_branches(
let dir = module.path.clone(); let dir = module.path.clone();
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时 // git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
let result = tokio::task::spawn_blocking(move || -> (String, Vec<serde_json::Value>) { let result = tokio::task::spawn_blocking(move || -> (String, Vec<serde_json::Value>) {
// git branch --format="%(HEAD)%00%(refname:short)" // git branch -a --format="%(HEAD)%00%(refname:short)%00%(refname)"
// -a 同时列出本地分支(refs/heads/*)与远程跟踪分支(refs/remotes/*,短名形如 origin/main);
// 原仅 `git branch` 只列本地分支,远程分支被排除(仓库只见一个分支的根因)。
let out = run_git_cmd( let out = run_git_cmd(
std::path::Path::new(&dir), std::path::Path::new(&dir),
&["branch", "--format=%(HEAD)%00%(refname:short)"], &[
"branch", "-a",
"--format=%(HEAD)%00%(refname:short)%00%(refname)",
],
std::time::Duration::from_secs(10), std::time::Duration::from_secs(10),
) )
.unwrap_or_default(); .unwrap_or_default();
@@ -425,10 +516,16 @@ pub async fn list_branches(
for line in out.lines() { for line in out.lines() {
let line = line.trim(); let line = line.trim();
if line.is_empty() { continue; } if line.is_empty() { continue; }
// 格式: "*\0branch_name" 或 "\0branch_name" // 格式: "*\0branch_name\0refs/...",三字段(HEAD 标记 / 短名 / 完整 ref)。
let (head, name) = line.split_once('\u{0}').unwrap_or(("", line)); let parts: Vec<&str> = line.split('\u{0}').collect();
if parts.len() < 2 { continue; }
let (head, short) = (parts[0], parts[1]);
// 跳过 remote HEAD 伪分支(refs/remotes/<remote>/HEAD → 短名即裸 remote 名如 origin,
// 指向远端默认分支,非真实分支,列出会误导)。
let full = parts.get(2).copied().unwrap_or("");
if full.ends_with("/HEAD") { continue; }
let is_current = head.contains('*'); let is_current = head.contains('*');
let name = name.trim().to_string(); let name = short.trim().to_string();
if name.is_empty() { continue; } if name.is_empty() { continue; }
if is_current { current = name.clone(); } if is_current { current = name.clone(); }
branches.push(serde_json::json!({ "name": name, "is_current": is_current })); branches.push(serde_json::json!({ "name": name, "is_current": is_current }));
@@ -448,8 +545,6 @@ pub async fn list_branches(
/// ///
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。 /// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
fn run_git_status(dir: &str) -> GitStatus { fn run_git_status(dir: &str) -> GitStatus {
use std::time::Duration;
let path = std::path::Path::new(dir); let path = std::path::Path::new(dir);
if !path.join(".git").exists() { if !path.join(".git").exists() {
return empty_status(); return empty_status();
@@ -463,24 +558,14 @@ fn run_git_status(dir: &str) -> GitStatus {
.filter(|s| !s.is_empty() && s != "HEAD") .filter(|s| !s.is_empty() && s != "HEAD")
.unwrap_or_default(); .unwrap_or_default();
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码) // 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
let mut changed_files = Vec::new(); // 走仓库级缓存(与文件树共用同一份扫描,TTL 内展开/切换不再整仓重扫)。
if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) { let mut changed_files: Vec<GitChangedFile> = git_status_map_cached(dir)
for line in out.lines() { .into_iter()
if line.len() < 4 { .map(|(path, status)| GitChangedFile { status, path })
continue; .collect();
} // map 无序,按路径排序保证前端展示顺序稳定(git status 输出本身即按路径有序)。
// porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + path) changed_files.sort_by(|a, b| a.path.cmp(&b.path));
let status = line[..2].to_string();
let file_path = line[3..].trim().to_string();
if !file_path.is_empty() {
changed_files.push(GitChangedFile {
status,
path: file_path,
});
}
}
}
// 3) 最近提交:`git log -50 --format="%h %ct %an %s"`(hash + 时间戳 + 作者 + subject) // 3) 最近提交:`git log -50 --format="%h %ct %an %s"`(hash + 时间戳 + 作者 + subject)
let mut recent_commits = Vec::new(); let mut recent_commits = Vec::new();
@@ -583,6 +668,30 @@ const NOISE_DIRS: &[&str] = &[
".vite", ".vite",
]; ];
/// 判断目录是否含可见子项(过滤隐藏文件/噪音目录后,与 `get_module_file_tree` 展开实际展示一致)。
///
/// 供 `FileTreeEntry.has_children` 用:前端未展开目录即可据此在行内标注空目录,
/// 不必等用户展开才发现是空的。`read_dir` 首个子项命中即短路返回,开销为 O(可见项数)。
fn dir_has_visible_children(dir: &Path) -> bool {
let Ok(read) = std::fs::read_dir(dir) else {
// 目录不可读(权限等)按空处理,避免误导为有内容
return false;
};
read.flatten().any(|entry| {
let name = entry.file_name();
let name = name.to_string_lossy();
// 与列目录过滤保持一致:隐藏文件 + 噪音目录不算可见子项
if name.starts_with('.') {
return false;
}
if NOISE_DIRS.iter().any(|n| *n == name.as_ref()) {
return false;
}
// 与列目录一致:无法判定类型的条目(断链符号链接等)视为不可见
entry.file_type().is_ok()
})
}
/// 文件树条目(单层;前端点击文件夹再懒加载下一层)。 /// 文件树条目(单层;前端点击文件夹再懒加载下一层)。
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct FileTreeEntry { struct FileTreeEntry {
@@ -596,6 +705,9 @@ struct FileTreeEntry {
/// 文件夹恒为 None(只标文件)。 /// 文件夹恒为 None(只标文件)。
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
git_status: Option<String>, git_status: Option<String>,
/// 目录是否含可见子项(过滤隐藏/噪音目录后,与展开实际展示一致)。
/// 文件恒为 false;目录在列目录时即时统计。前端未展开时行内标注空目录用。
has_children: bool,
} }
/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。 /// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。
@@ -652,7 +764,7 @@ pub async fn get_module_file_tree(
// 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。 // 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。
// 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。 // 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。
let dir_for_git = module.path.clone(); let dir_for_git = module.path.clone();
let git_map = tokio::task::spawn_blocking(move || collect_git_status_map(&dir_for_git)) let git_map = tokio::task::spawn_blocking(move || git_status_map_cached(&dir_for_git))
.await .await
.map_err(|e| format!("git status 采集任务失败: {e}"))?; .map_err(|e| format!("git status 采集任务失败: {e}"))?;
// 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。 // 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。
@@ -699,12 +811,18 @@ pub async fn get_module_file_tree(
} else { } else {
git_map_clone.get(&posix_rel).cloned() git_map_clone.get(&posix_rel).cloned()
}; };
let has_children = if is_dir {
dir_has_visible_children(&entry.path())
} else {
false
};
items.push(FileTreeEntry { items.push(FileTreeEntry {
name, name,
path: posix_rel, path: posix_rel,
is_dir, is_dir,
size, size,
git_status, git_status,
has_children,
}); });
} }
// 目录优先,各自字母序(稳定可预期,前端无需再排)。 // 目录优先,各自字母序(稳定可预期,前端无需再排)。
@@ -773,8 +891,14 @@ pub async fn read_module_file(
let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?; let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?;
buf.truncate(n); buf.truncate(n);
// 二进制检测:前 8KB 含 \0 → 二进制。8KB 内全为文本字节 → 当文本。 // 二进制检测:前 8KB 含 \0 → 二进制。8KB 内全为文本字节 → 当文本。
// 图片扩展名豁免:图片是二进制(含 \0),但前端走 convertFileSrc 本地预览,
// 需 is_binary=false 才能进 isImage 分支(否则显示"二进制不支持预览")。
let is_image = matches!(
abs_clone.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).as_deref(),
Some("png") | Some("jpg") | Some("jpeg") | Some("gif") | Some("webp") | Some("bmp") | Some("svg") | Some("ico") | Some("avif")
);
let check_len = buf.len().min(8192); let check_len = buf.len().min(8192);
let is_binary = buf[..check_len].iter().any(|&b| b == 0); let is_binary = !is_image && buf[..check_len].iter().any(|&b| b == 0);
Ok((buf, is_binary)) Ok((buf, is_binary))
}) })
.await .await
@@ -799,6 +923,75 @@ pub async fn read_module_file(
})) }))
} }
/// 提取文件符号概览(outline)—— 顶层定义符号列表,供前端预览面板做符号树。
///
/// 复用 code_intel::extract_outline(grammar_for + DEFINITION_KINDS + Vue 切 script 段)。
/// 只读前部 256KB(符号概览只需头部,超出部分不展示,符合预期)。
/// 不支持的语言(CSS/HTML/MD 等)返 `supported=false` + 空 symbols,前端据此隐藏面板。
#[tauri::command]
pub async fn file_outline(
state: State<'_, AppState>,
module_id: String,
file_path: String,
) -> Result<serde_json::Value, String> {
let module_id = module_id.trim().to_string();
let file_path = file_path.trim().to_string();
if module_id.is_empty() {
return Err("module_id 不能为空".to_string());
}
if file_path.is_empty() {
return Err("file_path 不能为空".to_string());
}
// 路径穿越防御:`..` 段一律拒(与 read_module_file 同规则)。
if has_path_traversal(&file_path) {
return Err("file_path 不允许包含 ..".to_string());
}
let module = state
.project_modules
.get_by_id(&module_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let ext = file_path.rsplit('.').next().unwrap_or("").to_lowercase();
if !code_intel::is_supported_ext(&ext) {
// 不支持的语言不读文件(省 IO),supported=false 供前端隐藏符号面板。
return Ok(serde_json::json!({
"path": file_path.replace('\\', "/"),
"symbols": [],
"supported": false,
}));
}
let root = PathBuf::from(&module.path);
let abs = root.join(&file_path);
if !abs.is_file() {
return Err(format!("文件不存在或不是普通文件: {}", file_path));
}
const MAX_BYTES: usize = 256 * 1024; // 256KB 上限(仅头部符号)
let abs_clone = abs.clone();
let bytes = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
use std::io::Read;
let mut f = std::fs::File::open(&abs_clone).map_err(|e| format!("打开文件失败: {e}"))?;
let mut buf = vec![0u8; MAX_BYTES];
let n = f.read(&mut buf).map_err(|e| format!("读取文件失败: {e}"))?;
buf.truncate(n);
Ok(buf)
})
.await
.map_err(|e| format!("读文件任务失败: {e}"))??;
let content = String::from_utf8_lossy(&bytes).to_string();
let symbols = code_intel::extract_outline(&content, &ext);
Ok(serde_json::json!({
"path": file_path.replace('\\', "/"),
"symbols": symbols,
"supported": true,
}))
}
/// 查询工程内文件元信息(不读内容,轻量检测变化用)。 /// 查询工程内文件元信息(不读内容,轻量检测变化用)。
/// 返回 { path, size, modified_at }。modified_at 是毫秒级时间戳。 /// 返回 { path, size, modified_at }。modified_at 是毫秒级时间戳。
#[tauri::command] #[tauri::command]
@@ -1012,7 +1205,14 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
return map; return map;
} }
let timeout = std::time::Duration::from_secs(10); let timeout = std::time::Duration::from_secs(10);
let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) else { // B1[PD-P1-1]:-c core.quotepath=false 让 git status 直接输出 UTF-8 原名(中文/非 ASCII
// 路径不再八进制转义 \xxx),否则 GitChanges/文件树展示一串转义码。其余特殊字符
// (空格/引号)仍按 C 风格加引号,由下方 strip_prefix 逻辑剥除兜底。
let Some(out) = run_git_cmd(
path,
&["-c", "core.quotepath=false", "status", "--porcelain"],
timeout,
) else {
return map; return map;
}; };
for line in out.lines() { for line in out.lines() {
@@ -1249,6 +1449,22 @@ pub async fn add_module_dependency(
if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() { if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() {
return Err("project_id / from_module_id / to_module_id 不能为空".to_string()); return Err("project_id / from_module_id / to_module_id 不能为空".to_string());
} }
// G9:依赖两端不能是同一工程(自环无意义)。
if from_module_id == to_module_id {
return Err("依赖两端不能是同一工程".to_string());
}
// G6:幂等 — 同 project_id + from + to 已存在则直接返回已存在记录,不重复插入。
let existing = state
.module_dependencies
.list_by_field("project_id", &project_id)
.await
.map_err(err_str)?;
if let Some(found) = existing
.iter()
.find(|d| d.from_module_id == from_module_id && d.to_module_id == to_module_id)
{
return Ok(found.clone());
}
let dep_type = dep_type.unwrap_or_else(|| "library".to_string()); let dep_type = dep_type.unwrap_or_else(|| "library".to_string());
let record = ModuleDependencyRecord { let record = ModuleDependencyRecord {
id: new_id(), id: new_id(),
@@ -1362,3 +1578,42 @@ pub async fn detect_module_cycles(
} }
Ok(cycle_nodes.into_iter().collect()) Ok(cycle_nodes.into_iter().collect())
} }
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
/// P1-f:git status 缓存 TTL 判定 — 5s 内新鲜,超时失效。
#[test]
fn test_is_git_status_cache_fresh() {
let now = Instant::now();
// TTL 内 → 新鲜
assert!(is_git_status_cache_fresh(now - Duration::from_millis(4000), now));
// 恰好 TTL → 不新鲜(duration_since < TTL 严格小于)
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(5), now));
// 超过 TTL → 失效
assert!(!is_git_status_cache_fresh(now - Duration::from_secs(6), now));
}
/// P1-f:git status 缓存同一仓库路径复用同一 map 条目、不同路径独立(纯逻辑,不经 git)。
#[test]
fn test_git_status_cache_shared_per_repo() {
let mut cache = GIT_STATUS_CACHE.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
let mut map_a = HashMap::new();
map_a.insert("src/main.rs".to_string(), " M".to_string());
let mut map_b = HashMap::new();
map_b.insert("README.md".to_string(), "??".to_string());
cache.insert("repoA".to_string(), (now, map_a.clone()));
cache.insert("repoB".to_string(), (now, map_b.clone()));
// 同路径命中同一份缓存,不同路径互不串扰
let (_, cached_a) = cache.get("repoA").unwrap();
assert_eq!(cached_a, &map_a);
assert_ne!(cached_a, &map_b);
// 清理后不再命中
cache.remove("repoA");
assert!(cache.get("repoA").is_none());
}
}
+47 -2
View File
@@ -351,10 +351,55 @@ pub async fn update_project(
field: String, field: String,
value: String, value: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。 // BE-CMD-3:白名单剔除 id/created_at 已下沉 storage(allowed_columns_for,update_field 直拒,
// 防改主键/伪造创建时间致子表悬空),此处补剩余字段的语义校验:
// - status:值合法性(ProjectStatus::from_db_str,防任意值进库静默归 planning)
// - stack:JSON 合法性(防脏 JSON 落库)
// - path:relocate 同款校验(normalize + `..` 段拦截 + is_dir + 防重复绑定)
if field == "status" && ProjectStatus::from_db_str(value.trim()).is_none() {
return Err(format!(
"非法 status 值 {:?},合法值: planning/in_progress/testing/releasing/completed/paused/cancelled",
value
));
}
if field == "stack" {
serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| format!("stack 不是合法 JSON: {e}"))?;
}
if field == "path" {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("路径不能为空".to_string());
}
// `..` 段拦截(防穿越,对齐 create_with_binding / relocate)
if trimmed.split(['\\', '/']).any(|seg| seg == "..") {
return Err(format!("路径不得包含 '..' 段: {}", trimmed));
}
// 目录必须存在(对齐 create_project / relocate 的 is_dir 校验,防绑空目录)
if !Path::new(trimmed).is_dir() {
return Err(format!("目录不存在: {}", trimmed));
}
// 防重复绑定(排除自身,对齐 relocate)
if let Some(conflict) = find_binding_conflict(&state, trimmed, Some(&id)).await? {
return Err(format!("目录已被项目「{}」绑定", conflict.name));
}
// 规范化后落库(relocate 同款 normalize_path,防路径写法差异)
let normalized = normalize_path(trimmed);
let updated = state
.projects
.update_field_active(&id, &field, &normalized)
.await
.map_err(err_str)?;
if !updated {
return Err(format!("项目 ID {id} 不存在或已删除"));
}
state.reload_allowed_dirs().await;
return Ok(true);
}
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站项目不可改字段。
let updated = state let updated = state
.projects .projects
.update_field(&id, &field, &value) .update_field_active(&id, &field, &value)
.await .await
.map_err(err_str)?; .map_err(err_str)?;
if !updated { if !updated {
+83 -2
View File
@@ -6,6 +6,8 @@ use tauri::State;
use df_types::types::{new_id, TaskStatus}; use df_types::types::{new_id, TaskStatus};
use df_storage::crud::TaskQuery; use df_storage::crud::TaskQuery;
use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord}; use df_storage::models::{ProjectEventRecord, TaskLinkRecord, TaskRecord};
// LW-9(BE-CMD-8):父任务聚合重算(df-nodes 共享层,与 advance_task 同源,消除双轨)。
use df_nodes::task_advance_node::recompute_parent_status;
use crate::state::AppState; use crate::state::AppState;
@@ -245,6 +247,19 @@ pub async fn create_task(
state: State<'_, AppState>, state: State<'_, AppState>,
input: CreateTaskInput, input: CreateTaskInput,
) -> Result<TaskRecord, String> { ) -> Result<TaskRecord, String> {
// ── BE-CMD-1:空 title trim 拒空 + priority 值域校验(对齐前端 PRIORITY_LABELS 与
// update_task 校验,拦截空白标题 / 99 等脏数据静默落库)。 ──
let title = input.title.trim().to_string();
if title.is_empty() {
return Err("任务标题不能为空".to_string());
}
if !(0..=3).contains(&input.priority) {
return Err(format!(
"priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low),收到 {}",
input.priority
));
}
// ── queue 校验(白名单 + 空串默认 todo)── // ── queue 校验(白名单 + 空串默认 todo)──
// 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致) // 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致)
let queue = if input.queue.trim().is_empty() { let queue = if input.queue.trim().is_empty() {
@@ -296,7 +311,7 @@ pub async fn create_task(
let record = TaskRecord { let record = TaskRecord {
id: new_id(), id: new_id(),
project_id: input.project_id, project_id: input.project_id,
title: input.title, title,
description: input.description, description: input.description,
status: TaskStatus::Todo, status: TaskStatus::Todo,
priority: input.priority, priority: input.priority,
@@ -333,6 +348,18 @@ pub async fn create_task(
Some(record.status.as_str()), Some(record.status.as_str()),
) )
.await; .await;
// LW-9(BE-CMD-8):建子任务后触发父聚合重算(容器模型,父 status 由子任务聚合)。
// 聚合失败仅 warn 不阻断(对齐 advance_task 宽容语义)。
if let Some(pid) = &record.parent_id {
if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
tracing::warn!(
task_id = %record.id,
parent_id = %pid,
error = %e,
"[父聚合] create_task 后重算父 status 失败(不阻断)"
);
}
}
Ok(record) Ok(record)
} }
@@ -412,12 +439,27 @@ pub async fn update_task(
} }
// 空字符串 = None(解除父),合法放行 // 空字符串 = None(解除父),合法放行
} }
// LW-4(BE-CMD-1):queue 字段校验(白名单 + queue/status 一致性,读当前 status)。
// update_task 只改单字段不改 status,故用严格一致性校验(与 move_task_queue 自动联动不同):
// 设置不兼容的 queue 直接拒绝,调用方需走 move_task_queue 自动联动调整 status。
if field == "queue" {
let q = value.trim();
validate_queue(q)?;
let current = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 ID {id} 不存在或已删除"))?;
assert_queue_status_consistent(q, current.status.as_str())?;
}
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。 // B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。 // 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
// //
// 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated // 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated
// 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。 // 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。
// 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。 // 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站任务不可改字段。
let current = state let current = state
.tasks .tasks
.get_by_id(&id) .get_by_id(&id)
@@ -425,12 +467,37 @@ pub async fn update_task(
.map_err(err_str)?; .map_err(err_str)?;
let updated = state let updated = state
.tasks .tasks
.update_field(&id, &field, &value) .update_field_active(&id, &field, &value)
.await .await
.map_err(err_str)?; .map_err(err_str)?;
if !updated { if !updated {
return Err(format!("任务 ID {id} 不存在或已删除")); return Err(format!("任务 ID {id} 不存在或已删除"));
} }
// LW-9(BE-CMD-8):改 parent_id 后重算父聚合——旧父(任务已离开,计数减少)与新父(任务挂上)。
// 从 current 读旧 parent_id(pre-update),value 是新父值。聚合失败仅 warn 不阻断。
if field == "parent_id" {
if let Some(old_pid) = current.as_ref().and_then(|c| c.parent_id.clone()) {
if let Err(e) = recompute_parent_status(&state.tasks, &old_pid).await {
tracing::warn!(
task_id = %id,
parent_id = %old_pid,
error = %e,
"[父聚合] 解除父后重算旧父 status 失败(不阻断)"
);
}
}
let new_pid = value.trim();
if !new_pid.is_empty() {
if let Err(e) = recompute_parent_status(&state.tasks, new_pid).await {
tracing::warn!(
task_id = %id,
parent_id = %new_pid,
error = %e,
"[父聚合] 挂载父后重算新父 status 失败(不阻断)"
);
}
}
}
if let Some(rec) = current { if let Some(rec) = current {
emit_event( emit_event(
&state, &state,
@@ -741,6 +808,20 @@ pub async fn move_task_queue(
.await; .await;
} }
// LW-9(BE-CMD-8):跨池移动后子任务 status 可能联动变化(一致性联动)→ 触发其父聚合重算。
// 仅当被移动任务是子任务;父任务自身 move 不重算自身聚合(容器 status 由子任务推进时重算)。
// 聚合失败仅 warn 不阻断。
if let Some(pid) = &updated.parent_id {
if let Err(e) = recompute_parent_status(&state.tasks, pid).await {
tracing::warn!(
task_id = %updated.id,
parent_id = %pid,
error = %e,
"[父聚合] move_task_queue 后重算父 status 失败(不阻断)"
);
}
}
Ok(updated) Ok(updated)
} }
+10
View File
@@ -292,6 +292,16 @@ pub async fn run_workflow_inner(
.lock() .lock()
.await .await
.insert(exec_id.clone(), executor.state_machine()); .insert(exec_id.clone(), executor.state_machine());
// P0-A: 把 task_id 注入 config,使 AiSelfReviewNode(ctx.config.get("task_id")) 与
// AiNode parse_params 兜底(ctx.config.get("task_id") 读 task 生成默认 prompt) 可读。
// config 是 serde_json::Value,仅 object 时 insert 安全;task_id None 跳过(向后兼容)。
// executor.run 之前的 config 是 move 进 run 的,此处 mutate 后整体 move,零额外开销。
let mut config = config;
if let Some(tid) = cb_task_id.as_ref() {
if let Some(obj) = config.as_object_mut() {
obj.insert("task_id".to_string(), serde_json::Value::String(tid.clone()));
}
}
let result = executor.run(&runtime_dag, config).await; let result = executor.run(&runtime_dag, config).await;
let (status, error) = match &result { let (status, error) = match &result {
+3
View File
@@ -389,6 +389,7 @@ pub fn run() {
// 工程文件浏览(Batch 10):文件树 + 单文件预览 // 工程文件浏览(Batch 10):文件树 + 单文件预览
commands::module::get_module_file_tree, commands::module::get_module_file_tree,
commands::module::read_module_file, commands::module::read_module_file,
commands::module::file_outline,
commands::module::get_module_file_meta, commands::module::get_module_file_meta,
commands::module::get_module_file_diff, commands::module::get_module_file_diff,
commands::module::get_module_commits, commands::module::get_module_commits,
@@ -465,6 +466,8 @@ pub fn run() {
commands::ai::ai_authorize_dir, commands::ai::ai_authorize_dir,
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断) // 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::record::list_tool_executions, commands::ai::audit::record::list_tool_executions,
// AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围)
commands::ai::audit::record::tool_failure_stats,
// 知识库 // 知识库
commands::knowledge::knowledge_list, commands::knowledge::knowledge_list,
commands::knowledge::knowledge_get, commands::knowledge::knowledge_get,
+27
View File
@@ -38,6 +38,15 @@
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂) 仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责 --> 应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责 -->
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" /> <ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
<!-- 人工审批对话框(全局,任意路由响应 store.pendingApproval)
ProjectDetail.vue 提至 App.vue,TaskDetail 等无 ApprovalDialog 的页面自动受益 -->
<ApprovalDialog
:visible="showApprovalDialog"
:store="projectStore"
@cancel="handleCancelApproval"
@later="showApprovalDialog = false"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -50,6 +59,7 @@ import { useAppSettingsStore } from './stores/appSettings'
import i18n from './i18n' import i18n from './i18n'
import AiChat from './components/AiChat.vue' import AiChat from './components/AiChat.vue'
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue' import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
import ApprovalDialog from './components/project/ApprovalDialog.vue'
import ErrorBoundary from './components/ErrorBoundary.vue' import ErrorBoundary from './components/ErrorBoundary.vue'
import AppLayout from './components/layout/AppLayout.vue' import AppLayout from './components/layout/AppLayout.vue'
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup' import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
@@ -91,6 +101,23 @@ watch(() => projectStore.error, (err) => {
errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500) errorTimer = setTimeout(() => { errorMsg.value = '' }, 3500)
}) })
// (,P0-C: HumanNode ,)
// ProjectDetail TaskDetail ApprovalDialog store.pendingApproval
// TaskDetail HumanNode App.vue :
// ( useApprovalPopup ;,)
const showApprovalDialog = ref(false)
watch(() => projectStore.pendingApproval, (newApproval) => {
if (newApproval) {
showApprovalDialog.value = true
}
}, { immediate: true })
// ( cancel_workflow_node IPC Cancelled), ProjectDetail.handleCancelApproval
async function handleCancelApproval() {
await projectStore.cancelHumanApproval()
showApprovalDialog.value = false
}
// 退 // 退
watch(() => route.path, (path) => { watch(() => route.path, (path) => {
if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) { if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) {
+32 -1
View File
@@ -36,6 +36,9 @@ export interface FileTreeEntry {
size: number size: number
/** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */ /** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */
git_status?: string git_status?: string
/** (/,); false
* , */
has_children: boolean
} }
/** getModuleFileTree 返回结构。 */ /** getModuleFileTree 返回结构。 */
@@ -57,6 +60,26 @@ export interface ReadFileResult {
truncated: boolean truncated: boolean
} }
/** fileOutline 返回的单个顶层符号。 */
export interface OutlineSymbol {
/** 符号名(如匿名 impl 可能为空) */
name: string
/** 定义节点 kind(function_item / class_declaration 等) */
kind: string
/** 定义起始行号(1-based) */
line: number
/** 签名(到首个 { 或 ; 为止) */
signature: string
}
/** fileOutline 返回结构。 */
export interface FileOutlineResult {
path: string
symbols: OutlineSymbol[]
/** 是否为支持语言(不支持时 symbols 为空) */
supported: boolean
}
/** Git 改动文件项(对齐后端 GitChangedFile)。 */ /** Git 改动文件项(对齐后端 GitChangedFile)。 */
export interface GitChangedFile { export interface GitChangedFile {
status: string status: string
@@ -146,6 +169,14 @@ export const moduleApi = {
return invoke('read_module_file', { moduleId, filePath }) return invoke('read_module_file', { moduleId, filePath })
}, },
/**
* (,)
* supported=false + symbols
*/
fileOutline(moduleId: string, filePath: string): Promise<FileOutlineResult> {
return invoke('file_outline', { moduleId, filePath })
},
/** 查询工程内文件元信息(不读内容,前端检测外部变化用)。返回 { path, size, modified_at }。 */ /** 查询工程内文件元信息(不读内容,前端检测外部变化用)。返回 { path, size, modified_at }。 */
getModuleFileMeta(moduleId: string, filePath: string): Promise<{ path: string; size: number; modified_at: number }> { getModuleFileMeta(moduleId: string, filePath: string): Promise<{ path: string; size: number; modified_at: number }> {
return invoke('get_module_file_meta', { moduleId, filePath }) return invoke('get_module_file_meta', { moduleId, filePath })
@@ -177,7 +208,7 @@ export const moduleApi = {
}, },
/** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */ /** 分页查询工程 Git 提交历史。返回 { commits, has_more }。 */
/** 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。 */ /** 列出工程分支(本地 + 远程跟踪,只读)。返回 { current, branches: [{ name, is_current }] }。 */
listBranches(moduleId: string): Promise<{ current: string; branches: { name: string; is_current: boolean }[] }> { listBranches(moduleId: string): Promise<{ current: string; branches: { name: string; is_current: boolean }[] }> {
return invoke('list_branches', { moduleId }) return invoke('list_branches', { moduleId })
}, },
+8
View File
@@ -188,6 +188,8 @@ const { toast, showToast } = useToast()
let _unlistenToolSlow: (() => void) | null = null let _unlistenToolSlow: (() => void) | null = null
// AE-2025-04: toast unlistener(useAiEvents 线, _unlistenToolSlow ) // AE-2025-04: toast unlistener(useAiEvents 线, _unlistenToolSlow )
let _unlistenAutoApproved: (() => void) | null = null let _unlistenAutoApproved: (() => void) | null = null
// P1-e-d: toast unlistener(useAiApproval IPC 线 emit, _unlistenToolSlow )
let _unlistenAiToast: (() => void) | null = null
// : inputText/inputEl////handleSend ChatInput.vue // : inputText/inputEl////handleSend ChatInput.vue
// chatInputRef: expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt) // chatInputRef: expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
@@ -466,6 +468,7 @@ onBeforeUnmount(() => {
store.stopContextListener() store.stopContextListener()
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12 if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04 if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d
// toast timer useToast composable onUnmounted // toast timer useToast composable onUnmounted
}) })
@@ -503,6 +506,11 @@ onMounted(async () => {
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => { _unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info') showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
}) })
// P1-e-d: toast(useAiApproval.approveToolCall IPC 线广,
// listen toast/ AiChat,)
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
showToast(e.payload.msg, e.payload.type)
})
// F-15 2: (/ toast/) // F-15 2: (/ toast/)
await initContextEventListeners() await initContextEventListeners()
// (/): // (/):
+3
View File
@@ -42,6 +42,9 @@ function onKey(e: KeyboardEvent) {
// Enter :() confirm , dangerLabel ; // Enter :() confirm , dangerLabel ;
// input/textarea (SVG/ mask ,) // input/textarea (SVG/ mask ,)
const target = e.target as HTMLElement const target = e.target as HTMLElement
// LW-1(): Enter return Enter
// cancel click(emit false) emit true ,=
if (target && target.tagName === 'BUTTON') return
if (target && (target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit' && (target as HTMLInputElement).type !== 'button'))) { if (target && (target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit' && (target as HTMLInputElement).type !== 'button'))) {
return return
} }
+17 -2
View File
@@ -346,6 +346,8 @@ function detectMentionTrigger() {
mentionStart.value = i mentionStart.value = i
mentionOpen.value = true mentionOpen.value = true
mentionIndex.value = 0 mentionIndex.value = 0
// P1-g: @ (,; computed )
void ensureTaskSuggestions()
return return
} }
// a@b: mention , @, // a@b: mention , @,
@@ -403,6 +405,19 @@ const pendingMentionSpans = ref<MentionSpan[]>([])
const MENTION_TASK_LIMIT = 50 const MENTION_TASK_LIMIT = 50
const projectTasksCache = ref<Map<string, TaskRecord[]>>(new Map()) const projectTasksCache = ref<Map<string, TaskRecord[]>>(new Map())
// P1-g: @ store.tasks( Tasks +)
// (taskApi.list() =)+ ; @ ,
const taskSuggestionCache = ref<TaskRecord[] | null>(null)
async function ensureTaskSuggestions(): Promise<void> {
if (taskSuggestionCache.value) return
try {
taskSuggestionCache.value = await taskApi.list()
} catch (e) {
taskSuggestionCache.value = []
console.error('[ChatInput] 加载任务联想失败:', e)
}
}
/** 拉取指定项目的任务并缓存(已缓存则跳过);失败静默降级为空数组(不阻塞 enrichment 渲染) */ /** 拉取指定项目的任务并缓存(已缓存则跳过);失败静默降级为空数组(不阻塞 enrichment 渲染) */
async function ensureProjectTasks(projectId: string): Promise<void> { async function ensureProjectTasks(projectId: string): Promise<void> {
if (projectTasksCache.value.has(projectId)) return if (projectTasksCache.value.has(projectId)) return
@@ -564,7 +579,7 @@ const mentionQuery = computed(() => {
const mentionItems = computed<MentionItem[]>(() => { const mentionItems = computed<MentionItem[]>(() => {
const q = mentionQuery.value.trim().toLowerCase() const q = mentionQuery.value.trim().toLowerCase()
const projects = (projectsStore.projects || []) as ProjectRecord[] const projects = (projectsStore.projects || []) as ProjectRecord[]
const tasks = (projectsStore.tasks || []) as TaskRecord[] const tasks = (taskSuggestionCache.value ?? []) as TaskRecord[]
const match = (s: string) => !q || s.toLowerCase().includes(q) const match = (s: string) => !q || s.toLowerCase().includes(q)
const projectItems: MentionItem[] = projects const projectItems: MentionItem[] = projects
.filter(p => match(p.name) || match(p.description || '')) .filter(p => match(p.name) || match(p.description || ''))
@@ -606,7 +621,7 @@ const mentionItems = computed<MentionItem[]>(() => {
/** 联想候选总数(未过滤,用于区分"无匹配" vs "空") */ /** 联想候选总数(未过滤,用于区分"无匹配" vs "空") */
const mentionTotal = computed(() => const mentionTotal = computed(() =>
((projectsStore.projects || []) as ProjectRecord[]).length ((projectsStore.projects || []) as ProjectRecord[]).length
+ ((projectsStore.tasks || []) as TaskRecord[]).length + (taskSuggestionCache.value?.length ?? 0)
+ ((projectsStore.ideas || []) as IdeaRecord[]).length, + ((projectsStore.ideas || []) as IdeaRecord[]).length,
) )
+21
View File
@@ -37,6 +37,13 @@
>{{ $t('aiChat.dirAuthDeny') }}</button> >{{ $t('aiChat.dirAuthDeny') }}</button>
</div> </div>
</div> </div>
<!-- P1-e-c: deny 拒绝二次确认(拒绝不可逆工具返 Errloop 续跑不再重试;对齐 ToolCard/ApprovalPopup) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="$t('common.confirm')"
@result="answerConfirm"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -45,6 +52,8 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { pendingDirAuths } from '../../composables/ai/useAiPendingState' import { pendingDirAuths } from '../../composables/ai/useAiPendingState'
import { aiApi } from '../../api' import { aiApi } from '../../api'
import { useConfirm } from '../../composables/useConfirm'
import ConfirmDialog from '../../components/ConfirmDialog.vue'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void (e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
@@ -53,6 +62,9 @@ const emit = defineEmits<{
const store = useAiStore() const store = useAiStore()
const { t } = useI18n() const { t } = useI18n()
// P1-e-c: deny (useConfirm + ConfirmDialog , ToolCard/ApprovalPopup)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// F-260620 (): isGenerating(conv), streaming // F-260620 (): isGenerating(conv), streaming
// streaming (useAiStream onStreamTimeout), AiDirAuthRequired // streaming (useAiStream onStreamTimeout), AiDirAuthRequired
// ( streaming) streaming=false generating=true // ( streaming) streaming=false generating=true
@@ -87,6 +99,15 @@ const dirAuthRefs = ref<HTMLElement[]>([])
async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> { async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
if (actingIds.has(id)) return if (actingIds.has(id)) return
actingIds.add(id) actingIds.add(id)
// P1-e-c: deny ( Errloop ; ToolCard onDeny )
// actingIds.add IPC ; actingIds ( pending ), IPC
if (decision === 'deny') {
const ok = await confirmDialog(t('aiChat.dirAuthDenyConfirm'))
if (!ok) {
actingIds.delete(id)
return
}
}
try { try {
await aiApi.authorizeDir(id, decision) await aiApi.authorizeDir(id, decision)
} catch (e) { } catch (e) {
+6 -3
View File
@@ -867,13 +867,16 @@ defineExpose({
@click="toggleJson(item.msg.id)" @click="toggleJson(item.msg.id)"
><pre>{{ item.msg.content }}</pre></div> ><pre>{{ item.msg.content }}</pre></div>
<div <div
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && store.state.currentText)" v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)"
class="ai-msg-bubble ai-msg-bubble--ai ai-md" class="ai-msg-bubble ai-msg-bubble--ai ai-md"
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }" :class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)" :key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
> >
<!-- 流式:分块渲染(已完成块key稳定DOM不重建) --> <!-- 流式:分块渲染(已完成块key稳定DOM不重建)
<template v-if="isLastAi(item.msg) && store.state.streaming && store.state.currentText"> AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
消除currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染的双渲染
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染) -->
<template v-if="isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0">
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div> <div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
<span v-if="isViewingGenerating" class="ai-cursor"></span> <span v-if="isViewingGenerating" class="ai-cursor"></span>
</template> </template>
@@ -58,16 +58,16 @@ function getProjectTaskCount(projectId: string): number {
return store.projectTaskCounts[projectId] ?? 0 return store.projectTaskCounts[projectId] ?? 0
} }
// label key:projectStageInfo stage (coding/testing/release/planning) // label key:projectStageInfo stage (coding/testing/release/planning/paused/cancelled)
// i18n key dashboard.stage.<stage>, constants/project.ts PROJECT_STAGE_INFO // i18n key dashboard.stage.<stage>, constants/project.ts PROJECT_STAGE_INFO
// "", active (DB ,ProjectStatus union // "",:/( 'active',
// 'active',;DB planning/in_progress ,) // filter status==='active' , DBP-1)
// //
// 3:(last_active_at ,退 updated_at), // 3:(last_active_at ,退 updated_at),
// (//) 6 (completed ) // (//) 6 (completed )
const displayProjects = computed(() => const displayProjects = computed(() =>
store.projects store.projects
.filter(p => (p.status as string) === 'active') .filter(p => p.status !== 'completed' && p.status !== 'cancelled')
.sort((a, b) => .sort((a, b) =>
(b.last_active_at ?? b.updated_at).localeCompare(a.last_active_at ?? a.updated_at) (b.last_active_at ?? b.updated_at).localeCompare(a.last_active_at ?? a.updated_at)
) )
@@ -114,6 +114,8 @@ const displayProjects = computed(() =>
.dot-release { background: var(--df-success); } .dot-release { background: var(--df-success); }
.dot-planning { background: var(--df-info); } .dot-planning { background: var(--df-info); }
.dot-cancelled { background: var(--df-text-dim); } .dot-cancelled { background: var(--df-text-dim); }
/* WK-1:paused 置灰(对齐 cancelled dim 档,临时暂停不误标为已取消) */
.dot-paused { background: var(--df-text-dim); }
.project-name { .project-name {
font-size: 12px; font-size: 12px;
@@ -135,6 +137,7 @@ const displayProjects = computed(() =>
.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); }
.chip-cancelled { background: rgba(255,255,255,0.06); color: var(--df-text-dim); } .chip-cancelled { background: rgba(255,255,255,0.06); color: var(--df-text-dim); }
.chip-paused { background: rgba(255,255,255,0.06); color: var(--df-text-dim); }
.project-bar-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; } .project-bar-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.project-bar { .project-bar {
@@ -154,6 +157,7 @@ const displayProjects = computed(() =>
.fill-release { background: var(--df-success); } .fill-release { background: var(--df-success); }
.fill-planning { background: var(--df-info); } .fill-planning { background: var(--df-info); }
.fill-cancelled { background: var(--df-text-dim); } .fill-cancelled { background: var(--df-text-dim); }
.fill-paused { background: var(--df-text-dim); }
.project-pct { .project-pct {
font-family: var(--df-font-mono); font-family: var(--df-font-mono);
+47 -13
View File
@@ -273,7 +273,7 @@
</button> </button>
<ul v-show="statusMenuOpen" class="status-menu"> <ul v-show="statusMenuOpen" class="status-menu">
<li <li
v-for="s in statusOptions" v-for="s in menuStatusOptions"
:key="s.value" :key="s.value"
class="status-menu-item" class="status-menu-item"
:class="{ active: s.value === idea.status }" :class="{ active: s.value === idea.status }"
@@ -322,9 +322,11 @@ const emit = defineEmits<{
(e: 'promote'): void (e: 'promote'): void
(e: 'delete'): void (e: 'delete'): void
(e: 'status-change', value: IdeaStatus): void (e: 'status-change', value: IdeaStatus): void
(e: 'update-desc', value: string): void // WK-16: Promise<boolean> await /
(e: 'update-related', ids: string[]): void // true=();false=(,toast )
(e: 'update-tags', tags: string): void (e: 'update-desc', value: string): Promise<boolean>
(e: 'update-related', ids: string[]): Promise<void>
(e: 'update-tags', tags: string): Promise<boolean>
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -341,16 +343,15 @@ function startEdit() {
editing.value = true editing.value = true
} }
// P1- saveEdit :emit store.updateIdea Promise, emit ; // P1- saveEdit :emit Promise<boolean>( store.updateIdea ),
// savingDesc ( patch props.idea.description // ; + toast (WK-16)
// ), UI updateIdea throw (沿)
async function saveEdit() { async function saveEdit() {
if (savingDesc.value) return if (savingDesc.value) return
savingDesc.value = true savingDesc.value = true
try { try {
emit('update-desc', editDesc.value) const ok = await emit('update-desc', editDesc.value)
editing.value = false if (ok) editing.value = false
// patch (,) // patch (,)
await nextTick() await nextTick()
} finally { } finally {
savingDesc.value = false savingDesc.value = false
@@ -391,6 +392,9 @@ watch(() => props.idea.description, () => void nextTick(measureDesc))
// P1- select badge : badge /, // P1- select badge : badge /,
// emit('status-change') +(沿, statusOptions ) // emit('status-change') +(沿, statusOptions )
const statusMenuOpen = ref(false) const statusMenuOpen = ref(false)
// WK-5: promoted( promoteToProject ,,
// Ideas.vue onStatusChange );statusOptions promoted label ( badge )
const menuStatusOptions = computed(() => props.statusOptions.filter(s => s.value !== 'promoted'))
function closeStatusMenu() { function closeStatusMenu() {
statusMenuOpen.value = false statusMenuOpen.value = false
} }
@@ -498,8 +502,9 @@ async function saveTagsEdit() {
savingTags.value = true savingTags.value = true
try { try {
const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean) const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean)
emit('update-tags', JSON.stringify(tags)) // WK-16:emit ,(),toast
tagsEditing.value = false const ok = await emit('update-tags', JSON.stringify(tags))
if (ok) tagsEditing.value = false
await nextTick() await nextTick()
} finally { } finally {
savingTags.value = false savingTags.value = false
@@ -542,13 +547,42 @@ interface AdversarialEval {
} }
// ai_analysis JSON // ai_analysis JSON
//
// WK-15: JSON.parse AdversarialEval,/
// undefined*100 .toFixed NaN,assessmentClass undefined
// .toLowerCase() (),:
// - / 0
// - recommendation/thesis ''(assessmentClass/assessmentLabel )
// - []
const adversarialEval = computed<AdversarialEval | null>(() => { const adversarialEval = computed<AdversarialEval | null>(() => {
if (!props.idea.ai_analysis) return null if (!props.idea.ai_analysis) return null
let raw: any
try { try {
return JSON.parse(props.idea.ai_analysis) as AdversarialEval raw = JSON.parse(props.idea.ai_analysis)
} catch { } catch {
return null return null
} }
if (typeof raw !== 'object' || raw === null) return null
const num = (v: unknown): number => typeof v === 'number' && !isNaN(v) ? v : 0
const str = (v: unknown): string => typeof v === 'string' ? v : ''
const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
const thesis = (v: unknown): { thesis: string; evidence: string[] } => {
const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
return { thesis: str(o.thesis), evidence: strArr(o.evidence) }
}
return {
positive_strength: num(raw.positive_strength),
negative_strength: num(raw.negative_strength),
net_sentiment: num(raw.net_sentiment),
recommendation: str(raw.recommendation),
final_score: num(raw.final_score),
summary: str(raw.summary),
action_items: strArr(raw.action_items),
positive: thesis(raw.positive),
negative: thesis(raw.negative),
analyst: { summary: str((typeof raw.analyst === 'object' && raw.analyst !== null ? (raw.analyst as Record<string, unknown>).summary : undefined)) },
evaluated_by: typeof raw.evaluated_by === 'string' ? raw.evaluated_by : undefined,
}
}) })
// (P0): evaluated_by i18n key // (P0): evaluated_by i18n key
+5 -3
View File
@@ -106,10 +106,12 @@ const resizing = ref(false)
// maximized=(flex:1) | sidebar= // maximized=(flex:1) | sidebar=
const panelStyle = computed(() => { const panelStyle = computed(() => {
if (aiStore.state.maximized) return { flex: '1', minWidth: '0' } if (aiStore.state.maximized) return { flex: '1', minWidth: '0' }
// LW-11: width min(panelWidth, 100vw) media query width:100vw
// ;min() 100vw,media query absolute /
return { return {
width: panelWidth.value + 'px', width: `min(${panelWidth.value}px, 100vw)`,
minWidth: panelWidth.value + 'px', minWidth: `min(${panelWidth.value}px, 100vw)`,
maxWidth: panelWidth.value + 'px', maxWidth: `min(${panelWidth.value}px, 100vw)`,
} }
}) })
+5 -5
View File
@@ -92,15 +92,16 @@ const appSettings = useAppSettingsStore()
// 192px(+) / 56px(,hover tooltip ) // 192px(+) / 56px(,hover tooltip )
// : > (768px) // : > (768px)
const COLLAPSE_THRESHOLD = 768 const COLLAPSE_THRESHOLD = 768
const userCollapsed = ref(false) // // LW-10:useSetting (loadAll watch )
// onMounted appSettings.get loadAll ,
const userCollapsed = appSettings.useSetting('df-sidebar-collapsed', false)
const isNarrowWindow = ref(false) // const isNarrowWindow = ref(false) //
const collapsed = computed(() => userCollapsed.value || isNarrowWindow.value) const collapsed = computed(() => userCollapsed.value || isNarrowWindow.value)
// // (useSetting ref debounce , appSettings.set)
function toggleCollapsed() { function toggleCollapsed() {
userCollapsed.value = !userCollapsed.value userCollapsed.value = !userCollapsed.value
void appSettings.set('df-sidebar-collapsed', userCollapsed.value)
} }
// :() // :()
@@ -118,8 +119,7 @@ function handleCollapseKeydown(e: KeyboardEvent) {
} }
onMounted(async () => { onMounted(async () => {
// () // LW-10: useSetting (loadAll watch ), onMounted
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
// //
checkWindowWidth() checkWindowWidth()
window.addEventListener('resize', checkWindowWidth) window.addEventListener('resize', checkWindowWidth)
+145 -33
View File
@@ -46,7 +46,13 @@
<div class="dep-graph__modal-field"> <div class="dep-graph__modal-field">
<label>{{ $t('dependencyGraph.targetLabel') }}</label> <label>{{ $t('dependencyGraph.targetLabel') }}</label>
<select v-model="depTo" class="dep-graph__modal-input"> <select v-model="depTo" class="dep-graph__modal-input">
<option v-for="m in modules" :key="m.id" :value="m.id">{{ m.name }}</option> <!-- G6:已存在 fromto 组合或选中自身作为目标时禁用该候选 -->
<option
v-for="m in modules"
:key="m.id"
:value="m.id"
:disabled="depFrom === m.id || (depFrom !== '' && existingDepKeys.has(`${depFrom}->${m.id}`))"
>{{ m.name }}</option>
</select> </select>
</div> </div>
<div class="dep-graph__modal-field"> <div class="dep-graph__modal-field">
@@ -61,7 +67,7 @@
</div> </div>
<div class="dep-graph__modal-actions"> <div class="dep-graph__modal-actions">
<button class="btn btn-ghost btn-sm" @click="showAddDep = false">{{ $t('common.cancel') }}</button> <button class="btn btn-ghost btn-sm" @click="showAddDep = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary btn-sm" :disabled="!depFrom || !depTo || depFrom === depTo" @click="onAddDep">{{ $t('dependencyGraph.confirm') }}</button> <button class="btn btn-primary btn-sm" :disabled="!depFrom || !depTo || depFrom === depTo || existingDepKeys.has(`${depFrom}->${depTo}`)" @click="onAddDep">{{ $t('dependencyGraph.confirm') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -74,11 +80,11 @@
* *
* 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册 * 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册
*/ */
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue' import { onMounted, onBeforeUnmount, onActivated, ref, watch, computed, markRaw } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6' import { Graph, Selection, Snapline, History, Scroller, MiniMap, Export } from '@antv/x6'
import dagre from 'dagre' import dagre from 'dagre'
import { register } from '@antv/x6-vue-shape'
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'
@@ -86,16 +92,16 @@ import { useConfirm } from '@/composables/useConfirm'
import ModuleNode from './ModuleNode.vue' import ModuleNode from './ModuleNode.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
// vue-shape : Vue 'vue-shape'
// shapeMaps register (view.js:48 renderVueComponent shapeMaps[node.shape]),
// import '@antv/x6-vue-shape' shape /(P0)
// :Graph.registerNode shape ,
register({ shape: 'vue-shape', component: ModuleNode })
const props = defineProps<{ const props = defineProps<{
projectId: string projectId: string
}>() }>()
const router = useRouter()
const emit = defineEmits<{
(e: 'select-module', moduleId: string): void
}>()
const containerRef = ref<HTMLElement | null>(null) const containerRef = ref<HTMLElement | null>(null)
const modules = ref<ProjectModuleRecord[]>([]) const modules = ref<ProjectModuleRecord[]>([])
const dependencies = ref<ModuleDependencyRecord[]>([]) const dependencies = ref<ModuleDependencyRecord[]>([])
@@ -115,8 +121,23 @@ const depFrom = ref('')
const depTo = ref('') const depTo = ref('')
const depType = ref('library') const depType = ref('library')
// (fromto),(G6)
const existingDepKeys = computed(
() => new Set(dependencies.value.map(d => `${d.from_module_id}->${d.to_module_id}`)),
)
// id(,;G4)
const selectedId = ref<string | null>(null)
async function onAddDep() { async function onAddDep() {
if (!depFrom.value || !depTo.value || depFrom.value === depTo.value) return if (!depFrom.value || !depTo.value || depFrom.value === depTo.value) return
// (G6):,,
if (existingDepKeys.value.has(`${depFrom.value}->${depTo.value}`)) return
// (G7): DFS , sourcetarget
if (wouldCreateCycle(depFrom.value, depTo.value)) {
Message.warning(t('dependencyGraph.cycleAddError'))
return
}
try { try {
await moduleApi.addModuleDependency(props.projectId, depFrom.value, depTo.value, depType.value) await moduleApi.addModuleDependency(props.projectId, depFrom.value, depTo.value, depType.value)
showAddDep.value = false showAddDep.value = false
@@ -127,11 +148,40 @@ async function onAddDep() {
renderGraph() renderGraph()
} catch (e) { } catch (e) {
console.error('[DependencyGraph] 添加依赖失败:', e) console.error('[DependencyGraph] 添加依赖失败:', e)
// G8:,(common.unknownError )
Message.error(t('common.unknownError'))
} }
} }
/**
* 本地防环:在现有依赖图上, target 已能到达 source(DFS),则新增 sourcetarget 会成环
* 复用 detectModuleCycles DFS 思路的最小前端校验(纯本地,不请求后端)
*/
function wouldCreateCycle(sourceId: string, targetId: string): boolean {
const adj = new Map<string, string[]>()
for (const d of dependencies.value) {
const list = adj.get(d.from_module_id) ?? []
list.push(d.to_module_id)
adj.set(d.from_module_id, list)
}
const visited = new Set<string>()
const stack = [targetId]
while (stack.length > 0) {
const cur = stack.pop()!
if (cur === sourceId) return true
if (visited.has(cur)) continue
visited.add(cur)
for (const next of adj.get(cur) ?? []) {
if (!visited.has(next)) stack.push(next)
}
}
return false
}
async function loadModules() { async function loadModules() {
loading.value = true loading.value = true
// ,(,G5)
cycleNodes.value = new Set()
try { try {
const [mods, deps] = await Promise.all([ const [mods, deps] = await Promise.all([
moduleApi.listProjectModules(props.projectId), moduleApi.listProjectModules(props.projectId),
@@ -147,8 +197,10 @@ async function loadModules() {
} }
} }
function renderGraph() { function renderGraph(opts?: { center?: boolean }) {
if (!graph) return if (!graph) return
// () centerContent,;(//)
const shouldCenter = opts?.center !== false
const nodeWidth = 200 const nodeWidth = 200
const nodeHeight = 80 const nodeHeight = 80
@@ -168,7 +220,6 @@ function renderGraph() {
// X6 // X6
const nodes = modules.value.map(m => { const nodes = modules.value.map(m => {
const pos = g.node(m.id) const pos = g.node(m.id)
const isCycle = cycleNodes.value.has(m.id)
return { return {
id: m.id, id: m.id,
shape: 'vue-shape', shape: 'vue-shape',
@@ -182,12 +233,12 @@ function renderGraph() {
path: m.path, path: m.path,
stack: m.stack, stack: m.stack,
gitUrl: m.git_url, gitUrl: m.git_url,
// (G2):vue-shape markup body ,attrs.body ,
// isCycle data,ModuleNode
isCycle: cycleNodes.value.has(m.id),
// (G4):
selected: selectedId.value === m.id,
}, },
// ();renderGraph ,,
// graph.fromJSON
attrs: isCycle ? {
body: { stroke: '#e05050', strokeWidth: 3 },
} : undefined,
} }
}) })
@@ -207,7 +258,31 @@ function renderGraph() {
})) }))
graph.fromJSON({ nodes, edges }) graph.fromJSON({ nodes, edges })
graph.centerContent() if (shouldCenter) graph.centerContent()
}
/**
* F4:局部更新节点选中态(替代整图 fromJSON 重建,保留平移/缩放态)
* 只刷新被点节点 + 上次选中节点两个 cell:更新其 data.selected,并手动触发该 node view
* 'vue' action 重挂载 Vue 组件原因:vue-shape node.setData 不触发组件重渲染
* (NodeView/VueShapeView actions 映射无 datarender,'component' 变化才触发 'vue' action)
*/
function updateNodeSelection(nextId: string) {
if (!graph) return
const prevId = selectedId.value
selectedId.value = nextId
const ids = new Set<string>()
if (nextId) ids.add(nextId)
if (prevId) ids.add(prevId)
for (const id of ids) {
const cell = graph.getCellById(id)
if (!cell) continue
const d = cell.getData() ?? {}
cell.setData({ ...d, selected: id === nextId })
// 'vue' FlagManagerAction (vue-shape ), any 访
const view = graph.findViewByCell(cell) as any
if (view?.confirmUpdate) view.confirmUpdate(view.getFlag('vue'))
}
} }
function buildGraph() { function buildGraph() {
@@ -215,8 +290,9 @@ function buildGraph() {
graph = new Graph({ graph = new Graph({
container: containerRef.value, container: containerRef.value,
background: { color: '#1a1a2e' }, // U1:/grid token (X6 background/grid , CSS ),
grid: { visible: true, size: 10, type: 'dot', args: { color: '#2a2a4e' } }, background: { color: themeColor('--df-bg', '#0c0e1a') },
grid: { visible: true, size: 10, type: 'dot', args: { color: themeColor('--df-border-strong', '#2a2a4e') } },
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.3, maxScale: 3 }, mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.3, maxScale: 3 },
interacting: { nodeMovable: true }, interacting: { nodeMovable: true },
}) })
@@ -226,16 +302,31 @@ function buildGraph() {
graph.use(new History({ enabled: true })) graph.use(new History({ enabled: true }))
graph.use(new Scroller({ enabled: true, pannable: true })) graph.use(new Scroller({ enabled: true, pannable: true }))
graph.use(new MiniMap({ width: 200, height: 120, padding: 10 })) graph.use(new MiniMap({ width: 200, height: 120, padding: 10 }))
// F3: Export X6 toPNG graph.use(new Export()) 'export' ,
// Graph.prototype.toPNG no-op(),
graph.use(new Export())
graph.on('node:click', ({ node }) => { graph.on('node:click', ({ node }) => {
emit('select-module', String(node.id)) // (G4):, emit (/,
router.push(`/projects/${props.projectId}`) // Tab , emit+router.push )
// F4:(+), fromJSON ,/
updateNodeSelection(String(node.id))
}) })
renderGraph() renderGraph()
bindEdgeDeleteButtons() bindEdgeDeleteButtons()
} }
/**
* U1:读取 CSS 主题 token 的实际色值(X6 画布 background/grid 需要具体颜色字符串,不支持 CSS 变量)
* 取不到时回退传入的默认色值buildGraph 在挂载后执行,此时 data-theme 已生效,读到当前主题色
*/
function themeColor(varName: string, fallback: string): string {
if (typeof window === 'undefined') return fallback
const v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
return v || fallback
}
function fitContent() { function fitContent() {
graph?.zoomToFit({ padding: 20, maxScale: 1.5 }) graph?.zoomToFit({ padding: 20, maxScale: 1.5 })
} }
@@ -267,19 +358,25 @@ async function checkCycles() {
} }
} }
/** 导出 PNG。 */ /**
async function exportPNG() { * 导出 PNG
* F3:toPNG 回调是异步触发,外层 try/catch ; X6 toPNGAsync
* 吞错永不 resolve(会挂死),故沿用回调风格,错误处理放入回调体(try/catch ),
* 失败 console.error + Message.error 用户可见提示
*/
function exportPNG() {
if (!graph) return if (!graph) return
try { graph.toPNG((dataUri: string) => {
graph.toPNG((dataUri: string) => { try {
const a = document.createElement('a') const a = document.createElement('a')
a.href = dataUri a.href = dataUri
a.download = `dependency-graph-${props.projectId}.png` a.download = `dependency-graph-${props.projectId}.png`
a.click() a.click()
}) } catch (e) {
} catch (e) { console.error('[DependencyGraph] 导出 PNG 失败:', e)
console.error('[DependencyGraph] 导出 PNG 失败:', e) Message.error(t('common.unknownError'))
} }
})
} }
/** 删除依赖(二次确认 + 重绘)。 */ /** 删除依赖(二次确认 + 重绘)。 */
@@ -331,12 +428,15 @@ function hideEdgeDeleteBtn() {
deleteDepTarget = null deleteDepTarget = null
} }
// F2:edge hover setup ( bind ),
// 使 graph.off handler, edge:mouseenter/leave showEdgeDeleteBtn
function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) }
function onEdgeLeave() { hideEdgeDeleteBtn() }
/** 给所有 edge 绑 hover 显示删除按钮的监听。 */ /** 给所有 edge 绑 hover 显示删除按钮的监听。 */
function bindEdgeDeleteButtons() { function bindEdgeDeleteButtons() {
if (!graph) return if (!graph) return
try { graph.off('edge:mouseenter', onEdgeEnter); graph.off('edge:mouseleave', onEdgeLeave) } catch { /* ignore */ } 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:mouseenter', onEdgeEnter)
graph.on('edge:mouseleave', onEdgeLeave) graph.on('edge:mouseleave', onEdgeLeave)
} }
@@ -357,6 +457,18 @@ onMounted(async () => {
buildGraph() buildGraph()
}) })
// KeepAlive : KeepAlive , onActivated tab (F1)
// tab +()
// KeepAlive , onMounted loadModules+renderGraph,
let activatedOnce = false
onActivated(() => {
if (!activatedOnce) {
activatedOnce = true
return
}
void loadModules().then(() => renderGraph())
})
watch(() => props.projectId, async () => { watch(() => props.projectId, async () => {
await loadModules() await loadModules()
renderGraph() renderGraph()
+110 -18
View File
@@ -84,9 +84,18 @@
<!-- 文件树视图 --> <!-- 文件树视图 -->
<div v-show="viewMode === 'tree'" class="explorer-tree"> <div v-show="viewMode === 'tree'" class="explorer-tree">
<div v-if="!currentModuleId" class="tree-loading"> <!-- B4[PD-P2-8P2-10]:区分加载中 vs 零工程currentModuleId 为空且已加载完工程列表
说明无工程,显示空态引导(添加工程 CTA,对齐概览模块空态),而非无限 spinner -->
<div v-if="!currentModuleId && !mLoaded" class="tree-loading">
<span class="spinner"></span> <span class="spinner"></span>
</div> </div>
<div v-else-if="!currentModuleId" class="module-empty">
<div class="module-empty-icon">📦</div>
<p class="module-empty-hint">{{ $t('projectDetail.modulesEmptyHint') }}</p>
<div class="module-empty-actions">
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">{{ $t('fileExplorer.addModule') }}</button>
</div>
</div>
<FileTree <FileTree
v-else v-else
:module-id="currentModuleId" :module-id="currentModuleId"
@@ -106,6 +115,7 @@
<GitChanges <GitChanges
v-if="currentModuleId" v-if="currentModuleId"
:module-id="currentModuleId" :module-id="currentModuleId"
:refresh-key="gitStatusRefreshKey"
@select-file="onChangeFileSelect" @select-file="onChangeFileSelect"
/> />
</div> </div>
@@ -119,6 +129,7 @@
:file-path="selectedFilePath" :file-path="selectedFilePath"
:module-root-path="currentModule?.path ?? ''" :module-root-path="currentModule?.path ?? ''"
:git-status="selectedFileGitStatus" :git-status="selectedFileGitStatus"
:external-diff="selectedCommitDiff"
/> />
<div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div> <div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div>
</div> </div>
@@ -198,7 +209,7 @@
* 父组件(ProjectDetail)传入 projectId,本组件按 id 拉工程列表(moduleApi.listProjectModules), * 父组件(ProjectDetail)传入 projectId,本组件按 id 拉工程列表(moduleApi.listProjectModules),
* 单工程自动选中;多工程默认选首个 + 提供下拉切换 * 单工程自动选中;多工程默认选首个 + 提供下拉切换
*/ */
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, reactive, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { listen, type UnlistenFn } from '@tauri-apps/api/event' import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module' import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
@@ -217,6 +228,9 @@ const modules = ref<ProjectModuleRecord[]>([])
const currentModuleId = ref<string>('') const currentModuleId = ref<string>('')
const refreshing = ref(false) const refreshing = ref(false)
const moduleDropdownOpen = ref(false) const moduleDropdownOpen = ref(false)
// B4[PD-P2-8P2-10]:( resolve)currentModuleId
// ()vs(), spinner
const mLoaded = ref(false)
// CRUD // CRUD
const showModuleModal = ref(false) const showModuleModal = ref(false)
@@ -239,6 +253,8 @@ const expandedPaths = reactive(new Set<string>())
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>()) const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
const selectedFilePath = ref<string | null>(null) const selectedFilePath = ref<string | null>(null)
const selectedFileGitStatus = ref<string | undefined>(undefined) const selectedFileGitStatus = ref<string | undefined>(undefined)
/** GitChanges(变更/提交历史)点文件注入的 diff:传给右侧 FilePreview 在宽区渲染(历史提交的 diff 优先)。 */
const selectedCommitDiff = ref('')
const currentModule = computed( const currentModule = computed(
() => modules.value.find((m) => m.id === currentModuleId.value) ?? null, () => modules.value.find((m) => m.id === currentModuleId.value) ?? null,
@@ -277,6 +293,9 @@ async function loadModules() {
} catch (e) { } catch (e) {
console.error('[FileExplorer] 加载工程列表失败', e) console.error('[FileExplorer] 加载工程列表失败', e)
modules.value = [] modules.value = []
} finally {
// B4[PD-P2-8P2-10]:(/),
mLoaded.value = true
} }
} }
@@ -286,6 +305,7 @@ function resetTreeState() {
loadedChildren.clear() loadedChildren.clear()
selectedFilePath.value = null selectedFilePath.value = null
selectedFileGitStatus.value = undefined selectedFileGitStatus.value = undefined
selectedCommitDiff.value = ''
} }
/** 自定义下拉选择工程。 */ /** 自定义下拉选择工程。 */
@@ -301,6 +321,8 @@ function onFileSelect(path: string) {
selectedFilePath.value = path selectedFilePath.value = path
// loadedChildren git_status() // loadedChildren git_status()
selectedFileGitStatus.value = findEntryGitStatus(path) selectedFileGitStatus.value = findEntryGitStatus(path)
// , GitChanges diff
selectedCommitDiff.value = ''
} }
/** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */ /** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */
@@ -335,6 +357,7 @@ async function refresh() {
loadedChildren.clear() loadedChildren.clear()
selectedFilePath.value = null selectedFilePath.value = null
selectedFileGitStatus.value = undefined selectedFileGitStatus.value = undefined
selectedCommitDiff.value = ''
// tick FileTree watch ;, // tick FileTree watch ;,
await new Promise((r) => setTimeout(r, 50)) await new Promise((r) => setTimeout(r, 50))
refreshing.value = false refreshing.value = false
@@ -364,6 +387,15 @@ function onDetach() {
// : / Git // : / Git
const viewMode = ref<'tree' | 'changes'>('tree') const viewMode = ref<'tree' | 'changes'>('tree')
const gitStatusData = ref<GitStatusResult | null>(null) const gitStatusData = ref<GitStatusResult | null>(null)
// B2[PD-P1-5]:git ()AI () GitChanges ,
// treechanges watchhack; gitStatusData
const gitStatusRefreshKey = ref(0)
// (KeepAlive onDeactivated false,onActivated true)
// overview , df-data-changed ;
// overview tab changes
const viewVisible = ref(true)
onActivated(() => { viewVisible.value = true })
onDeactivated(() => { viewVisible.value = false })
const changedCount = computed(() => gitStatusData.value?.changed_files.length ?? 0) const changedCount = computed(() => gitStatusData.value?.changed_files.length ?? 0)
@@ -377,10 +409,11 @@ async function switchToChanges() {
} }
} }
/** Git 变更视图中选择文件 → 在右侧预览显示 diff。 */ /** Git 变更视图中选择文件 → 在右侧预览显示 diff(变化/提交历史点文件都走此路,宽区渲染)。 */
function onChangeFileSelect(path: string) { function onChangeFileSelect(path: string, diff?: string) {
selectedFilePath.value = path selectedFilePath.value = path
selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status
selectedCommitDiff.value = diff ?? ''
} }
watch(() => props.projectId, loadModules, { immediate: true }) watch(() => props.projectId, loadModules, { immediate: true })
@@ -396,16 +429,22 @@ onUnmounted(() => document.removeEventListener('click', closeDropdown))
// AI (df-data-changed entity=file) + // AI (df-data-changed entity=file) +
let _unlistenDataChanged: UnlistenFn | null = null let _unlistenDataChanged: UnlistenFn | null = null
onMounted(async () => { onMounted(async () => {
_unlistenDataChanged = await listen<{ entity: string; action: string }>('df-data-changed', (e) => { _unlistenDataChanged = await listen<{ entity: string; action: string }>('df-data-changed', async (e) => {
if (e.payload.entity !== 'file') return if (e.payload.entity !== 'file') return
// git ,使 if (!currentModuleId.value) return
gitStatusData.value = null // B2[PD-P1-5]: git null null changedCount 0,
if (viewMode.value === 'changes') { // switchToChanges !gitStatusData,
// , GitChanges ( viewMode watch) try {
viewMode.value = 'tree' gitStatusData.value = await moduleApi.getModuleGitStatus(currentModuleId.value)
viewMode.value = 'changes' } catch {
} else { gitStatusData.value = null
// ,, AI }
// GitChanges ( changes ;)
// git status 5s TTL (module.rs GIT_STATUS_CACHE_TTL),
// ,5s /;()
gitStatusRefreshKey.value++
if (viewMode.value !== 'changes' && viewVisible.value) {
// :, AI
viewMode.value = 'changes' viewMode.value = 'changes'
} }
}) })
@@ -730,9 +769,10 @@ async function onRemoveModule() {
.explorer-tree { .explorer-tree {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 8px 4px 8px 0; padding: 4px 4px 4px 0;
scrollbar-width: thin; scrollbar-width: thin;
} }
@@ -757,6 +797,35 @@ async function onRemoveModule() {
height: 100%; height: 100%;
} }
/* B4[PD-P2-8→P2-10]:零工程空态引导(对齐概览模块空态视觉) */
.module-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 16px;
text-align: center;
height: 100%;
justify-content: center;
}
.module-empty-icon {
font-size: 28px;
opacity: 0.6;
line-height: 1;
}
.module-empty-hint {
margin: 0;
font-size: 12px;
color: var(--df-text-dim);
line-height: 1.6;
max-width: 300px;
}
.module-empty-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.tree-loading .spinner { .tree-loading .spinner {
width: 20px; width: 20px;
height: 20px; height: 20px;
@@ -785,19 +854,42 @@ async function onRemoveModule() {
/* 全局滚动条样式(薄) */ /* 全局滚动条样式(薄) */
.explorer-sidebar ::-webkit-scrollbar { .explorer-sidebar ::-webkit-scrollbar {
width: 4px; width: 5px;
height: 4px; height: 5px;
} }
.explorer-sidebar ::-webkit-scrollbar-thumb { .explorer-sidebar ::-webkit-scrollbar-thumb {
background: var(--df-border); background: rgba(255, 255, 255, 0.08);
border-radius: 2px; border-radius: var(--df-radius-xs);
}
.explorer-sidebar ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
} }
.explorer-sidebar ::-webkit-scrollbar-track { .explorer-sidebar ::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
/* 文件列表(.explorer-tree)滚动条显式对齐全局,与右侧预览区一致 */
.explorer-tree ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.explorer-tree ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-tree ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-tree ::-webkit-scrollbar-track {
background: transparent;
}
/* 窄窗口自适应 */ /* 窄窗口自适应 */
@media (max-width: 900px) { @media (max-width: 900px) {
.explorer-sidebar { .explorer-sidebar {
+452 -27
View File
@@ -39,28 +39,46 @@
<div class="preview-placeholder-text">{{ $t('fileExplorer.selectFileHint') }}</div> <div class="preview-placeholder-text">{{ $t('fileExplorer.selectFileHint') }}</div>
</div> </div>
<!-- 加载中 --> <!-- 加载中(外部 diff 存在时让位于 diff 视图,历史提交的文件可能不在当前工作树) -->
<div v-else-if="loading" class="preview-loading"> <div v-else-if="loading && !isExternalDiff" class="preview-loading">
<span class="spinner"></span> <span class="spinner"></span>
{{ $t('fileExplorer.loadingFile') }} {{ $t('fileExplorer.loadingFile') }}
</div> </div>
<!-- 错误 --> <!-- 错误(外部 diff 存在时同样让位:历史文件当前树不存在不应遮住 diff) -->
<div v-else-if="error" class="preview-error"> {{ error }}</div> <div v-else-if="error && !isExternalDiff" class="preview-error"> {{ error }}</div>
<!-- 二进制 --> <!-- 二进制:展示文件信息(路径/大小/类型),替代"不支持预览"空态 -->
<div v-else-if="isBinary" class="preview-binary"> <div v-else-if="isBinary && !isExternalDiff" class="preview-binary">
<span class="binary-icon">📄</span> <span class="binary-icon">🗂</span>
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p> <p class="binary-title">{{ $t('fileExplorer.binaryInfo') }}</p>
<div class="binary-meta">
<div class="binary-meta-row">
<span class="binary-meta-label">{{ $t('fileExplorer.pathLabel') }}</span>
<span class="binary-meta-value">{{ filePath }}</span>
</div>
<div class="binary-meta-row">
<span class="binary-meta-label">{{ $t('fileExplorer.sizeLabel') }}</span>
<span class="binary-meta-value">{{ formatSize(fileSize ?? 0) }}</span>
</div>
<div class="binary-meta-row">
<span class="binary-meta-label">{{ $t('fileExplorer.typeLabel') }}</span>
<span class="binary-meta-value">{{ fileType }}</span>
</div>
<div class="binary-meta-row">
<span class="binary-meta-label">{{ $t('fileExplorer.binaryLabel') }}</span>
<span class="binary-meta-value">{{ $t('fileExplorer.binaryNotSupported') }}</span>
</div>
</div>
</div> </div>
<!-- 图片 --> <!-- 图片(外部 diff 存在时让位) -->
<div v-else-if="isImage" class="preview-image"> <div v-else-if="isImage && !isExternalDiff" class="preview-image">
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" /> <img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
</div> </div>
<!-- Diff 视图(切到 diff 模式时显;置于 Markdown 之前,使 .md 文件也支持 Diff) --> <!-- Diff 视图(外部注入的 diff 优先展;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
<div v-else-if="showDiff && diffContent" class="preview-diff"> <div v-else-if="showDiff && diffViewContent" class="preview-diff">
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type"> <div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
<span class="diff-line-num">{{ ln.oldNum || '' }}</span> <span class="diff-line-num">{{ ln.oldNum || '' }}</span>
<span class="diff-line-num">{{ ln.newNum || '' }}</span> <span class="diff-line-num">{{ ln.newNum || '' }}</span>
@@ -76,19 +94,60 @@
<div v-else-if="isMarkdown" ref="previewMdRef" class="preview-md ai-md" v-html="renderedMd"></div> <div v-else-if="isMarkdown" ref="previewMdRef" class="preview-md ai-md" v-html="renderedMd"></div>
<!-- 文本/代码(highlight.js 语法高亮 + 行号; diff 模式时显示) --> <!-- 文本/代码(highlight.js 语法高亮 + 行号; diff 模式时显示) -->
<div v-else class="preview-code-scroll"> <div v-else ref="codeScrollRef" class="preview-code-scroll">
<div class="preview-line-numbers" aria-hidden="true"> <div class="preview-line-numbers" aria-hidden="true">
<div v-for="n in lineCount" :key="n" class="preview-line-num">{{ n }}</div> <div v-for="n in lineCount" :key="n" class="preview-line-num">{{ n }}</div>
</div> </div>
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre> <pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
</div> </div>
<!-- 符号概览:悬浮右上角(折叠态为 按钮,点击弹出分组面板) -->
<div v-if="showOutline" class="outline-overlay">
<button
v-if="outlineCollapsed"
class="outline-overlay-btn"
:title="$t('fileExplorer.symbols')"
@click="outlineCollapsed = false"
>
<span class="outline-overlay-count">{{ outlineSymbols.length }}</span>
</button>
<div v-else class="preview-outline">
<div class="preview-outline-head" @click="outlineCollapsed = true">
<span class="preview-outline-title"> {{ $t('fileExplorer.symbols') }}</span>
<span class="preview-outline-count">{{ outlineSymbols.length }}</span>
<span class="preview-outline-spacer"></span>
<span class="preview-outline-toggle"></span>
</div>
<div class="preview-outline-body">
<!-- 分组展示:类型/函数/变量/模块,组内按行号(DFS 已保序) -->
<div v-for="grp in outlineGroups" :key="grp.key" class="outline-group">
<div class="outline-group-head">
<span class="outline-group-label" :class="'grp-' + grp.key">{{ $t(grp.labelKey) }}</span>
<span class="outline-group-count">{{ grp.items.length }}</span>
</div>
<div
v-for="sym in grp.items"
:key="sym.line"
class="outline-item"
:class="['grp-' + grp.key, { 'is-active': activeOutlineLine === sym.line }]"
:title="sym.signature"
@click="gotoOutlineLine(sym.line)"
>
<span class="outline-kind">{{ outlineKindLabel(sym.kind) }}</span>
<span class="outline-name">{{ sym.name || sym.signature || '?' }}</span>
<span class="outline-line">{{ sym.line }}</span>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onUnmounted, nextTick } from 'vue' import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
import { moduleApi } from '@/api/module' import { moduleApi, type OutlineSymbol } from '@/api/module'
import hljs from 'highlight.js/lib/common' import hljs from 'highlight.js/lib/common'
import { useMarkdown, useRendered } from '@/composables/useMarkdown' import { useMarkdown, useRendered } from '@/composables/useMarkdown'
import { escapeHtml } from '@/utils/html' import { escapeHtml } from '@/utils/html'
@@ -98,6 +157,9 @@ const props = defineProps<{
filePath: string | null filePath: string | null
moduleRootPath: string moduleRootPath: string
gitStatus?: string gitStatus?: string
/** diff( GitChanges / diff, diff)
* 非空时优先渲染此 diff(宽预览区),不发起重复的 git diff 请求 */
externalDiff?: string
}>() }>()
const { } = useMarkdown() const { } = useMarkdown()
@@ -110,6 +172,22 @@ const fileSize = ref<number | null>(null)
const truncated = ref(false) const truncated = ref(false)
const imageUrl = ref<string | null>(null) const imageUrl = ref<string | null>(null)
/** 符号概览(fileOutline 结果;仅支持语言 + 非 diff 视图时展示)。 */
const outlineSupported = ref(false)
const outlineSymbols = ref<OutlineSymbol[]>([])
const outlineCollapsed = ref(true)
const activeOutlineLine = ref(-1)
const codeScrollRef = ref<HTMLElement | null>(null)
/** 文件类型(从扩展名推断,用于二进制文件信息展示)。 */
const fileType = computed(() => {
if (!props.filePath) return '—'
const name = props.filePath.split('/').pop() || props.filePath
const dot = name.lastIndexOf('.')
const ext = dot > 0 ? name.slice(dot + 1).toUpperCase() : 'FILE'
return ext.length <= 6 ? ext : 'FILE'
})
/** ( highlight.js HTML ) /** ( highlight.js HTML )
* 高亮 HTML 可能因多行 token / 转义与源码行数不一致,直接切分 htmlContent 易错位 * 高亮 HTML 可能因多行 token / 转义与源码行数不一致,直接切分 htmlContent 易错位
* 末尾无换行时 split 会多出空串,需按实际换行符计数对齐渲染 */ * 末尾无换行时 split 会多出空串,需按实际换行符计数对齐渲染 */
@@ -127,6 +205,17 @@ const showDiff = ref(false)
const diffContent = ref('') const diffContent = ref('')
const diffLoading = ref(false) const diffLoading = ref(false)
/** 是否存在外部注入的 diff(非空即视为外部 diff 模式:默认展示 diff,且 diff 优先级最高)。 */
const isExternalDiff = computed(() => !!props.externalDiff && props.externalDiff.trim().length > 0)
/** 实际展示的 diff 内容:外部注入优先,否则用本组件拉取的 git diff。 */
const diffViewContent = computed(() => (isExternalDiff.value ? props.externalDiff! : diffContent.value))
/** 外部 diff 注入时默认切到 diff 视图(点文件即看变化,无需再点 📝);清空则回到内容视图。 */
watch(() => props.externalDiff, (val) => {
showDiff.value = !!(val && val.trim().length > 0)
})
/** :loadFile/loadDiff ,await " seq", /** :loadFile/loadDiff ,await " seq",
* 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容) * 否则丢弃(快速切文件/切视图时旧响应晚到不覆盖新内容)
* :loadFile loadDiff 分用两个计数器 若共用一个,loadDiff 自增会让在途的 loadFile * :loadFile loadDiff 分用两个计数器 若共用一个,loadDiff 自增会让在途的 loadFile
@@ -152,11 +241,11 @@ function parseHunkHeader(line: string): { oldStart: number; newStart: number } {
} }
const diffLines = computed<DiffLine[]>(() => { const diffLines = computed<DiffLine[]>(() => {
if (!diffContent.value) return [] if (!diffViewContent.value) return []
const lines: DiffLine[] = [] const lines: DiffLine[] = []
let oldNum = 0 let oldNum = 0
let newNum = 0 let newNum = 0
for (const raw of diffContent.value.split('\n')) { for (const raw of diffViewContent.value.split('\n')) {
if (raw.startsWith('@@')) { if (raw.startsWith('@@')) {
const h = parseHunkHeader(raw) const h = parseHunkHeader(raw)
oldNum = h.oldStart oldNum = h.oldStart
@@ -180,7 +269,8 @@ const diffLines = computed<DiffLine[]>(() => {
function toggleDiff() { function toggleDiff() {
showDiff.value = !showDiff.value showDiff.value = !showDiff.value
if (showDiff.value && !diffContent.value) { // diff , git diff; diff
if (showDiff.value && !diffContent.value && !isExternalDiff.value) {
loadDiff() loadDiff()
} }
} }
@@ -281,6 +371,99 @@ const isImage = computed(() => {
return IMAGE_EXT.some((ext) => lower.endsWith(ext)) return IMAGE_EXT.some((ext) => lower.endsWith(ext))
}) })
/** 符号概览面板是否展示:支持语言 + 有符号 + 非 diff 视图。 */
const showOutline = computed(() =>
outlineSupported.value && outlineSymbols.value.length > 0 && !showDiff.value && !!props.filePath
)
/** outline 符号 kind → 短标签(顶部 chips 显示)。 */
const KIND_LABELS: Record<string, string> = {
function_item: 'fn', function_declaration: 'fn', function_definition: 'def',
method_declaration: 'fn', method_definition: 'fn',
struct_item: 'struct', class_declaration: 'class', class_definition: 'class',
enum_item: 'enum', enum_declaration: 'enum', impl_item: 'impl',
trait_item: 'trait', interface_declaration: 'interface', mod_item: 'mod',
const_item: 'const', static_item: 'static', type_item: 'type',
macro_definition: 'macro', variable_declarator: 'let', type_declaration: 'type',
constructor_declaration: 'ctor', record_declaration: 'record',
annotation_type_declaration: '@interface',
}
function outlineKindLabel(kind: string): string {
return KIND_LABELS[kind] || kind
}
/** kind → 展示分组(对齐 IDE 大纲语义:类型/函数/变量/模块)。 */
const KIND_GROUPS: Record<string, string> = {
//
struct_item: 'type', class_declaration: 'type', class_definition: 'type',
enum_item: 'type', enum_declaration: 'type', trait_item: 'type',
interface_declaration: 'type', type_item: 'type', type_declaration: 'type',
impl_item: 'type', record_declaration: 'type', annotation_type_declaration: 'type',
//
function_item: 'fn', function_declaration: 'fn', function_definition: 'fn',
method_declaration: 'fn', method_definition: 'fn', constructor_declaration: 'fn',
macro_definition: 'fn',
//
const_item: 'var', static_item: 'var', variable_declarator: 'var',
//
mod_item: 'mod',
}
/** 分组展示顺序。 */
const GROUP_ORDER = ['type', 'fn', 'var', 'mod', 'other']
/** 分组 → i18n key(模板 $t 解析)。 */
const GROUP_LABEL_KEY: Record<string, string> = {
type: 'fileExplorer.outlineGroupType',
fn: 'fileExplorer.outlineGroupFn',
var: 'fileExplorer.outlineGroupVar',
mod: 'fileExplorer.outlineGroupMod',
other: 'fileExplorer.outlineGroupOther',
}
function kindGroup(kind: string): string {
return KIND_GROUPS[kind] || 'other'
}
/** 按分组聚合 outline 符号(组序固定,组内按行号)。 */
const outlineGroups = computed(() => {
const buckets = new Map<string, OutlineSymbol[]>()
for (const sym of outlineSymbols.value) {
const g = kindGroup(sym.kind)
if (!buckets.has(g)) buckets.set(g, [])
buckets.get(g)!.push(sym)
}
return GROUP_ORDER
.filter((g) => buckets.has(g))
.map((g) => ({ key: g, labelKey: GROUP_LABEL_KEY[g], items: buckets.get(g)! }))
})
/** 点击符号滚动到对应行:偏移 = 代码区 padding-top 14px + (line-1) × 行高(12.5px × 1.55)。 */
function gotoOutlineLine(line: number) {
activeOutlineLine.value = line
const el = codeScrollRef.value
if (!el) return
const lineHeight = 12.5 * 1.55
const topPad = 14 // 对齐 .preview-code / .preview-line-numbers padding-top
el.scrollTo({ top: Math.max(0, topPad + (line - 1) * lineHeight), behavior: 'smooth' })
}
/** 拉取文件符号概览(独立 try/catch:outline 失败不阻断文件内容加载)。 */
async function loadOutline(seq: number) {
if (!props.moduleId || !props.filePath) {
outlineSupported.value = false
outlineSymbols.value = []
return
}
try {
const res = await moduleApi.fileOutline(props.moduleId, props.filePath)
if (seq !== fileReqSeq) return // , outline
outlineSupported.value = res.supported
outlineSymbols.value = res.symbols || []
} catch {
if (seq !== fileReqSeq) return
outlineSupported.value = false
outlineSymbols.value = []
}
}
/** 拉文件内容 + 高亮渲染。 */ /** 拉文件内容 + 高亮渲染。 */
async function loadFile() { async function loadFile() {
const seq = ++fileReqSeq // (使) const seq = ++fileReqSeq // (使)
@@ -288,6 +471,9 @@ async function loadFile() {
content.value = '' content.value = ''
htmlContent.value = '' htmlContent.value = ''
imageUrl.value = null imageUrl.value = null
outlineSupported.value = false
outlineSymbols.value = []
activeOutlineLine.value = -1
return return
} }
loading.value = true loading.value = true
@@ -300,8 +486,16 @@ async function loadFile() {
URL.revokeObjectURL(imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = null imageUrl.value = null
} }
// ()
outlineSupported.value = false
outlineSymbols.value = []
activeOutlineLine.value = -1
try { try {
const res = await moduleApi.readModuleFile(props.moduleId, props.filePath) // (readModuleFile fileOutline )
const [res] = await Promise.all([
moduleApi.readModuleFile(props.moduleId, props.filePath),
loadOutline(seq),
])
if (seq !== fileReqSeq) return // , if (seq !== fileReqSeq) return // ,
fileSize.value = res.size fileSize.value = res.size
truncated.value = res.truncated truncated.value = res.truncated
@@ -327,16 +521,35 @@ async function loadFile() {
// highlight.js :, // highlight.js :,
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? '' const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
const lang = EXT_LANG[ext] const lang = EXT_LANG[ext]
try { // (), hljs ,
if (lang && hljs.getLanguage(lang)) { // highlightAuto():,(,)
htmlContent.value = hljs.highlight(res.content, { language: lang }).value content.value = res.content
} else { const raw = res.content
htmlContent.value = hljs.highlightAuto(res.content).value const isLarge = raw.length > 50_000
const hljsTask = () => {
if (fileReqSeq !== seq) return // ,
try {
if (lang && hljs.getLanguage(lang)) {
htmlContent.value = hljs.highlight(raw, { language: lang }).value
} else if (!isLarge) {
// ;( highlightAuto )
htmlContent.value = hljs.highlightAuto(raw).value
} else {
htmlContent.value = escapeHtml(raw)
}
} catch {
htmlContent.value = escapeHtml(raw)
} }
} catch {
// ( XSS)
htmlContent.value = escapeHtml(res.content)
} }
// ():rAF ,50KB+ rAF
requestAnimationFrame(() => {
if (isLarge) {
// ,,
requestAnimationFrame(hljsTask)
} else {
hljsTask()
}
})
} }
} catch (e) { } catch (e) {
if (seq !== fileReqSeq) return // if (seq !== fileReqSeq) return //
@@ -453,6 +666,7 @@ onUnmounted(() => {
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; /* 符号悬浮层定位锚点 */
} }
.preview-placeholder, .preview-placeholder,
@@ -463,11 +677,55 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 14px;
height: 100%; height: 100%;
color: var(--df-text-dim); color: var(--df-text-dim);
font-size: 13px; font-size: 13px;
min-height: 200px; min-height: 200px;
padding: 24px;
}
.binary-icon {
font-size: 40px;
opacity: 0.8;
}
.binary-title {
font-size: 15px;
font-weight: 600;
color: var(--df-text);
margin: 0;
}
.binary-meta {
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
padding: 12px 16px;
min-width: 280px;
max-width: 90%;
}
.binary-meta-row {
display: flex;
gap: 8px;
align-items: baseline;
}
.binary-meta-label {
color: var(--df-text-dim);
font-size: 12px;
flex-shrink: 0;
min-width: 36px;
}
.binary-meta-value {
color: var(--df-text-secondary);
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 12.5px;
word-break: break-all;
} }
.preview-placeholder-icon { .preview-placeholder-icon {
@@ -604,6 +862,173 @@ onUnmounted(() => {
border-color: var(--df-accent); border-color: var(--df-accent);
} }
/* 符号概览悬浮层:右上角按钮 + 弹出分组面板 */
.outline-overlay {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
display: flex;
justify-content: flex-end;
}
/* 折叠态:右上角小 ☰ 按钮(带符号数徽标) */
.outline-overlay-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
border: 0.5px solid var(--df-border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: var(--df-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.outline-overlay-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--df-text);
border-color: var(--df-accent);
}
.outline-overlay-count {
background: var(--df-accent);
color: #fff;
border-radius: 7px;
padding: 0 5px;
font-size: 9px;
line-height: 14px;
}
/* 展开态:悬浮面板(右上角弹出,固定宽 + 限高滚动 + 阴影) */
.preview-outline {
width: 280px;
max-height: 320px;
display: flex;
flex-direction: column;
background: rgba(20, 22, 34, 0.95);
border: 0.5px solid var(--df-border);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
overflow: hidden;
}
/* 浅色主题:悬浮面板用浅色底 */
[data-theme='light'] .preview-outline {
background: rgba(255, 255, 255, 0.97);
}
.preview-outline-head {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 11px;
color: var(--df-text-dim);
cursor: pointer;
user-select: none;
border-bottom: 0.5px solid var(--df-border);
flex-shrink: 0;
}
.preview-outline-head:hover { color: var(--df-text); }
.preview-outline-title { font-weight: 600; }
.preview-outline-count {
background: rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 0 6px;
font-size: 10px;
}
.preview-outline-spacer { flex: 1; }
.preview-outline-toggle { font-size: 11px; opacity: 0.7; }
.preview-outline-body {
padding: 0 0 8px;
overflow-y: auto;
flex: 1;
}
.outline-group { padding: 0 10px; }
.outline-group-head {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 2px 3px;
font-size: 10px;
color: var(--df-text-dim);
letter-spacing: 0.04em;
}
.outline-group + .outline-group {
border-top: 0.5px solid var(--df-border);
margin-top: 3px;
}
.outline-group-count {
background: rgba(255, 255, 255, 0.07);
border-radius: 6px;
padding: 0 5px;
font-size: 9px;
}
.outline-item {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border-radius: 6px;
font-size: 11.5px;
cursor: pointer;
white-space: nowrap;
color: var(--df-text-secondary);
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
transition: background 0.15s;
}
.outline-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--df-text); }
.outline-item.is-active {
background: rgba(255, 255, 255, 0.09);
color: var(--df-text);
box-shadow: inset 2px 0 0 var(--df-accent);
}
/* 分组徽章配色:类型蓝 / 函数绿 / 变量橙 / 模块紫 */
.outline-item.grp-type .outline-kind { color: #4FC3F7; }
.outline-item.grp-fn .outline-kind { color: #81C784; }
.outline-item.grp-var .outline-kind { color: #FFB74D; }
.outline-item.grp-mod .outline-kind { color: #BA68C8; }
.outline-item.grp-other .outline-kind { color: var(--df-text-dim); }
.outline-kind {
font-size: 9px;
font-weight: 600;
flex-shrink: 0;
min-width: 30px;
text-align: right;
}
.outline-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.outline-line {
flex-shrink: 0;
min-width: 24px;
text-align: right;
font-size: 10px;
opacity: 0.5;
}
/* Diff 视图 */ /* Diff 视图 */
.preview-diff { .preview-diff {
font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace); font-family: var(--df-font-mono, 'Cascadia Code', Consolas, monospace);
File diff suppressed because one or more lines are too long
+46 -103
View File
@@ -130,7 +130,7 @@
</div> </div>
<div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div> <div v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
<template v-else-if="commitDetail"> <template v-else-if="commitDetail">
<!-- 变更文件列表 --> <!-- 变更文件列表(点击文件 diff 渲染到右侧宽预览区, emit select-file) -->
<div class="gc-commit-files"> <div class="gc-commit-files">
<div <div
v-for="f in commitDetail.files" v-for="f in commitDetail.files"
@@ -143,25 +143,6 @@
<span class="gc-file-path">{{ f.path }}</span> <span class="gc-file-path">{{ f.path }}</span>
</div> </div>
</div> </div>
<!-- Diff 预览(可收起,占更宽空间) -->
<div v-if="commitSelectedFile" class="gc-commit-diff">
<div class="gc-commit-diff-header">
<span class="gc-commit-diff-path">{{ commitSelectedFile }}</span>
<button class="gc-commit-diff-toggle" @click="commitDiffCollapsed = !commitDiffCollapsed">
{{ commitDiffCollapsed ? $t('gitChanges.expand') : $t('gitChanges.collapse') }}
</button>
</div>
<div v-show="!commitDiffCollapsed" class="gc-diff-content">
<div v-if="!commitDiff" class="gc-diff-empty">{{ $t('gitChanges.noDiff') }}</div>
<div v-for="(ln, idx) in commitDiffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
<span class="diff-hdr-text" v-if="ln.type === 'hdr'">{{ ln.text }}</span>
<template v-else>
<span class="diff-line-prefix">{{ ln.prefix }}</span>
<span class="diff-line-text">{{ ln.text }}</span>
</template>
</div>
</div>
</div>
</template> </template>
</div> </div>
</transition> </transition>
@@ -172,8 +153,9 @@
/** /**
* Git 变更查看器 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览 * Git 变更查看器 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览
* *
* 历史 Tab 布局:列表始终可见,选中提交的详情从底部滑出(可收起),diff 在更宽容器渲染, * 布局:变更 Tab 点文件 / 提交历史点文件 emit select-file,把该文件 diff 注入右侧 FilePreview
* 避免几百行 diff 把列表挤走或在 30% 窄栏内挤压 * 宽预览区渲染;本组件窄栏只保留文件列表与提交详情(文件清单),不再内嵌 diff 几百行 diff
* 30% 窄栏内渲染会被挤压/截断, diff 展示统一走右侧宽区
*/ */
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { moduleApi, type GitStatusResult } from '@/api/module' import { moduleApi, type GitStatusResult } from '@/api/module'
@@ -183,10 +165,13 @@ const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
moduleId: string moduleId: string
/** B2[PD-P1-5]:外部(FileExplorer df-data-changed)自增刷新信号,变化时重拉 git 状态。 */
refreshKey?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select-file', path: string): void /** 选择文件 → 在右侧宽预览区展示其 diff(第二个参数为可选注入的 diff 内容)。 */
(e: 'select-file', path: string, diff?: string): void
}>() }>()
const loading = ref(false) const loading = ref(false)
@@ -229,8 +214,6 @@ const selectedCommit = ref<CommitItem | null>(null)
const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null) const commitDetail = ref<{ files: { status: string; path: string }[]; diff: string } | null>(null)
const commitDetailLoading = ref(false) const commitDetailLoading = ref(false)
const commitSelectedFile = ref('') const commitSelectedFile = ref('')
const commitDiff = ref('')
const commitDiffCollapsed = ref(false)
const branchName = computed(() => gitStatus.value?.branch || '') const branchName = computed(() => gitStatus.value?.branch || '')
@@ -269,33 +252,6 @@ const groupedChanges = computed<FileGroup[]>(() => {
return groups.filter(g => g.files.length > 0) return groups.filter(g => g.files.length > 0)
}) })
interface DiffLine {
type: 'add' | 'del' | 'ctx' | 'hdr'
prefix: string
text: string
}
const commitDiffLines = computed<DiffLine[]>(() => parseDiff(commitDiff.value))
function parseDiff(text: string): DiffLine[] {
if (!text) return []
const lines: DiffLine[] = []
for (const raw of text.split('\n')) {
if (raw.startsWith('@@')) {
lines.push({ type: 'hdr', prefix: '', text: raw })
} else if (raw.startsWith('+')) {
lines.push({ type: 'add', prefix: '+', text: raw.slice(1) })
} else if (raw.startsWith('-')) {
lines.push({ type: 'del', prefix: '-', text: raw.slice(1) })
} else if (raw.startsWith('\\')) {
continue
} else {
lines.push({ type: 'ctx', prefix: ' ', text: raw })
}
}
return lines
}
function statusLabel(s: string): string { function statusLabel(s: string): string {
const x = s.trim() const x = s.trim()
if (x === '??') return 'U' if (x === '??') return 'U'
@@ -476,6 +432,7 @@ async function switchToHistory() {
async function selectFile(path: string) { async function selectFile(path: string) {
selectedFile.value = path selectedFile.value = path
// (),diff ,
emit('select-file', path) emit('select-file', path)
diffContent.value = '' diffContent.value = ''
diffLoading.value = true diffLoading.value = true
@@ -487,6 +444,10 @@ async function selectFile(path: string) {
} finally { } finally {
diffLoading.value = false diffLoading.value = false
} }
// diff,
if (selectedFile.value === path) {
emit('select-file', path, diffContent.value)
}
} }
/** 点击提交行 → 加载该提交的变更文件列表 */ /** 点击提交行 → 加载该提交的变更文件列表 */
@@ -499,8 +460,8 @@ async function selectCommit(c: CommitItem) {
selectedCommit.value = c selectedCommit.value = c
commitDetail.value = null commitDetail.value = null
commitSelectedFile.value = '' commitSelectedFile.value = ''
commitDiff.value = '' // , diff
commitDiffCollapsed.value = false emit('select-file', '', '')
commitDetailLoading.value = true commitDetailLoading.value = true
try { try {
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash) const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
@@ -516,21 +477,27 @@ function closeCommitDetail() {
selectedCommit.value = null selectedCommit.value = null
commitDetail.value = null commitDetail.value = null
commitSelectedFile.value = '' commitSelectedFile.value = ''
commitDiff.value = '' emit('select-file', '', '')
} }
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块) */ /** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块),路由到右侧宽预览区渲染 */
function showCommitFileDiff(path: string) { function showCommitFileDiff(path: string) {
commitSelectedFile.value = path commitSelectedFile.value = path
commitDiffCollapsed.value = false
if (!commitDetail.value) { if (!commitDetail.value) {
commitDiff.value = '' emit('select-file', path, '')
return return
} }
commitDiff.value = extractFileDiff(commitDetail.value.diff, path) emit('select-file', path, extractFileDiff(commitDetail.value.diff, path))
} }
watch(() => props.moduleId, loadStatus, { immediate: true }) watch(() => props.moduleId, loadStatus, { immediate: true })
// B2[PD-P1-5]:(AI FileExplorer ) git
// Tab/( gitStatus/commits, showHistory/selectedCommit)
watch(() => props.refreshKey, () => {
if (props.refreshKey === undefined) return
loadStatus()
})
</script> </script>
<style scoped> <style scoped>
@@ -590,23 +557,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
position: sticky; top: 0; z-index: 1; position: sticky; top: 0; z-index: 1;
} }
/* Diff 预览 */ /* Diff 预览样式已随窄栏内 diff 面板移除(diff 统一在右侧 FilePreview 宽区渲染) */
.gc-diff-content {
font-family: var(--df-font-mono, Consolas, monospace);
font-size: 12px; line-height: 1.5; overflow: auto; scrollbar-width: thin;
}
.diff-line { display: flex; padding: 0 14px; }
.diff-line-prefix { width: 16px; flex-shrink: 0; text-align: center; user-select: none; }
.diff-line-text { flex: 1; white-space: pre; overflow: hidden; }
.diff-hdr-text {
padding: 4px 14px; background: rgba(60,140,220,0.08);
color: var(--df-text-dim); font-weight: 500; font-size: 11px; display: block;
}
.diff-add { background: rgba(40,160,70,0.12); }
.diff-add .diff-line-prefix { color: #4caf50; }
.diff-del { background: rgba(220,60,60,0.12); }
.diff-del .diff-line-prefix { color: #e05050; }
.diff-ctx { color: var(--df-text); }
/* ====== 提交历史 ====== */ /* ====== 提交历史 ====== */
.gc-history { display: flex; flex-direction: column; min-height: 0; } .gc-history { display: flex; flex-direction: column; min-height: 0; }
@@ -705,31 +656,6 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
.gc-commit-file-row:hover { background: rgba(255,255,255,0.04); } .gc-commit-file-row:hover { background: rgba(255,255,255,0.04); }
.gc-commit-file-row.active { background: rgba(255,255,255,0.06); } .gc-commit-file-row.active { background: rgba(255,255,255,0.06); }
/* 提交 diff(可收起,占更宽空间) */
.gc-commit-diff {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
overflow: hidden;
}
.gc-commit-diff-header {
display: flex; align-items: center; gap: 8px;
padding: 5px 14px; background: rgba(255,255,255,0.03);
border-bottom: 0.5px solid var(--df-border); flex-shrink: 0;
}
.gc-commit-diff-path {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--df-font-mono, Consolas, monospace); font-size: 11px;
color: var(--df-text);
}
.gc-commit-diff-toggle {
background: none; border: 0.5px solid var(--df-border);
color: var(--df-text-dim); cursor: pointer; font-size: 11px;
padding: 2px 8px; border-radius: 4px;
}
.gc-commit-diff-toggle:hover { color: var(--df-text); border-color: var(--df-accent); }
.gc-commit-diff .gc-diff-content { flex: 1; min-height: 0; }
/* 滑出动画 */ /* 滑出动画 */
.gc-detail-slide-enter-active, .gc-detail-slide-leave-active { .gc-detail-slide-enter-active, .gc-detail-slide-leave-active {
transition: transform 0.18s ease, opacity 0.18s ease; transition: transform 0.18s ease, opacity 0.18s ease;
@@ -842,12 +768,29 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
padding: 40px 0; color: var(--df-text-dim); font-size: 13px; padding: 40px 0; color: var(--df-text-dim); font-size: 13px;
} }
.gc-empty-inline { padding: 24px 0; } .gc-empty-inline { padding: 24px 0; }
.gc-diff-loading { display: flex; justify-content: center; padding: 20px; }
.gc-diff-empty { padding: 20px 14px; color: var(--df-text-dim); font-size: 12px; text-align: center; }
.spinner { .spinner {
width: 16px; height: 16px; width: 16px; height: 16px;
border: 2px solid var(--df-border); border-top-color: var(--df-accent); border: 2px solid var(--df-border); border-top-color: var(--df-accent);
border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block; border-radius: 50%; animation: df-spin 0.8s linear infinite; display: inline-block;
} }
@keyframes df-spin { to { transform: rotate(360deg); } } @keyframes df-spin { to { transform: rotate(360deg); } }
/* 滚动条对齐全局(变更/历史提交列表与全局统一样式) */
.git-changes ::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.git-changes ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.git-changes ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.git-changes ::-webkit-scrollbar-track {
background: transparent;
}
</style> </style>
+32 -20
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="module-node" :class="{ active: selected }"> <div class="module-node" :class="{ active: isSelected, 'module-node--cycle': data.isCycle }">
<div class="module-node__header"> <div class="module-node__header">
<span class="module-node__icon">📦</span> <span class="module-node__icon">📦</span>
<span class="module-node__name">{{ data.name }}</span> <span class="module-node__name">{{ data.name }}</span>
@@ -16,39 +16,45 @@ import { computed } from 'vue'
import { parseJsonArray } from '@/utils/json' import { parseJsonArray } from '@/utils/json'
const props = defineProps<{ const props = defineProps<{
data: { // x6-vue-shape props node + graph(view.js:48 h(component, { node, graph })),
name: string // node.getData() , data propdata prop x6
path: string node?: any
stack: string | null graph?: any
gitUrl: string | null data?: Record<string, unknown>
}
selected?: boolean selected?: boolean
}>() }>()
// : node.getData()(vue-shape ), props.data()
// data { name, path, stack, gitUrl } + { isCycle, selected }
const data = computed<Record<string, any>>(() => props.node?.getData?.() ?? props.data ?? {})
// :data.selected(vue-shape ) selected prop
const isSelected = computed(() => !!props.selected || !!data.value.selected)
// :(/)+, // :(/)+,
// '...' + slice(-29) ,( C:/a D:/b ) // '...' + slice(-29) ,( C:/a D:/b )
// :(/)+ + ( 32 ) // :(/)+ + ( 32 )
const truncatedPath = computed(() => { const truncatedPath = computed(() => {
const p = props.data?.path || '' const p = data.value?.path || ''
if (p.length <= 32) return p if (p.length <= 32) return p
// :(/ \,)( C: ) // :(/ \,)( C: )
const sepMatch = p.match(/[\\/]/) const sepMatch = p.match(/[\\/]/)
if (!sepMatch) { if (!sepMatch) {
// ,退 // ,退
return '...' + p.slice(-29) return '' + p.slice(-29)
} }
const sepIdx = sepMatch.index! + 1 const sepIdx = sepMatch.index! + 1
const head = p.slice(0, sepIdx) const head = p.slice(0, sepIdx)
const tailBudget = 32 - head.length - 1 // -1 ( ) const tailBudget = 32 - head.length - 1 // -1
if (tailBudget < 4) { if (tailBudget < 4) {
// ,退() // ,退()
return '...' + p.slice(-29) return '' + p.slice(-29)
} }
const tail = p.slice(-tailBudget) const tail = p.slice(-tailBudget)
return head + '…' + tail return head + '…' + tail
}) })
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3)) const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
</script> </script>
<style scoped> <style scoped>
@@ -60,15 +66,21 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
width: 100%; width: 100%;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
background: #16213e; background: var(--df-bg-card);
border: 1px solid #0f3460; border: 1px solid var(--df-border-strong);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
.module-node.active { .module-node.active {
border-color: #5378e8; border-color: var(--df-accent);
box-shadow: 0 0 0 2px rgba(83, 120, 232, 0.3); box-shadow: 0 0 0 2px var(--df-accent-soft);
}
/* (G2):vue-shape markup body , attrs.body ,
改由依赖图把 isCycle 注入节点 data,组件根元素按类描边 */
.module-node--cycle {
border-color: var(--df-danger);
box-shadow: 0 0 0 2px var(--df-danger-bg);
} }
.module-node__header { .module-node__header {
display: flex; display: flex;
@@ -81,14 +93,14 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
.module-node__name { .module-node__name {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #e0e0e0; color: var(--df-text);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.module-node__path { .module-node__path {
font-size: 10px; font-size: 10px;
color: #888; color: var(--df-text-secondary);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -102,7 +114,7 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
font-size: 9px; font-size: 9px;
padding: 1px 6px; padding: 1px 6px;
border-radius: 4px; border-radius: 4px;
background: rgba(83, 120, 232, 0.15); background: var(--df-accent-bg);
color: #7c9aff; color: var(--df-accent);
} }
</style> </style>
+2
View File
@@ -136,6 +136,8 @@ watch(() => props.view, () => nextTick(measureDesc))
.stage-coding { background: var(--df-accent-soft); 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); }
/* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */
.stage-dim { background: rgba(158,158,158,0.15); color: #9e9e9e; }
.card-desc-wrap { .card-desc-wrap {
position: relative; position: relative;
@@ -91,8 +91,13 @@ const emit = defineEmits<{
}>() }>()
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => { const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
try { return JSON.parse(props.dagJson) } try {
catch { return null } const parsed = JSON.parse(props.dagJson)
// :dag_json {} / nodes / nodes ( DAG ),
// for..of "d.nodes is not iterable" null
if (!parsed || !Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null
return parsed as { nodes: DagNode[]; edges: EdgeDef[] }
} catch { return null }
}) })
const nodeTypeLabel: Record<string, string> = { const nodeTypeLabel: Record<string, string> = {
+7 -1
View File
@@ -13,6 +13,7 @@
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现) //! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
import { ref } from 'vue' import { ref } from 'vue'
import { emit } from '@tauri-apps/api/event'
import { aiApi } from '@/api' import { aiApi } from '@/api'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
@@ -99,9 +100,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支 // 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败 // 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
console.error('[AI] 审批操作未送达后端:', e) console.error('[AI] 审批操作未送达后端:', e)
// P1-e-d:审批失败给用户可见反馈——composable 无组件上下文,经 Tauri 事件总线
// emit('ai-toast'),由 AiChat.vue listen 弹本地 toast(对齐 ai-tool-slow-toast 先例)。
// 文案复用 ai.approvalNotDelivered(与下方 tc.result 同源)。
const errMsg = e instanceof Error ? e.message : String(e)
void emit('ai-toast', { msg: t('ai.approvalNotDelivered', { error: errMsg }), type: 'error' })
if (tc) { if (tc) {
tc.status = 'completed' tc.status = 'completed'
tc.result = t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) }) tc.result = t('ai.approvalNotDelivered', { error: errMsg })
} }
throw e throw e
} }
+95 -48
View File
@@ -11,7 +11,7 @@ import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared' import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types' import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -62,6 +62,20 @@ async function loadConversations() {
} }
} }
// FE1(AC-EFF-F1-1/2/3):会话列表刷新收敛——所有「操作后/事件后」的列表刷新统一走本调度器。
// 约 250ms trailing debounce:合并同一窗口内多次触发(回合收尾的 notify 自触发 + 显式
// loadConversations 曾双拉,switch/new/delete 的本地刷新 + notify)为一次 IPC 列表拉取。
// 跨窗口同步仍靠 notifyConversationChanged 的 emit——分离窗口 listener 也收敛到本调度器,
// 各自窗口只拉一次。初始加载(AiChat.vue 挂载)仍直调 loadConversations,不受防抖延迟。
let _refreshTimer: ReturnType<typeof setTimeout> | null = null
function scheduleConversationsRefresh(): void {
if (_refreshTimer) clearTimeout(_refreshTimer)
_refreshTimer = setTimeout(() => {
_refreshTimer = null
void loadConversations()
}, 250)
}
// 新建防抖锁:快速连点「+」/ Ctrl+N 只触发一次(300ms 内重复调用直接 return, // 新建防抖锁:快速连点「+」/ Ctrl+N 只触发一次(300ms 内重复调用直接 return,
// 防多个空会话 + 虚拟项堆积)。模块级(跨组件共享,主/分离窗口各自实例由 store 单例统一)。 // 防多个空会话 + 虚拟项堆积)。模块级(跨组件共享,主/分离窗口各自实例由 store 单例统一)。
let _newConvLock = false let _newConvLock = false
@@ -103,7 +117,8 @@ async function newConversation() {
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain) // queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
state.agentRound = 0 state.agentRound = 0
state.searchQuery = '' state.searchQuery = ''
await loadConversations() // FE1:走防抖调度(250ms trailing),与下行 notify 自触发的 listener 刷新合并为一次。
scheduleConversationsRefresh()
notifyConversationChanged() notifyConversationChanged()
} finally { } finally {
// 防抖释放:300ms 后允许再次新建 // 防抖释放:300ms 后允许再次新建
@@ -176,6 +191,8 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
cache_hit: m.prompt_cache_hit_tokens, cache_hit: m.prompt_cache_hit_tokens,
cache_miss: m.prompt_cache_miss_tokens, cache_miss: m.prompt_cache_miss_tokens,
reasoning: m.reasoning_tokens, reasoning: m.reasoning_tokens,
// 消息级估算标记(DB 列,老消息 null → undefined):reload 逐条回显对齐 live 态 AiCompleted
is_estimated: m.is_estimated,
} }
: undefined, : undefined,
// 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致) // 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致)
@@ -286,15 +303,15 @@ export async function switchConversation(id: string, force = false) {
loadMoreCursor.loading = false loadMoreCursor.loading = false
state.messages = [] state.messages = []
notifyConversationChanged() notifyConversationChanged()
void loadConversations() scheduleConversationsRefresh()
return return
} }
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。 // 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
// 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。 // 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e) console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
if (mySwitchId !== _latestSwitchId) return if (mySwitchId !== _latestSwitchId) return
pushConvOpFail('switchConvFail') pushConvOpFail('switchConvFail')
void loadConversations() scheduleConversationsRefresh()
return return
} finally { } finally {
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。 // 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
@@ -333,6 +350,28 @@ export async function switchConversation(id: string, force = false) {
state.messages = parseConvMessages(rawMsgs, 'loaded') state.messages = parseConvMessages(rawMsgs, 'loaded')
// parse 成功才置 active(保证 active 与 messages 一致) // parse 成功才置 active(保证 active 与 messages 一致)
state.activeConversationId = id state.activeConversationId = id
// CSW-P1-4: 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
// 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中,
// flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText,
// 生成完成由 AiCompleted/AiAgentRound 收尾 flushCurrentText 回填 content。
// targetGen(非终止三态 generating/stopping/compressed)在上文已算,直接复用;
// round0-pending- 前缀防御判重(force 重刷同会话时 parseConvMessages 已整体重建,不叠加)。
// 字段满足 AiMessage 必填(id/role/content/timestamp),仅 id 因 branded MessageId 逐字段 cast。
const lastMsg = state.messages[state.messages.length - 1]
if (
targetGen
&& lastMsg
&& lastMsg.role === 'user'
&& !state.messages.some((m) => m.role === 'assistant' && m.id.startsWith('round0-pending-'))
) {
state.messages.push({
id: `round0-pending-${id}` as MessageId,
role: 'assistant',
content: '',
isError: false,
timestamp: Date.now(),
})
}
} catch (e) { } catch (e) {
// 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。 // 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。 // 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
@@ -371,53 +410,60 @@ export async function switchConversation(id: string, force = false) {
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批, // 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。 // 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。 // 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
try { // F2-1(AC-EFF-F2-1):纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息
const pending = await aiApi.pendingToolCalls(id) // 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0)
if (mySwitchId !== _latestSwitchId) return if (!targetGen && !hasToolCalls) {
if (pending.length) { state.pendingApprovals = []
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染 } else {
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind])) try {
const pendingIds = new Set(pending.map(p => p.tool_call_id)) const pending = await aiApi.pendingToolCalls(id)
const restored: AiToolCallInfo[] = [] // 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
for (const m of state.messages) { if (mySwitchId !== _latestSwitchId) return
for (const tc of (m.toolCalls || [])) { if (pending.length) {
if (pendingIds.has(tc.id)) { // kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
tc.status = 'pending_approval' const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
const kind = pendingKindMap.get(tc.id) ?? 'risk' const pendingIds = new Set(pending.map(p => p.tool_call_id))
tc.kind = kind const restored: AiToolCallInfo[] = []
// path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path; for (const m of state.messages) {
// 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。 for (const tc of (m.toolCalls || [])) {
if (kind === 'path') { if (pendingIds.has(tc.id)) {
const p = (tc.args as { path?: string } | null)?.path tc.status = 'pending_approval'
tc.path = p const kind = pendingKindMap.get(tc.id) ?? 'risk'
tc.dir = p ?? undefined tc.kind = kind
tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' }) // path 类审批:从 tc.args.path 推 path/dir 文案(后端 IPC 仅返 kind,无 dir/path;
// 此处从工具参数派生,供 ToolCard 审批提示展示)。缺 path 参数的 path 类回退空。
if (kind === 'path') {
const p = (tc.args as { path?: string } | null)?.path
tc.path = p
tc.dir = p ?? undefined
tc.reason = t('aiChat.dirAuthHint', { tool: tc.name, path: p ?? '' })
}
restored.push({
id: tc.id,
name: tc.name,
args: tc.args,
status: 'pending_approval',
kind,
path: tc.path,
dir: tc.dir,
reason: tc.reason,
// 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。
conversationId: id,
})
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min0=不限时跳过;
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
startApprovalTimer(tc.id, tc.name, kind)
} }
restored.push({
id: tc.id,
name: tc.name,
args: tc.args,
status: 'pending_approval',
kind,
path: tc.path,
dir: tc.dir,
reason: tc.reason,
// 恢复的历史挂起带目标会话 id:会话终止收尾按此仅清本会话的待审批项,不连累并发会话。
conversationId: id,
})
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min0=不限时跳过;
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
startApprovalTimer(tc.id, tc.name, kind)
} }
} }
state.pendingApprovals = restored
} else {
state.pendingApprovals = []
} }
state.pendingApprovals = restored } catch {
} else {
state.pendingApprovals = [] state.pendingApprovals = []
} }
} catch {
state.pendingApprovals = []
} }
} }
@@ -471,7 +517,7 @@ async function deleteConversation(id: string) {
loadMoreCursor.convId = null loadMoreCursor.convId = null
loadMoreCursor.loading = false loadMoreCursor.loading = false
} }
await loadConversations() scheduleConversationsRefresh()
// 删的是活跃会话:回落相邻会话作为新活跃视图 // 删的是活跃会话:回落相邻会话作为新活跃视图
if (neighborId) await switchConversation(neighborId) if (neighborId) await switchConversation(neighborId)
notifyConversationChanged({ deletedConvId: id }) notifyConversationChanged({ deletedConvId: id })
@@ -582,6 +628,7 @@ function toggleSidebar() {
export function useAiConversations() { export function useAiConversations() {
return { return {
loadConversations, loadConversations,
scheduleConversationsRefresh,
newConversation, newConversation,
switchConversation, switchConversation,
deleteConversation, deleteConversation,
@@ -595,4 +642,4 @@ export function useAiConversations() {
} }
} }
export { loadConversations, loadMoreHistory, getLoadMoreState } export { loadConversations, scheduleConversationsRefresh, loadMoreHistory, getLoadMoreState }
+7 -4
View File
@@ -18,7 +18,7 @@ import { state } from '@/stores/ai'
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared' import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared'
import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream' import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations' import { scheduleConversationsRefresh } from './useAiConversations'
import { handleStreamingEvent } from './useAiStreamingEvents' import { handleStreamingEvent } from './useAiStreamingEvents'
import { handleToolEvent } from './useAiToolEvents' import { handleToolEvent } from './useAiToolEvents'
import { handleLifecycleEvent } from './useAiLifecycleEvents' import { handleLifecycleEvent } from './useAiLifecycleEvents'
@@ -61,7 +61,8 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
// 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表 // 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表
const uc = event.conversation_id ?? null const uc = event.conversation_id ?? null
if (uc && state.activeConversationId && uc !== state.activeConversationId) { if (uc && state.activeConversationId && uc !== state.activeConversationId) {
void loadConversations() // FE1:非当前会话的用户消息只刷新列表(走防抖调度,与收尾 notify 合并为一次)。
scheduleConversationsRefresh()
return true return true
} }
const last = state.messages[state.messages.length - 1] const last = state.messages[state.messages.length - 1]
@@ -96,7 +97,8 @@ export function handleEvent(event: AiChatEvent) {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') { if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成) // 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
convStates.delete(convId || '') convStates.delete(convId || '')
void loadConversations() // FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列) // 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
if (event.type === 'AiCompleted') { if (event.type === 'AiCompleted') {
emit('ai-drain-queue', { conversationId: event.conversation_id }) emit('ai-drain-queue', { conversationId: event.conversation_id })
@@ -142,7 +144,8 @@ export async function startListener() {
try { try {
_unlistenAiEvent = await aiApi.onEvent(handleEvent) _unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', (e) => { _unlistenConvChanged = await listen('ai-conversation-changed', (e) => {
void loadConversations() // FE1:notify 自触发的刷新走防抖调度,同窗口内多次 notify 合并为一次列表拉取。
scheduleConversationsRefresh()
// 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。 // 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。
const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId
if (deleted && state.activeConversationId === deleted) { if (deleted && state.activeConversationId === deleted) {
+6 -3
View File
@@ -13,7 +13,6 @@ import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllTo
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState' import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths } from './useAiPendingState'
import { clearStreamWatchdog } from './useAiStream' import { clearStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard' import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types' import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore() const appSettings = useAppSettingsStore()
@@ -116,7 +115,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
const conv = state.conversations.find(c => c.id === state.activeConversationId) const conv = state.conversations.find(c => c.id === state.activeConversationId)
if (conv) conv.pinned_goals = event.pinned_goals if (conv) conv.pinned_goals = event.pinned_goals
} }
void loadConversations() // FE1(AC-EFF-F1-1/2/3):去掉显式 loadConversations(曾与下方 notify 自触发双拉),
// 列表刷新统一收敛到 ai-conversation-changed listener 的防抖调度,一次收尾只拉一次。
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量 // token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
if (isShowTokenUsage()) { if (isShowTokenUsage()) {
state.lastTokenUsage = { state.lastTokenUsage = {
@@ -135,6 +135,9 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens } state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens }
} }
// 每轮 token 写入最后一条 AI 消息,供 MessageList 逐条显示 // 每轮 token 写入最后一条 AI 消息,供 MessageList 逐条显示
// 口径说明:后端 AiCompleted 每 loop 只发一次,携带本 loop 累计用量,故 live 态
// 聚合到「最后一条 assistant」;reload(switchConversation)从 DB 逐条回显各轮本轮量
// (消息级 token)。两者展示层级不同(live=loop 聚合,reload=单轮),属已接受口径。
for (let i = state.messages.length - 1; i >= 0; i--) { for (let i = state.messages.length - 1; i >= 0; i--) {
const m = state.messages[i] const m = state.messages[i]
if (m.role === 'assistant' && !m.isError) { if (m.role === 'assistant' && !m.isError) {
@@ -191,7 +194,7 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
options: event.options, options: event.options,
conversationId: event.conversation_id || state.activeConversationId || null, conversationId: event.conversation_id || state.activeConversationId || null,
} }
void loadConversations() // FE1:去掉显式 loadConversations(与下行 notify 双拉),由 listener 防抖调度统一收口。
notifyConversationChanged() notifyConversationChanged()
return true return true
} }
+58 -4
View File
@@ -64,6 +64,20 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
let rafId: number | null = null let rafId: number | null = null
let lastStreamText = '' let lastStreamText = ''
// ═══ AC-EFF-S1-1 增量渲染状态(2026-08-09) ═══
// 流式追加场景(text.startsWith(_prevStreamText))下,已完成块(除末块外)跨帧稳定:
// splitBlocks 按 \n\n / 已闭合代码围栏切块,块边界随文本追加保持稳定(末块外不重切)。
// 原实现每帧对全文 marked.lexer O(n)(长回复/单块回复病态甚至每帧 O(n) lexer + O(n) parse)。
// 现缓存上一帧「已完成块 + 末块文本」,只对「旧末块 + 增长段」增量 lexer,已完成块直接复用
// (块内容寻址缓存 _blockCache 本就命中,此处省掉对全文的重复 lexer)。
// 非追加(首帧 / 重渲染 / 内容替换)回退全量 splitBlocks(与旧行为一致,渲染结果等价)。
let _prevStreamText = ''
let _prevCompleted: string[] = []
let _prevTail = ''
/// 单块回复病态阈值:整个回复无 \n\n/围栏边界(末块=全文)且超此长度时,末块降级纯文本
/// (escapeFallback,避免每帧 marked.lexer+parse O(n) 卡顿),AiCompleted 后整段 renderMd 完整渲染。
const SINGLE_BLOCK_TEXT_LIMIT = 12000
/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块 /// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块
/// (跨双换行不切),非代码段按双换行切段。 /// (跨双换行不切),非代码段按双换行切段。
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。 /// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
@@ -124,17 +138,46 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
return html return html
} }
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse) /// 流式渲染:增量切块 + 块级 memo(前块缓存/末块重 parse)
/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增), /// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。 /// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
///
/// AC-EFF-S1-1 增量:追加场景复用上一帧已完成块,只对「旧末块+增长段」增量 lexer,
/// 不再每帧全文 lexer;单块回复(整回复无块边界)超阈值降级纯文本防 O(n) parse 卡顿。
function renderStreamingBlocks(text: string): StreamBlock[] { function renderStreamingBlocks(text: string): StreamBlock[] {
if (!text) return [] if (!text) return []
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为) // AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。 // mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) { if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
_prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)
return [{ html: escapeFallback(text), key: 'fallback' }] return [{ html: escapeFallback(text), key: 'fallback' }]
} }
const blocks = splitBlocks(text) // 增量切块:追加场景复用上一帧已完成块,只对「旧末块 + 增长段」增量 lexer。
// 非追加(首帧/重渲染/内容替换)→ 全量 splitBlocks(与旧行为一致)。
const appendOnly = _prevStreamText.length > 0 && text.startsWith(_prevStreamText)
let blocks: string[]
if (appendOnly) {
const growth = text.slice(_prevStreamText.length)
const combined = _prevTail + growth
const combinedBlocks = splitBlocks(combined)
blocks = combinedBlocks.length <= 1
// 仍单块增长(旧末块未跨边界)→ 已完成块不变,新末块 = 旧末块+增长
? _prevCompleted.concat(combinedBlocks)
// 旧末块跨边界拆出若干已完成块(围栏闭合/新 \n\n)+ 新末块
: _prevCompleted.concat(combinedBlocks.slice(0, -1), combinedBlocks[combinedBlocks.length - 1])
} else {
blocks = splitBlocks(text)
}
// 记录下一帧增量基线:已完成块 = 除末块外全部;末块 = 最后一块(可能不完整)。
if (blocks.length > 1) {
_prevCompleted = blocks.slice(0, -1)
_prevTail = blocks[blocks.length - 1]
} else {
_prevCompleted = []
_prevTail = blocks.length === 1 ? blocks[0] : ''
}
_prevStreamText = text
const n = blocks.length const n = blocks.length
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新 let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
return blocks.map((b, i) => { return blocks.map((b, i) => {
@@ -144,7 +187,10 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted // 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。 // 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
const degrade = isTail && isUnclosedCodeFence(b) const degrade = isTail && isUnclosedCodeFence(b)
const html = degrade // AC-EFF-S1-1:单块回复病态(整回复无 \n\n/围栏边界,末块=全文)超阈值 → 降级纯文本,
// 防每帧 marked.lexer+parse O(n) 卡顿(流式期间临时纯文本,AiCompleted 后完整 markdown)。
const longSingleBlock = isTail && n === 1 && b.length > SINGLE_BLOCK_TEXT_LIMIT
const html = (degrade || longSingleBlock)
? escapeFallback(b) ? escapeFallback(b)
: isTail ? parseBlockNoCache(b) : parseBlock(b) : isTail ? parseBlockNoCache(b) : parseBlock(b)
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新) // 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
@@ -194,11 +240,15 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
} }
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调) /// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText。 /// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线
function clearStreamingState(): void { function clearStreamingState(): void {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null } if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
streamingBlocks.value = [] streamingBlocks.value = []
lastStreamText = '' lastStreamText = ''
// AC-EFF-S1-1:清增量基线(防新轮/新会话误命中旧帧已完成块)。
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
} }
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。 /// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
@@ -209,8 +259,12 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用, /// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用,
/// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。 /// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。
/// AC-EFF-S1-1:同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
function resetStreamCache(): void { function resetStreamCache(): void {
_blockCache.clear() _blockCache.clear()
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
} }
return { return {
+37 -2
View File
@@ -8,17 +8,32 @@
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts) // 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
// F-09 对齐:删 in_progress/paused/cancelled(project 维度无数据产生源、无 UI 入口、无消费方), // F-09 对齐:删 in_progress/paused/cancelled(project 维度无数据产生源、无 UI 入口、无消费方),
// 保留与后端真实生命周期一致的最小集(planning 常态 + 软删 deleted_at 由 list_deleted 单独处理)。 // 保留与后端真实生命周期一致的最小集(planning 常态 + 软删 deleted_at 由 list_deleted 单独处理)。
// A6[PD-P2-7] 补全:in_progress/testing/releasing/paused/cancelled —— 老 DB 数据或后端可能
// 返回这些状态,projectStatusLabel 兜底逻辑会把未收录的 status 原样透传给 $t(报错/显裸 key),
// 补齐映射 + i18n 文案避免渲染裸 key。阶段 badge class(.stage-*) 已存在(CSS 无需动)。
export const PROJECT_STATUS_LABELS: Record<string, string> = { export const PROJECT_STATUS_LABELS: Record<string, string> = {
planning: 'projects.status.planning', planning: 'projects.status.planning',
active: 'projects.status.active', active: 'projects.status.active',
in_progress: 'projects.status.in_progress',
testing: 'projects.status.testing',
releasing: 'projects.status.releasing',
completed: 'projects.status.completed', completed: 'projects.status.completed',
paused: 'projects.status.paused',
cancelled: 'projects.status.cancelled',
} }
/** 项目状态 → 卡片 badge 样式 class */ /** 项目状态 → 卡片 badge 样式 class */
// WK-1:对齐 7 态真实生命周期(planning/in_progress/testing/releasing/completed/paused/cancelled)。
// 类名对齐 ProjectCard.vue scoped 的 stage-* 色系(design/coding/testing/release),paused/cancelled 走新增
// stage-dim(置灰)。'active' 键删除:后端无此状态,老数据兜底走 stage-design(与 projectStageInfo 兜底一致)。
export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = { export const PROJECT_STATUS_BADGE_CLASS: Record<string, string> = {
planning: 'stage-design', planning: 'stage-design',
active: 'stage-coding', in_progress: 'stage-coding',
testing: 'stage-testing',
releasing: 'stage-release',
completed: 'stage-release', completed: 'stage-release',
paused: 'stage-dim',
cancelled: 'stage-dim',
} }
/** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */ /** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */
@@ -31,10 +46,17 @@ export interface ProjectStageInfo {
stage: string stage: string
} }
// WK-1:对齐 7 态。stage 值决定 Dashboard dot/chip/fill 颜色档(planning/coding/testing/release/paused/cancelled),
// 需与 ActiveProjectsPanel.vue 的 dot-*/chip-*/fill-* 类一一对应(paused 为该组件新增 dim 类)。
// 'active' 键已删,老数据兜底走 projectStageInfo 默认 planning。
export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = { export const PROJECT_STAGE_INFO: Record<string, ProjectStageInfo> = {
planning: { stepIndex: 0, progress: 20, stage: 'planning' }, planning: { stepIndex: 0, progress: 20, stage: 'planning' },
active: { stepIndex: 2, progress: 55, stage: 'coding' }, in_progress: { stepIndex: 2, progress: 55, stage: 'coding' },
testing: { stepIndex: 3, progress: 80, stage: 'testing' },
releasing: { stepIndex: 4, progress: 95, stage: 'release' },
completed: { stepIndex: 4, progress: 100, stage: 'release' }, completed: { stepIndex: 4, progress: 100, stage: 'release' },
paused: { stepIndex: 1, progress: 40, stage: 'paused' },
cancelled: { stepIndex: 0, progress: 0, stage: 'cancelled' },
} }
export function projectStatusLabel(status: string): string { export function projectStatusLabel(status: string): string {
@@ -105,6 +127,19 @@ export function taskStatusClass(status: string): string {
return TASK_STATUS_CLASS[mapLegacyStatus(status)] ?? 'status-todo' return TASK_STATUS_CLASS[mapLegacyStatus(status)] ?? 'status-todo'
} }
// WK-8:任务状态机合法流转目标(源状态 → 可达目标态,对齐 TaskDetail.vue ADVANCE_MAP 的 target 集)。
// 列表快捷菜单据此过滤,避免直接置终态/非法跳转(done/cancelled 为终态,无推进目标);
// 新建/手动改状态仍走后端 TaskStatus::is_valid 兜底校验。
export const TASK_STATUS_TRANSITIONS: Record<string, string[]> = {
todo: ['in_progress', 'cancelled'],
in_progress: ['in_review', 'blocked', 'cancelled'],
in_review: ['testing', 'in_progress', 'blocked', 'cancelled'],
testing: ['done', 'in_review', 'blocked', 'cancelled'],
blocked: ['in_progress', 'cancelled'],
done: [],
cancelled: [],
}
// ── 任务优先级 ── // ── 任务优先级 ──
export const PRIORITY_LABELS: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' } export const PRIORITY_LABELS: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
+3
View File
@@ -125,6 +125,9 @@ export default {
inputHint: 'Enter to send · Shift+Enter for newline', inputHint: 'Enter to send · Shift+Enter for newline',
// ── F-260614-05 Phase 2b: image input (paste / drag-drop) ── // ── F-260614-05 Phase 2b: image input (paste / drag-drop) ──
pickImage: 'Pick image',
pickImageTitle: 'Pick image (or paste / drag-drop)',
imageFilter: 'Image files',
removeImage: 'Remove image', removeImage: 'Remove image',
imagePreviewAlt: 'Pending image', imagePreviewAlt: 'Pending image',
imageTooLarge: 'Image too large (max 10MB), skipped', imageTooLarge: 'Image too large (max 10MB), skipped',
+12
View File
@@ -49,6 +49,13 @@ export default {
decided: { decided: {
human: 'Human', human: 'Human',
auto: 'Auto', auto: 'Auto',
// SA-1: cover all backend decided_by sources (different decision paths, more precise than generic 'auto')
auto_trust: 'Auto (trusted)',
auto_dedup: 'Auto (deduped)',
cache_hit: 'Auto (cached)',
auto_blacklist: 'Auto (blacklist)',
auto_takeover_medium: 'Auto (med-takeover)',
auto_takeover_all: 'Auto (full-takeover)',
}, },
// Tab navigation // Tab navigation
tabAudit: 'Approval History', tabAudit: 'Approval History',
@@ -59,5 +66,10 @@ export default {
convTruncated: 'Content truncated (expand to view full conversation)', convTruncated: 'Content truncated (expand to view full conversation)',
convSelectHint: '← Select a conversation to view full interaction log', convSelectHint: '← Select a conversation to view full interaction log',
convReasoning: 'Reasoning (thinking)', convReasoning: 'Reasoning (thinking)',
// SA-2: conversation role labels (previously hardcoded Chinese, now i18n)
convRoleUser: '🧑 User',
convRoleAssistant: '🤖 Assistant',
convRoleTool: '🔧 Tool',
convRoleSystem: '⚙️ System',
}, },
} }
+3
View File
@@ -41,8 +41,11 @@ export default {
stage: { stage: {
planning: 'Planning', planning: 'Planning',
coding: 'Coding', coding: 'Coding',
testing: 'Testing',
release: 'Release', release: 'Release',
done: 'Done', done: 'Done',
paused: 'Paused',
cancelled: 'Cancelled',
}, },
ideaStatus: { ideaStatus: {
draft: 'Draft', draft: 'Draft',
+1
View File
@@ -20,6 +20,7 @@ export default {
cycleWarning: 'Detected {n} cycle nodes (highlighted in red)', cycleWarning: 'Detected {n} cycle nodes (highlighted in red)',
noCycle: 'No cyclic dependencies detected', noCycle: 'No cyclic dependencies detected',
cycleError: 'Cycle detection failed', cycleError: 'Cycle detection failed',
cycleAddError: 'This dependency would create a cycle — blocked (remove related edges first if needed)',
deleteConfirm: 'Delete the dependency "{from} → {to}"? This cannot be undone.', deleteConfirm: 'Delete the dependency "{from} → {to}"? This cannot be undone.',
deleteSuccess: 'Dependency deleted', deleteSuccess: 'Dependency deleted',
deleteError: 'Failed to delete dependency', deleteError: 'Failed to delete dependency',
+12
View File
@@ -10,11 +10,23 @@ export default {
// File tree // File tree
loading: 'Loading…', loading: 'Loading…',
emptyDir: 'Empty directory', emptyDir: 'Empty directory',
emptyDirMark: 'empty',
// File preview // File preview
selectFileHint: '← Select a file on the left to preview', selectFileHint: '← Select a file on the left to preview',
loadingFile: 'Loading file…', loadingFile: 'Loading file…',
binaryNotSupported: 'Binary file preview not supported', binaryNotSupported: 'Binary file preview not supported',
binaryInfo: 'Binary file',
pathLabel: 'Path',
sizeLabel: 'Size',
typeLabel: 'Type',
binaryLabel: 'Note',
truncated: 'File too large, only first 1MB shown', truncated: 'File too large, only first 1MB shown',
symbols: 'Outline',
outlineGroupType: 'Types',
outlineGroupFn: 'Functions',
outlineGroupVar: 'Variables',
outlineGroupMod: 'Modules',
outlineGroupOther: 'Others',
// Errors // Errors
loadFailed: 'Failed to load', loadFailed: 'Failed to load',
// Diff // Diff
+7
View File
@@ -30,6 +30,13 @@ export default {
relateFailed: 'Failed to update relations', relateFailed: 'Failed to update relations',
}, },
// Action feedback toasts (WK-16/18: success/failure light toast, aligns Knowledge.createdToast)
createdToast: '✅ Idea created',
descSaved: '✅ Description saved',
descSaveFailed: 'Failed to save description',
tagsSaved: '✅ Tags saved',
tagsSaveFailed: 'Failed to save tags',
// Adversarial evaluation // Adversarial evaluation
adversarialTitle: '⚖️ Adversarial Evaluation', adversarialTitle: '⚖️ Adversarial Evaluation',
evalModeHeuristic: 'Heuristic', evalModeHeuristic: 'Heuristic',
+2
View File
@@ -35,6 +35,8 @@ export default {
emptyTasks: 'No tasks yet', emptyTasks: 'No tasks yet',
// Workflow log panel // Workflow log panel
workflowLogTitle: '📋 Workflow Log', workflowLogTitle: '📋 Workflow Log',
// A2[PD-P1-3]: event stream has no project_id dimension, cannot filter per project; label "global events" to avoid misreading as this project's logs
workflowLogGlobal: 'Global events',
// B-41 workflow progress panel // B-41 workflow progress panel
workflowProgressTitle: '📈 Workflow Progress', workflowProgressTitle: '📈 Workflow Progress',
runDemoWorkflow: '▶ Run Demo Workflow', runDemoWorkflow: '▶ Run Demo Workflow',
+5
View File
@@ -64,7 +64,12 @@ export default {
status: { status: {
planning: '📐 Planning', planning: '📐 Planning',
active: '🚀 In Progress', active: '🚀 In Progress',
in_progress: '🔨 Developing',
testing: '🧪 Testing',
releasing: '🚢 Releasing',
completed: '✅ Completed', completed: '✅ Completed',
paused: '⏸ Paused',
cancelled: '✖ Cancelled',
}, },
// Error fallbacks (user-visible via state.error) // Error fallbacks (user-visible via state.error)
err: { err: {

Some files were not shown because too many files have changed in this diff Show More