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-shm
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-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.0",
"@vue/tsconfig": "^0.5.0",
@@ -5056,6 +5057,16 @@
"@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": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-1.1.2.tgz",
@@ -12995,6 +13006,13 @@
"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": {
"version": "4.0.8",
"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:mp-weixin": "uni -p mp-weixin",
"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"
},
"dependencies": {
@@ -25,6 +25,7 @@
"@dcloudio/uni-automator": "3.0.0-alpha-4080720251125001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-4080720251125001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-4080720251125001",
"@swc/helpers": "^0.5.23",
"@types/node": "^20.0.0",
"@vue/runtime-core": "^3.4.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_miss: event.prompt_cache_miss_tokens,
reasoning: event.reasoning_tokens,
is_estimated: event.is_estimated,
}
// 不完整标记(网络中断保文)
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
}
// ────────────────────────────────────────────────────────────────
// 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'">
<!-- 图片预览(消息带图时) -->
<image v-for="(img, i) in m.images || []" :key="i" :src="img" mode="widthFix" class="msg-user-img" />
<!-- 文本(有文本才显,图片+文本共存;纯图片则只有图,不空白) -->
<text v-if="m.content" user-select>{{ m.content }}</text>
<!-- MR-P2-2 文本: mention 标记时分段渲染 chip(命中已知实体的 `[项目:名]`/`[任务:名]`/`[灵感:名]`
渲染为 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>
<template v-else>
<!-- P0-3:错误气泡纯文本渲染(不走 markdown 二次解析,错误串含 `**`/`#` 时不会被解析成格式) -->
@@ -1054,6 +1107,23 @@ function tokenInOf(t: TokenUsage): number {
.msg.user text {
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 高自适应 */
.msg-user-img {
display: block;
+4
View File
@@ -53,6 +53,8 @@ export type AiChatEvent =
prompt_cache_hit_tokens: number
prompt_cache_miss_tokens: number
reasoning_tokens: number
/** G4.3:该轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated 兜底打标) */
is_estimated: boolean
incomplete?: boolean | null
conversation_id?: string | null
}
@@ -170,6 +172,8 @@ export interface TokenUsage {
cache_hit?: number
cache_miss?: 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 输出自带转义)。
* 同时补语言徽标:围栏有 lang 即显示(用原始标识如 ts,更贴近用户书写),无 lang 不显示。
*/
function highlightCodeBlocks(html: string): string {
return html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/gi, (m, attrs: string, inner: string) => {
const langMatch = /class="language-([^"]+)"/i.exec(attrs)
const langRaw = langMatch ? langMatch[1] : ''
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
try {
value = hljs.highlight(unescapeCode(inner), { language: lang, ignoreIllegals: true }).value
} 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 {
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 {
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 {
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 {
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 {
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 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷);parts 透传给 vision 端点。
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 判定走多模态分支)。
@@ -320,6 +320,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: None,
};
assert_eq!(m.content, "字面量构造");
@@ -377,6 +378,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: None,
};
let json = serde_json::to_string(&m).unwrap();
@@ -437,6 +439,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: None,
};
let json = serde_json::to_string(&m).unwrap();
+4
View File
@@ -138,6 +138,10 @@ pub struct ChatMessage {
/// 前端仅 > 0 时显示(reason 后缀);老 JSON 反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
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,内联避免新增依赖)。
+86 -4
View File
@@ -29,10 +29,38 @@ use crate::retry::{
// 协议数据结构 + SSE 纯解析(apply_anthropic_event / AnthropicRequest 等)抽至 anthropic_helpers
// 此处 use 引入以保持本模块内引用路径不变(零行为变更搬迁)。
use crate::anthropic_helpers::{
apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef,
apply_anthropic_event, AnthropicRequest, AnthropicResponse, AnthropicToolDef, SystemBlock,
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 实现
// ============================================================
@@ -92,8 +120,10 @@ impl AnthropicCompatProvider {
req.model
};
// 抽离 system 消息
let system: Option<String> = {
// 抽离 system 消息
// 开关开 → blocks 数组 + cache_control(Anthropic prompt caching,让 system 稳定段在
// 支持缓存的端点上命中);开关关 → 纯字符串(兼容非官方网关,零风险)。
let system: Option<SystemBlock> = {
let sys: Vec<String> = req
.messages
.iter()
@@ -102,8 +132,14 @@ impl AnthropicCompatProvider {
.collect();
if sys.is_empty() {
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 {
Some(sys.join("\n\n"))
Some(SystemBlock::Plain(sys.join("\n\n")))
}
};
@@ -1126,4 +1162,50 @@ mod tests {
"补占位后 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 `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 请求体
#[derive(Debug, Clone, Serialize)]
pub(crate) struct AnthropicRequest {
@@ -25,7 +39,7 @@ pub(crate) struct AnthropicRequest {
pub messages: Vec<serde_json::Value>,
pub max_tokens: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
pub system: Option<SystemBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
pub stream: bool,
@@ -151,7 +165,7 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
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
"message_delta" => {
@@ -159,9 +173,11 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
let acc = usage_accum
.get_or_insert(TokenUsage::default());
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" => {
+11
View File
@@ -275,6 +275,17 @@ impl ContextManager {
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 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
// 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,
finished,
tool_calls,
usage: None,
// 加固:usage 与 finish_reason 同帧的端点(部分兼容实现),此处已累积则挂上,
// 使下游 stream_recv 不必等到 [DONE] 帧即可拿到真实 usage。
usage: usage_accum.clone(),
error: None,
reasoning_content: choice.delta.reasoning_content,
}
} else {
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)usage 一并带出
StreamChunk {
delta: String::new(),
finished: false,
tool_calls: None,
usage: None,
usage: usage_accum.clone(),
error: None,
reasoning_content: None,
}
+87 -25
View File
@@ -13,7 +13,7 @@
//!
//! 重构(strategy·自底向上):类型定义 / 常量 / prompt 构造 / JSON 解析等无副作用逻辑抽至
//! [`adversarial_helpers`],本文件只保留 [`AdversarialEngine`](有状态引擎 + evaluate 主入口,
//! 含 ARC-260618-01-e evaluate_with_llm 一致性待决策逻辑,原样保留)。外部已用路径(
//! 含 ARC-260618-01-e 自洽性校验,已实施——解析后按 final_score 修正矛盾字段)。外部已用路径(
//! `df_ideas::adversarial::{AdversarialEval, Recommendation, …}`)经 `pub use` 不变。
use std::sync::Arc;
@@ -33,8 +33,8 @@ pub use crate::adversarial_helpers::{
AdversarialEval, AnalystAnalysis, Argument, AssessmentLevel, EvaluatedBy, Recommendation,
};
use crate::adversarial_helpers::{
SYSTEM_PROMPT, action_hint, assessment_desc, build_adversarial_prompt, parse_llm_eval,
priority_label,
SYSTEM_PROMPT, action_hint, assessment_desc, assessment_for_score, build_adversarial_prompt,
enforce_score_consistency, parse_llm_eval, priority_label, recommendation_for_level,
};
/// 对抗评估引擎
@@ -97,8 +97,9 @@ impl AdversarialEngine {
/// temperature 取 0.4:低于 0.3 偏机械重复启发式信号,高于 0.5 易发散到无关风险,
/// 0.4 在「稳定可复现」与「论点多样性」间取得平衡。
///
/// ARC-260618-01-e: evaluate_with_llm 返回值一致性未校验(final_score 与
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
/// ARC-260618-01-e(已实施): evaluate_with_llm 解析后经 [`enforce_score_consistency`]
/// 做自洽性校验——final_score 与 recommendation / analyst.final_assessment 不一致时,
/// 按 final_score 统一重新判定两字段(LLM 论点等自由文本保留,不改 LLM 调用/输出结构)。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea);
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
@@ -134,7 +135,9 @@ impl AdversarialEngine {
// 数值 clamp 由 parse_llm_eval 单点收口(final_score∈[0,10]、confidence∈[0,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 negative = self.generate_negative_argument(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 {
idea_id: idea.id.clone(),
@@ -240,13 +243,8 @@ impl AdversarialEngine {
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
fn analyst_analysis(&self, idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
let final_assessment = match scores.overall {
x if x >= 7.5 => AssessmentLevel::StrongGo,
x if x >= 6.0 => AssessmentLevel::Recommended,
x if x >= 4.5 => AssessmentLevel::Conditional,
x if x >= 3.0 => AssessmentLevel::Revised,
_ => AssessmentLevel::Defer,
};
// 分档与 LLM 自洽性校验共用 assessment_for_score,单源防两处阈值漂移
let final_assessment = assessment_for_score(scores.overall);
let mut strengths = Vec::new();
if scores.impact >= 6.0 {
@@ -305,22 +303,12 @@ impl AdversarialEngine {
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)]
mod tests {
use super::*;
use crate::adversarial_helpers::recommendation_for_score;
use crate::capture::Idea;
use crate::scoring::ScoringEngine;
use df_types::types::{IdeaStatus, Priority};
@@ -594,4 +582,78 @@ mod tests {
assert!((eval.final_score - 7.0).abs() < 1e-9);
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 抽离,纯搬迁,零行为变更)。
//!
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造JSON 解析等无副作用
//! 逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm 主入口与
//! ARC-260618-01-e 待决策逻辑)仍留在 `adversarial.rs`,二者经 [`use`] 互相引用。
//! 本文件承载 [`AdversarialEval`] 及其字段类型、LLM prompt 构造JSON 解析与自洽性校验
//! 等无副作用逻辑;有状态的引擎 [`AdversarialEngine`](含 evaluate / evaluate_with_llm
//! 主入口,经 [`enforce_score_consistency`] 落地 ARC-260618-01-e 自洽性校验)仍留在
//! `adversarial.rs`,二者经 [`use`] 互相引用。
//!
//! 抽离边界:类型定义(非 impl+ 常量 + 顶层 free function。impl 主体不动,外部
//! 已用路径(`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 {
match p {
Priority::Critical => "紧急",
+50 -1
View File
@@ -152,13 +152,35 @@ where
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?;
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(())
}
/// 判定 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。
///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
@@ -626,4 +648,31 @@ mod tests {
*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;
// ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI)
if resp.error.is_none() {
// ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
// (桌面端据此刷新 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) {
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)")
}), &["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),
]
})
@@ -211,14 +211,6 @@ fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
.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)。
/// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。
fn pagination(args: &Value) -> (u32, u32) {
@@ -360,6 +352,14 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(v) => v,
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 status = arg_str_or(&args, "status", "planning");
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) {
return r;
}
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据
// 部分更新:name/description 缺省回退 existing,避免空默认清空数据
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 status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned());
let status = match ProjectStatus::from_db_str(&status) {
Some(s) => s,
None => return CallToolResult::error(
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
),
};
// MC-1(MCP-1):status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task
// 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。
// 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。
let status = existing.status;
let now = now_millis();
let rec = ProjectRecord {
id: id.clone(),
@@ -440,12 +437,15 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
created_at: existing.created_at,
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) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
}
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
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}")),
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 {
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();
json_ok(json!({ "id": id, "project": updated }))
@@ -571,6 +578,13 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v,
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", "");
medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move {
@@ -694,12 +708,14 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at,
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) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "task": updated }))
}
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
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)),
};
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);
Box::pin(async move {
let now = now_millis();
@@ -854,12 +877,14 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at,
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) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "idea": updated }))
}
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
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}")),
});
rec.updated_at = now;
if let Err(e) = repo.update_full(&rec).await {
return err_str(e);
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 旧 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 分。
/// 确定性纯函数,与 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 {
let desc_len = description.chars().count();
// feasibility:描述越详细越可行(评估前已有思考)
@@ -973,8 +1012,9 @@ fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult>
// 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 (offset, limit) = pagination(&args);
Box::pin(async move {
let projects = match ProjectRepo::new(&db).list_deleted().await {
Ok(v) => v,
@@ -984,11 +1024,34 @@ fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v,
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!({
"projects": projects,
"tasks": tasks,
"project_count": projects.len(),
"task_count": tasks.len()
"projects": project_page,
"tasks": task_page,
"project_count": project_page.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, "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 路由
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
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::models::AiProviderRecord;
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>,
) -> anyhow::Result<AiNodeParams> {
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 逐字重复)。
+1 -1
View File
@@ -6,7 +6,7 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 脚本节点
pub struct ScriptNode;
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ──
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,优先于 env var;env var 作回退) ──
use std::sync::OnceLock;
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
+45 -3
View File
@@ -61,10 +61,10 @@ pub(super) fn truncate_chars(s: &str, max: usize) -> String {
format!("{}…(已截断)", truncated)
}
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 规范化路径用于比较/查重:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径
pub fn normalize_path(p: &str) -> String {
/// 注:仅用于比较,存库请用 [`canonicalize_for_store`](保留大小写)
pub fn normalize_for_compare(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
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)]
mod tests {
use super::*;
@@ -111,4 +127,30 @@ mod tests {
assert!(!n.contains('\\'), "反斜杠未归一: {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/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]
async fn main() -> df_relay::Result<()> {
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 集成)。
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)");
+112 -6
View File
@@ -10,6 +10,14 @@
//! 3. 校验通过 → 注册连接、进入收发循环;失败 → 发 Error 帧 + Close。
//! 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 + 方向转发。
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::error::{RelayError, Result};
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置,未设置时 panic)。
/// 读取期望 token(必需:env `DF_RELAY_TOKEN` 必须设置)。
/// RLY-5:不再 panic,改为返回 Option——缺失时由连接层显式拒绝握手(main 启动时也已校验)。
/// 生产级鉴权(每 device 独立 token + 过期刷新)留 Phase3。
fn expected_token() -> String {
std::env::var("DF_RELAY_TOKEN").unwrap_or_else(|_| {
panic!("必须设置环境变量 DF_RELAY_TOKEN")
})
fn expected_token() -> Option<String> {
std::env::var("DF_RELAY_TOKEN").ok()
}
/// 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;
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!(
device_id = %hello.device_id,
"握手失败:token 校验不通过"
@@ -270,6 +332,15 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
maybe_msg = socket_rx.next() => {
match maybe_msg {
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 {
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())),
};
// 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 =
serde_json::from_str(&text).map_err(|e| RelayError::Client(format!("Hello 解析失败: {e}")))?;
Ok(hello)
@@ -440,3 +518,31 @@ fn now_ms() -> i64 {
.map(|d| d.as_millis() as i64)
.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) 兜底:
// 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。
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")?,
arguments: row.get("arguments")?,
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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
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.requested_at, rec.executed_at, rec.decided_by
],
@@ -231,7 +233,7 @@ impl_repo!(
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",
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.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。详见 ai.rs audit_finalize。
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 查最新一条审计记录(审批回填定位用)。
pub async fn find_by_tool_call_id(
&self,
@@ -463,6 +515,55 @@ impl AiToolExecutionRepo {
.await
.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 提供。
+183
View File
@@ -401,6 +401,61 @@ impl IdeaRepo {
.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`。
///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
@@ -538,6 +593,43 @@ impl IdeaRepo {
.await
.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 {
@@ -1479,6 +1571,51 @@ mod tests {
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]
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
let repo = setup_idea_repo().await;
@@ -1497,4 +1634,50 @@ mod tests {
// i1 活跃不出现;i3 删除最晚在前
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_miss_tokens: row.get("prompt_cache_miss_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,
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
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, ?19)",
)
.map_err(storage_err)?;
for rec in &records {
@@ -96,7 +97,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at,
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)?;
}
@@ -122,7 +123,8 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at,
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",
)
.map_err(storage_err)?;
@@ -164,13 +166,13 @@ impl AiMessageRepo {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at,
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"
} else {
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
tool_calls, model, status, reasoning_content, timestamp, created_at,
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"
};
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,
tool_calls, model, status, reasoning_content, timestamp, created_at,
prompt_tokens, completion_tokens,
prompt_cache_hit_tokens, prompt_cache_miss_tokens, reasoning_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
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, ?19)",
)
.map_err(storage_err)?;
for rec in &records {
@@ -299,7 +301,7 @@ impl AiMessageRepo {
rec.parts, rec.tool_call_id, rec.tool_calls, rec.model, rec.status,
rec.reasoning_content, rec.timestamp, rec.created_at,
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)?;
}
@@ -373,6 +375,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
}
}
@@ -535,6 +538,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
},
AiMessageRecord {
id: "new_1".into(),
@@ -555,6 +559,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
},
];
repo.replace_conversation("conv", records).await.expect("replace");
@@ -627,6 +632,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
}],
)
.await
@@ -668,6 +674,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_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("2nd");
+148 -4
View File
@@ -43,10 +43,17 @@ pub struct ProjectQuery {
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 片段(列名走白名单校验防注入)。
///
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name`
/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `PROJECT_ORDER_BY_ALLOWED`
/// 白名单校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
fn build_order_clause(order_by: Option<&str>) -> Result<String> {
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 col = parts[0];
let dir = parts.get(1).map(|s| s.to_ascii_uppercase());
// 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。
validate_column_name(col, "projects")?;
// 排序白名单校验列名(防 SQL 注入:列名拼字符串前必须校验;独立于 update_field 白名单)。
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() {
None | Some("DESC") => Ok(format!("{col} DESC")),
Some("ASC") => Ok(format!("{col} ASC")),
@@ -414,6 +426,67 @@ impl ProjectRepo {
.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(不可恢复)
///
/// 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",
],
"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",
],
"tasks" => &[
+202 -4
View File
@@ -455,12 +455,20 @@ impl TaskRepo {
///
/// 复用 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> {
let conn = self.conn.clone();
let project_id = query.project_id.clone();
let status = query.status.clone();
let priority = query.priority;
let assignee = query.assignee.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 || {
let guard = conn.blocking_lock();
@@ -480,6 +488,11 @@ impl TaskRepo {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
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 {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
@@ -489,6 +502,19 @@ impl TaskRepo {
params_vec.push(Box::new(pat.clone()));
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!(
"SELECT COUNT(*) FROM tasks WHERE {}",
@@ -743,11 +769,72 @@ impl TaskRepo {
.await
.map_err(storage_err)?
}
}
// ============================================================
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB)
// ============================================================
/// 原子乐观更新(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`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖。对齐 [`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)]
mod tests {
@@ -1160,4 +1247,115 @@ mod tests {
assert_eq!(updated.queue, "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 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
// 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),
(2, migrate_v2),
(3, migrate_v3),
@@ -88,6 +89,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(39, migrate_v39),
(40, migrate_v40),
(41, migrate_v41),
(42, migrate_v42),
];
for (version, migrate_fn) in steps {
@@ -1257,6 +1259,22 @@ fn migrate_v41(conn: &Connection) -> Result<()> {
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 表
///
/// 与 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_miss_tokens INTEGER,
reasoning_tokens INTEGER,
is_estimated INTEGER,
UNIQUE(conversation_id, seq)
);
@@ -1532,6 +1551,7 @@ CREATE TABLE IF NOT EXISTS ai_messages (
prompt_cache_hit_tokens INTEGER,
prompt_cache_miss_tokens INTEGER,
reasoning_tokens INTEGER,
is_estimated INTEGER,
UNIQUE(conversation_id, seq)
);
@@ -2023,7 +2043,7 @@ mod tests {
"tasks.module_id 列缺失(V41 加)"
);
// 3. schema_version 应推进到 41(全量迁移成功落版本号)
// 3. schema_version 应推进到 42(全量迁移成功落版本号)
let max_version: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -2032,8 +2052,8 @@ mod tests {
)
.expect("查 schema_version 应成功");
assert_eq!(
max_version, 41,
"全量迁移后 schema_version 应为 41(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version, 42,
"全量迁移后 schema_version 应为 42(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version
);
@@ -2046,6 +2066,11 @@ mod tests {
column_exists(&conn, "project_modules", "status"),
"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),
)
.unwrap();
assert_eq!(max_v, 41, "首轮应推进到 41");
assert_eq!(max_v, 42, "首轮应推进到 42");
// 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2117,7 +2142,7 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v2, 41, "重跑后应重新推进到 41");
assert_eq!(max_v2, 42, "重跑后应重新推进到 42");
}
// ============================================================
+7 -2
View File
@@ -1,7 +1,7 @@
//! 数据模型定义 — 与数据库表对应的 Rust 结构体
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};
// ============================================================
@@ -383,7 +383,9 @@ pub struct AiToolExecutionRecord {
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入。
#[serde(default, skip_serializing_if = "Option::is_none")]
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 arguments: String,
pub result: Option<String>,
@@ -440,6 +442,9 @@ pub struct AiMessageRecord {
pub prompt_cache_miss_tokens: Option<u32>,
/// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。
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 引用二进制)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/收发循环;断线**自动重连由调用方 supervisor
//! 负责**,src-tauri 已实现指数退避重连,本 crate 提供 connect/disconnect/is_connected
//! 原语 + `backoff_delay` 退避工具)
//!
//! ## 模块
//! - [`events`]:隧道透传事件/指令/消息包装类型
+15 -10
View File
@@ -1,7 +1,9 @@
//! df-tunnel 隧道客户端(Phase2 完整实现)
//!
//! 设计依据:设计文档「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)
//! - 桌面端连 `/ws/device`,首帧发 `Hello { kind:"device", device_id, token }`
@@ -130,9 +132,9 @@ enum OutFrame {
/// 默认 WS 隧道客户端(完整实现)
pub struct WsTunnelClient {
/// 目标服务器 URL(connect 后保存,重连用;字段供未来 auto-reconnect 读)
/// 注:当前 connect 一次性建立,未实现自动后台重连(手动 connect 即重连),
/// 该字段保留以备 Phase3「断线后台自动重连」消费
/// 目标服务器 URL(connect 后保存,供调用方 supervisor 经 [`reconnect_params`] 复用)。
/// 注:本 crate 不内置后台自动重连(见模块文档);重连由调用方 supervisor 轮询
/// is_connected + 再 connect 完成(src-tauri 已实现,指数退避)
server_url: Mutex<Option<String>>,
device_id: 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)> {
let url = self.server_url.lock().await.clone()?;
let device_id = self.device_id.lock().await.clone()?;
@@ -344,8 +346,9 @@ impl WsTunnelClient {
/// 3. 心跳定时器:每 HEARTBEAT_INTERVAL 发一次 Ping
/// 4. 任一端断开 → 退出 task,置 connected=false
///
/// 注:本任务范围不含「自动后台重连」(disconnect 即退出)。Phase3 可在外层包一层
/// supervisor task,检测 task 退出后用指数退避重新 connect(参数从 reconnect_params 取)。
/// 注:本任务范围不含「自动后台重连」(断开即退出)。自动重连由**调用方 supervisor** 承担:
/// 轮询 is_connected → 用 [`reconnect_params`](WsTunnelClient::reconnect_params)参数
/// + [`backoff_delay`] 退避 → 再 connect。src-tauri 已实现该 supervisor。
async fn run_loop(
mut sink: futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
@@ -497,9 +500,11 @@ fn parse_relay_error(msg: &WsMessage) -> Option<RelayControlError> {
}
}
/// 计算指数退避延迟(供未来 supervisor 重连使用,本任务未直接调用,但属完整重连语义的一部分)
#[allow(dead_code)]
fn backoff_delay(attempt: u32) -> Duration {
/// 计算指数退避延迟(1s, 2s, 4s, 8s, 16s, 30s, 30s, ...)
///
/// 供调用方 supervisor 断线重连使用(本 crate 不内置自动重连,见模块文档)。
/// 暴露为 pub 使外部(supervisor / src-tauri)可直接复用,不再属死代码。
pub fn backoff_delay(attempt: u32) -> Duration {
// 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
let secs = RECONNECT_BASE
.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
pub type DecisionId = String;
/// 节点类型(如 ai / human / ai_self_review)
@@ -484,6 +551,22 @@ impl Priority {
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 {
@@ -503,7 +586,7 @@ pub fn new_id() -> String {
#[cfg(test)]
mod tests {
use super::TaskStatus;
use super::{Priority, TaskStatus};
// 合法值:覆盖全部枚举变体(与 as_str 一致)
#[test]
@@ -592,4 +675,33 @@ mod tests {
// 数量应等于枚举变体数
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)。
- [ ] 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)
@@ -346,16 +346,16 @@ graph TD
**P2(中期改进)**:
- [ ] **WC-P2-status-union** — ✅ **status union 已修**(前端 status 强类型 union 收口完成),保留此处仅作核对销账
- [ ] **WC-P2-useAiEvents-胖**`useAiEvents` 仍偏胖,按事件分组抽子 composable / 拆 listener 注册逻辑(前端架构技术债 P0 大文件,对齐 memory [[fe-arch-tech-debt]]
- [ ] **WC-P2-dfnodes-6未注册**`df-nodes` 6 个节点类型未在 NodeRegistry 注册(节点清单核验,补注册 + 单测覆盖)
- [ ] **WC-P2-dfmcp-schema-required**`df-mcp` tool schema `required` 字段处理:核对 array vs bool 兼容(MCP 规范允许多形态),补强反序列化 + 测试
- [ ] **WC-P2-bind-directory**`tool_registry` bind_directoryMED-1tool_registry:546)核对推进 — 历史登记项复核
- [x] **WC-P2-status-union** — ✅ **已销账(2026-08-09)**:status union 已修
- [x] **WC-P2-useAiEvents-胖****已销账(2026-08-09)**:useAiEvents 已拆 12 个 composable(useAiLifecycleEvents/StreamingEvents/ToolEvents 等,65c0f6b)
- [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)
- [x] **WC-P2-dfmcp-schema-required****已销账(2026-08-09)**:required_names() 兼容 array/bool 两形态 + 三例单测(server.rs:277-294/491-522)
- [x] **WC-P2-bind-directory****已销账(2026-08-09)**:tool_registry.rs:456-485 bind_dir_to_project 四要素齐全(id/路径校验/防重绑/stack探测)
**P3(风格/可读·低优)**:
- [ ] **WC-P3-SubflowNode-风格**`SubflowNode` 实现风格对齐其他 Node(trait impl 顺序/命名/注释规范一致性)
- [ ] **WC-P3-script-注释**`script` 节点注释清理(过时/冗余注释,对齐当前实现)
- [x] **WC-P3-SubflowNode-风格****已销账(2026-08-09)**:subflow_node trait 顺序(execute→schema→node_type)对齐 ai/human 节点
- [~] **WC-P3-script-注释**🟡 部分:script_node.rs:9「替代纯 env var」措辞与 env var 回退并存,需改措辞(待批)
**专项(已登记 memory,留指针)**:
@@ -385,11 +385,11 @@ graph TD
- [x] ✅ **P1-a** 长内容折叠(MessageList AI 文本气泡>1500 字 + ConflictResolver 多冲突 + HelpRequiredCard 堆栈,`9e85cad`)
- [x] ✅ **P1-b** Dashboard 统计源解耦(治 H6/H7 翻页/筛选后统计错乱;统计走后端 count_tasks 独立通道,`d1321af`)
- [ ] **P1-c** 列表状态持久化(抽 `usePersistedRef` composable + Tasks/Ideas/Knowledge/Projects/ProjectDetail Tab 接入)
- [ ] **P1-d** AuditLog 后端 WHERE 筛选 + 真实 total + 前端持久化(治"客户端筛选只作用当前页 50 条"假象)
- [ ] **P1-e** 审批体系一致(ApprovalPopup 浮窗挂起计时 H1 + 授权粒度统一 ToolCard/Popup/DirAuthDialog + 拒绝二次确认 + 失败 toast)
- [ ] **P1-f** cmd 闪烁 N²(FileTree 展开缓存 git status,避免每次全仓扫描)
- [ ] **P1-g** 延伸:ProjectDetail.vue:368 + ChatInput.vue:367 同源 `store.tasks.filter` 污染(项目任务展示 / @ 联想,P1-b 核查时发现)
- [x] ✅ **P1-c** 列表状态持久化**2026-08-09 核验销账**:usePersistedRef 已接入 Tasks/Projects/Knowledge/Ideas/AuditLog;ProjectDetail Tab 手写 localStorage 按 projectId 隔离(动态 key,usePersistedRef 静态 key 不适用)保留
- [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 持久化
- [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)
- [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 刷新已重拉目录树,无独立缺口)
- [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)
@@ -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)。
> 核心:弱模型(sensenova-6.7-flash-lite)在任务中**重复探索/绕工具/参数错误**是系统性现象,非偶发。
- [ ] **AC-1** 机制层「同参数重复调用」拦截:同会话同工具同参数组 ≥2 次即返缓存/警告(现仅 prompt.rs:107 说教,弱模型无效;**实证 83 个重复组合 ≥3 次**,09e7abfa 同一文件读 6 次、e0b5d8fc update_task 同参 8 次)
- [ ] **AC-2** 探索预算上限:单轮工具调用数上限 + 目录列举去重 + list_directory 后禁止绕 run_command 同探(09e7abfa 同一目录列 3 次再 Get-ChildItem 绕行)
- [ ] **AC-3** 系统 prompt 项目清单补 UUID(prompt.rs:304 注入 `- {name}: {desc}` 无 id):**实证 list_tasks("DevFlow"/"meta-kit"/"u-ask") 12+ 处传 name 当 id 全返空**
- [ ] **AC-4** read_symbol 采用率诊断:**实证 read_file 2582 vs read_symbol 36(采用率 1.4%)**,AST 代码智能(7c2e3b2)落地空转,prompt 引导对弱模型无效,需机制(read_file 命中已知符号时提示换 read_symbol / 按模型分级)
- [ ] **AC-5** 失败工具画像复盘:run_command 67 / read_file 55 / search_files 33 / advance_task 31(状态机拒) / patch_file 27,逐类找机制根因降失败
- [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)
- [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 含列举命令且引用已列目录→警告,保守式不硬拒)
- [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** 🟡 部分(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** 🟡 部分(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 多进程架构潜在问题(分析登记)
> 来源: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)
- [ ] **P0-2** MCP 写库后 GUI 无感知:df-data-changed 事件仅 GUI AI 工具能 emit,MCP 进程无 AppHandle,双端数据断层致重复创建(方案:GUI 轮询/文件 watcher/定时刷新)
- [ ] **P0-3** 审计形同虚设:run_mcp_server 直接 return 未 init tracing subscriber,medium_audit warn 无订阅者不落盘(方案:MCP 进程 init tracing + 文件 appender)
- [ ] **P1-4** 两套工具规则不一致:GUI AI 工具有 queue 白名单/data_change/完整审计,MCP 精简实现无,演进易漂移(方案:校验下沉 Repo 层复用同源函数)
- [ ] **P1-5** 多进程同库:迁移 PRAGMA 幂等但并发 ALTER 可能 database is locked;MCP release/GUI debug 版本可能不一致
- [ ] **P1-6** 孤儿进程:stdin EOF 才退出,无超时/心跳,Claude Code 强杀致进程累积(方案:空闲超时退出)
- [ ] **P1-7** AI 全写权限:Medium 默认允许 + 无幂等键,AI 可污染真实业务库(方案:默认 --read-only 或写前确认)
- [ ] **P2-8** list 无分页; **P2-9** Windows lowercase 跨平台语义; **P2-10** MCP 仅 CRUD 能力弱(有意收敛)
- [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** 🟡 部分(2026-08-09):GUI 内嵌 HTTP 形态已通(advance_task 数据变更映射本批补,data_change.rs);**独立 stdio 进程仍无感知**(on_write_call: None 无 AppHandle + 无轮询兜底),需设计 GUI 侧刷新机制
- [x] **P0-3** ✅ 已落地:main.rs:19-37 init_mcp_tracing(tracing-appender non_blocking 写 %TEMP%/devflow-trace.log,medium_audit warn 落盘)
- [x] **P1-4** ✅ 已落地(e722823):校验下沉 crates/df-storage/src/crud/task_validation.rs,df-mcp/GUI 复用同源函数
- [x] **P1-5** ✅ 已落地(02c8d8e):db.rs busy_timeout(5s) + WAL + foreign_keys
- [x] **P1-6** ✅ 已落地(02c8d8e):server.rs:100-107 空闲超时 60s 退出(--idle-timeout 可配)
- [~] **P1-7** 🟡 部分(设计取舍):高危默认拒 + 中危审计 + --read-only 可选;未做默认只读/写前确认(有缓解可接受)
- [x] **P2-8** ✅ 已落地(8cb666a):主 list 工具分页 + list_trash 补分页(offset/limit/has_more); **P2-9** 🟡 Windows lowercase 待核; **P2-10** 有意收敛
---
### 🔍 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:本会话仅走查+登记,未实施代码。
> **✅ 2026-08-09 核查销账(2 agent 源码核验 + 主代抽查)**:9 项中 7 已落地(e722823/795e05f/8cb666a)、本批补 2(mention chip + 语言标签)。
**P1(兼容性/体验·2 项)**:
- [ ] **MR-P1-1** — miniapp 流式生成中 raw markdown 字面量显示:生成中 currentText 用纯 `<text>` 渲染,`**bold**`/`#` 等显示语法字符,完成才渲染 markdown。需块级 memo 渲染设计(对齐桌面 useStreamRenderer),性能+体验权衡,暂缓
- [ ] **MR-P1-2** — miniapp 表格 overflow 破坏列对齐:`mdRenderer.ts``<table>` 注入 `display:block`,破坏表格列对齐;需 `display:inline-table` 或保留原生行为,需真机验证
- [x] **MR-P1-1** ✅ 已落地:chat/index.vue:66-131 splitStreamBlocks 块级 memo 流式渲染(currentText 不再纯 text 显语法字符;未闭合围栏降级 escapeFallback 属流式固有取舍)
- [x] **MR-P1-2** ✅ 已落地:mdRenderer.ts:203-207 table 显式 `display:table`(非 block)保留列对齐 + max-width/word-break 兜底
**P2(打磨·6 项)**:
- [ ] **MR-P2-1** — miniapp 图片溢出:rich-text `<img>` 自然尺寸无 max-width,宽图溢出气泡;需注入 max-width + 域名白名单 + tap 预览(需 mp-html 决策)
- [ ] **MR-P2-2** — miniapp mention chip 可视化:用户消息内 `[项目:名]` 显示字面文本,桌面端有 chip 样式;需分段渲染
- [ ] **MR-P2-3** — miniapp 任务列表 checkbox 丢失:rich-text 不认 `<input>`,GFM task-list 勾选框被剥离成纯文本
- [ ] **MR-P2-4** — miniapp 代码块无高亮/语言标签:对齐桌面 hljs 17 语言高亮,需引入方案
- [ ] **MR-P2-5** — 桌面端实时 JSON 工具结果无折叠防御:LLM 文本 echo 工具 JSON 时桌面渲染成正常气泡(miniapp 已有 isToolResultJson 折叠);罕见场景,决策是否做
- [ ] **MR-P2-6** — miniapp 设置页编辑功能:只读版已上线(`pages/settings/index.vue`),编辑(relayHost/deviceId/token 手填)接 setConfig 即可,后续做
- [x] **MR-P2-1** ✅ 已落地:mdRenderer.ts:198 img max-width:100% + border-radius(网络图依赖微信 downloadFile 白名单,非渲染代码)
- [x] **MR-P2-2** ✅ 已落地(2026-08-09 8cb666a):chat/index.vue segmentUserContent 分段渲染 `[项目:名]`/`[任务:名]`/`[灵感:名]` chip(命中已知实体才 chip,未命中保持字面,纯文本零回归)
- [x] **MR-P2-3** ✅ 已落地:mdRenderer.ts:167-172 input checkbox → ☑/☐ 字符符号
- [x] **MR-P2-4** ✅ 已落地(8cb666a):highlight.js 14 语言高亮 + 代码块语言徽标(codeLangBadge,围栏 lang 显标签)
- [x] **MR-P2-5** ✅ 已落地:桌面 MessageList.vue:698-708 isToolResultJson 折叠(对齐 miniapp)
- [x] **MR-P2-6** ✅ 已落地(795e05f):settings/index.vue 编辑模式(setConfig + 脱敏 + resumeIfDisconnected)
**P3(外部依赖/低优·2 项)**:
@@ -453,25 +455,326 @@ graph TD
### 🔍 2026-08-07 aichat 切换/新建对话缺陷走查(用户实测 2 bug + 双 agent 深挖)
> 用户 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 待发送队列串到新对话(用户实测,根因已确认)**:
- [ ] **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(前后端合并去重,按严重度)**:
- [ ] **CSW-P1-1**后台会话排队消息永不 drain:非当前 `AiCompleted` 被 isCurrent 守卫拦截(`useAiEvents.ts:883-889`)不 emit `ai-drain-queue`,切回后队列卡死。修复:守卫外补该 conv 的 drain + `drainQueue` 用目标 convId 发消息(勿用 `state.activeConversationId` 误发到 B)
- [ ] **CSW-P1-2**后端 switch 回切 reload 无条件覆盖未落库内存(`conversation.rs:338-343` 只判 `is_active` 不判内存更脏)→ 熔断不 save/异步 save 未落前切走再切回,消息+工具结果永久丢失(`context/mod.rs:279-290``needs_full_rewrite=false` 固化陈旧态)
- [ ] **CSW-P1-3**switch 对 live 目标 conv 无条件 `finalize_pending_placeholders`+`retain` 再 extend DB 快照(`conversation.rs:386-388/400-422`)→ live pending 误终态化「会话已切换」+ 新审批丢 + 陈旧条目(`Ok(None)` 分支未根治)反复
- [ ] **CSW-P1-4** — 切到生成中 conv 返回 DB 旧快照 + round0 首响应前端气泡丢失(`conversation.rs:278-316` + `useAiEvents.ts:209-228`):前端整体替换 `state.messages` 丢弃本地气泡,currentText 无气泡可落被清空 → 首条回复闪现/缺失
- [ ] **CSW-P1-5**delete 在 loop 运行中 → save 惰性重建 per_conv + `Ok(None)` 分支 INSERT 复活对话(`conversation.rs:573-587/388-420`)+ 该轮消息丢
- [ ] **CSW-P1-6**点击当前活跃流式会话无同 id 短路(`useAiConversations.ts:236`)→ 清 in-flight 文本 + 全量重拉 + 重复恢复审批(P1-2)
- [ ] **CSW-P1-7**切后台生成中会话丢「切换 IPC 往返窗口」的流式 token(`useAiConversations.ts:240-276` active 在 await 后置 + `useAiEvents.ts:881-890` 非当前 delta 直接 drop)→ 回复中间缺块
- [ ] **CSW-P1-8**switch parse 失败 active/messages 错配(`useAiConversations.ts:276,303` catch 向旧 messages push 错误气泡但 active 已置新 id)→ 视图显示旧会话
- [x] **CSW-P1-1**✅ 已修:`useAiEvents.ts:101-102` 非当前会话 `AiCompleted` 仍 emit `ai-drain-queue`(带 conversationId,未被 isCurrent 吞);`useAiSend.ts:271-306` drainQueue 用目标 convId
- [x] **CSW-P1-2**✅ 已修:`conversation.rs:363-368` 非 live 分支先判 `mem_has_unsaved`(len>persisted_msg_count || needs_full_rewrite),有未落库内存则跳过 restore,DB 快照不再覆盖内存
- [x] **CSW-P1-3**✅ 已修:`conversation.rs:335/415-417` `already_live` 分支 pending 完全不动(不 finalize/不 retain/不从 DB 恢复),首屏用 per_conv 内存消息
- [x] **CSW-P1-4****已修(2026-08-09)**:切到生成中 conv 首响应气泡丢失——前端 switchConversation(useAiConversations.ts:336-355)在 parse 成功、active 置位后,若 `targetGen && 末条是 user` 补前端占位 assistant 气泡(`round0-pending-` 判重),承接 currentText 供 MessageList isLastAi 流式渲染 + flushCurrentText 回填。纯前端,不需后端协同
- [x] **CSW-P1-5**✅ 已修:`conversation.rs:613-616` 删除先登记 `deleted_convs` 再删库,save 入口 :259-265 命中直接 return + :416-426 INSERT 前二次校验,封死 Ok(None) 复活窗口
- [x] **CSW-P1-6**✅ 已修:`useAiConversations.ts:255-259` `if (!force && id === state.activeConversationId) return` 同 id 短路,不清文本不重拉
- [x] **CSW-P1-7**✅ 已修:`useAiConversations.ts:269/303-305` switchingConvs 集合缓冲切换往返窗口;`useAiEvents.ts:110-115` 非当前命中 switchingConvs 的 delta 累积到 per-conv 流式态而非 drop
- [x] **CSW-P1-8**✅ 已修:`useAiConversations.ts:333-335` 先 messages 后 active 同一同步块,parse 失败 catch :336-348 不切走,无 active/messages 错配
**🔵 P2(打磨/边缘)**:
- [ ] **CSW-P2-1** — newConversation `clearAllApprovalTimers`(switch/delete 都有,`useAiConversations.ts:67-98` 没有)→ 旧 conv 审批超时到点误拒+气泡进新会话
- [ ] **CSW-P2-2**后端 reload 清 session_trust/model_override/agent_language(`conversation.rs:343-348`)→ 切走再切回信任遗忘,重复审批
- [ ] **CSW-P2-3**save 并发全量重写竞态(「单线程 save 串行」不成立,`conversation.rs:427`),DELETE+INSERT 吞新行 + token 丢
- [ ] **CSW-P2-4**clear_session_allowed_dirs 使后台 loop 文件工具静默重挂审批(真并发下 session 授权语义错配)
- [ ] **CSW-P2-5**新建无防抖/双击无短路 → 多个空会话 + 虚拟项堆积;虚拟会话切回触发 create-new 复制(P2-7/8)
- [ ] **CSW-P2-6**setStreaming 忽略 `opts.convId` 按键 active(`streamingGuard.ts:51-80`)→ detach/restore 生成态错乱(待确认)
- [ ] **CSW-P2-7** — 切生成中会话快照陈旧 + 恢复双占位气泡;d2 分离窗口视图不随主窗口失效;跨端生成中会话不流式(待确认)
- [x] **CSW-P2-1** ✅ 已修:`useAiConversations.ts:71-73` newConversation 首行 `clearAllApprovalTimers()`
- [x] **CSW-P2-2**✅ 已修:`conversation.rs:356/400-405``!conv_was_present` 才复位 session_trust/model_override/agent_language
- [x] **CSW-P2-3**✅ 已修:`conversation.rs:211-226` save_conversation_serialized 持 per-conv 锁(`mod.rs:696-701` save_locks)串行化 DELETE+INSERT,20+ 调用点无旁路
- [x] **CSW-P2-4**✅ 已修:`state.rs:561-565` clear_session_allowed_dirs_if_idle 仅在 `active_convs().is_empty()` 才清,后台 loop 文件工具不丢临时授权(三调用点 create/switch/delete 均用 _if_idle)
- [x] **CSW-P2-5**✅ 已修:`useAiConversations.ts:67/75-76/110` `_newConvLock` 防抖 + 300ms 释放
- [x] **CSW-P2-6**✅ 已修:`streamingGuard.ts:78` 带 convId 写 per-conv 流式态,无 convId 才写全局
- [ ] **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 自动压缩(智能裁剪)。
//!
//! 从 `mod.rs::run_agentic_loop` 机械抽取(L1017-1193),行为零变更
//! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)
//!
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
//!
//! 流程(对齐 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 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
//!
@@ -14,14 +14,21 @@
//! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
//! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
//! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁;L0-3 后台化后窗口拉长
//! (loop 继续推进,可能 push 新消息),但 loop 只 push 增长、protect_start 基于触发时 len,
//! 压缩区间始终落在当前轮之前,不误伤当前轮(见 maybe_auto_compress L0-3 注释)。
//!
//! 安全:复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。
//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。
//! 安全(L0-3 后台化后):provider 在后台 task 内经 `secret::build_provider_for`
//! (不再复用 loop 顶部已 build 的 provider——后者是 Box 非 Clone 无法移入 task;多一次
//! 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)不触发,零行为变化。
//! messages 操作改 per_conv。
//!
//! L0-3(2026-08-09):压缩 LLM 调用改后台 fire-and-forget(tokio::spawn),不再同步阻塞本轮
//! loop(非流式 LLM 最长 60s)。当前轮用未压缩上下文正常走(不丢回复),压缩结果下一轮生效。
use std::sync::Arc;
@@ -29,7 +36,7 @@ use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex;
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 crate::state::{AppState, LlmConcurrency};
@@ -53,19 +60,26 @@ const KEYWORD_FALLBACK_ENABLED: bool = true;
/// 自动压缩(智能裁剪)——在 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)。
/// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。
///
/// 参数沿用原内联代码引用的局部:
/// - `provider`:loop 顶部 build 的 provider(F-04b 切换 candidate 后由调用方传入最新)。
/// - `provider_config`:同上,compress 沿用主 candidate(非 fallback 范围)。
/// - `provider_config`:compress 沿用主 candidate(非 fallback 范围)。后台 task 内经
/// `secret::build_provider_for` 重建 provider(复用 loop 顶部同款三步打包,仅多一次
/// keyring resolve,在后台线程不阻塞 loop)。
/// - `iteration`:当前轮次(仅用于日志)。
pub(super) async fn maybe_auto_compress(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
app_handle: &AppHandle,
provider: &Box<dyn LlmProvider>,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
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 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
@@ -151,101 +165,197 @@ pub(super) async fn maybe_auto_compress(
String::new()
};
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop
let compress_outcome: Result<Option<CompressedSummary>, String> = if active_msgs.is_empty() {
Ok(None)
} else {
compress_via_llm(
provider.as_ref(),
provider_config,
active_msgs,
&lang,
conv_id,
llm_concurrency,
).await.map(Some)
};
// L0-3:保护区外无 active 可压缩(active_msgs 空)→ noop,同步复位 is_compressing
// 后返回(不 spawn 空任务)。对齐原 Ok(None) 分支语义
if active_msgs.is_empty() {
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
return false;
}
match compress_outcome {
Ok(Some(summary)) => {
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
{
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false);
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
}
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.to_string()),
summary: summary.nl_summary,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
}
Ok(None) => {
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing
session_arc.lock().await.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 全量保留),与成功路径一致。
// 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 = {
// ── L0-3 后台化:压缩 LLM 调用改 fire-and-forget(tokio::spawn 独立 task) ──
//
// 原实现在此 `compress_via_llm(...).await` 同步阻塞整轮(非流式 LLM 最长 60s),
// 期间 loop 无法推进 build_for_request / stream,当前轮回复整体延迟。
//
// 现改为 spawn 独立 task 后台执行压缩,本函数立即返回 false:
// - 当前轮 build_for_request 用**未压缩上下文**,回复不受压缩阻塞影响(关键:不丢当前轮);
// - 后台 task 完成后更新 per_conv.messages(compress_old_messages + insert 摘要),
// 下一轮 build_for_request 生效;
// - is_compressing 防重入:触发时已置 true(上方),后台 task 各出口(成功/失败/conv 已删)
// 均复位,下一轮 maybe_auto_compress 检测到 true 即跳过,不会并发二次压缩。
//
// 选型理由(后台化 vs 降级独立线程):并发安全成立——所有 per_conv 访问经
// session_arc 锁(数据无竞争),且压缩只读「触发时刻快照的 active_msgs」+ 完成后
// 一次性 mutate,与 loop 的 push 均为「锁内短段」,protect_start 基于触发时 len
// (loop 只 push 增长,压缩区间始终落在当前轮之前),故选 tokio::spawn 后台化,
// 无需降级独立线程。provider 在 task 内重建(secret::build_provider_for),避免
// 与 loop 共享 Box<dyn LlmProvider>(后者非 Clone,无法移入 task)。
//
// 无感知降级:provider 重建失败 / LLM 失败 → 仅复位 is_compressing + 关键词兜底,
// 不发 AiError(前端 AiError 会误判生成结束致"压缩后停止"),loop 继续 stream
let session_arc = session_arc.clone();
let app_handle = app_handle.clone();
let conv_id = conv_id.to_string();
let provider_config = provider_config.clone();
let llm_concurrency = llm_concurrency.clone();
tokio::spawn(async move {
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
// 下轮 maybe_auto_compress 会重复触发(每次仅 spawn 后台任务,不阻塞 loop,但浪费)
let provider = match super::super::secret::build_provider_for(&provider_config) {
Ok(p) => p,
Err(e) => {
tracing::warn!(
conv_id = %conv_id,
error = %e,
keyword_fallback_len = keyword_fallback.len(),
"[ai] 后台压缩 provider 重建失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED,
);
// 单次锁内 check+mutate:复位 is_compressing(防 conv 两次锁间被删惰性重建)。
{
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 _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
}
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false);
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
}
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 {
tracing::info!(
conv_id = %conv_id,
pre_tokens = pre_compress_tokens,
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
);
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
}
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
+26 -5
View File
@@ -4,8 +4,8 @@
use tauri::{AppHandle, Emitter, Manager};
use crate::state::AppState;
use super::approval_timeout;
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider};
use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider};
use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
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 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 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 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_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 {
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(忽略其他参数),同目录即命中缓存,
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
/// 其他只读工具仍走全参 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") {
args.get("path")
.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_task" => ("task", "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_task" => ("task", "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 序列化)。
pub mod record;
#[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)]
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
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
@@ -67,7 +70,7 @@ pub(crate) use finalize::audit_finalize;
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
// 第三批从本文件抽离,行为零变更。
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 数据变更联动刷新。
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
@@ -591,16 +594,37 @@ pub(crate) async fn process_tool_calls(
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
// 安全边界见 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 查,本段不持锁。
// 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());
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;
if let Some((cached_content, hit_count)) = cached {
// 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源),
// 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次)
// 缓存命中:存轮内缓存(供后续同 key 复用)+ 直接 push tool_result + 审计
// (decided_by=cache_hit 标记缓存来源),不走真执行 + 不重emit Started/Completed。
// AC-1 根治:弱模型不知道结果来自缓存,仍死循环重调同参工具。此处给回填的
// 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);
// emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。
let ev = AiChatEvent::AiToolCallCompleted {
@@ -677,7 +701,12 @@ pub(crate) async fn process_tool_calls(
}
})).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 {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
@@ -714,7 +743,89 @@ pub(crate) async fn process_tool_calls(
RiskLevel::Medium => "auto_takeover_medium",
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` 引用。
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::State;
use df_ai::ai_tools::RiskLevel;
@@ -23,10 +24,51 @@ use crate::state::AppState;
use super::risk_str;
/// 一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
/// 构造一条工具执行审计记录(纯函数,单条 [`audit_tool_call`] / 批量插入路径共用)。
///
/// `decided_by` 有值auto/human= 已决策执行 → 记 executed_at
/// `None`pending 待审批→ executed_at 留空待 audit_finalize 回填。
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
/// `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(
repo: &AiToolExecutionRepo,
conv_id: &str,
@@ -39,28 +81,11 @@ pub(crate) async fn audit_tool_call(
decided_by: Option<&str>,
message_id: Option<&str>,
) {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
if let Err(e) = repo
.insert(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()),
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
{
let record = build_audit_record(
conv_id, tool_call_id, tool_name, arguments,
status, risk_level, result, decided_by, message_id,
);
if let Err(e) = repo.insert(record).await {
tracing::error!(
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
conv_id,
@@ -204,7 +229,8 @@ pub async fn list_tool_executions(
.map(|r| ToolExecutionDto {
id: r.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,
arguments_brief: super::truncate_chars(&r.arguments, 120),
result_brief: r.result.map(|s| super::truncate_chars(&s, 160)),
@@ -223,3 +249,206 @@ pub async fn list_tool_executions(
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(
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 {
tool_call_id: rec.tool_call_id,
tool_call_id: rec.tool_call_id.to_string(),
tool_name: rec.tool_name,
arguments: args,
conversation_id: rec.conversation_id,
+305 -5
View File
@@ -83,15 +83,61 @@ pub fn grammar_for(ext: &str) -> Option<Language> {
}
}
/// 判断 ext 是否为支持语言(含 Vue 借 TS)。
///
/// 预留:当前 handler 直接调 read_symbol(内部已兜底 ext 判断),本函数供测试 + 未来
/// handler 层提前判 ext 省一次 fs read(不支持直接返兜底不读文件)。测试消费,非真死代码。
#[allow(dead_code)]
/// 判断 ext 是否为支持语言(含 Vue 借 TS)。file_outline 据此提前判 ext(不支持不读文件)。
pub fn is_supported_ext(ext: &str) -> bool {
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 单文件组件逻辑在 `<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。
fn fallback(symbol: &str, path: &str, file_hash: &str, reason: &str, suggestion: &str) -> serde_json::Value {
serde_json::json!({
@@ -633,4 +791,146 @@ fn outer() {
let calls = skeleton["calls"].as_array().expect("骨架应含 calls");
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),
//! 命中则改走 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,
//! 兜底不报错不阻断。
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 super::AiChatEvent;
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 事件所需上下文)。
#[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 {
app: AppHandle,
tool_call_id: 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 {
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)。
/// emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。
/// 累积一行 + 触发 flush 判定(行加入 stdout/stderr 分桶)。
///
/// AC-EFF-S2-5:不立即 emit,先累积入缓冲;体积超阈值立即 flush,否则等 50ms 定时 flush
/// 或 Drop 兜底 flush。emit 失败静默吞(前端未 listen / 总线无订阅不阻断命令执行)。
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 {
id: self.tool_call_id.clone(),
id: tool_call_id.to_string(),
stream: kind.as_str().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 _ = self.app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
let _ = app.emit("ai-chat-event", ev.clone());
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::sanitize;
use super::super::conversation::save_conversation;
use super::super::knowledge_inject::inject_knowledge_into_prompt;
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded};
use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use super::super::prompt::{build_system_prompt_parts, cache_enabled};
use super::super::{AiChatEvent, ApprovalKind, SessionState};
@@ -240,6 +240,20 @@ fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<Strin
(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 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)。
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 同款,语义命中刷新上下文)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
// 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
// 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。
let mut system_prompt = {
{
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)。
// 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
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 覆盖)
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 {
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())
@@ -633,12 +661,19 @@ pub async fn ai_chat_send(
// (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
// mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
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 前。
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
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。
@@ -648,8 +683,16 @@ pub async fn ai_chat_send(
// 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{
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)。
// ai_chat_send 是 miniapp send_message 路由的入口,override 持久语义对跨端设定模型关键:
@@ -679,7 +722,7 @@ pub async fn ai_chat_send(
};
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())
@@ -1718,23 +1761,37 @@ pub async fn ai_chat_edit(
let _tool_defs = state.ai_tools.tool_definitions();
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)。
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 同款)。
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
// helper 读到的末条 active user 即编辑后的新文本,语义等价。
let mut system_prompt = {
{
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)。
let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
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;
@@ -1761,6 +1818,7 @@ pub async fn ai_chat_edit(
app_handle,
provider_config,
system_prompt,
volatile_tail,
conv_id,
knowledge_config,
llm_concurrency,
@@ -1960,19 +2018,34 @@ pub async fn ai_chat_force_send(
let lang = language.unwrap_or_else(|| "zh-CN".to_string());
// 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
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)。
let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
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 已在上方状态占用块得出。
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
// 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
{
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)。
// 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);
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())
@@ -106,6 +106,7 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
prompt_cache_hit_tokens: rec.prompt_cache_hit_tokens,
prompt_cache_miss_tokens: rec.prompt_cache_miss_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_miss_tokens: msg.prompt_cache_miss_tokens,
reasoning_tokens: msg.reasoning_tokens,
is_estimated: msg.is_estimated,
}
}
@@ -836,6 +838,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
}
}
+63 -37
View File
@@ -251,7 +251,9 @@ async fn save_conversation_inner(
// 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。
// 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow,
// 避免同时持有 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;
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
@@ -264,50 +266,66 @@ async fn save_conversation_inner(
return;
}
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 created_at = session.active_conv_created_at.clone();
let conv = session.conv(conv_id);
let cloned = (
conv.messages.all_messages_clone(),
let msg_len = conv.messages.len();
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,
created_at,
conv.pinned_goals.clone(),
conv.messages.persisted_msg_count(),
conv.messages.needs_full_rewrite(),
);
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
cloned
pinned_goals,
msg_len,
persisted_count,
needs_full_rewrite,
cloned,
)
};
let __lock_total = __lock_start.elapsed();
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 真相源)
// T2: 展开 namespace 引用为原文再落库
{
let session = session_arc.lock().await;
for m in &mut 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();
// BE1:仅当消息确有可能变化(非无变化捷径)才对 clone 副本做 namespace 展开 + truncate。
let no_message_change = !needs_full_rewrite && msg_len == persisted_count;
if !no_message_change {
// truncate 在锁外(clone 副本上操作,不影响 session 真相源)
// T2: 展开 namespace 引用为原文再落库
{
let session = session_arc.lock().await;
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 msgs {
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
m.content = truncate_for_persist(&m.content);
}
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
if let Some(parts) = m.parts.as_ref() {
m.parts = truncate_parts_for_persist(parts);
for m in &mut persist_msgs {
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
m.content = truncate_for_persist(&m.content);
}
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
if let Some(parts) = m.parts.as_ref() {
m.parts = truncate_parts_for_persist(parts);
}
}
}
let persist_msgs = msgs;
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
@@ -335,12 +353,19 @@ async fn save_conversation_inner(
// 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。
let now = now_millis();
let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone());
let total_len = persist_msgs.len();
let records: Vec<df_storage::models::AiMessageRecord> = persist_msgs
.iter()
.enumerate()
.map(|(seq, m)| message_to_record(m, conv_id, seq as i64, &msg_created_at))
.collect();
// BE1:no_message_change 时 persist_msgs 为空,records 也空(下方无变化捷径直接跳过消息写入,
// 仅元数据 update_full);非无变化时 records 由全量 persist_msgs 构建(append 切片仍按 persisted_count)。
let records: Vec<df_storage::models::AiMessageRecord> = if no_message_change {
Vec::new()
} else {
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 msg_repo = df_storage::crud::AiMessageRepo::new(db);
@@ -681,6 +706,7 @@ mod tests {
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: None,
}
}
+34 -24
View File
@@ -351,6 +351,39 @@ pub(crate) async fn build_knowledge_context(
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 五处合一)。
///
/// 把原本散落在 `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,
config: &crate::state::KnowledgeConfig,
) -> String {
// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤
// 导致两值取自不同消息。
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;
let knowledge_context = build_knowledge_context_for_conv(state, conv_id, config).await;
if knowledge_context.is_empty() {
system_prompt
} 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 crate::commands::err_str;
@@ -43,7 +47,24 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
fn env_profile_line() -> String {
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")
} else if cfg!(target_os = "macos") {
("macOS", "bash/zsh", "python3、node")
@@ -51,11 +72,13 @@ fn env_profile_line() -> String {
("Linux", "bash", "python3、node")
} else {
("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),
@@ -260,6 +375,11 @@ fn app_config_query_guidance_section(lang: &str) -> &'static str {
///
/// 保留旧签名供 agentic/mod.rs(无 mention 场景)等零改动调用,内部委托给
/// [`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 {
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_task_ids: &[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();
prompt.push_str(prefix);
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
@@ -291,55 +419,142 @@ pub(crate) async fn build_system_prompt_with_excluded(
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
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 !projects.is_empty() {
prompt.push_str(proj_label);
out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) {
if excluded_project_ids.iter().any(|id| id == &p.id) {
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 {
prompt.push_str(&format!(" 目录: {}\n", dir));
out.push_str(&format!(" 目录: {}\n", dir));
}
}
// 机制层注明语(中/英):项目已全部列出,降 list_projects 重复调用
prompt.push_str(&projects_listed_note(lang, 20));
out.push_str(&projects_listed_note(lang, 20));
}
}
// 任务全貌清单(供 LLM 知道存在哪些任务;被@任务的精准投影走 augmentation 层)
// 仅最近 20 条未删除任务,按 created_at DESC(同 list_active 顺序)。
if let Ok(tasks) = state.tasks.list_active().await {
if !tasks.is_empty() {
prompt.push_str(task_label);
out.push_str(task_label);
// 去重:被 @ 的任务跳过(同项目去重机理)
for tk in tasks.iter().take(20) {
if excluded_task_ids.iter().any(|id| id == &tk.id) {
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
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 !custom.is_empty() {
let clean = custom.trim().trim_matches('"');
if !clean.is_empty() {
prompt.push_str("\n## 自定义指令\n");
prompt.push_str(clean);
prompt.push('\n');
return format!("\n## 自定义指令\n{}\n", clean);
}
}
}
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!(
provider = %provider.name(),
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!(
provider = %provider.name(),
conv_id = %conv_id,
@@ -461,7 +471,14 @@ pub(crate) async fn stream_llm(
}
if chunk.finished {
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))) => {
+1
View File
@@ -71,6 +71,7 @@ pub(crate) async fn ensure_conversation_title(
prompt_cache_hit_tokens: None,
prompt_cache_miss_tokens: None,
reasoning_tokens: None,
is_estimated: None,
timestamp: m.timestamp,
})
.collect();
+147 -13
View File
@@ -87,10 +87,7 @@ pub fn register(
let mut file = File::open(path).await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
path
)
file_not_found_error(path)
} else {
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
}
@@ -182,14 +179,13 @@ pub fn register(
consumed,
))
} else { None };
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且扩展名在 read_symbol
// 支持语言集合内,则末尾追加一句引导提示改用 read_symbol 精确提取符号,避免读全文件
// 浪费上下文。复用 code_intel::is_supported_ext 单真相源(与 read_symbol handler 语言
// 集合一致,不重复定义)。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且**符号级命中**检测通过
// (内容里真的含可提取的函数/类/结构体定义)才追加 read_symbol 引导——比旧按扩展名
// 判断更准,无定义的文件不瞎提示。用全量 content(非分页 result)检测,符号可能在
// offset 范围外。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
let content_field = if offset_used.is_none()
&& crate::commands::ai::code_intel::is_supported_ext(
path.rsplit('.').next().unwrap_or("").to_lowercase().as_str(),
)
&& crate::commands::ai::code_intel::contains_definition(&content, &ext)
{
format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。")
} else {
@@ -227,7 +223,13 @@ pub fn register(
use tokio::fs::File;
use tokio::io::AsyncReadExt;
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
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
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?;
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
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()
}
// ============================================================
// 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)]
mod tests {
use super::*;
@@ -1571,6 +1658,53 @@ mod tests {
"多行 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,
value: 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
.update_field(&id, &field, &value)
.update_field_active(&id, &field, &value)
.await
.map_err(err_str)
.map_err(err_str)?;
if !updated {
return Err(format!("灵感 ID {id} 不存在或已删除"));
}
Ok(true)
}
/// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。
@@ -204,23 +230,21 @@ pub async fn promote_idea(
.await
.map_err(err_str)?;
// 回写灵感status=promoted + promoted_toupdate_full 单事务覆盖可变字段)
// 补偿删除:第二步失败时回滚第一步已建的 project,保证最终一致性(非原子,但防项目存留而
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
// 删除而非真事务(改动最小,工程投入产出比最高)
let updated = IdeaRecord {
status: IdeaStatus::Promoted,
promoted_to: Some(project_id.clone()),
updated_at: now,
..record
};
if let Err(e) = state.ideas.update_full(&updated).await {
// 回写失败:补偿软删已建项目(进回收站,可恢复),避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
// LW-8(BE-CMD-7):CAS 回写灵感(status=promoted + promoted_to,WHERE id AND promoted_to IS NULL)。
// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;本方法原子「立项认领」,
// 仅首个 affected=1 成功,第二个 affected=0 → 判定「已立项」并补偿软删刚建项目(回滚)。
// 替代原 update_full(无条件覆盖):并发下两个项目都保留、灵感只指向一个,留悬空项目
if !state
.ideas
.claim_promotion(&id, &project_id)
.await
.map_err(err_str)?
{
tracing::warn!("灵感 {id} 已被并发立项,回滚本次新建项目 {project_id}");
if let Err(del_err) = state.projects.soft_delete(&project_id).await {
tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
}
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
return Err(format!("灵感 {id} 已立项(并发双击),本次立项已回滚"));
}
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。
@@ -362,6 +386,15 @@ async fn evaluate_one(
engine: &df_ideas::adversarial::AdversarialEngine,
) -> Result<IdeaRecord, String> {
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);
// 多维评分(0-10IPC 层 *10 缩放为 0-100
+284 -29
View File
@@ -15,6 +15,8 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tauri::State;
@@ -23,6 +25,8 @@ use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord};
use df_types::types::new_id;
// 工程扫描技术栈探测(scan_project_modules 自动填 stack 字段)
use df_project::scan::detect_stack;
// 文件符号概览(file_outline)复用 code_intel 的 AST 符号提取
use crate::commands::ai::code_intel;
use crate::state::AppState;
@@ -209,6 +213,10 @@ pub async fn update_project_module(
if path.is_empty() {
return Err("path 不能为空".to_string());
}
// path 变更 → 清旧路径的 git status 缓存(避免后续同路径复用脏状态)
if existing.path != path {
invalidate_git_status_cache(&existing.path);
}
existing.path = path;
}
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() {
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)?;
Ok(())
}
@@ -301,6 +332,61 @@ pub async fn list_project_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 状态查询(实时派生,不进表)
// ============================================================
@@ -390,7 +476,7 @@ pub async fn get_module_git_status(
Ok(serde_json::to_value(status).map_err(err_str)?)
}
/// 列出工程本地分支(只读)。返回 { current, branches: [{ name, is_current }] }。
/// 列出工程分支(本地 + 远程跟踪,只读)。返回 { current, branches: [{ name, is_current }] }。
#[tauri::command]
pub async fn list_branches(
state: State<'_, AppState>,
@@ -413,10 +499,15 @@ pub async fn list_branches(
let dir = module.path.clone();
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
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(
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),
)
.unwrap_or_default();
@@ -425,10 +516,16 @@ pub async fn list_branches(
for line in out.lines() {
let line = line.trim();
if line.is_empty() { continue; }
// 格式: "*\0branch_name" 或 "\0branch_name"
let (head, name) = line.split_once('\u{0}').unwrap_or(("", line));
// 格式: "*\0branch_name\0refs/...",三字段(HEAD 标记 / 短名 / 完整 ref)。
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 name = name.trim().to_string();
let name = short.trim().to_string();
if name.is_empty() { continue; }
if is_current { current = name.clone(); }
branches.push(serde_json::json!({ "name": name, "is_current": is_current }));
@@ -448,8 +545,6 @@ pub async fn list_branches(
///
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
fn run_git_status(dir: &str) -> GitStatus {
use std::time::Duration;
let path = std::path::Path::new(dir);
if !path.join(".git").exists() {
return empty_status();
@@ -463,24 +558,14 @@ fn run_git_status(dir: &str) -> GitStatus {
.filter(|s| !s.is_empty() && s != "HEAD")
.unwrap_or_default();
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
let mut changed_files = Vec::new();
if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) {
for line in out.lines() {
if line.len() < 4 {
continue;
}
// porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + 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,
});
}
}
}
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
// 走仓库级缓存(与文件树共用同一份扫描,TTL 内展开/切换不再整仓重扫)。
let mut changed_files: Vec<GitChangedFile> = git_status_map_cached(dir)
.into_iter()
.map(|(path, status)| GitChangedFile { status, path })
.collect();
// map 无序,按路径排序保证前端展示顺序稳定(git status 输出本身即按路径有序)。
changed_files.sort_by(|a, b| a.path.cmp(&b.path));
// 3) 最近提交:`git log -50 --format="%h %ct %an %s"`(hash + 时间戳 + 作者 + subject)
let mut recent_commits = Vec::new();
@@ -583,6 +668,30 @@ const NOISE_DIRS: &[&str] = &[
".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)]
struct FileTreeEntry {
@@ -596,6 +705,9 @@ struct FileTreeEntry {
/// 文件夹恒为 None(只标文件)。
#[serde(skip_serializing_if = "Option::is_none")]
git_status: Option<String>,
/// 目录是否含可见子项(过滤隐藏/噪音目录后,与展开实际展示一致)。
/// 文件恒为 false;目录在列目录时即时统计。前端未展开时行内标注空目录用。
has_children: bool,
}
/// 列出工程目录(可钻入子目录)的文件树(单层 + git 状态合并)。
@@ -652,7 +764,7 @@ pub async fn get_module_file_tree(
// 采集 git 改动文件映射(相对仓库根):{posix_rel: status}。
// 10s 超时;非 git 仓库/失败 → 空映射(条目 git_status 全 None,不阻断列目录)。
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
.map_err(|e| format!("git status 采集任务失败: {e}"))?;
// 路径前缀(工程根本身)的相对基准;target_dir 下条目相对路径要以此拼成 posix 形式查 git_map。
@@ -699,12 +811,18 @@ pub async fn get_module_file_tree(
} else {
git_map_clone.get(&posix_rel).cloned()
};
let has_children = if is_dir {
dir_has_visible_children(&entry.path())
} else {
false
};
items.push(FileTreeEntry {
name,
path: posix_rel,
is_dir,
size,
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}"))?;
buf.truncate(n);
// 二进制检测:前 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 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))
})
.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 是毫秒级时间戳。
#[tauri::command]
@@ -1012,7 +1205,14 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
return map;
}
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;
};
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() {
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 record = ModuleDependencyRecord {
id: new_id(),
@@ -1362,3 +1578,42 @@ pub async fn detect_module_cycles(
}
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,
value: 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
.projects
.update_field(&id, &field, &value)
.update_field_active(&id, &field, &value)
.await
.map_err(err_str)?;
if !updated {
+83 -2
View File
@@ -6,6 +6,8 @@ use tauri::State;
use df_types::types::{new_id, TaskStatus};
use df_storage::crud::TaskQuery;
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;
@@ -245,6 +247,19 @@ pub async fn create_task(
state: State<'_, AppState>,
input: CreateTaskInput,
) -> 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)──
// 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致)
let queue = if input.queue.trim().is_empty() {
@@ -296,7 +311,7 @@ pub async fn create_task(
let record = TaskRecord {
id: new_id(),
project_id: input.project_id,
title: input.title,
title,
description: input.description,
status: TaskStatus::Todo,
priority: input.priority,
@@ -333,6 +348,18 @@ pub async fn create_task(
Some(record.status.as_str()),
)
.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)
}
@@ -412,12 +439,27 @@ pub async fn update_task(
}
// 空字符串 = 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 行)。
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
//
// 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated
// 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。
// 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站任务不可改字段。
let current = state
.tasks
.get_by_id(&id)
@@ -425,12 +467,37 @@ pub async fn update_task(
.map_err(err_str)?;
let updated = state
.tasks
.update_field(&id, &field, &value)
.update_field_active(&id, &field, &value)
.await
.map_err(err_str)?;
if !updated {
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 {
emit_event(
&state,
@@ -741,6 +808,20 @@ pub async fn move_task_queue(
.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)
}
+10
View File
@@ -292,6 +292,16 @@ pub async fn run_workflow_inner(
.lock()
.await
.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 (status, error) = match &result {
+3
View File
@@ -389,6 +389,7 @@ pub fn run() {
// 工程文件浏览(Batch 10):文件树 + 单文件预览
commands::module::get_module_file_tree,
commands::module::read_module_file,
commands::module::file_outline,
commands::module::get_module_file_meta,
commands::module::get_module_file_diff,
commands::module::get_module_commits,
@@ -465,6 +466,8 @@ pub fn run() {
commands::ai::ai_authorize_dir,
// 审批历史面板(AE-2025-08:查 ai_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_get,
+27
View File
@@ -38,6 +38,15 @@
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责 -->
<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>
<script setup lang="ts">
@@ -50,6 +59,7 @@ import { useAppSettingsStore } from './stores/appSettings'
import i18n from './i18n'
import AiChat from './components/AiChat.vue'
import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
import ApprovalDialog from './components/project/ApprovalDialog.vue'
import ErrorBoundary from './components/ErrorBoundary.vue'
import AppLayout from './components/layout/AppLayout.vue'
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
@@ -91,6 +101,23 @@ watch(() => projectStore.error, (err) => {
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) => {
if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) {
+32 -1
View File
@@ -36,6 +36,9 @@ export interface FileTreeEntry {
size: number
/** Git 状态码(`git status --porcelain` 的 XY 两字符,如 " M"/"M "/"??");文件夹恒无 */
git_status?: string
/** (/,); false
* , */
has_children: boolean
}
/** getModuleFileTree 返回结构。 */
@@ -57,6 +60,26 @@ export interface ReadFileResult {
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)。 */
export interface GitChangedFile {
status: string
@@ -146,6 +169,14 @@ export const moduleApi = {
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 }。 */
getModuleFileMeta(moduleId: string, filePath: string): Promise<{ path: string; size: number; modified_at: number }> {
return invoke('get_module_file_meta', { moduleId, filePath })
@@ -177,7 +208,7 @@ export const moduleApi = {
},
/** 分页查询工程 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 }[] }> {
return invoke('list_branches', { moduleId })
},
+8
View File
@@ -188,6 +188,8 @@ const { toast, showToast } = useToast()
let _unlistenToolSlow: (() => void) | null = null
// AE-2025-04: toast unlistener(useAiEvents 线, _unlistenToolSlow )
let _unlistenAutoApproved: (() => void) | null = null
// P1-e-d: toast unlistener(useAiApproval IPC 线 emit, _unlistenToolSlow )
let _unlistenAiToast: (() => void) | null = null
// : inputText/inputEl////handleSend ChatInput.vue
// chatInputRef: expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
@@ -466,6 +468,7 @@ onBeforeUnmount(() => {
store.stopContextListener()
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d
// toast timer useToast composable onUnmounted
})
@@ -503,6 +506,11 @@ onMounted(async () => {
_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')
})
// 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/)
await initContextEventListeners()
// (/):
+3
View File
@@ -42,6 +42,9 @@ function onKey(e: KeyboardEvent) {
// Enter :() confirm , dangerLabel ;
// input/textarea (SVG/ mask ,)
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'))) {
return
}
+17 -2
View File
@@ -346,6 +346,8 @@ function detectMentionTrigger() {
mentionStart.value = i
mentionOpen.value = true
mentionIndex.value = 0
// P1-g: @ (,; computed )
void ensureTaskSuggestions()
return
}
// a@b: mention , @,
@@ -403,6 +405,19 @@ const pendingMentionSpans = ref<MentionSpan[]>([])
const MENTION_TASK_LIMIT = 50
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 渲染) */
async function ensureProjectTasks(projectId: string): Promise<void> {
if (projectTasksCache.value.has(projectId)) return
@@ -564,7 +579,7 @@ const mentionQuery = computed(() => {
const mentionItems = computed<MentionItem[]>(() => {
const q = mentionQuery.value.trim().toLowerCase()
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 projectItems: MentionItem[] = projects
.filter(p => match(p.name) || match(p.description || ''))
@@ -606,7 +621,7 @@ const mentionItems = computed<MentionItem[]>(() => {
/** 联想候选总数(未过滤,用于区分"无匹配" vs "空") */
const mentionTotal = computed(() =>
((projectsStore.projects || []) as ProjectRecord[]).length
+ ((projectsStore.tasks || []) as TaskRecord[]).length
+ (taskSuggestionCache.value?.length ?? 0)
+ ((projectsStore.ideas || []) as IdeaRecord[]).length,
)
+21
View File
@@ -37,6 +37,13 @@
>{{ $t('aiChat.dirAuthDeny') }}</button>
</div>
</div>
<!-- P1-e-c: deny 拒绝二次确认(拒绝不可逆工具返 Errloop 续跑不再重试;对齐 ToolCard/ApprovalPopup) -->
<ConfirmDialog
:visible="confirmState.visible"
:msg="confirmState.msg"
:danger-label="$t('common.confirm')"
@result="answerConfirm"
/>
</template>
<script setup lang="ts">
@@ -45,6 +52,8 @@ import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingDirAuths } from '../../composables/ai/useAiPendingState'
import { aiApi } from '../../api'
import { useConfirm } from '../../composables/useConfirm'
import ConfirmDialog from '../../components/ConfirmDialog.vue'
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
@@ -53,6 +62,9 @@ const emit = defineEmits<{
const store = useAiStore()
const { t } = useI18n()
// P1-e-c: deny (useConfirm + ConfirmDialog , ToolCard/ApprovalPopup)
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// F-260620 (): isGenerating(conv), streaming
// streaming (useAiStream onStreamTimeout), AiDirAuthRequired
// ( 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> {
if (actingIds.has(id)) return
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 {
await aiApi.authorizeDir(id, decision)
} catch (e) {
+6 -3
View File
@@ -867,13 +867,16 @@ defineExpose({
@click="toggleJson(item.msg.id)"
><pre>{{ item.msg.content }}</pre></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--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg) }"
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
>
<!-- 流式:分块渲染(已完成块key稳定DOM不重建) -->
<template v-if="isLastAi(item.msg) && store.state.streaming && store.state.currentText">
<!-- 流式:分块渲染(已完成块key稳定DOM不重建)
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>
<span v-if="isViewingGenerating" class="ai-cursor"></span>
</template>
@@ -58,16 +58,16 @@ function getProjectTaskCount(projectId: string): number {
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
// "", active (DB ,ProjectStatus union
// 'active',;DB planning/in_progress ,)
// "",:/( 'active',
// filter status==='active' , DBP-1)
//
// 3:(last_active_at ,退 updated_at),
// (//) 6 (completed )
const displayProjects = computed(() =>
store.projects
.filter(p => (p.status as string) === 'active')
.filter(p => p.status !== 'completed' && p.status !== 'cancelled')
.sort((a, b) =>
(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-planning { background: var(--df-info); }
.dot-cancelled { background: var(--df-text-dim); }
/* WK-1:paused 置灰(对齐 cancelled dim 档,临时暂停不误标为已取消) */
.dot-paused { background: var(--df-text-dim); }
.project-name {
font-size: 12px;
@@ -135,6 +137,7 @@ const displayProjects = computed(() =>
.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-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 {
@@ -154,6 +157,7 @@ const displayProjects = computed(() =>
.fill-release { background: var(--df-success); }
.fill-planning { background: var(--df-info); }
.fill-cancelled { background: var(--df-text-dim); }
.fill-paused { background: var(--df-text-dim); }
.project-pct {
font-family: var(--df-font-mono);
+47 -13
View File
@@ -273,7 +273,7 @@
</button>
<ul v-show="statusMenuOpen" class="status-menu">
<li
v-for="s in statusOptions"
v-for="s in menuStatusOptions"
:key="s.value"
class="status-menu-item"
:class="{ active: s.value === idea.status }"
@@ -322,9 +322,11 @@ const emit = defineEmits<{
(e: 'promote'): void
(e: 'delete'): void
(e: 'status-change', value: IdeaStatus): void
(e: 'update-desc', value: string): void
(e: 'update-related', ids: string[]): void
(e: 'update-tags', tags: string): void
// WK-16: Promise<boolean> await /
// true=();false=(,toast )
(e: 'update-desc', value: string): Promise<boolean>
(e: 'update-related', ids: string[]): Promise<void>
(e: 'update-tags', tags: string): Promise<boolean>
}>()
const { t } = useI18n()
@@ -341,16 +343,15 @@ function startEdit() {
editing.value = true
}
// P1- saveEdit :emit store.updateIdea Promise, emit ;
// savingDesc ( patch props.idea.description
// ), UI updateIdea throw (沿)
// P1- saveEdit :emit Promise<boolean>( store.updateIdea ),
// ; + toast (WK-16)
async function saveEdit() {
if (savingDesc.value) return
savingDesc.value = true
try {
emit('update-desc', editDesc.value)
editing.value = false
// patch (,)
const ok = await emit('update-desc', editDesc.value)
if (ok) editing.value = false
// patch (,)
await nextTick()
} finally {
savingDesc.value = false
@@ -391,6 +392,9 @@ watch(() => props.idea.description, () => void nextTick(measureDesc))
// P1- select badge : badge /,
// emit('status-change') +(沿, statusOptions )
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() {
statusMenuOpen.value = false
}
@@ -498,8 +502,9 @@ async function saveTagsEdit() {
savingTags.value = true
try {
const tags = tagsEditInput.value.split(',').map(t => t.trim()).filter(Boolean)
emit('update-tags', JSON.stringify(tags))
tagsEditing.value = false
// WK-16:emit ,(),toast
const ok = await emit('update-tags', JSON.stringify(tags))
if (ok) tagsEditing.value = false
await nextTick()
} finally {
savingTags.value = false
@@ -542,13 +547,42 @@ interface AdversarialEval {
}
// 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>(() => {
if (!props.idea.ai_analysis) return null
let raw: any
try {
return JSON.parse(props.idea.ai_analysis) as AdversarialEval
raw = JSON.parse(props.idea.ai_analysis)
} catch {
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
+5 -3
View File
@@ -106,10 +106,12 @@ const resizing = ref(false)
// maximized=(flex:1) | sidebar=
const panelStyle = computed(() => {
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 {
width: panelWidth.value + 'px',
minWidth: panelWidth.value + 'px',
maxWidth: panelWidth.value + 'px',
width: `min(${panelWidth.value}px, 100vw)`,
minWidth: `min(${panelWidth.value}px, 100vw)`,
maxWidth: `min(${panelWidth.value}px, 100vw)`,
}
})
+5 -5
View File
@@ -92,15 +92,16 @@ const appSettings = useAppSettingsStore()
// 192px(+) / 56px(,hover tooltip )
// : > (768px)
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 collapsed = computed(() => userCollapsed.value || isNarrowWindow.value)
//
// (useSetting ref debounce , appSettings.set)
function toggleCollapsed() {
userCollapsed.value = !userCollapsed.value
void appSettings.set('df-sidebar-collapsed', userCollapsed.value)
}
// :()
@@ -118,8 +119,7 @@ function handleCollapseKeydown(e: KeyboardEvent) {
}
onMounted(async () => {
// ()
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
// LW-10: useSetting (loadAll watch ), onMounted
//
checkWindowWidth()
window.addEventListener('resize', checkWindowWidth)
+145 -33
View File
@@ -46,7 +46,13 @@
<div class="dep-graph__modal-field">
<label>{{ $t('dependencyGraph.targetLabel') }}</label>
<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>
</div>
<div class="dep-graph__modal-field">
@@ -61,7 +67,7 @@
</div>
<div class="dep-graph__modal-actions">
<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>
@@ -74,11 +80,11 @@
*
* 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册
*/
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue'
import { useRouter } from 'vue-router'
import { onMounted, onBeforeUnmount, onActivated, ref, watch, computed, markRaw } from 'vue'
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 { register } from '@antv/x6-vue-shape'
import '@antv/x6-vue-shape'
import { Message } from '@arco-design/web-vue'
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
@@ -86,16 +92,16 @@ import { useConfirm } from '@/composables/useConfirm'
import ModuleNode from './ModuleNode.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<{
projectId: string
}>()
const router = useRouter()
const emit = defineEmits<{
(e: 'select-module', moduleId: string): void
}>()
const containerRef = ref<HTMLElement | null>(null)
const modules = ref<ProjectModuleRecord[]>([])
const dependencies = ref<ModuleDependencyRecord[]>([])
@@ -115,8 +121,23 @@ const depFrom = ref('')
const depTo = ref('')
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() {
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 {
await moduleApi.addModuleDependency(props.projectId, depFrom.value, depTo.value, depType.value)
showAddDep.value = false
@@ -127,11 +148,40 @@ async function onAddDep() {
renderGraph()
} catch (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() {
loading.value = true
// ,(,G5)
cycleNodes.value = new Set()
try {
const [mods, deps] = await Promise.all([
moduleApi.listProjectModules(props.projectId),
@@ -147,8 +197,10 @@ async function loadModules() {
}
}
function renderGraph() {
function renderGraph(opts?: { center?: boolean }) {
if (!graph) return
// () centerContent,;(//)
const shouldCenter = opts?.center !== false
const nodeWidth = 200
const nodeHeight = 80
@@ -168,7 +220,6 @@ function renderGraph() {
// X6
const nodes = modules.value.map(m => {
const pos = g.node(m.id)
const isCycle = cycleNodes.value.has(m.id)
return {
id: m.id,
shape: 'vue-shape',
@@ -182,12 +233,12 @@ function renderGraph() {
path: m.path,
stack: m.stack,
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.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() {
@@ -215,8 +290,9 @@ function buildGraph() {
graph = new Graph({
container: containerRef.value,
background: { color: '#1a1a2e' },
grid: { visible: true, size: 10, type: 'dot', args: { color: '#2a2a4e' } },
// U1:/grid token (X6 background/grid , CSS ),
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 },
interacting: { nodeMovable: true },
})
@@ -226,16 +302,31 @@ function buildGraph() {
graph.use(new History({ enabled: true }))
graph.use(new Scroller({ enabled: true, pannable: true }))
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 }) => {
emit('select-module', String(node.id))
router.push(`/projects/${props.projectId}`)
// (G4):, emit (/,
// Tab , emit+router.push )
// F4:(+), fromJSON ,/
updateNodeSelection(String(node.id))
})
renderGraph()
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() {
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
try {
graph.toPNG((dataUri: string) => {
graph.toPNG((dataUri: string) => {
try {
const a = document.createElement('a')
a.href = dataUri
a.download = `dependency-graph-${props.projectId}.png`
a.click()
})
} catch (e) {
console.error('[DependencyGraph] 导出 PNG 失败:', e)
}
} catch (e) {
console.error('[DependencyGraph] 导出 PNG 失败:', e)
Message.error(t('common.unknownError'))
}
})
}
/** 删除依赖(二次确认 + 重绘)。 */
@@ -331,12 +428,15 @@ function hideEdgeDeleteBtn() {
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 显示删除按钮的监听。 */
function bindEdgeDeleteButtons() {
if (!graph) return
try { graph.off('edge:mouseenter', onEdgeEnter); graph.off('edge:mouseleave', onEdgeLeave) } catch { /* ignore */ }
function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) }
function onEdgeLeave() { hideEdgeDeleteBtn() }
graph.on('edge:mouseenter', onEdgeEnter)
graph.on('edge:mouseleave', onEdgeLeave)
}
@@ -357,6 +457,18 @@ onMounted(async () => {
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 () => {
await loadModules()
renderGraph()
+110 -18
View File
@@ -84,9 +84,18 @@
<!-- 文件树视图 -->
<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>
</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
v-else
:module-id="currentModuleId"
@@ -106,6 +115,7 @@
<GitChanges
v-if="currentModuleId"
:module-id="currentModuleId"
:refresh-key="gitStatusRefreshKey"
@select-file="onChangeFileSelect"
/>
</div>
@@ -119,6 +129,7 @@
:file-path="selectedFilePath"
:module-root-path="currentModule?.path ?? ''"
:git-status="selectedFileGitStatus"
:external-diff="selectedCommitDiff"
/>
<div v-else class="preview-placeholder-inline">{{ $t('fileExplorer.selectFileHint') }}</div>
</div>
@@ -198,7 +209,7 @@
* 父组件(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 { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
@@ -217,6 +228,9 @@ const modules = ref<ProjectModuleRecord[]>([])
const currentModuleId = ref<string>('')
const refreshing = ref(false)
const moduleDropdownOpen = ref(false)
// B4[PD-P2-8P2-10]:( resolve)currentModuleId
// ()vs(), spinner
const mLoaded = ref(false)
// CRUD
const showModuleModal = ref(false)
@@ -239,6 +253,8 @@ const expandedPaths = reactive(new Set<string>())
const loadedChildren = reactive(new Map<string, FileTreeEntry[]>())
const selectedFilePath = ref<string | null>(null)
const selectedFileGitStatus = ref<string | undefined>(undefined)
/** GitChanges(变更/提交历史)点文件注入的 diff:传给右侧 FilePreview 在宽区渲染(历史提交的 diff 优先)。 */
const selectedCommitDiff = ref('')
const currentModule = computed(
() => modules.value.find((m) => m.id === currentModuleId.value) ?? null,
@@ -277,6 +293,9 @@ async function loadModules() {
} catch (e) {
console.error('[FileExplorer] 加载工程列表失败', e)
modules.value = []
} finally {
// B4[PD-P2-8P2-10]:(/),
mLoaded.value = true
}
}
@@ -286,6 +305,7 @@ function resetTreeState() {
loadedChildren.clear()
selectedFilePath.value = null
selectedFileGitStatus.value = undefined
selectedCommitDiff.value = ''
}
/** 自定义下拉选择工程。 */
@@ -301,6 +321,8 @@ function onFileSelect(path: string) {
selectedFilePath.value = path
// loadedChildren git_status()
selectedFileGitStatus.value = findEntryGitStatus(path)
// , GitChanges diff
selectedCommitDiff.value = ''
}
/** 递归在 loadedChildren 缓存中查指定路径条目的 git_status。 */
@@ -335,6 +357,7 @@ async function refresh() {
loadedChildren.clear()
selectedFilePath.value = null
selectedFileGitStatus.value = undefined
selectedCommitDiff.value = ''
// tick FileTree watch ;,
await new Promise((r) => setTimeout(r, 50))
refreshing.value = false
@@ -364,6 +387,15 @@ function onDetach() {
// : / Git
const viewMode = ref<'tree' | 'changes'>('tree')
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)
@@ -377,10 +409,11 @@ async function switchToChanges() {
}
}
/** Git 变更视图中选择文件 → 在右侧预览显示 diff。 */
function onChangeFileSelect(path: string) {
/** Git 变更视图中选择文件 → 在右侧预览显示 diff(变化/提交历史点文件都走此路,宽区渲染)。 */
function onChangeFileSelect(path: string, diff?: string) {
selectedFilePath.value = path
selectedFileGitStatus.value = gitStatusData.value?.changed_files.find(f => f.path === path)?.status
selectedCommitDiff.value = diff ?? ''
}
watch(() => props.projectId, loadModules, { immediate: true })
@@ -396,16 +429,22 @@ onUnmounted(() => document.removeEventListener('click', closeDropdown))
// AI (df-data-changed entity=file) +
let _unlistenDataChanged: UnlistenFn | null = null
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
// git ,使
gitStatusData.value = null
if (viewMode.value === 'changes') {
// , GitChanges ( viewMode watch)
viewMode.value = 'tree'
viewMode.value = 'changes'
} else {
// ,, AI
if (!currentModuleId.value) return
// B2[PD-P1-5]: git null null changedCount 0,
// switchToChanges !gitStatusData,
try {
gitStatusData.value = await moduleApi.getModuleGitStatus(currentModuleId.value)
} catch {
gitStatusData.value = null
}
// 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'
}
})
@@ -730,9 +769,10 @@ async function onRemoveModule() {
.explorer-tree {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 4px 8px 0;
padding: 4px 4px 4px 0;
scrollbar-width: thin;
}
@@ -757,6 +797,35 @@ async function onRemoveModule() {
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 {
width: 20px;
height: 20px;
@@ -785,19 +854,42 @@ async function onRemoveModule() {
/* 全局滚动条样式(薄) */
.explorer-sidebar ::-webkit-scrollbar {
width: 4px;
height: 4px;
width: 5px;
height: 5px;
}
.explorer-sidebar ::-webkit-scrollbar-thumb {
background: var(--df-border);
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
border-radius: var(--df-radius-xs);
}
.explorer-sidebar ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.14);
}
.explorer-sidebar ::-webkit-scrollbar-track {
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) {
.explorer-sidebar {
+452 -27
View File
@@ -39,28 +39,46 @@
<div class="preview-placeholder-text">{{ $t('fileExplorer.selectFileHint') }}</div>
</div>
<!-- 加载中 -->
<div v-else-if="loading" class="preview-loading">
<!-- 加载中(外部 diff 存在时让位于 diff 视图,历史提交的文件可能不在当前工作树) -->
<div v-else-if="loading && !isExternalDiff" class="preview-loading">
<span class="spinner"></span>
{{ $t('fileExplorer.loadingFile') }}
</div>
<!-- 错误 -->
<div v-else-if="error" class="preview-error"> {{ error }}</div>
<!-- 错误(外部 diff 存在时同样让位:历史文件当前树不存在不应遮住 diff) -->
<div v-else-if="error && !isExternalDiff" class="preview-error"> {{ error }}</div>
<!-- 二进制 -->
<div v-else-if="isBinary" class="preview-binary">
<span class="binary-icon">📄</span>
<p>{{ $t('fileExplorer.binaryNotSupported') }}</p>
<!-- 二进制:展示文件信息(路径/大小/类型),替代"不支持预览"空态 -->
<div v-else-if="isBinary && !isExternalDiff" class="preview-binary">
<span class="binary-icon">🗂</span>
<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 v-else-if="isImage" class="preview-image">
<!-- 图片(外部 diff 存在时让位) -->
<div v-else-if="isImage && !isExternalDiff" class="preview-image">
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
</div>
<!-- Diff 视图(切到 diff 模式时显;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
<div v-else-if="showDiff && diffContent" class="preview-diff">
<!-- Diff 视图(外部注入的 diff 优先展;置于 Markdown 之前,使 .md 文件也支持 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">
<span class="diff-line-num">{{ ln.oldNum || '' }}</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>
<!-- 文本/代码(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 v-for="n in lineCount" :key="n" class="preview-line-num">{{ n }}</div>
</div>
<pre class="preview-code"><code :class="hljsClass" v-html="htmlContent"></code></pre>
</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>
</template>
<script setup lang="ts">
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 { useMarkdown, useRendered } from '@/composables/useMarkdown'
import { escapeHtml } from '@/utils/html'
@@ -98,6 +157,9 @@ const props = defineProps<{
filePath: string | null
moduleRootPath: string
gitStatus?: string
/** diff( GitChanges / diff, diff)
* 非空时优先渲染此 diff(宽预览区),不发起重复的 git diff 请求 */
externalDiff?: string
}>()
const { } = useMarkdown()
@@ -110,6 +172,22 @@ const fileSize = ref<number | null>(null)
const truncated = ref(false)
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 )
* 高亮 HTML 可能因多行 token / 转义与源码行数不一致,直接切分 htmlContent 易错位
* 末尾无换行时 split 会多出空串,需按实际换行符计数对齐渲染 */
@@ -127,6 +205,17 @@ const showDiff = ref(false)
const diffContent = ref('')
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 分用两个计数器 若共用一个,loadDiff 自增会让在途的 loadFile
@@ -152,11 +241,11 @@ function parseHunkHeader(line: string): { oldStart: number; newStart: number } {
}
const diffLines = computed<DiffLine[]>(() => {
if (!diffContent.value) return []
if (!diffViewContent.value) return []
const lines: DiffLine[] = []
let oldNum = 0
let newNum = 0
for (const raw of diffContent.value.split('\n')) {
for (const raw of diffViewContent.value.split('\n')) {
if (raw.startsWith('@@')) {
const h = parseHunkHeader(raw)
oldNum = h.oldStart
@@ -180,7 +269,8 @@ const diffLines = computed<DiffLine[]>(() => {
function toggleDiff() {
showDiff.value = !showDiff.value
if (showDiff.value && !diffContent.value) {
// diff , git diff; diff
if (showDiff.value && !diffContent.value && !isExternalDiff.value) {
loadDiff()
}
}
@@ -281,6 +371,99 @@ const isImage = computed(() => {
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() {
const seq = ++fileReqSeq // (使)
@@ -288,6 +471,9 @@ async function loadFile() {
content.value = ''
htmlContent.value = ''
imageUrl.value = null
outlineSupported.value = false
outlineSymbols.value = []
activeOutlineLine.value = -1
return
}
loading.value = true
@@ -300,8 +486,16 @@ async function loadFile() {
URL.revokeObjectURL(imageUrl.value)
imageUrl.value = null
}
// ()
outlineSupported.value = false
outlineSymbols.value = []
activeOutlineLine.value = -1
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 // ,
fileSize.value = res.size
truncated.value = res.truncated
@@ -327,16 +521,35 @@ async function loadFile() {
// highlight.js :,
const ext = props.filePath.split('.').pop()?.toLowerCase() ?? ''
const lang = EXT_LANG[ext]
try {
if (lang && hljs.getLanguage(lang)) {
htmlContent.value = hljs.highlight(res.content, { language: lang }).value
} else {
htmlContent.value = hljs.highlightAuto(res.content).value
// (), hljs ,
// highlightAuto():,(,)
content.value = res.content
const raw = res.content
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) {
if (seq !== fileReqSeq) return //
@@ -453,6 +666,7 @@ onUnmounted(() => {
min-height: 0;
display: flex;
flex-direction: column;
position: relative; /* 符号悬浮层定位锚点 */
}
.preview-placeholder,
@@ -463,11 +677,55 @@ onUnmounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
gap: 14px;
height: 100%;
color: var(--df-text-dim);
font-size: 13px;
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 {
@@ -604,6 +862,173 @@ onUnmounted(() => {
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 视图 */
.preview-diff {
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 v-if="commitDetailLoading" class="gc-loading"><span class="spinner"></span></div>
<template v-else-if="commitDetail">
<!-- 变更文件列表 -->
<!-- 变更文件列表(点击文件 diff 渲染到右侧宽预览区, emit select-file) -->
<div class="gc-commit-files">
<div
v-for="f in commitDetail.files"
@@ -143,25 +143,6 @@
<span class="gc-file-path">{{ f.path }}</span>
</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>
</div>
</transition>
@@ -172,8 +153,9 @@
/**
* Git 变更查看器 变更文件列表(按目录树分组) + 提交历史(分页/搜索) + 文件 diff 预览
*
* 历史 Tab 布局:列表始终可见,选中提交的详情从底部滑出(可收起),diff 在更宽容器渲染,
* 避免几百行 diff 把列表挤走或在 30% 窄栏内挤压
* 布局:变更 Tab 点文件 / 提交历史点文件 emit select-file,把该文件 diff 注入右侧 FilePreview
* 宽预览区渲染;本组件窄栏只保留文件列表与提交详情(文件清单),不再内嵌 diff 几百行 diff
* 30% 窄栏内渲染会被挤压/截断, diff 展示统一走右侧宽区
*/
import { ref, computed, watch } from 'vue'
import { moduleApi, type GitStatusResult } from '@/api/module'
@@ -183,10 +165,13 @@ const { t } = useI18n()
const props = defineProps<{
moduleId: string
/** B2[PD-P1-5]:外部(FileExplorer df-data-changed)自增刷新信号,变化时重拉 git 状态。 */
refreshKey?: number
}>()
const emit = defineEmits<{
(e: 'select-file', path: string): void
/** 选择文件 → 在右侧宽预览区展示其 diff(第二个参数为可选注入的 diff 内容)。 */
(e: 'select-file', path: string, diff?: string): void
}>()
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 commitDetailLoading = ref(false)
const commitSelectedFile = ref('')
const commitDiff = ref('')
const commitDiffCollapsed = ref(false)
const branchName = computed(() => gitStatus.value?.branch || '')
@@ -269,33 +252,6 @@ const groupedChanges = computed<FileGroup[]>(() => {
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 {
const x = s.trim()
if (x === '??') return 'U'
@@ -476,6 +432,7 @@ async function switchToHistory() {
async function selectFile(path: string) {
selectedFile.value = path
// (),diff ,
emit('select-file', path)
diffContent.value = ''
diffLoading.value = true
@@ -487,6 +444,10 @@ async function selectFile(path: string) {
} finally {
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
commitDetail.value = null
commitSelectedFile.value = ''
commitDiff.value = ''
commitDiffCollapsed.value = false
// , diff
emit('select-file', '', '')
commitDetailLoading.value = true
try {
const res = await moduleApi.getCommitDetail(props.moduleId, c.hash)
@@ -516,21 +477,27 @@ function closeCommitDetail() {
selectedCommit.value = null
commitDetail.value = null
commitSelectedFile.value = ''
commitDiff.value = ''
emit('select-file', '', '')
}
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块) */
/** 在提交详情中点击文件 → 精确提取该文件的 diff 块(按 diff --git 头切块),路由到右侧宽预览区渲染 */
function showCommitFileDiff(path: string) {
commitSelectedFile.value = path
commitDiffCollapsed.value = false
if (!commitDetail.value) {
commitDiff.value = ''
emit('select-file', path, '')
return
}
commitDiff.value = extractFileDiff(commitDetail.value.diff, path)
emit('select-file', path, extractFileDiff(commitDetail.value.diff, path))
}
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>
<style scoped>
@@ -590,23 +557,7 @@ watch(() => props.moduleId, loadStatus, { immediate: true })
position: sticky; top: 0; z-index: 1;
}
/* Diff 预览 */
.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); }
/* Diff 预览样式已随窄栏内 diff 面板移除(diff 统一在右侧 FilePreview 宽区渲染) */
/* ====== 提交历史 ====== */
.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.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 {
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;
}
.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 {
width: 16px; height: 16px;
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;
}
@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>
+32 -20
View File
@@ -1,5 +1,5 @@
<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">
<span class="module-node__icon">📦</span>
<span class="module-node__name">{{ data.name }}</span>
@@ -16,39 +16,45 @@ import { computed } from 'vue'
import { parseJsonArray } from '@/utils/json'
const props = defineProps<{
data: {
name: string
path: string
stack: string | null
gitUrl: string | null
}
// x6-vue-shape props node + graph(view.js:48 h(component, { node, graph })),
// node.getData() , data propdata prop x6
node?: any
graph?: any
data?: Record<string, unknown>
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 )
// :(/)+ + ( 32 )
const truncatedPath = computed(() => {
const p = props.data?.path || ''
const p = data.value?.path || ''
if (p.length <= 32) return p
// :(/ \,)( C: )
const sepMatch = p.match(/[\\/]/)
if (!sepMatch) {
// ,退
return '...' + p.slice(-29)
return '' + p.slice(-29)
}
const sepIdx = sepMatch.index! + 1
const head = p.slice(0, sepIdx)
const tailBudget = 32 - head.length - 1 // -1 ( )
const tailBudget = 32 - head.length - 1 // -1
if (tailBudget < 4) {
// ,退()
return '...' + p.slice(-29)
return '' + p.slice(-29)
}
const tail = p.slice(-tailBudget)
return head + '…' + tail
})
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
</script>
<style scoped>
@@ -60,15 +66,21 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
width: 100%;
height: 100%;
box-sizing: border-box;
background: #16213e;
border: 1px solid #0f3460;
background: var(--df-bg-card);
border: 1px solid var(--df-border-strong);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s;
}
.module-node.active {
border-color: #5378e8;
box-shadow: 0 0 0 2px rgba(83, 120, 232, 0.3);
border-color: var(--df-accent);
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 {
display: flex;
@@ -81,14 +93,14 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
.module-node__name {
font-size: 13px;
font-weight: 600;
color: #e0e0e0;
color: var(--df-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-node__path {
font-size: 10px;
color: #888;
color: var(--df-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -102,7 +114,7 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
font-size: 9px;
padding: 1px 6px;
border-radius: 4px;
background: rgba(83, 120, 232, 0.15);
color: #7c9aff;
background: var(--df-accent-bg);
color: var(--df-accent);
}
</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-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); }
/* WK-1:暂停/取消置灰(PROJECT_STATUS_BADGE_CLASS 映射) */
.stage-dim { background: rgba(158,158,158,0.15); color: #9e9e9e; }
.card-desc-wrap {
position: relative;
@@ -91,8 +91,13 @@ const emit = defineEmits<{
}>()
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
try { return JSON.parse(props.dagJson) }
catch { return null }
try {
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> = {
+7 -1
View File
@@ -13,6 +13,7 @@
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
import { ref } from 'vue'
import { emit } from '@tauri-apps/api/event'
import { aiApi } from '@/api'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
@@ -99,9 +100,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
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) {
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
}
+95 -48
View File
@@ -11,7 +11,7 @@ import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared'
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()
@@ -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,
// 防多个空会话 + 虚拟项堆积)。模块级(跨组件共享,主/分离窗口各自实例由 store 单例统一)。
let _newConvLock = false
@@ -103,7 +117,8 @@ async function newConversation() {
// queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain)
state.agentRound = 0
state.searchQuery = ''
await loadConversations()
// FE1:走防抖调度(250ms trailing),与下行 notify 自触发的 listener 刷新合并为一次。
scheduleConversationsRefresh()
notifyConversationChanged()
} finally {
// 防抖释放:300ms 后允许再次新建
@@ -176,6 +191,8 @@ function parseConvMessages(rawMsgs: any[], fallbackPrefix: string): AiMessage[]
cache_hit: m.prompt_cache_hit_tokens,
cache_miss: m.prompt_cache_miss_tokens,
reasoning: m.reasoning_tokens,
// 消息级估算标记(DB 列,老消息 null → undefined):reload 逐条回显对齐 live 态 AiCompleted
is_estimated: m.is_estimated,
}
: undefined,
// 分项 token 消息级字段(详情面板直接读 msg.xxx,与实时态 useAiEvents 写入一致)
@@ -286,15 +303,15 @@ export async function switchConversation(id: string, force = false) {
loadMoreCursor.loading = false
state.messages = []
notifyConversationChanged()
void loadConversations()
scheduleConversationsRefresh()
return
}
// 瞬态失败:保留当前视图 + 错误气泡(复用会话操作失败气泡模式);过期响应丢弃。
// 保留 loadConversations() 刷新:陈旧 id(已被后端删除)在下一次列表刷新中消失。
// 保留 scheduleConversationsRefresh():陈旧 id(已被后端删除)在下一次列表刷新中消失。
console.error('[AI] switchConversation 失败(瞬态),保留当前视图:', e)
if (mySwitchId !== _latestSwitchId) return
pushConvOpFail('switchConvFail')
void loadConversations()
scheduleConversationsRefresh()
return
} finally {
// 切换结束(含失败/过期/新建会话路径)清切换中标记 + 统一收集并清空缓冲。
@@ -333,6 +350,28 @@ export async function switchConversation(id: string, force = false) {
state.messages = parseConvMessages(rawMsgs, 'loaded')
// parse 成功才置 active(保证 active 与 messages 一致)
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) {
// 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
@@ -371,53 +410,60 @@ export async function switchConversation(id: string, force = false) {
// 恢复该对话积压的待审批:重启后后端从审计表重建了挂起审批,
// 此处查回并把对应 toolCard.status 置为待审批,使审批卡片重新可见。
// 按 IPC 返的 kind 渲染:'path' 类显 once/always/deny,'risk' 类显 approve/reject。
try {
const pending = await aiApi.pendingToolCalls(id)
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
if (mySwitchId !== _latestSwitchId) return
if (pending.length) {
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
const pendingIds = new Set(pending.map(p => p.tool_call_id))
const restored: AiToolCallInfo[] = []
for (const m of state.messages) {
for (const tc of (m.toolCalls || [])) {
if (pendingIds.has(tc.id)) {
tc.status = 'pending_approval'
const kind = pendingKindMap.get(tc.id) ?? 'risk'
tc.kind = kind
// 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 ?? '' })
// F2-1(AC-EFF-F2-1):纯文本会话跳过挂起查询(省 1 次 IPC + 后端全扫)——仅当目标会话消息
// 含工具卡,或目标会话生成中(可能在途产生待审批)才需要恢复挂起。纯文本/无工具历史直接置空。
const hasToolCalls = state.messages.some(m => (m.toolCalls ?? []).length > 0)
if (!targetGen && !hasToolCalls) {
state.pendingApprovals = []
} else {
try {
const pending = await aiApi.pendingToolCalls(id)
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的挂起覆写 B 的 pendingApprovals
if (mySwitchId !== _latestSwitchId) return
if (pending.length) {
// kind 索引:tool_call_id → kind('risk'/'path'),供恢复工具卡/挂起列表按类型渲染
const pendingKindMap = new Map(pending.map(p => [p.tool_call_id, p.kind]))
const pendingIds = new Set(pending.map(p => p.tool_call_id))
const restored: AiToolCallInfo[] = []
for (const m of state.messages) {
for (const tc of (m.toolCalls || [])) {
if (pendingIds.has(tc.id)) {
tc.status = 'pending_approval'
const kind = pendingKindMap.get(tc.id) ?? 'risk'
tc.kind = kind
// 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
} else {
} catch {
state.pendingApprovals = []
}
} catch {
state.pendingApprovals = []
}
}
@@ -471,7 +517,7 @@ async function deleteConversation(id: string) {
loadMoreCursor.convId = null
loadMoreCursor.loading = false
}
await loadConversations()
scheduleConversationsRefresh()
// 删的是活跃会话:回落相邻会话作为新活跃视图
if (neighborId) await switchConversation(neighborId)
notifyConversationChanged({ deletedConvId: id })
@@ -582,6 +628,7 @@ function toggleSidebar() {
export function useAiConversations() {
return {
loadConversations,
scheduleConversationsRefresh,
newConversation,
switchConversation,
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 { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import { scheduleConversationsRefresh } from './useAiConversations'
import { handleStreamingEvent } from './useAiStreamingEvents'
import { handleToolEvent } from './useAiToolEvents'
import { handleLifecycleEvent } from './useAiLifecycleEvents'
@@ -61,7 +61,8 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
// 多会话并行隔离:AiUserMessage 必带 conversation_id;非当前会话不污染当前视图,仅刷新列表
const uc = event.conversation_id ?? null
if (uc && state.activeConversationId && uc !== state.activeConversationId) {
void loadConversations()
// FE1:非当前会话的用户消息只刷新列表(走防抖调度,与收尾 notify 合并为一次)。
scheduleConversationsRefresh()
return true
}
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') {
// 非当前会话终止:收敛会话状态(删 Map 项回不在生成)
convStates.delete(convId || '')
void loadConversations()
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
scheduleConversationsRefresh()
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
if (event.type === 'AiCompleted') {
emit('ai-drain-queue', { conversationId: event.conversation_id })
@@ -142,7 +144,8 @@ export async function startListener() {
try {
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
_unlistenConvChanged = await listen('ai-conversation-changed', (e) => {
void loadConversations()
// FE1:notify 自触发的刷新走防抖调度,同窗口内多次 notify 合并为一次列表拉取。
scheduleConversationsRefresh()
// 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。
const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId
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 { clearStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { loadConversations } from './useAiConversations'
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore()
@@ -116,7 +115,8 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
const conv = state.conversations.find(c => c.id === state.activeConversationId)
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 累加对话总量
if (isShowTokenUsage()) {
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 }
}
// 每轮 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--) {
const m = state.messages[i]
if (m.role === 'assistant' && !m.isError) {
@@ -191,7 +194,7 @@ export function handleLifecycleEvent(event: AiChatEvent): boolean {
options: event.options,
conversationId: event.conversation_id || state.activeConversationId || null,
}
void loadConversations()
// FE1:去掉显式 loadConversations(与下行 notify 双拉),由 listener 防抖调度统一收口。
notifyConversationChanged()
return true
}
+58 -4
View File
@@ -64,6 +64,20 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
let rafId: number | null = null
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 字符串)整体一块
/// (跨双换行不切),非代码段按双换行切段。
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
@@ -124,17 +138,46 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
return html
}
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
/// 流式渲染:增量切块 + 块级 memo(前块缓存/末块重 parse)
/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
///
/// AC-EFF-S1-1 增量:追加场景复用上一帧已完成块,只对「旧末块+增长段」增量 lexer,
/// 不再每帧全文 lexer;单块回复(整回复无块边界)超阈值降级纯文本防 O(n) parse 卡顿。
function renderStreamingBlocks(text: string): StreamBlock[] {
if (!text) return []
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
_prevStreamText = '' // 纯文本降级路径不增量缓存(下帧回全量,防旧帧基线误命中)
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
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
return blocks.map((b, i) => {
@@ -144,7 +187,10 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
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)
: isTail ? parseBlockNoCache(b) : parseBlock(b)
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
@@ -194,11 +240,15 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
}
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText。
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText + 清增量基线
function clearStreamingState(): void {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
streamingBlocks.value = []
lastStreamText = ''
// AC-EFF-S1-1:清增量基线(防新轮/新会话误命中旧帧已完成块)。
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
}
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
@@ -209,8 +259,12 @@ export function useStreamRenderer(opts: UseStreamRendererOptions) {
/// 清空实例级块缓存(内容寻址 Map + LRU)。会话切换/开关变化时调用,
/// 防旧会话已缓存块被新会话命中(跨会话内容错乱)。仅清缓存,不动 streamingBlocks。
/// AC-EFF-S1-1:同步清增量基线(已完成块引用旧会话内容,防跨会话误复用)。
function resetStreamCache(): void {
_blockCache.clear()
_prevStreamText = ''
_prevCompleted = []
_prevTail = ''
}
return {
+37 -2
View File
@@ -8,17 +8,32 @@
// 值为 i18n key,实际文案走 $t('projects.status.<key>')(见 src/i18n/{zh-CN,en}/projects.ts)
// F-09 对齐:删 in_progress/paused/cancelled(project 维度无数据产生源、无 UI 入口、无消费方),
// 保留与后端真实生命周期一致的最小集(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> = {
planning: 'projects.status.planning',
active: 'projects.status.active',
in_progress: 'projects.status.in_progress',
testing: 'projects.status.testing',
releasing: 'projects.status.releasing',
completed: 'projects.status.completed',
paused: 'projects.status.paused',
cancelled: 'projects.status.cancelled',
}
/** 项目状态 → 卡片 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> = {
planning: 'stage-design',
active: 'stage-coding',
in_progress: 'stage-coding',
testing: 'stage-testing',
releasing: 'stage-release',
completed: 'stage-release',
paused: 'stage-dim',
cancelled: 'stage-dim',
}
/** 项目状态 → 阶段/进度信息(详情页 pipeline + Dashboard 进度条共用) */
@@ -31,10 +46,17 @@ export interface ProjectStageInfo {
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> = {
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' },
paused: { stepIndex: 1, progress: 40, stage: 'paused' },
cancelled: { stepIndex: 0, progress: 0, stage: 'cancelled' },
}
export function projectStatusLabel(status: string): string {
@@ -105,6 +127,19 @@ export function taskStatusClass(status: string): string {
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' }
+3
View File
@@ -125,6 +125,9 @@ export default {
inputHint: 'Enter to send · Shift+Enter for newline',
// ── 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',
imagePreviewAlt: 'Pending image',
imageTooLarge: 'Image too large (max 10MB), skipped',
+12
View File
@@ -49,6 +49,13 @@ export default {
decided: {
human: 'Human',
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
tabAudit: 'Approval History',
@@ -59,5 +66,10 @@ export default {
convTruncated: 'Content truncated (expand to view full conversation)',
convSelectHint: '← Select a conversation to view full interaction log',
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: {
planning: 'Planning',
coding: 'Coding',
testing: 'Testing',
release: 'Release',
done: 'Done',
paused: 'Paused',
cancelled: 'Cancelled',
},
ideaStatus: {
draft: 'Draft',
+1
View File
@@ -20,6 +20,7 @@ export default {
cycleWarning: 'Detected {n} cycle nodes (highlighted in red)',
noCycle: 'No cyclic dependencies detected',
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.',
deleteSuccess: 'Dependency deleted',
deleteError: 'Failed to delete dependency',
+12
View File
@@ -10,11 +10,23 @@ export default {
// File tree
loading: 'Loading…',
emptyDir: 'Empty directory',
emptyDirMark: 'empty',
// File preview
selectFileHint: '← Select a file on the left to preview',
loadingFile: 'Loading file…',
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',
symbols: 'Outline',
outlineGroupType: 'Types',
outlineGroupFn: 'Functions',
outlineGroupVar: 'Variables',
outlineGroupMod: 'Modules',
outlineGroupOther: 'Others',
// Errors
loadFailed: 'Failed to load',
// Diff
+7
View File
@@ -30,6 +30,13 @@ export default {
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
adversarialTitle: '⚖️ Adversarial Evaluation',
evalModeHeuristic: 'Heuristic',
+2
View File
@@ -35,6 +35,8 @@ export default {
emptyTasks: 'No tasks yet',
// Workflow log panel
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
workflowProgressTitle: '📈 Workflow Progress',
runDemoWorkflow: '▶ Run Demo Workflow',
+5
View File
@@ -64,7 +64,12 @@ export default {
status: {
planning: '📐 Planning',
active: '🚀 In Progress',
in_progress: '🔨 Developing',
testing: '🧪 Testing',
releasing: '🚢 Releasing',
completed: '✅ Completed',
paused: '⏸ Paused',
cancelled: '✖ Cancelled',
},
// Error fallbacks (user-visible via state.error)
err: {

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