Compare commits
18
Commits
3bb662f092
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9453b4b7a1 | ||
|
|
070223baa5 | ||
|
|
d97cbb4439 | ||
|
|
0dd95e0e6e | ||
|
|
a30f599fe2 | ||
|
|
23e505bc40 | ||
|
|
1d9c511b84 | ||
|
|
62776a30f1 | ||
|
|
3caf2d047f | ||
|
|
0317873a5b | ||
|
|
eec5978fa7 | ||
|
|
25ec9cf69a | ||
|
|
302c4bcf52 | ||
|
|
05d0f89c16 | ||
|
|
a273a86a7d | ||
|
|
45f0560f85 | ||
|
|
f74d51b905 | ||
|
|
e08b0f4311 |
Generated
+1
-1
@@ -1039,7 +1039,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "devflow"
|
name = "devflow"
|
||||||
version = "0.1.2"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
* - listSkills(): 拉技能列表(list_skills Command → AiSkillList 事件回流 skills)
|
* - listSkills(): 拉技能列表(list_skills Command → AiSkillList 事件回流 skills)
|
||||||
* - listEntities(): 拉实体列表(list_entities Command → AiEntityList 事件回流 entities)
|
* - listEntities(): 拉实体列表(list_entities Command → AiEntityList 事件回流 entities)
|
||||||
* - approve(toolCallId, approved): 审批工具调用(approve Command → device invoke ai_approve)
|
* - approve(toolCallId, approved): 审批工具调用(approve Command → device invoke ai_approve)
|
||||||
* - authorizeDir(toolCallId, decision): 路径授权(authorize_dir Command → device invoke ai_authorize_dir)
|
|
||||||
* - continueLoop(convId): 继续循环(continue_loop Command → device invoke ai_continue_loop)
|
* - continueLoop(convId): 继续循环(continue_loop Command → device invoke ai_continue_loop)
|
||||||
* - stopLoop(convId): 停止循环(stop_loop Command → device invoke ai_stop_loop)
|
* - stopLoop(convId): 停止循环(stop_loop Command → device invoke ai_stop_loop)
|
||||||
*/
|
*/
|
||||||
@@ -235,7 +234,7 @@ const deviceOnline = ref<boolean>(false)
|
|||||||
* 现机制(130s 持续 watchdog,非首事件清除):
|
* 现机制(130s 持续 watchdog,非首事件清除):
|
||||||
* - send 后 resetWatchdog 启动
|
* - send 后 resetWatchdog 启动
|
||||||
* - 活跃事件(AiTextDelta/AiAgentRound/AiToolCallStarted/Heartbeat/Retry)resetWatchdog 重置 130s
|
* - 活跃事件(AiTextDelta/AiAgentRound/AiToolCallStarted/Heartbeat/Retry)resetWatchdog 重置 130s
|
||||||
* - 终态(AiCompleted/AiError)/挂起(Approval/DirAuth/MaxRounds/Help)/断连 clearWatchdog 清除
|
* - 终态(AiCompleted/AiError)/挂起(Approval/MaxRounds/Help)/断连 clearWatchdog 清除
|
||||||
* - 超时(130s 无活跃事件)= 中途断 → flush + 错误气泡 + reset generating,用户可重发
|
* - 超时(130s 无活跃事件)= 中途断 → flush + 错误气泡 + reset generating,用户可重发
|
||||||
*/
|
*/
|
||||||
let streamTimer: ReturnType<typeof setTimeout> | null = null
|
let streamTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -312,7 +311,7 @@ function resetWatchdog(): void {
|
|||||||
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
|
const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟
|
||||||
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */
|
/** 启动审批超时计时器(AiApprovalRequired 触发)。 */
|
||||||
function startApprovalTimer(tcId: string, toolName: string): void {
|
function startApprovalTimer(tcId: string, toolName: string): void {
|
||||||
if (_approvalTimers.has(tcId)) return // 幂等
|
if (_approvalTimers.has(tcId)) return // 幂等
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -768,37 +767,6 @@ export function handleEvent(event: AiChatEvent): void {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AiDirAuthRequired': {
|
|
||||||
clearWatchdog() // 挂起等授权,停看门狗
|
|
||||||
startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝
|
|
||||||
// F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮
|
|
||||||
// (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。
|
|
||||||
const authInfo: AiToolCallInfo = {
|
|
||||||
id: event.id,
|
|
||||||
name: event.tool,
|
|
||||||
args: {},
|
|
||||||
status: 'pending_approval',
|
|
||||||
kind: 'path',
|
|
||||||
dir: event.dir,
|
|
||||||
path: event.path,
|
|
||||||
}
|
|
||||||
// req3 去重:同 AiApprovalRequired(sync_pending 重发 + 实时事件竞态防重复)
|
|
||||||
if (!pendingApprovals.value.some((p) => p.id === authInfo.id)) {
|
|
||||||
pendingApprovals.value.push(authInfo)
|
|
||||||
}
|
|
||||||
// 对齐 AiApprovalRequired:同步更新消息内工具卡片为 pending_approval + path 授权信息,
|
|
||||||
// 使 chat 页消息内联渲染 once/always/deny 按钮(单一渲染源,避免消息 tc 卡 running 态)。
|
|
||||||
const authTc = findToolCall(event.id)
|
|
||||||
if (authTc) {
|
|
||||||
authTc.status = 'pending_approval'
|
|
||||||
authTc.kind = 'path'
|
|
||||||
authTc.dir = event.dir
|
|
||||||
authTc.path = event.path
|
|
||||||
triggerRef(messages) // P1-5:路径授权态嵌套变更,刷新工具卡
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'AiCompleted': {
|
case 'AiCompleted': {
|
||||||
stopping = false // F9:终态清停止标志
|
stopping = false // F9:终态清停止标志
|
||||||
flushCurrentText()
|
flushCurrentText()
|
||||||
@@ -1158,26 +1126,6 @@ function approve(toolCallId: string, approved: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 路径授权(authorize_dir Command → relay → device invoke ai_authorize_dir)
|
|
||||||
*
|
|
||||||
* Phase3 §2.2 路由表:{cmd:"authorize_dir", args:{tool_call_id, decision}}
|
|
||||||
* 对应事件:AiDirAuthRequired(决策:'once'=本次 / 'always'=永久 / 'deny'=拒绝)。
|
|
||||||
*/
|
|
||||||
function authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): void {
|
|
||||||
if (!toolCallId) return
|
|
||||||
const cmd: MiniCommand = {
|
|
||||||
cmd: 'authorize_dir',
|
|
||||||
args: {
|
|
||||||
tool_call_id: toolCallId,
|
|
||||||
decision,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (!wsClient.send(cmd)) {
|
|
||||||
// 发送失败:wsClient.send 返 false(未连接),提示用户操作未发出
|
|
||||||
uni.showToast({ title: '未连接,操作未发出', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 继续循环(continue_loop Command → relay → device invoke ai_continue_loop)
|
* 继续循环(continue_loop Command → relay → device invoke ai_continue_loop)
|
||||||
*
|
*
|
||||||
@@ -1475,7 +1423,6 @@ export function useAiChat() {
|
|||||||
setActiveModel,
|
setActiveModel,
|
||||||
newConversation,
|
newConversation,
|
||||||
approve,
|
approve,
|
||||||
authorizeDir,
|
|
||||||
continueLoop,
|
continueLoop,
|
||||||
stopLoop,
|
stopLoop,
|
||||||
handleEvent,
|
handleEvent,
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ function handleSend(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
|
// —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) ——
|
||||||
// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进
|
// 后端推 AiToolCallStarted/AiApprovalRequired → useAiChat 收进
|
||||||
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
|
// 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮,
|
||||||
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。
|
// miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。
|
||||||
|
|
||||||
@@ -666,11 +666,6 @@ function onReject(tc: AiToolCallInfo): void {
|
|||||||
ai.approve(tc.id, false)
|
ai.approve(tc.id, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */
|
|
||||||
function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void {
|
|
||||||
ai.authorizeDir(tc.id, decision)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
|
/** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */
|
||||||
const STATUS_TEXT: Record<string, string> = {
|
const STATUS_TEXT: Record<string, string> = {
|
||||||
connecting: '连接中…',
|
connecting: '连接中…',
|
||||||
@@ -782,23 +777,14 @@ function tokenInOf(t: TokenUsage): number {
|
|||||||
<text class="tool-arg-val">{{ a.val }}</text>
|
<text class="tool-arg-val">{{ a.val }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="p.kind === 'path' && p.dir" class="tool-dir">
|
|
||||||
<text>📁 {{ p.dir }}</text>
|
|
||||||
</view>
|
|
||||||
<view v-if="p.reason" class="tool-reason">
|
<view v-if="p.reason" class="tool-reason">
|
||||||
<text>⚠ {{ p.reason }}</text>
|
<text>⚠ {{ p.reason }}</text>
|
||||||
</view>
|
</view>
|
||||||
<!-- risk 类:批准/拒绝 -->
|
<!-- 批准/拒绝 -->
|
||||||
<view v-if="p.kind !== 'path'" class="approval-actions">
|
<view class="approval-actions">
|
||||||
<button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
|
<button class="approval-btn approve" size="mini" @tap="onApprove(p)">批准</button>
|
||||||
<button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
|
<button class="approval-btn reject" size="mini" @tap="onReject(p)">拒绝</button>
|
||||||
</view>
|
</view>
|
||||||
<!-- path 类:本次/永久/拒绝 -->
|
|
||||||
<view v-else class="approval-actions">
|
|
||||||
<button class="approval-btn approve" size="mini" @tap="onAuthorize(p, 'once')">本次</button>
|
|
||||||
<button class="approval-btn always" size="mini" @tap="onAuthorize(p, 'always')">永久</button>
|
|
||||||
<button class="approval-btn reject" size="mini" @tap="onAuthorize(p, 'deny')">拒绝</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -859,10 +845,6 @@ function tokenInOf(t: TokenUsage): number {
|
|||||||
<text class="tool-arg-val">{{ a.val }}</text>
|
<text class="tool-arg-val">{{ a.val }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- path 授权待授权目录 -->
|
|
||||||
<view v-if="tc.kind === 'path' && tc.status === 'pending_approval' && tc.dir" class="tool-dir">
|
|
||||||
<text>📁 {{ tc.dir }}</text>
|
|
||||||
</view>
|
|
||||||
<!-- 风险说明 -->
|
<!-- 风险说明 -->
|
||||||
<view v-if="tc.reason" class="tool-reason">
|
<view v-if="tc.reason" class="tool-reason">
|
||||||
<text>⚠ {{ tc.reason }}</text>
|
<text>⚠ {{ tc.reason }}</text>
|
||||||
@@ -1494,13 +1476,6 @@ function tokenInOf(t: TokenUsage): number {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
word-break: break-all; /* 工具参数值(路径/长字符串)断行防撑开 */
|
word-break: break-all; /* 工具参数值(路径/长字符串)断行防撑开 */
|
||||||
}
|
}
|
||||||
.tool-dir {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
.tool-dir text {
|
|
||||||
color: #6bd99a;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.tool-reason {
|
.tool-reason {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ export type AiChatEvent =
|
|||||||
| { type: 'AiHeartbeat'; conversation_id?: string | null }
|
| { type: 'AiHeartbeat'; conversation_id?: string | null }
|
||||||
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
|
| { type: 'AiMaxRoundsReached'; conversation_id?: string | null }
|
||||||
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
|
| { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null }
|
||||||
| {
|
|
||||||
type: 'AiDirAuthRequired'
|
|
||||||
id: string
|
|
||||||
tool: string
|
|
||||||
path: string
|
|
||||||
dir: string
|
|
||||||
conversation_id?: string | null
|
|
||||||
}
|
|
||||||
| { type: 'AiContextCleared'; conversation_id?: string | null }
|
| { type: 'AiContextCleared'; conversation_id?: string | null }
|
||||||
| { type: 'AiCompressing'; conversation_id?: string | null }
|
| { type: 'AiCompressing'; conversation_id?: string | null }
|
||||||
// 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流,
|
// 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流,
|
||||||
@@ -154,10 +146,6 @@ export interface AiToolCallInfo {
|
|||||||
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
|
status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected'
|
||||||
reason?: string
|
reason?: string
|
||||||
diff?: string
|
diff?: string
|
||||||
/** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */
|
|
||||||
kind?: 'risk' | 'path'
|
|
||||||
dir?: string
|
|
||||||
path?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
|
/** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ export type ControlMessage =
|
|||||||
* - cmd:命令名(对齐 src-tauri command 函数名)
|
* - cmd:命令名(对齐 src-tauri command 函数名)
|
||||||
* - args:命令参数(对齐各 command 的参数)
|
* - args:命令参数(对齐各 command 的参数)
|
||||||
*
|
*
|
||||||
* 路由表全覆盖(14 条):
|
* 路由表全覆盖:
|
||||||
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
|
* - send_message / stop / switch_conversation / regenerate(MVP 已发)
|
||||||
* - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts)
|
* - approve / continue_loop / stop_loop(见 useAiChat.ts;authorize_dir 已随 path_auth 退役)
|
||||||
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
|
* - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取)
|
||||||
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
|
* - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts)
|
||||||
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
|
* - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23)
|
||||||
@@ -207,21 +207,6 @@ export interface ApproveArgs {
|
|||||||
approved: boolean
|
approved: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表)
|
|
||||||
*
|
|
||||||
* decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数
|
|
||||||
* - 'once':本次授权(下次同路径仍需问)
|
|
||||||
* - 'always':永久授权(写入会话级信任目录)
|
|
||||||
* - 'deny':拒绝(本次不授权)
|
|
||||||
*/
|
|
||||||
export interface AuthorizeDirArgs {
|
|
||||||
/** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */
|
|
||||||
tool_call_id: string
|
|
||||||
/** 授权决策('once' / 'always' / 'deny') */
|
|
||||||
decision: 'once' | 'always' | 'deny'
|
|
||||||
}
|
|
||||||
|
|
||||||
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
|
/** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */
|
||||||
export interface ContinueLoopArgs {
|
export interface ContinueLoopArgs {
|
||||||
/** 需继续运行的会话 id */
|
/** 需继续运行的会话 id */
|
||||||
|
|||||||
@@ -488,10 +488,7 @@ pub fn object_schema(properties: Vec<(&str, &str, bool)>) -> Value {
|
|||||||
let mut required = Vec::new();
|
let mut required = Vec::new();
|
||||||
|
|
||||||
for (name, type_str, is_required) in properties {
|
for (name, type_str, is_required) in properties {
|
||||||
props.insert(
|
props.insert(name.to_string(), serde_json::json!({ "type": type_str }));
|
||||||
name.to_string(),
|
|
||||||
serde_json::json!({ "type": type_str, "description": "" }),
|
|
||||||
);
|
|
||||||
if is_required {
|
if is_required {
|
||||||
required.push(name.to_string());
|
required.push(name.to_string());
|
||||||
}
|
}
|
||||||
@@ -579,6 +576,9 @@ mod tests {
|
|||||||
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
|
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
|
||||||
assert_eq!(schema["type"], "object");
|
assert_eq!(schema["type"], "object");
|
||||||
assert_eq!(schema["properties"]["name"]["type"], "string");
|
assert_eq!(schema["properties"]["name"]["type"], "string");
|
||||||
|
// 空属性描述不落盘:序列化输出不含 "description" 键(省 LLM 请求白携带)
|
||||||
|
let serialized = serde_json::to_string(&schema).unwrap();
|
||||||
|
assert!(!serialized.contains("description"));
|
||||||
let required = schema["required"].as_array().unwrap();
|
let required = schema["required"].as_array().unwrap();
|
||||||
assert_eq!(required.len(), 1);
|
assert_eq!(required.len(), 1);
|
||||||
assert_eq!(required[0], "name");
|
assert_eq!(required[0], "name");
|
||||||
|
|||||||
@@ -806,12 +806,21 @@ impl LlmProvider for AnthropicCompatProvider {
|
|||||||
other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"),
|
other => warn!(block_type = other, "Anthropic 响应含未知 content 块类型,已忽略"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 口径归一(G1,P0-A/u-claw 三口径,与 anthropic_helpers message_start 同表):
|
||||||
|
// anthropic input_tokens 只含未缓存输入,不含 cache_read/creation 两段;
|
||||||
|
// 落库 prompt_tokens 对齐 openai/deepseek 全量输入语义(hit/miss ⊆ prompt),
|
||||||
|
// 故 prompt = input + read + creation,total = prompt + output。
|
||||||
|
// cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价。
|
||||||
|
let prompt_tokens = resp
|
||||||
|
.usage
|
||||||
|
.input_tokens
|
||||||
|
.saturating_add(resp.usage.cache_read())
|
||||||
|
.saturating_add(resp.usage.cache_creation_input_tokens);
|
||||||
let usage = TokenUsage {
|
let usage = TokenUsage {
|
||||||
prompt_tokens: resp.usage.input_tokens,
|
prompt_tokens,
|
||||||
completion_tokens: resp.usage.output_tokens,
|
completion_tokens: resp.usage.output_tokens,
|
||||||
total_tokens: resp.usage.input_tokens + resp.usage.output_tokens,
|
total_tokens: prompt_tokens.saturating_add(resp.usage.output_tokens),
|
||||||
// anthropic cache 字段:creation(写入)= miss 全价语义,read(命中)= hit 低价
|
prompt_cache_hit_tokens: resp.usage.cache_read(),
|
||||||
prompt_cache_hit_tokens: resp.usage.cache_read_input_tokens,
|
|
||||||
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
|
prompt_cache_miss_tokens: resp.usage.cache_creation_input_tokens,
|
||||||
reasoning_tokens: 0,
|
reasoning_tokens: 0,
|
||||||
};
|
};
|
||||||
@@ -1016,6 +1025,47 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 辅助:构造带缓存分项的 message_start 事件(cache_read/cache_creation,P0-A 三口径)
|
||||||
|
fn message_start_with_cache(input_tokens: u32, cache_read: u32, cache_creation: u32) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"cache_read_input_tokens":{},"cache_creation_input_tokens":{},"output_tokens":0}}}}}}"#,
|
||||||
|
input_tokens, cache_read, cache_creation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 辅助:构造智谱 anthropic 兼容端点形态的 message_start(隐式缓存命中走嵌套
|
||||||
|
/// prompt_tokens_details.cached_tokens,无顶层 cache 字段——官方缓存文档口径)
|
||||||
|
fn message_start_zhipu_nested(input_tokens: u32, cached_tokens: u32) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"prompt_tokens_details":{{"cached_tokens":{}}},"output_tokens":0}}}}}}"#,
|
||||||
|
input_tokens, cached_tokens
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 智谱嵌套形态回退:顶层 cache 字段缺席时从 prompt_tokens_details.cached_tokens
|
||||||
|
/// 取命中量,prompt=input+cached(隐式缓存无写入分项,miss=0)
|
||||||
|
#[test]
|
||||||
|
fn anthropic_zhipu_nested_cache_tokens_fallback() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
let c = apply_anthropic_event(&message_start_zhipu_nested(500, 4000), &mut acc);
|
||||||
|
let u = c.usage.expect("usage 应随 message_start 带出");
|
||||||
|
assert_eq!(u.prompt_cache_hit_tokens, 4000);
|
||||||
|
assert_eq!(u.prompt_cache_miss_tokens, 0);
|
||||||
|
// 口径归一:prompt = input + cached
|
||||||
|
assert_eq!(u.prompt_tokens, 4500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 顶层优先:官方 Anthropic 端点回顶层字段时嵌套值(即便异常并存)不覆盖顶层
|
||||||
|
#[test]
|
||||||
|
fn anthropic_top_level_cache_read_wins_over_nested() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
let frame = r#"{"type":"message_start","message":{"usage":{"input_tokens":100,"cache_read_input_tokens":200,"prompt_tokens_details":{"cached_tokens":999},"output_tokens":0}}}"#;
|
||||||
|
let c = apply_anthropic_event(frame, &mut acc);
|
||||||
|
let u = c.usage.expect("usage 应随 message_start 带出");
|
||||||
|
assert_eq!(u.prompt_cache_hit_tokens, 200);
|
||||||
|
assert_eq!(u.prompt_tokens, 300);
|
||||||
|
}
|
||||||
|
|
||||||
/// 辅助:构造 message_delta 事件(output_tokens 为累计值)
|
/// 辅助:构造 message_delta 事件(output_tokens 为累计值)
|
||||||
fn message_delta(output_tokens: u32) -> String {
|
fn message_delta(output_tokens: u32) -> String {
|
||||||
format!(
|
format!(
|
||||||
@@ -1135,6 +1185,48 @@ mod tests {
|
|||||||
assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None");
|
assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 三口径映射锁定(G1,P0-A):anthropic input_tokens 不含缓存两段,落库 prompt_tokens
|
||||||
|
/// 对齐 openai/deepseek 全量输入语义(hit/miss ⊆ prompt)→ prompt = input + read +
|
||||||
|
/// creation;message_delta 的 total 重算基于累加器 prompt(已含缓存段)自动继承新口径。
|
||||||
|
#[test]
|
||||||
|
fn anthropic_message_start_cache_three_caliber_prompt_sums_cache_segments() {
|
||||||
|
let mut acc: Option<TokenUsage> = None;
|
||||||
|
let c = apply_anthropic_event(&message_start_with_cache(100, 64, 36), &mut acc);
|
||||||
|
let u0 = c.usage.expect("message_start 应带出初始 usage 快照");
|
||||||
|
assert_eq!(u0.prompt_tokens, 200, "prompt = input(100)+read(64)+creation(36)");
|
||||||
|
assert_eq!(u0.prompt_cache_hit_tokens, 64, "cache_read → hit 原样映射");
|
||||||
|
assert_eq!(u0.prompt_cache_miss_tokens, 36, "cache_creation → miss 原样映射");
|
||||||
|
assert_eq!(u0.total_tokens, 200, "初始 total 同口径");
|
||||||
|
|
||||||
|
// message_delta 累计 output 后 total 重算:继承含缓存段 prompt 基数(200+30)
|
||||||
|
let _ = apply_anthropic_event(&message_delta(30), &mut acc);
|
||||||
|
let a = acc.as_ref().unwrap();
|
||||||
|
assert_eq!(a.prompt_tokens, 200, "delta 不改 prompt");
|
||||||
|
assert_eq!(a.total_tokens, 230, "total = prompt(含缓存两段)+output");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缺失容错(G2,P0-A):端点 200 但 usage 键整体缺失/usage 对象缺计数字段时,
|
||||||
|
/// 不再整帧反序列化失败硬炸(三路走查 #3「有效文本回复整体丢弃」),全零兜底
|
||||||
|
/// 下行走既有估算轨(round_usage_effective prompt==0 → 估算打标),对齐 OpenAI 臂。
|
||||||
|
#[test]
|
||||||
|
fn anthropic_response_missing_or_partial_usage_deserializes_not_fatal() {
|
||||||
|
// usage 键整体缺失
|
||||||
|
let resp: AnthropicResponse = serde_json::from_str(
|
||||||
|
r#"{"id":"msg_1","model":"claude-x","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn"}"#,
|
||||||
|
)
|
||||||
|
.expect("usage 键缺失不应炸帧");
|
||||||
|
assert_eq!(resp.usage.input_tokens, 0, "缺 usage → 全零兜底走估算轨");
|
||||||
|
assert_eq!(resp.usage.output_tokens, 0);
|
||||||
|
|
||||||
|
// usage 对象存在但缺计数字段(部分网关只回 cache 分项)
|
||||||
|
let resp2: AnthropicResponse = serde_json::from_str(
|
||||||
|
r#"{"id":"msg_2","model":"claude-x","content":[],"usage":{"cache_read_input_tokens":7}}"#,
|
||||||
|
)
|
||||||
|
.expect("usage 缺 input/output 字段不应炸帧");
|
||||||
|
assert_eq!(resp2.usage.input_tokens, 0);
|
||||||
|
assert_eq!(resp2.usage.cache_read_input_tokens, 7, "已有分项不丢");
|
||||||
|
}
|
||||||
|
|
||||||
/// content_block_start (tool_use) 带 id+name
|
/// content_block_start (tool_use) 带 id+name
|
||||||
#[test]
|
#[test]
|
||||||
fn anthropic_content_block_start_tool_use() {
|
fn anthropic_content_block_start_tool_use() {
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ pub(crate) struct AnthropicResponse {
|
|||||||
// Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
// Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
stop_reason: Option<String>,
|
stop_reason: Option<String>,
|
||||||
|
/// usage 整段 default(G2):端点不回 usage 键时不再 missing-field 炸帧,全零兜底
|
||||||
|
/// 下行走既有估算轨(round_usage_effective)。字段级容错在 AnthropicUsage 侧,
|
||||||
|
/// 此处兜「usage 键整体缺失」——两层合起来才覆盖「端点不回 usage」全形态。
|
||||||
|
#[serde(default)]
|
||||||
pub usage: AnthropicUsage,
|
pub usage: AnthropicUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +92,15 @@ pub(crate) struct AnthropicContentBlock {
|
|||||||
pub input: Option<serde_json::Value>,
|
pub input: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
pub(crate) struct AnthropicUsage {
|
pub(crate) struct AnthropicUsage {
|
||||||
|
/// 计数字段全部 default 容错(G2,P0-A 口径统一):部分网关 200 但 usage 缺失/缺字段,
|
||||||
|
/// 必填会让整帧反序列化失败 → 有效回复整体丢弃(三路走查 #3/#5)。缺省 0 后全零
|
||||||
|
/// TokenUsage 下行走既有估算兜底轨(round_usage_effective,prompt==0 → 估算打标),
|
||||||
|
/// 与 OpenAI 臂的 serde default 容错(openai_helpers.rs:139-157)对齐。
|
||||||
|
#[serde(default)]
|
||||||
pub input_tokens: u32,
|
pub input_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub output_tokens: u32,
|
pub output_tokens: u32,
|
||||||
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
|
/// Anthropic prompt caching 扩展:cache 写入 token(本次写入缓存,计费如输入但稍便宜)。
|
||||||
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
|
/// 映射到 TokenUsage.prompt_cache_miss_tokens(全价输入语义)。
|
||||||
@@ -102,6 +112,32 @@ pub(crate) struct AnthropicUsage {
|
|||||||
/// 非 cache 场景无此字段 → serde default 0。
|
/// 非 cache 场景无此字段 → serde default 0。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_read_input_tokens: u32,
|
pub cache_read_input_tokens: u32,
|
||||||
|
/// 智谱 anthropic 兼容端点的隐式缓存(自动生效,命中半价)不回顶层 cache 字段,
|
||||||
|
/// 命中量走 OpenAI 风格嵌套 prompt_tokens_details.cached_tokens(智谱官方缓存文档
|
||||||
|
/// 口径)。serde 容错读入,消费侧经 [`Self::cache_read`] 回退取用——缺此层时
|
||||||
|
/// GLM 链路账本/命中率恒 0(实测 54 条观测全零的根因)。
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt_tokens_details: Option<PromptTokensDetails>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OpenAI 风格嵌套的缓存命中分项(智谱 anthropic 兼容端点口径)。
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub(crate) struct PromptTokensDetails {
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnthropicUsage {
|
||||||
|
/// 缓存命中读取量:顶层 cache_read_input_tokens 非零优先(官方 Anthropic 端点),
|
||||||
|
/// 智谱嵌套形态(顶层缺省 0)回退;两处都不存在时 0。
|
||||||
|
pub fn cache_read(&self) -> u32 {
|
||||||
|
if self.cache_read_input_tokens > 0 {
|
||||||
|
return self.cache_read_input_tokens;
|
||||||
|
}
|
||||||
|
self.prompt_tokens_details
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |d| d.cached_tokens)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -155,15 +191,30 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUs
|
|||||||
let cache_read = u_obj
|
let cache_read = u_obj
|
||||||
.and_then(|u| u.get("cache_read_input_tokens"))
|
.and_then(|u| u.get("cache_read_input_tokens"))
|
||||||
.and_then(|t| t.as_u64())
|
.and_then(|t| t.as_u64())
|
||||||
|
// 智谱 anthropic 兼容端点隐式缓存走嵌套 prompt_tokens_details.cached_tokens,
|
||||||
|
// 顶层字段缺席时回退读取(与非流式 AnthropicUsage::cache_read 同表)。
|
||||||
|
.or_else(|| {
|
||||||
|
u_obj
|
||||||
|
.and_then(|u| u.get("prompt_tokens_details"))
|
||||||
|
.and_then(|d| d.get("cached_tokens"))
|
||||||
|
.and_then(|t| t.as_u64())
|
||||||
|
})
|
||||||
.unwrap_or(0) as u32;
|
.unwrap_or(0) as u32;
|
||||||
let cache_creation = u_obj
|
let cache_creation = u_obj
|
||||||
.and_then(|u| u.get("cache_creation_input_tokens"))
|
.and_then(|u| u.get("cache_creation_input_tokens"))
|
||||||
.and_then(|t| t.as_u64())
|
.and_then(|t| t.as_u64())
|
||||||
.unwrap_or(0) as u32;
|
.unwrap_or(0) as u32;
|
||||||
|
// 口径归一(G1,P0-A/u-claw 三口径): anthropic input_tokens 只含未缓存输入,
|
||||||
|
// 不含 cache_read/cache_creation 两段;落库 prompt_tokens 对齐 openai/deepseek
|
||||||
|
// 全量输入语义(prompt=全量输入,hit/miss 为其子集),故 prompt = input + read
|
||||||
|
// + creation。total 同口径(prompt + 当前 output)。
|
||||||
|
let prompt = (inp as u32)
|
||||||
|
.saturating_add(cache_read)
|
||||||
|
.saturating_add(cache_creation);
|
||||||
*usage_accum = Some(TokenUsage {
|
*usage_accum = Some(TokenUsage {
|
||||||
prompt_tokens: inp as u32,
|
prompt_tokens: prompt,
|
||||||
completion_tokens: 0,
|
completion_tokens: 0,
|
||||||
total_tokens: inp as u32,
|
total_tokens: prompt,
|
||||||
prompt_cache_hit_tokens: cache_read,
|
prompt_cache_hit_tokens: cache_read,
|
||||||
prompt_cache_miss_tokens: cache_creation,
|
prompt_cache_miss_tokens: cache_creation,
|
||||||
reasoning_tokens: 0,
|
reasoning_tokens: 0,
|
||||||
|
|||||||
@@ -705,8 +705,16 @@ impl AiConversationRepo {
|
|||||||
///
|
///
|
||||||
/// A2-B9(G3.2 clearChat 裁决):原 `clear_messages` + `delete_range` 两条独立 DB 写非原子,
|
/// A2-B9(G3.2 clearChat 裁决):原 `clear_messages` + `delete_range` 两条独立 DB 写非原子,
|
||||||
/// DB 失败会致 messages JSON 列与 ai_messages 表不一致(如仅一条成功)。本方法一次 transaction
|
/// DB 失败会致 messages JSON 列与 ai_messages 表不一致(如仅一条成功)。本方法一次 transaction
|
||||||
/// 覆盖两条写(① UPDATE ai_conversations 置空消息 + 清零 token;② DELETE ai_messages 该 conv
|
/// 覆盖两条写(① UPDATE ai_conversations 置空消息 + 清零 token + 生命周期标记归位;
|
||||||
/// 全部行),成功全成功 / 失败回滚全失败。供 `ai_chat_clear` 先停 loop 再单事务清空。
|
/// ② DELETE ai_messages 该 conv 全部行),成功全成功 / 失败回滚全失败。供 `ai_chat_clear`
|
||||||
|
/// 先停 loop 再单事务清空。
|
||||||
|
///
|
||||||
|
/// 生命周期标记归位(P1-B 方案甲代史收尾,2026-10-21):硬清空=被清代的终局,对话壳虽
|
||||||
|
/// 保留但属全新一代,旧代终局语义必须随事务归位防泄漏给同壳新任务——loop_state→NULL
|
||||||
|
/// (被清的 parked 会话不得再进 list_parked 可恢复清单)、status→'active'(旧 completed/
|
||||||
|
/// incomplete/error 不再代表新代)、boundary_json→NULL(V45「随会话同亡」:旧任务边界
|
||||||
|
/// 不得约束同壳新任务;内存侧 per_conv.boundary 由调用方同步清,防下轮 save 随行覆写
|
||||||
|
/// 复活 DB 侧刚清的快照)。
|
||||||
///
|
///
|
||||||
/// 对话壳保留(侧栏仍可见,可继续在该对话内聊);返回 Ok(())——调用方只关心成功与否
|
/// 对话壳保留(侧栏仍可见,可继续在该对话内聊);返回 Ok(())——调用方只关心成功与否
|
||||||
/// (对齐 replace_conversation 语义,不返回受影响行数)。
|
/// (对齐 replace_conversation 语义,不返回受影响行数)。
|
||||||
@@ -718,9 +726,9 @@ impl AiConversationRepo {
|
|||||||
let mut guard = conn.blocking_lock();
|
let mut guard = conn.blocking_lock();
|
||||||
let tx = guard.transaction().map_err(storage_err)?;
|
let tx = guard.transaction().map_err(storage_err)?;
|
||||||
{
|
{
|
||||||
// ① ai_conversations.messages 置空 + token 清零(对话壳保留)
|
// ① ai_conversations.messages 置空 + token 清零 + 生命周期标记归位(对话壳保留)
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE ai_conversations SET messages = '[]', prompt_tokens = 0, completion_tokens = 0, updated_at = ?1 WHERE id = ?2",
|
"UPDATE ai_conversations SET messages = '[]', prompt_tokens = 0, completion_tokens = 0, loop_state = NULL, status = 'active', boundary_json = NULL, updated_at = ?1 WHERE id = ?2",
|
||||||
params![now, id],
|
params![now, id],
|
||||||
)
|
)
|
||||||
.map_err(storage_err)?;
|
.map_err(storage_err)?;
|
||||||
@@ -1244,6 +1252,47 @@ mod tests {
|
|||||||
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
|
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// P1-B 方案甲代史收尾契约(2026-10-21):硬清空=被清代的终局,生命周期标记必须随
|
||||||
|
/// 同一事务归位——loop_state→NULL(被清的 parked 会话不得再进可恢复清单)、status→
|
||||||
|
/// 'active'(旧终局态不得泄漏给同壳新任务)、boundary_json→NULL(旧任务边界随代同亡,
|
||||||
|
/// 不得约束同壳新任务)。锁死 clear_conversation_atomic 的收尾语义(调用方 ai_chat_clear)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clear_conversation_atomic_resets_lifecycle_markers() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiConversationRepo::new(&db);
|
||||||
|
// 前置态:带旧边界 + parked + incomplete 的非空会话(清空前的典型停靠残留)
|
||||||
|
repo.insert(AiConversationRecord {
|
||||||
|
id: "c1".into(),
|
||||||
|
title: None,
|
||||||
|
messages: r#"[{"role":"user","content":"旧任务"}]"#.into(),
|
||||||
|
provider_id: None,
|
||||||
|
model: None,
|
||||||
|
models: None,
|
||||||
|
archived: false,
|
||||||
|
pinned: false,
|
||||||
|
prompt_tokens: Some(120),
|
||||||
|
completion_tokens: Some(80),
|
||||||
|
pinned_goals: None,
|
||||||
|
pending_approvals: None,
|
||||||
|
created_at: "0".into(),
|
||||||
|
updated_at: "0".into(),
|
||||||
|
origin: None,
|
||||||
|
boundary_json: Some(r#"{"risk_ceiling":"Medium"}"#.into()),
|
||||||
|
loop_state: Some("parked".into()),
|
||||||
|
status: "incomplete".into(),
|
||||||
|
working_context_json: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
repo.clear_conversation_atomic("c1").await.expect("clear");
|
||||||
|
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||||
|
assert_eq!(got.loop_state, None, "清空后 loop_state 应归位 NULL(停靠残留不得进可恢复清单)");
|
||||||
|
assert_eq!(got.status, "active", "清空后 status 应归位 active(旧代终局不得泄漏)");
|
||||||
|
assert_eq!(got.boundary_json, None, "清空后 boundary_json 应随代同亡");
|
||||||
|
assert_eq!(got.messages, "[]", "消息内容照旧清空");
|
||||||
|
}
|
||||||
|
|
||||||
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
|
/// V53 契约:working_context_json 经 insert → get_by_id → update_full 全程原样存取
|
||||||
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
|
/// (存储层按不透明 TEXT,结构解析归调用方);老库行(缺列插入)读回 None 不炸。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use tokio::sync::Mutex;
|
|||||||
use df_types::error::Result;
|
use df_types::error::Result;
|
||||||
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
|
use crate::models::{IdeaRecord, KnowledgeEdgeRecord, KnowledgeEventRecord, KnowledgeRecord, RelatedKnowledge};
|
||||||
use df_types::types::IdeaStatus;
|
use df_types::types::IdeaStatus;
|
||||||
|
|
||||||
use super::impl_repo;
|
use super::impl_repo;
|
||||||
@@ -90,7 +90,8 @@ fn parse_json_id_array(raw: &Option<String>) -> Vec<String> {
|
|||||||
///
|
///
|
||||||
/// 检索词里的 `%`/`_`/`|` 会被当成通配/转义符,用户输入与分词产物都可能含这些字符,
|
/// 检索词里的 `%`/`_`/`|` 会被当成通配/转义符,用户输入与分词产物都可能含这些字符,
|
||||||
/// 统一在此转义,避免关键词意外扩大命中面。
|
/// 统一在此转义,避免关键词意外扩大命中面。
|
||||||
fn escape_like(s: &str) -> String {
|
/// pub(crate):message_repo 关键词检索(P1-A)同规则复用,单一实现。
|
||||||
|
pub(crate) fn escape_like(s: &str) -> String {
|
||||||
s.replace('|', "||").replace('%', "|%").replace('_', "|_")
|
s.replace('|', "||").replace('%', "|%").replace('_', "|_")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,10 +797,14 @@ impl KnowledgeRepo {
|
|||||||
"status = 'published'"
|
"status = 'published'"
|
||||||
};
|
};
|
||||||
let limit_i = limit as i64;
|
let limit_i = limit as i64;
|
||||||
|
// confidence 同档决胜键(P0 淘汰机制,2026-10-23):title 分打平时置信先验优先,
|
||||||
|
// 与 search_keywords_scoped 同序(后者第一档位键为 project=None 特例不参与)。
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
|
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
|
||||||
WHERE {status_clause} AND ({}) \
|
WHERE {status_clause} AND ({}) \
|
||||||
ORDER BY ({}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}",
|
ORDER BY ({}) DESC, \
|
||||||
|
(CASE confidence WHEN 'high' THEN 2 WHEN 'medium' THEN 1 ELSE 0 END) DESC, \
|
||||||
|
reuse_count DESC, created_at DESC LIMIT ?{}",
|
||||||
where_parts.join(" OR "),
|
where_parts.join(" OR "),
|
||||||
title_score_terms.join(" + "),
|
title_score_terms.join(" + "),
|
||||||
patterns.len() + 1,
|
patterns.len() + 1,
|
||||||
@@ -862,6 +867,8 @@ impl KnowledgeRepo {
|
|||||||
// 项目档位表达式复用 ?(n+1):0=同项目 1=全局(NULL) 2=异项目
|
// 项目档位表达式复用 ?(n+1):0=同项目 1=全局(NULL) 2=异项目
|
||||||
// 档位是**第一排序键**(ASC:0 最前),title 命分是同档内第二键(DESC)——
|
// 档位是**第一排序键**(ASC:0 最前),title 命分是同档内第二键(DESC)——
|
||||||
// 若档位与 title 分相加,档位会被 title 分稀释(异项目 title 高分压过同项目)。
|
// 若档位与 title 分相加,档位会被 title 分稀释(异项目 title 高分压过同项目)。
|
||||||
|
// confidence 是同档内第三键(P0 淘汰机制,2026-10-23):低置信噪音条与高置信
|
||||||
|
// 经验条 title 分打平时,置信先验决胜,减少 low 条目挤占检索带宽。
|
||||||
let pid_n = keywords.len() + 1;
|
let pid_n = keywords.len() + 1;
|
||||||
let project_tier =
|
let project_tier =
|
||||||
format!("CASE WHEN source_project = ?{pid_n} THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END");
|
format!("CASE WHEN source_project = ?{pid_n} THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END");
|
||||||
@@ -875,7 +882,9 @@ impl KnowledgeRepo {
|
|||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
|
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
|
||||||
WHERE {status_clause} AND ({}) \
|
WHERE {status_clause} AND ({}) \
|
||||||
ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}",
|
ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, \
|
||||||
|
(CASE confidence WHEN 'high' THEN 2 WHEN 'medium' THEN 1 ELSE 0 END) DESC, \
|
||||||
|
reuse_count DESC, created_at DESC LIMIT ?{}",
|
||||||
where_parts.join(" OR "),
|
where_parts.join(" OR "),
|
||||||
pid_n + 1,
|
pid_n + 1,
|
||||||
);
|
);
|
||||||
@@ -899,6 +908,39 @@ impl KnowledgeRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按状态+kind 列出(蒸馏合并分组用,2026-10-24):created_at 升序(最老优先——
|
||||||
|
/// 早期无防重时期的重复重灾区),limit 封顶单批蒸馏规模
|
||||||
|
pub async fn list_by_status_kind(
|
||||||
|
&self,
|
||||||
|
status: &str,
|
||||||
|
kind: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<KnowledgeRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let status = status.to_owned();
|
||||||
|
let kind = kind.to_owned();
|
||||||
|
let limit_i = limit as i64;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(&format!(
|
||||||
|
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
|
||||||
|
WHERE status = ?1 AND kind = ?2 ORDER BY created_at ASC LIMIT ?3",
|
||||||
|
))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![status, kind, limit_i], |row| knowledge_from_row(row))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
|
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
|
||||||
///
|
///
|
||||||
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
|
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
|
||||||
@@ -1003,6 +1045,265 @@ impl KnowledgeRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 提炼查重融合更新:覆盖 content + reasoning 追加演进轨迹(P0 写入防重,2026-10-23)
|
||||||
|
///
|
||||||
|
/// 背景(知识库治理审查 2026-10-23):提炼零查重直接落新条,同坑换会话再存一条,
|
||||||
|
/// 语义重复线性累积(10 月 695 条)。此方法是「重复经验并入已有条目」的落库通道:
|
||||||
|
/// LLM 已看过旧 content 后给出的融合版直接覆盖(单一真值=最新版),reasoning 尾部
|
||||||
|
/// 追加一行更新轨迹(旧轨迹保留,演进可溯)。status/复用计数不动——update 不构成
|
||||||
|
/// 复用证据,不参与「复用即信任」晋升。
|
||||||
|
///
|
||||||
|
/// UPDATE 幂等可重复调用;id 不存在返回 false(调用方降级走新建)。
|
||||||
|
pub async fn update_content_merged(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
content: &str,
|
||||||
|
reasoning_append: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let content = content.to_owned();
|
||||||
|
let reasoning_append = reasoning_append.to_owned();
|
||||||
|
let now = now_millis_str();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE knowledges SET content = ?1, updated_at = ?2, \
|
||||||
|
reasoning = CASE WHEN reasoning IS NULL OR reasoning = '' \
|
||||||
|
THEN ?3 ELSE reasoning || char(10) || ?3 END \
|
||||||
|
WHERE id = ?4",
|
||||||
|
params![content, now, reasoning_append, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 淘汰批:零复用 + 非 high 置信 + 超龄条目批量转 archived(P0 淘汰机制,2026-10-23)
|
||||||
|
///
|
||||||
|
/// 背景:71% 条目零复用长尾不是中性的——每次检索都参与 LIKE 匹配和排序竞争,
|
||||||
|
/// 稀释 head 知识召回。检索面是 status 白名单(`IN ('published','candidate')`),
|
||||||
|
/// archived 天然被排除,故本方法只需改 status、无需动任何检索 SQL。
|
||||||
|
///
|
||||||
|
/// 判据校准(2026-10-23 生产实测):LLM 提炼 confidence 系统性偏高
|
||||||
|
/// (high 1416/medium 233/low 仅 1),原 `confidence='low'` 判据命中 0 条形同虚设,
|
||||||
|
/// 校准为「非 high」(low/medium/NULL 同档)——高置信零复用长尾(632 条)不动,
|
||||||
|
/// 需单独拍板。
|
||||||
|
///
|
||||||
|
/// UPDATE...RETURNING 一次拿回归档 id 列表(SQLite 3.35+,bundled 3.45 支持),
|
||||||
|
/// 调用方逐条打时间线留痕。幂等:条件含 status 白名单,已 archived 不再命中。
|
||||||
|
/// 返回归档的 id 列表(0 条=无可淘汰)。
|
||||||
|
pub async fn archive_stale(&self, older_than_days: i64, limit: i64) -> Result<Vec<String>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let cutoff = (now_millis_str().parse::<i64>().unwrap_or(0)) - older_than_days * 86_400_000;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(
|
||||||
|
"UPDATE knowledges SET status = 'archived', updated_at = ?1 \
|
||||||
|
WHERE id IN (SELECT id FROM knowledges \
|
||||||
|
WHERE status IN ('published','candidate') AND reuse_count = 0 \
|
||||||
|
AND (confidence IS NULL OR confidence != 'high') AND created_at < ?2 \
|
||||||
|
ORDER BY created_at LIMIT ?3) \
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let ids = stmt
|
||||||
|
.query_map(params![now_millis_str(), cutoff, limit], |row| row.get::<_, String>(0))
|
||||||
|
.map_err(storage_err)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(ids)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 知识关联边:幂等插入(双向去重)+ 触碰验证时间(知识中枢阶段1,V54)
|
||||||
|
///
|
||||||
|
/// 幂等语义:同两节点任意方向已有边 → 不重复建,只 touch last_verified_at(复验信号)
|
||||||
|
/// 并返回已有边 id。调用方(id 不存在的外键由应用层保证,表无 FK 约束与 knowledges 一致)。
|
||||||
|
pub async fn insert_edge(
|
||||||
|
&self,
|
||||||
|
src_id: &str,
|
||||||
|
dst_id: &str,
|
||||||
|
kind: &str,
|
||||||
|
origin: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let src_id = src_id.to_owned();
|
||||||
|
let dst_id = dst_id.to_owned();
|
||||||
|
let kind = kind.to_owned();
|
||||||
|
let origin = origin.to_owned();
|
||||||
|
let now = now_millis_str();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
// 双向去重(关联语义无方向):同对节点任一方向的既有边即复用
|
||||||
|
let existing: Option<String> = guard
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM knowledge_edges \
|
||||||
|
WHERE (src_id = ?1 AND dst_id = ?2) OR (src_id = ?2 AND dst_id = ?1) \
|
||||||
|
LIMIT 1",
|
||||||
|
params![src_id, dst_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
if let Some(id) = existing {
|
||||||
|
guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE knowledge_edges SET last_verified_at = ?1 WHERE id = ?2",
|
||||||
|
params![now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
return Ok(id);
|
||||||
|
}
|
||||||
|
let id = df_types::types::new_id();
|
||||||
|
guard
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO knowledge_edges (id, src_id, dst_id, kind, weight, origin, created_at, last_verified_at) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4, 0.5, ?5, ?6, ?6)",
|
||||||
|
params![id, src_id, dst_id, kind, origin, now],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关联知识列表(双向读 + 对端条目展开,详情页「关联知识」区块消费)
|
||||||
|
///
|
||||||
|
/// direction: out=边从当前条目指出,in=边指入当前条目。对端 archived 不展示
|
||||||
|
/// (与检索面口径一致:归档知识退出活跃视图)。
|
||||||
|
pub async fn list_related(
|
||||||
|
&self,
|
||||||
|
knowledge_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RelatedKnowledge>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let knowledge_id = knowledge_id.to_owned();
|
||||||
|
let limit_i = limit as i64;
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
// 边表与知识表列名重叠(kind/created_at 等),KNOWLEDGE_COLS 裸名在 JOIN 下歧义,
|
||||||
|
// 统一加 k. 前缀(split-join 保单一列清单来源)
|
||||||
|
let k_cols = KNOWLEDGE_COLS
|
||||||
|
.split(',')
|
||||||
|
.map(|c| format!("k.{}", c.trim()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(&format!(
|
||||||
|
"SELECT {k_cols}, e.id, e.src_id, e.dst_id, e.kind, e.weight, e.origin, e.created_at, e.last_verified_at, \
|
||||||
|
CASE WHEN e.src_id = ?1 THEN 'out' ELSE 'in' END \
|
||||||
|
FROM knowledge_edges e \
|
||||||
|
JOIN knowledges k ON k.id = (CASE WHEN e.src_id = ?1 THEN e.dst_id ELSE e.src_id END) \
|
||||||
|
WHERE (e.src_id = ?1 OR e.dst_id = ?1) AND k.status != 'archived' \
|
||||||
|
ORDER BY e.weight DESC, e.last_verified_at DESC LIMIT ?2"
|
||||||
|
))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut rows = stmt
|
||||||
|
.query_map(params![knowledge_id, limit_i], |row| {
|
||||||
|
let edge = KnowledgeEdgeRecord {
|
||||||
|
id: row.get::<_, String>(KNOWLEDGE_COL_COUNT)?,
|
||||||
|
src_id: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 1)?,
|
||||||
|
dst_id: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 2)?,
|
||||||
|
kind: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 3)?,
|
||||||
|
weight: row.get::<_, f64>(KNOWLEDGE_COL_COUNT + 4)?,
|
||||||
|
origin: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 5)?,
|
||||||
|
created_at: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 6)?,
|
||||||
|
last_verified_at: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 7)?,
|
||||||
|
};
|
||||||
|
Ok(RelatedKnowledge {
|
||||||
|
knowledge: knowledge_from_row(row)?,
|
||||||
|
edge,
|
||||||
|
direction: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 8)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
while let Some(r) = rows.next() {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图召回·激活扩散(多种子 1-hop):命中集种子沿边扩一圈,排除种子自身与 archived
|
||||||
|
/// 对端,按边权重降序限条。knowledge_search 命中后调用,把「连着的知识」带出检索面
|
||||||
|
/// (阶段2 图召回,2026-10-26);单种子场景 = list_related 去种子子集。
|
||||||
|
pub async fn list_neighbors_for_seeds(
|
||||||
|
&self,
|
||||||
|
seed_ids: &[String],
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RelatedKnowledge>> {
|
||||||
|
if seed_ids.is_empty() || limit == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let seeds = seed_ids.to_vec();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
// 种子 id 全参数化(动态 IN 占位,与既有检索同惯例)
|
||||||
|
let seed_set = (1..=seeds.len())
|
||||||
|
.map(|i| format!("?{i}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let k_cols = KNOWLEDGE_COLS
|
||||||
|
.split(',')
|
||||||
|
.map(|c| format!("k.{}", c.trim()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT DISTINCT {k_cols}, e.id, e.src_id, e.dst_id, e.kind, e.weight, e.origin, e.created_at, e.last_verified_at, \
|
||||||
|
CASE WHEN e.src_id IN ({seed_set}) THEN 'out' ELSE 'in' END \
|
||||||
|
FROM knowledge_edges e \
|
||||||
|
JOIN knowledges k ON k.id = (CASE WHEN e.src_id IN ({seed_set}) THEN e.dst_id ELSE e.src_id END) \
|
||||||
|
WHERE (e.src_id IN ({seed_set}) OR e.dst_id IN ({seed_set})) \
|
||||||
|
AND k.id NOT IN ({seed_set}) AND k.status != 'archived' \
|
||||||
|
ORDER BY e.weight DESC, e.last_verified_at DESC LIMIT ?{}",
|
||||||
|
seeds.len() + 1
|
||||||
|
);
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let limit_i = limit as i64;
|
||||||
|
let mut bind: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
seeds.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
|
||||||
|
bind.push(&limit_i);
|
||||||
|
let mut rows = stmt
|
||||||
|
.query_map(bind.as_slice(), |row| {
|
||||||
|
let edge = KnowledgeEdgeRecord {
|
||||||
|
id: row.get::<_, String>(KNOWLEDGE_COL_COUNT)?,
|
||||||
|
src_id: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 1)?,
|
||||||
|
dst_id: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 2)?,
|
||||||
|
kind: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 3)?,
|
||||||
|
weight: row.get::<_, f64>(KNOWLEDGE_COL_COUNT + 4)?,
|
||||||
|
origin: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 5)?,
|
||||||
|
created_at: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 6)?,
|
||||||
|
last_verified_at: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 7)?,
|
||||||
|
};
|
||||||
|
Ok(RelatedKnowledge {
|
||||||
|
knowledge: knowledge_from_row(row)?,
|
||||||
|
edge,
|
||||||
|
direction: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 8)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
while let Some(r) = rows.next() {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 写入向量嵌入(BLOB = Vec<f32> 小端字节序列化)
|
/// 写入向量嵌入(BLOB = Vec<f32> 小端字节序列化)
|
||||||
///
|
///
|
||||||
/// embedding 列不进 KnowledgeRecord(IPC 不需要传向量给前端),专用方法读写。
|
/// embedding 列不进 KnowledgeRecord(IPC 不需要传向量给前端),专用方法读写。
|
||||||
@@ -2442,4 +2743,161 @@ mod tests {
|
|||||||
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
|
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
|
||||||
assert_eq!(after.updated_at, "1800000000000");
|
assert_eq!(after.updated_at, "1800000000000");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- P0 写入防重/淘汰机制(2026-10-23) ----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_content_merged_overrides_and_appends_reasoning() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("k1", "路由模型选择", "旧内容", "pitfall", "published", Some("high"), 2))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// 首次融合更新:content 覆盖 + reasoning 从 None 直接成为追加行
|
||||||
|
assert!(repo
|
||||||
|
.update_content_merged("k1", "融合后的新版内容", "update: 并入会话B同坑经验")
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(rec.content, "融合后的新版内容");
|
||||||
|
assert_eq!(rec.reasoning.as_deref(), Some("update: 并入会话B同坑经验"));
|
||||||
|
// 二次更新:reasoning 尾部追加,旧轨迹保留(演进可溯)
|
||||||
|
assert!(repo
|
||||||
|
.update_content_merged("k1", "二轮融合版", "update: 并入会话C补充")
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(rec.content, "二轮融合版");
|
||||||
|
assert_eq!(
|
||||||
|
rec.reasoning.as_deref(),
|
||||||
|
Some("update: 并入会话B同坑经验\nupdate: 并入会话C补充")
|
||||||
|
);
|
||||||
|
// id 不存在 → false(调用方降级新建)
|
||||||
|
assert!(!repo.update_content_merged("nope", "x", "y").await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_neighbors_for_seeds_expands_one_hop_excluding_seeds_and_archived() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
for (id, status) in [
|
||||||
|
("s1", "published"),
|
||||||
|
("s2", "published"),
|
||||||
|
("n1", "published"),
|
||||||
|
("n2", "published"),
|
||||||
|
("arch", "archived"),
|
||||||
|
] {
|
||||||
|
repo.insert(krec(id, &format!("标题{id}"), "内容", "pitfall", status, Some("high"), 0)).await.unwrap();
|
||||||
|
}
|
||||||
|
// s1-n1 / s2-n2 正常展开;s1-arch 对端 archived 排除
|
||||||
|
repo.insert_edge("s1", "n1", "similar", "llm-tag").await.unwrap();
|
||||||
|
repo.insert_edge("s2", "n2", "similar", "manual").await.unwrap();
|
||||||
|
repo.insert_edge("s1", "arch", "similar", "manual").await.unwrap();
|
||||||
|
let nb = repo
|
||||||
|
.list_neighbors_for_seeds(&["s1".into(), "s2".into()], 10)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut ids: Vec<&str> = nb.iter().map(|r| r.knowledge.id.as_str()).collect();
|
||||||
|
ids.sort();
|
||||||
|
assert_eq!(ids, vec!["n1", "n2"], "1-hop 展开:排除种子与 archived 对端");
|
||||||
|
let one = repo.list_neighbors_for_seeds(&["s1".into()], 10).await.unwrap();
|
||||||
|
assert_eq!(one.len(), 1, "单种子 s1 仅 n1(archived 对端排除)");
|
||||||
|
assert!(repo.list_neighbors_for_seeds(&[], 5).await.unwrap().is_empty(), "空种子返空");
|
||||||
|
let lim = repo
|
||||||
|
.list_neighbors_for_seeds(&["s1".into(), "s2".into()], 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(lim.len(), 1, "limit 封顶");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn archive_stale_moves_only_non_high_unused_aged() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
// a 低置信零复用超龄 → 淘汰;b 有复用/c 高置信 → 保留;
|
||||||
|
// d candidate 也淘汰;e 已 archived 幂等不重复命中;
|
||||||
|
// f medium/n 无置信 → 淘汰(2026-10-23 判据校准:low 实测仅 1/1688 形同虚设,非 high 同档)
|
||||||
|
repo.insert(krec("a", "过期低值1", "x", "pitfall", "published", Some("low"), 0)).await.unwrap();
|
||||||
|
repo.insert(krec("b", "过期低值但复用过", "x", "pitfall", "published", Some("low"), 3)).await.unwrap();
|
||||||
|
repo.insert(krec("c", "过期高置信", "x", "pitfall", "published", Some("high"), 0)).await.unwrap();
|
||||||
|
repo.insert(krec("d", "候选期低值", "x", "pitfall", "candidate", Some("low"), 0)).await.unwrap();
|
||||||
|
repo.insert(krec("e", "已归档", "x", "pitfall", "archived", Some("low"), 0)).await.unwrap();
|
||||||
|
repo.insert(krec("f", "过期中置信", "x", "pitfall", "published", Some("medium"), 0)).await.unwrap();
|
||||||
|
let mut nrec = krec("n", "过期无置信", "x", "pitfall", "published", None, 0);
|
||||||
|
nrec.confidence = None;
|
||||||
|
repo.insert(nrec).await.unwrap();
|
||||||
|
// krec created_at=1700000000000(2023-11),older_than_days=1 → 全部超龄,仅三条件筛
|
||||||
|
let mut ids = repo.archive_stale(1, 100).await.unwrap();
|
||||||
|
ids.sort();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["a".to_string(), "d".to_string(), "f".to_string(), "n".to_string()],
|
||||||
|
"非high置信+零复用+超龄(published/candidate)淘汰"
|
||||||
|
);
|
||||||
|
for (id, want) in [("a", "archived"), ("b", "published"), ("c", "published"), ("d", "archived"), ("e", "archived"), ("f", "archived"), ("n", "archived")] {
|
||||||
|
assert_eq!(repo.get_by_id(id).await.unwrap().unwrap().status, want, "条目 {id} 终态");
|
||||||
|
}
|
||||||
|
// limit 生效:再插 3 条同类,limit=2 只归档 2 条
|
||||||
|
for i in 0..3 {
|
||||||
|
repo.insert(krec(&format!("g{i}"), "批量", "x", "pitfall", "published", Some("low"), 0)).await.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(repo.archive_stale(1, 2).await.unwrap().len(), 2);
|
||||||
|
// 幂等复跑:剩余 1 条
|
||||||
|
assert_eq!(repo.archive_stale(1, 100).await.unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_tiebreaks_by_confidence() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
// title 分打平(都含关键词一次)、reuse 打平(0) → confidence high 排 low 前
|
||||||
|
repo.insert(krec("low-1", "签名配置", "低置信噪音", "pitfall", "published", Some("low"), 0)).await.unwrap();
|
||||||
|
repo.insert(krec("high-1", "签名配置经验", "高置信经验", "pitfall", "published", Some("high"), 0)).await.unwrap();
|
||||||
|
let hits = repo
|
||||||
|
.search_keywords(&["签名".to_string()], true, 10)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let ids: Vec<&str> = hits.iter().map(|r| r.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["high-1", "low-1"], "title/reuse 打平时 confidence 先验决胜");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 知识关联边(阶段1 连边,V54) ----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_edge_dedups_bidirectionally_and_lists_related() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
for (id, status) in [("k1", "published"), ("k2", "published"), ("k3", "published"), ("k4", "archived")] {
|
||||||
|
repo.insert(krec(id, &format!("标题{id}"), "内容", "pitfall", status, Some("high"), 0)).await.unwrap();
|
||||||
|
}
|
||||||
|
// 首建 + 反向重复:双向去重返回同一边 id
|
||||||
|
let e1 = repo.insert_edge("k1", "k2", "similar", "llm-tag").await.unwrap();
|
||||||
|
let e1_rev = repo.insert_edge("k2", "k1", "similar", "llm-tag").await.unwrap();
|
||||||
|
assert_eq!(e1, e1_rev, "同对节点反向建边应复用既有边");
|
||||||
|
// k3 → k4:对端 archived,list_related 不展示
|
||||||
|
repo.insert_edge("k1", "k3", "similar", "manual").await.unwrap();
|
||||||
|
repo.insert_edge("k1", "k4", "similar", "manual").await.unwrap();
|
||||||
|
let rel = repo.list_related("k1", 10).await.unwrap();
|
||||||
|
let mut peer_ids: Vec<&str> = rel.iter().map(|r| r.knowledge.id.as_str()).collect();
|
||||||
|
peer_ids.sort();
|
||||||
|
assert_eq!(peer_ids, vec!["k2", "k3"], "archived 对端不展示(k4)");
|
||||||
|
// 方向标注(k1 视角):e1 src=k1→dst=k2 对端 k2 是 out;k1→k3 对端 k3 也是 out;
|
||||||
|
// 反向边视角:另查 k2,其对端 k1 是 in(边指进来)
|
||||||
|
let dir_of_k2 = repo.list_related("k2", 10).await.unwrap();
|
||||||
|
assert_eq!(dir_of_k2.iter().find(|r| r.knowledge.id == "k1").unwrap().direction, "in");
|
||||||
|
let dir_of = |id: &str| rel.iter().find(|r| r.knowledge.id == id).unwrap().direction.clone();
|
||||||
|
assert_eq!(dir_of("k3"), "out");
|
||||||
|
assert_eq!(dir_of("k2"), "out");
|
||||||
|
// 无关联条目 → 空表
|
||||||
|
assert!(repo.list_related("nobody", 10).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_by_status_kind_filters_and_orders_oldest_first() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("a", "老pitfall", "x", "pitfall", "archived", Some("low"), 0)).await.unwrap();
|
||||||
|
repo.insert(krec("b", "新pitfall", "x", "pitfall", "archived", Some("low"), 0)).await.unwrap();
|
||||||
|
repo.insert(krec("c", "未归档pitfall", "x", "pitfall", "published", Some("low"), 0)).await.unwrap();
|
||||||
|
repo.insert(krec("d", "其他kind", "x", "diagnosis", "archived", Some("low"), 0)).await.unwrap();
|
||||||
|
let hits = repo.list_by_status_kind("archived", "pitfall", 10).await.unwrap();
|
||||||
|
let ids: Vec<&str> = hits.iter().map(|r| r.id.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["a", "b"], "仅命中 status+kind,created_at 升序(最老优先)");
|
||||||
|
// limit 封顶
|
||||||
|
assert_eq!(repo.list_by_status_kind("archived", "pitfall", 1).await.unwrap().len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
//! P0 阶段(本文件):仅建 Repo + CRUD 方法,不接 ContextManager 读写路径
|
//! P0 阶段(本文件):仅建 Repo + CRUD 方法,不接 ContextManager 读写路径
|
||||||
//! (切读策略待用户决策,P2)。ContextManager 仍走旧 messages JSON 列双写期未启动。
|
//! (切读策略待用户决策,P2)。ContextManager 仍走旧 messages JSON 列双写期未启动。
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use rusqlite::{params, Connection, Row};
|
use rusqlite::{params, Connection, Row};
|
||||||
@@ -20,6 +21,7 @@ use df_types::error::Result;
|
|||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::models::AiMessageRecord;
|
use crate::models::AiMessageRecord;
|
||||||
|
|
||||||
|
use super::idea_repo::escape_like;
|
||||||
use super::storage_err;
|
use super::storage_err;
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -51,6 +53,72 @@ fn ai_message_from_row(row: &Row<'_>) -> std::result::Result<AiMessageRecord, ru
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 跨会话关键词检索(P1-A,对标 u-claw #5/#6)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// LIKE 检索不做通配模糊,关键词为分词产物(调用方 src-tauri 层 extract_query_keywords
|
||||||
|
/// 分好传入,本层不做分词——df-storage 在依赖下层,反向依赖 src-tauri 会成环)。
|
||||||
|
///
|
||||||
|
/// **FTS5 升级触发条件(2026-10-21 拍板留档)**:ai_messages 行数 > 50 万,或 role 过滤后
|
||||||
|
/// LIKE 检索 P95 实测 > 500ms 时,再评估升级 FTS5 虚表(V54+ 建虚表 + 一次性回填,
|
||||||
|
/// 检索接口签名不变)。当前生产库实测(32,349 行 / content 39.5MB):role 过滤后单关键词
|
||||||
|
/// LIKE 113ms、双关键词 127ms,桌面场景百毫秒级无体感;且中文 unicode61 按字切分召回
|
||||||
|
/// 质量反不如子串 LIKE,仓内 idea_repo/conversation_repo 两处既有「不上 FTS5」决策先例。
|
||||||
|
|
||||||
|
/// 关键词检索单条命中。
|
||||||
|
///
|
||||||
|
/// `snippet` 为命中片段:content 中首个关键词命中点前后各约 200 字,整体截 500 字
|
||||||
|
/// (截断处以 … 标记),避免全文回灌撑爆调用方上下文。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MessageSearchHit {
|
||||||
|
pub message_id: String,
|
||||||
|
pub conversation_id: String,
|
||||||
|
pub seq: i64,
|
||||||
|
pub role: String,
|
||||||
|
pub timestamp: Option<i64>,
|
||||||
|
pub snippet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话摘要降级链数据源(无参列会话时逐会话取用)。
|
||||||
|
///
|
||||||
|
/// `last_system` = 该会话最近一条 role=system 消息(压缩摘要/关键词锚点的载体,
|
||||||
|
/// 四段式「## 意图/## 决策…」);`first_user` = 首条 user 消息。调用方按
|
||||||
|
/// 「最近 system 摘要 > 首条 user」降级链取会话概要,两级皆空(空会话)则无摘要。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConversationSummarySource {
|
||||||
|
pub last_system: Option<String>,
|
||||||
|
pub first_user: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 命中片段窗口:命中点前保留上下文与片段总长(按字符计,中文一字一符)。
|
||||||
|
const SNIPPET_CONTEXT_CHARS: usize = 200;
|
||||||
|
const SNIPPET_MAX_CHARS: usize = 500;
|
||||||
|
|
||||||
|
/// 从 content 提取命中片段:首个关键词命中点前后各约 200 字,整体截 500 字。
|
||||||
|
///
|
||||||
|
/// 大小写不敏感定位(与 SQLite LIKE 的 ASCII 大小写不敏感对齐);分词产物在 LIKE 层
|
||||||
|
/// 已命中,理论必有命中点,兜底未找到时退回首部截断。全程按字符迭代操作,无字节
|
||||||
|
/// 索引切边界的乱码风险。
|
||||||
|
fn search_snippet(content: &str, keywords: &[String]) -> String {
|
||||||
|
let ellipsis = |s: &str, head_cut: bool, tail_cut: bool| {
|
||||||
|
format!("{}{}{}", if head_cut { "…" } else { "" }, s, if tail_cut { "…" } else { "" })
|
||||||
|
};
|
||||||
|
let lower = content.to_lowercase();
|
||||||
|
let hit_char = keywords.iter().find_map(|kw| lower.find(&kw.to_lowercase())).map(|byte| {
|
||||||
|
// 命中字节位 → 字符位(to_lowercase 极端 Unicode 下字符数可能漂移,
|
||||||
|
// 但仅影响窗口中心偏移,chars().skip 的迭代本身无越界风险)
|
||||||
|
lower[..byte].chars().count()
|
||||||
|
});
|
||||||
|
let Some(hit_char) = hit_char else {
|
||||||
|
let head: String = content.chars().take(SNIPPET_MAX_CHARS).collect();
|
||||||
|
return ellipsis(&head, false, content.chars().count() > SNIPPET_MAX_CHARS);
|
||||||
|
};
|
||||||
|
let start = hit_char.saturating_sub(SNIPPET_CONTEXT_CHARS);
|
||||||
|
let body: String = content.chars().skip(start).take(SNIPPET_MAX_CHARS).collect();
|
||||||
|
ellipsis(&body, start > 0, content.chars().count() > start + SNIPPET_MAX_CHARS)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AiMessageRepo
|
// AiMessageRepo
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -265,6 +333,12 @@ impl AiMessageRepo {
|
|||||||
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
|
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
|
||||||
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
|
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
|
||||||
///
|
///
|
||||||
|
/// created_at 保出生时间(2026-10-17 修复批量污染):created_at 语义 = 消息首次入库时间,
|
||||||
|
/// 全量重写只该变内容不变出生时间。调用方(save_conversation)对整批 records 统一盖
|
||||||
|
/// 对话级 created_at(老对话 None 时 now 兜底),直插会把全对话消息 created_at 抹成同一秒。
|
||||||
|
/// 故 DELETE 前先取存量 `id → created_at` 映射,重插时同 id 行回填原值;新 id(真新消息)
|
||||||
|
/// 保留入参值。ai_messages 表无 updated_at 列,内容变更无单独时间戳(不在此列语义内)。
|
||||||
|
///
|
||||||
/// 设计权衡(非 dirty 增量,留 P2.1 优化):
|
/// 设计权衡(非 dirty 增量,留 P2.1 优化):
|
||||||
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
|
/// - 内存 ContextManager 是运行时真相源,save 是同步点,每轮 save 全量回写简单可靠;
|
||||||
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
|
/// - INSERT OR IGNORE 幂等:records 内 id 重复或与残留行(理论不应有,事务已 DELETE)冲突跳过;
|
||||||
@@ -274,7 +348,7 @@ impl AiMessageRepo {
|
|||||||
pub async fn replace_conversation(
|
pub async fn replace_conversation(
|
||||||
&self,
|
&self,
|
||||||
conv_id: &str,
|
conv_id: &str,
|
||||||
records: Vec<AiMessageRecord>,
|
mut records: Vec<AiMessageRecord>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
let conv_id = conv_id.to_owned();
|
let conv_id = conv_id.to_owned();
|
||||||
@@ -282,7 +356,29 @@ impl AiMessageRepo {
|
|||||||
let mut guard = conn.blocking_lock();
|
let mut guard = conn.blocking_lock();
|
||||||
let tx = guard.transaction().map_err(storage_err)?;
|
let tx = guard.transaction().map_err(storage_err)?;
|
||||||
{
|
{
|
||||||
// 先删该 conv 全部旧行(全量重写语义)
|
// 先取存量 id → created_at 映射(DELETE 前留证,重插回填保出生时间)
|
||||||
|
{
|
||||||
|
let mut stmt = tx
|
||||||
|
.prepare("SELECT id, created_at FROM ai_messages WHERE conversation_id = ?1")
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![conv_id], |r| {
|
||||||
|
Ok((r.get::<_, String>("id")?, r.get::<_, String>("created_at")?))
|
||||||
|
})
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut original: std::collections::HashMap<String, String> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for r in rows {
|
||||||
|
let (id, created_at) = r.map_err(storage_err)?;
|
||||||
|
original.insert(id, created_at);
|
||||||
|
}
|
||||||
|
for rec in &mut records {
|
||||||
|
if let Some(orig) = original.get(&rec.id) {
|
||||||
|
rec.created_at = orig.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 再删该 conv 全部旧行(全量重写语义)
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"DELETE FROM ai_messages WHERE conversation_id = ?1",
|
"DELETE FROM ai_messages WHERE conversation_id = ?1",
|
||||||
params![conv_id],
|
params![conv_id],
|
||||||
@@ -347,6 +443,156 @@ impl AiMessageRepo {
|
|||||||
.await
|
.await
|
||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跨会话关键词检索(P1-A):LIKE OR 逐词命中任一即入候选,timestamp DESC,limit/offset 分页。
|
||||||
|
///
|
||||||
|
/// - 分词在调用方(src-tauri 层 extract_query_keywords)完成,本层只收关键词数组,
|
||||||
|
/// 不反向依赖 src-tauri(依赖方向铁律,对齐 KnowledgeRepo.search_keywords_scoped 先例);
|
||||||
|
/// - role 恒过滤 user+assistant+system(拍板④:tool 结果占内容量 86% 且噪音大,默认排除);
|
||||||
|
/// - `conversation_id` 传入时限定单会话(两跳式深查第二跳);
|
||||||
|
/// - LIKE 转义与 `ESCAPE '|'` 复用 [`super::idea_repo::escape_like`] 单一实现,
|
||||||
|
/// 关键词含 %/_/| 时按字面匹配;
|
||||||
|
/// - 返回带命中片段(500 字截断,见 [`search_snippet`]),全文不出存储层;
|
||||||
|
/// - status 不过滤:compressed 消息原文完整保留(ai_messages = 全量账本),照常可检索。
|
||||||
|
///
|
||||||
|
/// FTS5 升级触发条件见模块顶部注释(留档,>50 万行或 P95>500ms 再评估)。
|
||||||
|
pub async fn search_keywords(
|
||||||
|
&self,
|
||||||
|
keywords: &[String],
|
||||||
|
conversation_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Result<Vec<MessageSearchHit>> {
|
||||||
|
if keywords.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let conv_id = conversation_id.map(|s| s.to_owned());
|
||||||
|
// 每个关键词占一个编号占位符;conversation_id 占 ?(n+1),LIMIT/OFFSET 依次后推。
|
||||||
|
// 动态 WHERE 拼接模式对齐 AiToolExecutionRepo::list_by_query 先例。
|
||||||
|
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
|
||||||
|
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
|
||||||
|
for (i, kw) in keywords.iter().enumerate() {
|
||||||
|
let n = i + 1;
|
||||||
|
patterns.push(format!("%{}%", escape_like(kw)));
|
||||||
|
where_parts.push(format!("content LIKE ?{n} ESCAPE '|'"));
|
||||||
|
}
|
||||||
|
let mut extra: Vec<String> = Vec::new();
|
||||||
|
if let Some(c) = &conv_id {
|
||||||
|
extra.push(c.clone());
|
||||||
|
}
|
||||||
|
// 编号必须连续(SQLite 参数计数取最大 ?N,跳号会产生未绑定空洞报错):
|
||||||
|
// 无 conv 条件时 LIMIT/OFFSET 紧跟关键词占位符之后。
|
||||||
|
let conv_n = patterns.len() + 1;
|
||||||
|
let (limit_n, offset_n) = if conv_id.is_some() {
|
||||||
|
(conv_n + 1, conv_n + 2)
|
||||||
|
} else {
|
||||||
|
(conv_n, conv_n + 1)
|
||||||
|
};
|
||||||
|
let limit_i = limit as i64;
|
||||||
|
let offset_i = offset as i64;
|
||||||
|
let conv_clause = if conv_id.is_some() {
|
||||||
|
format!(" AND conversation_id = ?{conv_n}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id, conversation_id, seq, role, timestamp, content FROM ai_messages \
|
||||||
|
WHERE role IN ('user','assistant','system'){conv_clause} AND ({}) \
|
||||||
|
ORDER BY timestamp DESC LIMIT ?{limit_n} OFFSET ?{offset_n}",
|
||||||
|
where_parts.join(" OR "),
|
||||||
|
);
|
||||||
|
let keywords_owned: Vec<String> = keywords.to_vec();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
// 参数序列:关键词 pattern × n [+ conv_id] + limit + offset
|
||||||
|
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
|
||||||
|
for e in &extra {
|
||||||
|
param_refs.push(e);
|
||||||
|
}
|
||||||
|
param_refs.push(&limit_i);
|
||||||
|
param_refs.push(&offset_i);
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
|
let content: String = row.get("content")?;
|
||||||
|
Ok(MessageSearchHit {
|
||||||
|
message_id: row.get("id")?,
|
||||||
|
conversation_id: row.get("conversation_id")?,
|
||||||
|
seq: row.get("seq")?,
|
||||||
|
role: row.get("role")?,
|
||||||
|
timestamp: row.get("timestamp")?,
|
||||||
|
snippet: search_snippet(&content, &keywords_owned),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for r in rows {
|
||||||
|
results.push(r.map_err(storage_err)?);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话摘要降级链数据源(无参列最近会话用):一批会话各取「最近一条 system 消息」与
|
||||||
|
/// 「首条 user 消息」content,逐会话单行返回(无消息的会话无行,调用方按无摘要兜底)。
|
||||||
|
///
|
||||||
|
/// 关联子查询走 idx_ai_messages_conv(conversation_id, seq) 索引,批量一次查询替代
|
||||||
|
/// 逐会话 list_by_conversation 全量物化(276 条/会话均值,10 会话省 ~340KB 物化)。
|
||||||
|
pub async fn summary_sources(
|
||||||
|
&self,
|
||||||
|
conversation_ids: &[String],
|
||||||
|
) -> Result<HashMap<String, ConversationSummarySource>> {
|
||||||
|
if conversation_ids.is_empty() {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
// IN 占位符逐个编号(?1..?n)
|
||||||
|
let placeholders: Vec<String> =
|
||||||
|
(1..=conversation_ids.len()).map(|n| format!("?{n}")).collect();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT m.conversation_id, \
|
||||||
|
(SELECT s.content FROM ai_messages s \
|
||||||
|
WHERE s.conversation_id = m.conversation_id AND s.role = 'system' \
|
||||||
|
ORDER BY s.seq DESC LIMIT 1) AS last_system, \
|
||||||
|
(SELECT u.content FROM ai_messages u \
|
||||||
|
WHERE u.conversation_id = m.conversation_id AND u.role = 'user' \
|
||||||
|
ORDER BY u.seq ASC LIMIT 1) AS first_user \
|
||||||
|
FROM ai_messages m \
|
||||||
|
WHERE m.conversation_id IN ({}) \
|
||||||
|
GROUP BY m.conversation_id",
|
||||||
|
placeholders.join(","),
|
||||||
|
);
|
||||||
|
let ids: Vec<String> = conversation_ids.to_vec();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
ids.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>("conversation_id")?,
|
||||||
|
ConversationSummarySource {
|
||||||
|
last_system: row.get("last_system")?,
|
||||||
|
first_user: row.get("first_user")?,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
for r in rows {
|
||||||
|
let (id, source) = r.map_err(storage_err)?;
|
||||||
|
out.insert(id, source);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -693,6 +939,62 @@ mod tests {
|
|||||||
assert_eq!(got.len(), 1, "重复 replace 不应叠加");
|
assert_eq!(got.len(), 1, "重复 replace 不应叠加");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// replace_conversation 保 created_at 出生时间(2026-10-17 批量污染回归):
|
||||||
|
/// 调用方对整批统一盖一个 created_at(如对话级时间或 now),重写后既有 id 行
|
||||||
|
/// 的 created_at 必须逐行保留原入库值,不得被统一时间抹平;新 id 行保留入参值。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn replace_conversation_preserves_created_at() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
|
||||||
|
// 预置两行,created_at 各异(模拟真实逐条入库的出生时间)
|
||||||
|
let mut m0 = mk_msg("m0", "c", 0, "user", "v1");
|
||||||
|
m0.created_at = "2026-10-01T10:00:00.000".into();
|
||||||
|
let mut m1 = mk_msg("m1", "c", 1, "assistant", "v1");
|
||||||
|
m1.created_at = "2026-10-02T11:30:00.000".into();
|
||||||
|
repo.insert_batch(vec![m0, m1]).await.expect("insert");
|
||||||
|
|
||||||
|
// 重写:既有 id 内容变了、created_at 被调用方统一盖成同一时间;
|
||||||
|
// 另带一条新 id 消息(真新消息,该保留入参时间)。
|
||||||
|
let uniform_ts = "2026-10-17T21:25:01.000";
|
||||||
|
let rewrite = |id: &str, seq: i64, role: &str, content: &str| {
|
||||||
|
let mut m = mk_msg(id, "c", seq, role, content);
|
||||||
|
m.created_at = uniform_ts.into();
|
||||||
|
m
|
||||||
|
};
|
||||||
|
repo.replace_conversation(
|
||||||
|
"c",
|
||||||
|
vec![
|
||||||
|
rewrite("m0", 0, "user", "v2-改写内容"),
|
||||||
|
rewrite("m1", 1, "assistant", "v2-改写内容"),
|
||||||
|
rewrite("m2_new", 2, "user", "新消息"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("replace");
|
||||||
|
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got.len(), 3);
|
||||||
|
// 逐行断言:既有 id 的 created_at 保持原值,内容确实变了
|
||||||
|
assert_eq!(got[0].id, "m0");
|
||||||
|
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "既有行 created_at 不得被重写抹平");
|
||||||
|
assert_eq!(got[0].content, "v2-改写内容");
|
||||||
|
assert_eq!(got[1].id, "m1");
|
||||||
|
assert_eq!(got[1].created_at, "2026-10-02T11:30:00.000", "既有行 created_at 不得被重写抹平");
|
||||||
|
assert_eq!(got[1].content, "v2-改写内容");
|
||||||
|
// 新 id(首次入库)保留入参 created_at
|
||||||
|
assert_eq!(got[2].id, "m2_new");
|
||||||
|
assert_eq!(got[2].created_at, uniform_ts, "新消息保留入参 created_at");
|
||||||
|
|
||||||
|
// 二次重写再验:出生时间已固化,连续重写不漂移
|
||||||
|
repo.replace_conversation("c", vec![rewrite("m0", 0, "user", "v3")])
|
||||||
|
.await
|
||||||
|
.expect("replace again");
|
||||||
|
let got = repo.list_by_conversation("c").await.expect("list");
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].created_at, "2026-10-01T10:00:00.000", "连续重写 created_at 不漂移");
|
||||||
|
}
|
||||||
|
|
||||||
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
|
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
|
||||||
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
|
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -717,4 +1019,137 @@ mod tests {
|
|||||||
assert_eq!(got3.len(), 1);
|
assert_eq!(got3.len(), 1);
|
||||||
assert_eq!(got3[0].seq, 1, "limit=1 应取 seq 最大(最新)一条");
|
assert_eq!(got3[0].seq, 1, "limit=1 应取 seq 最大(最新)一条");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- search_keywords(P1-A 跨会话检索) ----------
|
||||||
|
|
||||||
|
/// role 过滤(tool 默认排除)+ timestamp DESC 排序 + conversation_id 限定深查。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_filters_role_and_orders_desc() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
let mut u0 = mk_msg("u0", "conv_a", 0, "user", "怎么部署服务");
|
||||||
|
u0.timestamp = Some(1000);
|
||||||
|
let mut a0 = mk_msg("a0", "conv_a", 1, "assistant", "部署 分三步走");
|
||||||
|
a0.timestamp = Some(2000);
|
||||||
|
let mut hit_tool = mk_msg("t0", "conv_a", 2, "tool", "部署流程的 tool 结果");
|
||||||
|
hit_tool.timestamp = Some(3000);
|
||||||
|
let mut u1 = mk_msg("u1", "conv_b", 0, "user", "旧会话也提过部署");
|
||||||
|
u1.timestamp = Some(500);
|
||||||
|
repo.insert_batch(vec![u0, a0, hit_tool, u1])
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
// tool role 默认排除:命中 3 条(两 conv 的 user/assistant),tool 结果不入检索面
|
||||||
|
let hits = repo
|
||||||
|
.search_keywords(&["部署".to_string()], None, 20, 0)
|
||||||
|
.await
|
||||||
|
.expect("search");
|
||||||
|
assert_eq!(hits.len(), 3, "tool role 应被排除: {:?}", hits.iter().map(|h| &h.role).collect::<Vec<_>>());
|
||||||
|
// timestamp DESC:conv_a(3000 之外的 2000/1000 应在 conv_b 100 之前)
|
||||||
|
assert_eq!(hits[0].message_id, "a0");
|
||||||
|
assert_eq!(hits[1].message_id, "u0");
|
||||||
|
|
||||||
|
// conversation_id 限定:只回该会话命中(两跳式深查第二跳)
|
||||||
|
let scoped = repo
|
||||||
|
.search_keywords(&["部署".to_string()], Some("conv_b"), 20, 0)
|
||||||
|
.await
|
||||||
|
.expect("search scoped");
|
||||||
|
assert_eq!(scoped.len(), 1);
|
||||||
|
assert_eq!(scoped[0].conversation_id, "conv_b");
|
||||||
|
|
||||||
|
// offset 分页:跳过第一条
|
||||||
|
let paged = repo
|
||||||
|
.search_keywords(&["部署".to_string()], None, 20, 1)
|
||||||
|
.await
|
||||||
|
.expect("search paged");
|
||||||
|
assert_eq!(paged.len(), 2);
|
||||||
|
assert_eq!(paged[0].message_id, "u0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LIKE 通配符按字面匹配(%/_ 不扩大命中面)+ 命中片段以关键词为中心截 500 字。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_escapes_wildcards_and_centers_snippet() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
let long_prefix = "前".repeat(600); // 命中点前 600 字:窗口应前推 200 字且带 …
|
||||||
|
let long_tail = "后".repeat(600);
|
||||||
|
repo.insert_batch(vec![
|
||||||
|
mk_msg("m0", "c", 0, "user", &format!("{long_prefix}配额100%封顶{long_tail}")),
|
||||||
|
mk_msg("m1", "c", 1, "user", "配额100x封顶,百分号不应误命中"),
|
||||||
|
mk_msg("m2", "c", 2, "user", "变量名 quota_limit 带下划线"),
|
||||||
|
mk_msg("m3", "c", 3, "user", "quotaXlimit 下划线不该当通配命中"),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
// % 按字面:只命中 m0,不命中 100x
|
||||||
|
let pct = repo
|
||||||
|
.search_keywords(&["100%".to_string()], None, 20, 0)
|
||||||
|
.await
|
||||||
|
.expect("search %");
|
||||||
|
assert_eq!(pct.len(), 1, "100% 不应通配命中 100x: {:?}", pct.iter().map(|h| &h.message_id).collect::<Vec<_>>());
|
||||||
|
assert_eq!(pct[0].message_id, "m0");
|
||||||
|
|
||||||
|
// _ 按字面:只命中 quota_limit,不命中 quotaXlimit
|
||||||
|
let under = repo
|
||||||
|
.search_keywords(&["quota_limit".to_string()], None, 20, 0)
|
||||||
|
.await
|
||||||
|
.expect("search _");
|
||||||
|
assert_eq!(under.len(), 1);
|
||||||
|
assert_eq!(under[0].message_id, "m2");
|
||||||
|
|
||||||
|
// 片段:命中点前约 200 字,总长 500+省略号,两端截断带 …
|
||||||
|
let snip = &pct[0].snippet;
|
||||||
|
assert!(snip.contains('…'), "两端截断应带省略号: {snip}");
|
||||||
|
assert!(snip.contains("100%封顶"), "片段应以命中点为中心: {snip}");
|
||||||
|
let body = snip.trim_matches('…');
|
||||||
|
assert!(
|
||||||
|
body.chars().count() <= 500,
|
||||||
|
"片段正文应截 500 字,实际 {}",
|
||||||
|
body.chars().count()
|
||||||
|
);
|
||||||
|
assert!(snip.starts_with('…'), "命中点前 600 字,片段应带前省略号");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空关键词防御:直返空集不发 SQL(LIKE %% 恒真会全表命中)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_empty_keywords_returns_empty() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
repo.insert_batch(vec![mk_msg("m0", "c", 0, "user", "任意内容")])
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
let hits = repo.search_keywords(&[], None, 20, 0).await.expect("search empty");
|
||||||
|
assert!(hits.is_empty(), "空关键词应返空集");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- summary_sources(摘要降级链数据源) ----------
|
||||||
|
|
||||||
|
/// 每会话取「最近一条 system」与「首条 user」;无消息会话无行;批量一次查询。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn summary_sources_takes_last_system_and_first_user() {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
repo.insert_batch(vec![
|
||||||
|
mk_msg("u0", "c1", 0, "user", "首个问题"),
|
||||||
|
mk_msg("a0", "c1", 1, "assistant", "回复"),
|
||||||
|
mk_msg("s0", "c1", 2, "system", "旧锚点"),
|
||||||
|
mk_msg("s1", "c1", 3, "system", "## 意图\n- 最新压缩摘要"),
|
||||||
|
mk_msg("u1", "c2", 0, "user", "c2 的唯一用户消息"),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let out = repo
|
||||||
|
.summary_sources(&["c1".to_string(), "c2".to_string(), "c3".to_string()])
|
||||||
|
.await
|
||||||
|
.expect("summary_sources");
|
||||||
|
assert_eq!(out.len(), 2, "无消息的 c3 不应有行");
|
||||||
|
let c1 = &out["c1"];
|
||||||
|
assert_eq!(c1.last_system.as_deref(), Some("## 意图\n- 最新压缩摘要"), "应取 seq 最大的 system");
|
||||||
|
assert_eq!(c1.first_user.as_deref(), Some("首个问题"));
|
||||||
|
let c2 = &out["c2"];
|
||||||
|
assert!(c2.last_system.is_none(), "无 system 消息应返 None(降级链走首条 user)");
|
||||||
|
assert_eq!(c2.first_user.as_deref(), Some("c2 的唯一用户消息"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
|
|||||||
|
|
||||||
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
|
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
|
||||||
///
|
///
|
||||||
/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at):
|
/// `last_active_at` = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
|
||||||
/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」
|
/// projects.updated_at):任务活跃为主信号(任务面是真实工作发生地),事件表已停写仅保留维度,
|
||||||
/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。
|
/// updated_at 兜底。供前端「最近活跃排序」展示与 system prompt 项目清单排序。
|
||||||
///
|
///
|
||||||
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
|
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
@@ -243,29 +243,35 @@ impl ProjectRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间,
|
/// 列出未删除项目,**按「最近活跃」排序**(三源取大:任务活跃为主信号)。
|
||||||
/// COALESCE 回退 updated_at)。
|
|
||||||
///
|
///
|
||||||
/// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at
|
/// 业务语义:last_active_at = MAX(tasks.updated_at 最新值, project_events 最新 created_at,
|
||||||
/// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询
|
/// projects.updated_at)。**任务活跃为主信号**——ai-chat 会话/任务面是真实工作发生地,
|
||||||
/// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。
|
/// 任务停更即项目停更;project_events 已停写(现网空表,保留维度向后兼容);
|
||||||
|
/// projects.updated_at 仅元信息修改(改 description/path 也会刷新,不反映真实业务活跃),
|
||||||
|
/// 仅作兜底。无任务无事件的项目回退 updated_at,排序列统一可比。
|
||||||
///
|
///
|
||||||
/// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询
|
/// 子查询而非 JOIN:tasks/project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,
|
||||||
/// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次
|
/// 标量子查询每行一次聚合,语义清晰无笛卡尔积风险(SQLite 多参 MAX 为标量取大,
|
||||||
/// 聚合,语义清晰无笛卡尔积风险。单用户桌面应用项目数小,无性能压力。
|
/// IFNULL 兜空子查询 NULL)。单用户桌面应用项目数小,无性能压力。
|
||||||
///
|
///
|
||||||
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示。
|
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示
|
||||||
|
/// 与 system prompt「当前项目」清单(build_context_lists)按活跃度排序。
|
||||||
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
|
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let guard = conn.blocking_lock();
|
let guard = conn.blocking_lock();
|
||||||
// COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at,
|
// 三源取大(SQLite 多参 MAX=标量取大,IFNULL 兜空子查询):任务活跃为主信号,
|
||||||
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。
|
// 事件表已停写仅保留维度,updated_at 兜底。ORDER BY 该列 DESC,同时间 created_at DESC
|
||||||
|
// 兜底稳定。
|
||||||
let mut stmt = guard
|
let mut stmt = guard
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \
|
"SELECT id, name, description, status, idea_id, path, stack, archived, created_at, updated_at, \
|
||||||
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \
|
MAX(\
|
||||||
updated_at) AS last_active_at \
|
IFNULL((SELECT MAX(updated_at) FROM tasks WHERE project_id = projects.id AND deleted_at IS NULL), 0), \
|
||||||
|
IFNULL((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), 0), \
|
||||||
|
updated_at\
|
||||||
|
) AS last_active_at \
|
||||||
FROM projects WHERE deleted_at IS NULL AND archived = 0 \
|
FROM projects WHERE deleted_at IS NULL AND archived = 0 \
|
||||||
ORDER BY last_active_at DESC, created_at DESC",
|
ORDER BY last_active_at DESC, created_at DESC",
|
||||||
)
|
)
|
||||||
@@ -840,4 +846,62 @@ mod tests {
|
|||||||
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
|
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
|
||||||
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_active_with_activity_orders_by_task_activity_over_created_at() {
|
||||||
|
// 回归(事故形状):项目 A created_at 较晚(创建序排前)但无任务/停更;项目 B
|
||||||
|
// created_at 较早但有一条 tasks.updated_at 最新 → 活跃序 B 排 A 前
|
||||||
|
// (任务活跃为主信号,治创建序误导 AI 把停摆项目当最高优先锚点)。
|
||||||
|
use crate::crud::task_repo::TaskRepo;
|
||||||
|
use crate::models::TaskRecord;
|
||||||
|
use df_types::types::TaskStatus;
|
||||||
|
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let projects = ProjectRepo::new(&db);
|
||||||
|
let tasks = TaskRepo::new(&db);
|
||||||
|
|
||||||
|
// B 先建(早),A 后建(晚)——创建序会排 A 前
|
||||||
|
let mut early = prec("pb", "早建但活跃");
|
||||||
|
early.created_at = "1700000000000".to_string();
|
||||||
|
early.updated_at = "1700000000000".to_string();
|
||||||
|
let mut late = prec("pa", "晚建但停更");
|
||||||
|
late.created_at = "1700000010000".to_string();
|
||||||
|
late.updated_at = "1700000010000".to_string();
|
||||||
|
projects.insert(early).await.unwrap();
|
||||||
|
projects.insert(late).await.unwrap();
|
||||||
|
|
||||||
|
// B 挂一条最新任务;A 无任务 → B 活跃时间取任务 updated_at,A 回退自身 updated_at
|
||||||
|
let task = TaskRecord {
|
||||||
|
id: "tb".to_string(),
|
||||||
|
project_id: "pb".to_string(),
|
||||||
|
title: "B 的任务".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
status: TaskStatus::InProgress,
|
||||||
|
priority: 0,
|
||||||
|
branch_name: None,
|
||||||
|
assignee: None,
|
||||||
|
workflow_def_id: None,
|
||||||
|
base_branch: None,
|
||||||
|
review_rounds: 0,
|
||||||
|
output_json: None,
|
||||||
|
idea_id: None,
|
||||||
|
queue: "todo".to_string(),
|
||||||
|
parent_id: None,
|
||||||
|
content_json: None,
|
||||||
|
module_id: None,
|
||||||
|
archived: false,
|
||||||
|
created_at: "1700000000000".to_string(),
|
||||||
|
updated_at: "1790000000000".to_string(),
|
||||||
|
};
|
||||||
|
tasks.insert(task).await.unwrap();
|
||||||
|
|
||||||
|
let listed = projects.list_active_with_activity().await.unwrap();
|
||||||
|
assert_eq!(listed.len(), 2);
|
||||||
|
assert_eq!(listed[0].record.id, "pb", "任务活跃最新的早建项目应排前");
|
||||||
|
assert_eq!(listed[1].record.id, "pa", "无任务的晚建项目应排后");
|
||||||
|
assert_eq!(
|
||||||
|
listed[0].last_active_at, "1790000000000",
|
||||||
|
"活跃时间应取该项目任务 updated_at 最新值"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
|
// V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。
|
||||||
// V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4
|
// V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4
|
||||||
// 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。
|
// 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [
|
let steps: [(i32, fn(&Connection) -> Result<()>); 54] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -129,6 +129,8 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(52, migrate_v52),
|
(52, migrate_v52),
|
||||||
// V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。
|
// V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。
|
||||||
(53, migrate_v53),
|
(53, migrate_v53),
|
||||||
|
// V54 = knowledge_edges 边表(知识中枢阶段1:节点连边,提炼 related_ids 标注落边)。
|
||||||
|
(54, migrate_v54),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -1536,7 +1538,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> {
|
|||||||
/// 调度器启动时 reset_stale_sending 复位为 pending。
|
/// 调度器启动时 reset_stale_sending 复位为 pending。
|
||||||
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
|
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
|
||||||
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
|
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
|
||||||
pub const LATEST_SCHEMA_VERSION: i32 = 53;
|
pub const LATEST_SCHEMA_VERSION: i32 = 54;
|
||||||
|
|
||||||
fn migrate_v51(conn: &Connection) -> Result<()> {
|
fn migrate_v51(conn: &Connection) -> Result<()> {
|
||||||
if !table_exists(conn, "ai_outbox") {
|
if !table_exists(conn, "ai_outbox") {
|
||||||
@@ -1609,6 +1611,38 @@ fn migrate_v53(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V54: knowledge_edges 边表(知识中枢阶段1「连边」,2026-10-23)
|
||||||
|
///
|
||||||
|
/// 终局设计(temp/知识中枢终局设计-20261023.md):知识从「清单」变「图」,节点=knowledges
|
||||||
|
/// 零改动,边独立成表——related 视图走边表,不在 knowledges 上加列(防双源)。
|
||||||
|
/// - kind: similar(强相关)|evolves(演进)|causal(因果)|merges(合并)|anchors(锚点),首两版
|
||||||
|
/// 只产 similar(提炼 related_ids)/evolves(预留),其余为阶段 3 自动建边预留;
|
||||||
|
/// - origin: llm-tag(LLM 标注)|embed-sim(向量相似)|co-cite(共引)|manual(人工)|tag-co-occur
|
||||||
|
/// (标签共现)——每条边可溯源,错的边可按 origin 批量撤销;
|
||||||
|
/// - 双列索引:激活扩散(阶段2)从任一端遍历,百万级实测双索引是毫秒级前提;
|
||||||
|
/// - weight/last_verified_at: 边随复用强化、久未验证降权(阶段 3 演化循环消费)。
|
||||||
|
/// - **幂等**:table_exists 守卫(`CREATE TABLE IF NOT EXISTS` 同效);
|
||||||
|
/// **可逆**:`DROP TABLE knowledge_edges`。
|
||||||
|
fn migrate_v54(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS knowledge_edges (\
|
||||||
|
id TEXT PRIMARY KEY,\
|
||||||
|
src_id TEXT NOT NULL,\
|
||||||
|
dst_id TEXT NOT NULL,\
|
||||||
|
kind TEXT NOT NULL DEFAULT 'similar',\
|
||||||
|
weight REAL NOT NULL DEFAULT 0.5,\
|
||||||
|
origin TEXT NOT NULL DEFAULT 'llm-tag',\
|
||||||
|
created_at TEXT NOT NULL,\
|
||||||
|
last_verified_at TEXT NOT NULL\
|
||||||
|
);\
|
||||||
|
CREATE INDEX IF NOT EXISTS ke_src ON knowledge_edges(src_id);\
|
||||||
|
CREATE INDEX IF NOT EXISTS ke_dst ON knowledge_edges(dst_id);",
|
||||||
|
)?;
|
||||||
|
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [54])?;
|
||||||
|
tracing::info!("迁移 v54 完成: knowledge_edges 边表(知识中枢阶段1)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
|
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
|
||||||
/// prompt_tokens+completion_tokens 求真实总量。
|
/// prompt_tokens+completion_tokens 求真实总量。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -559,6 +559,31 @@ pub struct KnowledgeEventRecord {
|
|||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 知识关联边(知识中枢阶段1:节点连边,V54)
|
||||||
|
///
|
||||||
|
/// 有向 src→dst,查询侧双向读(关联语义无方向)。kind/origin 枚举见 migrations V54 注释;
|
||||||
|
/// 首两版只产 similar(提炼 related_ids)与 manual(人工),embed-sim/co-cite 归阶段 3 自动建边。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct KnowledgeEdgeRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub src_id: String,
|
||||||
|
pub dst_id: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub weight: f64,
|
||||||
|
pub origin: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub last_verified_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关联知识条目(边 + 对端知识展开,详情页「关联知识」区块直接消费)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RelatedKnowledge {
|
||||||
|
pub edge: KnowledgeEdgeRecord,
|
||||||
|
pub knowledge: KnowledgeRecord,
|
||||||
|
/// 对端相对当前条目的方向:out=当前是 src(边指向对方),in=当前是 dst(对方指进来)
|
||||||
|
pub direction: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库)
|
/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库)
|
||||||
///
|
///
|
||||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# devflow-core 抽取设计(云化阶段2)
|
||||||
|
|
||||||
|
> 任务 711e1390 · 2026-10-22 · 勘察依据:workspace 9 个 crate Cargo.toml 实测
|
||||||
|
|
||||||
|
## 一、现状 crate 依赖图(实测)
|
||||||
|
|
||||||
|
```
|
||||||
|
df-types (零依赖: serde/thiserror/chrono/uuid)
|
||||||
|
┌────┴──────────────────────┐
|
||||||
|
df-ai-core df-workflow
|
||||||
|
(零tauri, trait层) (DAG/条件引擎, 仅依赖df-types)
|
||||||
|
│ │
|
||||||
|
df-storage ←──df-ai─────┐ df-nodes ──┐
|
||||||
|
(rusqlite+keyring, (reqwest流式) (聚合5crate) │
|
||||||
|
依赖df-types/df-ai-core) │ │
|
||||||
|
└──────┬───────────┴──────────────┘
|
||||||
|
▼
|
||||||
|
src-tauri (Tauri 壳, 68 IPC commands)
|
||||||
|
|
||||||
|
隧道链: df-tunnel(出站WS) → df-relay(云端中继镜像) → df-im-gateway(IM适配)
|
||||||
|
工具链: df-execute(零依赖) / df-mcp / df-ideas / df-project(依赖df-types)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、核心发现:devflow-core 已基本存在,无需「抽取」只需「收口」
|
||||||
|
|
||||||
|
关键事实:**UI 无关的 crate 已经全部独立**——df-types/df-ai-core/df-workflow/df-storage/df-ai/df-nodes/df-project/df-execute/df-tunnel 均零 tauri 依赖,tauri 只在 src-tauri 壳层。昨天的「抽取」假设(需要从 8-crate 里剥离 UI 耦合)不成立,工作量大幅下降。
|
||||||
|
|
||||||
|
## 三、设计(三步收口,非重构)
|
||||||
|
|
||||||
|
### S1. 虚拟 target:workspace 内定义 `devflow-core` 集合(0.5 天)
|
||||||
|
不建新 crate,先以约定划界:`crates/df-{types,ai-core,workflow,storage,ai,nodes,project,execute}` = core 集合。云端服务直接 path 依赖这组 crate。**零代码改动**。
|
||||||
|
|
||||||
|
### S2. 存储层 trait 化(2-3 天,唯一实质改造)
|
||||||
|
- 现状:df-storage 直用 rusqlite(bundled),SQL 内嵌
|
||||||
|
- 改造:定义 `StorageProvider` trait(项目/任务/工作流/知识 CRUD 切面),SQLite 为默认实现;PostgreSQL 实现放独立 `df-storage-pg`(feature-gated,云端服务才编译)
|
||||||
|
- 保留双库分歧模式(devflow.db / devflow-dev.db 的 cfg! 分支语义)
|
||||||
|
- 风险:rusqlite 与 sqlx-tokio-postgres 的异步模型差异 → trait 方法全部 async(workspace 已有 async-trait)
|
||||||
|
|
||||||
|
### S3. AI Provider 双模式(1 天)
|
||||||
|
- df-ai/df-ai-core 已是 trait 层(reqwest 直连,key 本机)——天然支持云端托管:新增 `RemoteProvider` 实现(key 由 df-cloud-server 下发),编译期 feature 切换,个人版行为零变更
|
||||||
|
|
||||||
|
### 云端落地形态(阶段2 末期)
|
||||||
|
- `df-cloud-server`:新 crate(或独立仓),path 依赖 core 集合 + df-storage-pg,Axum/actix 暴露 HTTP+WS,部署同主机 Nginx 后
|
||||||
|
- 复用 df-tunnel 协议与 u-relay 管道层,拓扑不变
|
||||||
|
|
||||||
|
## 四、迁移步骤
|
||||||
|
|
||||||
|
1. S1 约定划界 + `cargo check` 全绿验证(不动代码)
|
||||||
|
2. S2 StorageProvider trait 引入,SQLite 实现先行,桌面端切到 trait 调用(行为零变更,IPC 68 commands 不动)
|
||||||
|
3. df-storage-pg 新 crate + 集成测试(本地 docker postgres)
|
||||||
|
4. S3 RemoteProvider feature
|
||||||
|
5. df-cloud-server 骨架(healthz + 只读任务查询先行)
|
||||||
|
|
||||||
|
## 五、风险 Top3
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| rusqlite 同步 API vs PG 异步驱动的 trait 撕裂 | trait 全 async + SQLite 实现内 spawn_blocking |
|
||||||
|
| df-storage 依赖 df-ai-core(ModelConfig 反序列化)造成边界混杂 | 接受(同属 core 集合,不跨集合) |
|
||||||
|
| 与阶段1 联调互踩 src-tauri | S2 改动收敛在 df-storage 内部 + 调用方,分批提交攒批 |
|
||||||
|
|
||||||
|
## 六、工作量总估
|
||||||
|
|
||||||
|
S1 0.5d + S2 2-3d + S3 1d + cloud-server 骨架 1-2d ≈ **5-7 天**(远低于原「抽取重构」预期)。
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DevFlow 云化架构 — 2026-10-21</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#0d1117; --bg2:#161b22; --border:#30363d; --text:#e6edf3; --muted:#8b949e;
|
||||||
|
--blue:#58a6ff; --green:#3fb950; --orange:#d29922; --red:#f85149; --purple:#bc8cff; --pink:#ff7b72;
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:var(--bg);color:var(--text);font-family:"JetBrains Mono","Cascadia Code",Consolas,"Microsoft YaHei",monospace;font-size:14px;line-height:1.7;padding:32px 16px}
|
||||||
|
.wrap{max-width:1080px;margin:0 auto}
|
||||||
|
h1{font-size:26px;margin-bottom:4px}
|
||||||
|
.meta{color:var(--muted);font-size:12px;margin-bottom:28px}
|
||||||
|
h2{font-size:18px;margin:34px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border);color:var(--blue)}
|
||||||
|
h3{font-size:15px;margin:20px 0 10px;color:var(--purple)}
|
||||||
|
.card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:18px 20px;margin:12px 0}
|
||||||
|
.tag{display:inline-block;padding:1px 9px;border-radius:20px;font-size:12px;margin-right:6px;border:1px solid}
|
||||||
|
.t-blue{color:var(--blue);border-color:var(--blue);background:rgba(88,166,255,.1)}
|
||||||
|
.t-green{color:var(--green);border-color:var(--green);background:rgba(63,185,80,.1)}
|
||||||
|
.t-orange{color:var(--orange);border-color:var(--orange);background:rgba(210,153,34,.1)}
|
||||||
|
.t-red{color:var(--red);border-color:var(--red);background:rgba(248,81,73,.1)}
|
||||||
|
.t-purple{color:var(--purple);border-color:var(--purple);background:rgba(188,140,255,.1)}
|
||||||
|
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:13px}
|
||||||
|
th,td{border:1px solid var(--border);padding:8px 12px;text-align:left;vertical-align:top}
|
||||||
|
th{background:var(--bg2);color:var(--muted);font-weight:600}
|
||||||
|
pre{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:20px;overflow-x:auto;font-size:12.5px;line-height:1.55}
|
||||||
|
.ascii{color:var(--green)}
|
||||||
|
.ascii .cloud{color:var(--blue)}
|
||||||
|
ul{margin:8px 0 8px 22px}
|
||||||
|
li{margin:4px 0}
|
||||||
|
.warn{border-left:3px solid var(--orange);padding-left:14px;color:var(--orange);font-size:13px;margin:10px 0}
|
||||||
|
.ok{color:var(--green)} .no{color:var(--red)} .hl{color:var(--blue)}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:12px}
|
||||||
|
.phase{border-top:3px solid var(--border)}
|
||||||
|
.p1{border-top-color:var(--green)} .p2{border-top-color:var(--orange)} .p3{border-top-color:var(--purple)}
|
||||||
|
code{background:#21262d;padding:1px 6px;border-radius:4px;font-size:12.5px;color:var(--pink)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<h1>DevFlow 云化架构方案</h1>
|
||||||
|
<div class="meta">拍板日期 2026-10-21 · 阶段1 已在飞书实机闭环验证 · 状态源:任务 711e1390 / 6e13f49b</div>
|
||||||
|
|
||||||
|
<h2>一、核心决策(已拍板)</h2>
|
||||||
|
<div class="card">
|
||||||
|
<ul>
|
||||||
|
<li><span class="tag t-blue">决策1</span><b>中继不新建</b>:复用生产 <code>u-relay</code>(:9180,纯中继零改动),df-im-gateway 以 <span class="hl">WS Commander</span> 身份接入</li>
|
||||||
|
<li><span class="tag t-blue">决策2</span><b>df-im-gateway 走方案 A</b>:先落在 devflow workspace 当第 9 个 crate,四端全通后再拆独立仓(避免模块 vs 实际代码脱节坑)</li>
|
||||||
|
<li><span class="tag t-blue">决策3</span><b>云端零业务逻辑零落盘</b>:任务/AI对话/审批状态全留桌面端 SQLite;AI Provider key 不上云,本机直连</li>
|
||||||
|
<li><span class="tag t-blue">决策4</span><b>语言对齐 Rust</b>:复用 df-tunnel 类型与 systemd 原子切换发布模式(publish-u-relay 同款)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>二、部署拓扑总览</h2>
|
||||||
|
<pre><span class="ascii"> 用户四端 ┌─────────────────── 云端(已有云主机复用,不新购)──────────────────┐
|
||||||
|
┌────────┐ │ │
|
||||||
|
│ 飞书 │──WS 长连接──┼──▶ ┌─────────────────────────────┐ │
|
||||||
|
│ 钉钉 │─Stream Mode─┼──▶ │ <span class="cloud">df-im-gateway</span> (Rust, 新增) │ │
|
||||||
|
│ 企微 │──WS 长连接──┼──▶ │ · 四端协议适配(验签/AES/卡片) │ │
|
||||||
|
│ Web │─HTTPS/WSS───┼──▶ │ · WS Commander 身份 │ │
|
||||||
|
└────────┘ │ │ · 审批转发 │ │
|
||||||
|
│ └────────────┬────────────────┘ │
|
||||||
|
│ │ df-tunnel 协议 │
|
||||||
|
<span class="cloud">u-relay :9180</span> ◀──────┼─────────────────┘ (纯中继:WS 长连接管理/路由/Commander 注册表) │
|
||||||
|
│ │
|
||||||
|
│ Nginx :443 (TLS 终结 + 反代, 后续挂 df-cloud-server 用) │
|
||||||
|
│ │
|
||||||
|
└────────┬──────────────────────────────┬──────────────────────────┘
|
||||||
|
│ 出站 WS(穿透 NAT) │ 出站 WS
|
||||||
|
▼ ▼
|
||||||
|
┌── 用户本机 ─────────────────────────────────────────────────┐
|
||||||
|
│ DevFlow 桌面端 (Tauri 2, 唯一执行引擎 + 状态源) │
|
||||||
|
│ · AI Provider 直连 (GLM/DeepSeek, key 本机) │
|
||||||
|
│ · SQLite (任务/对话/审批 全量状态) │
|
||||||
|
│ · 8-crate workspace (+未来第9 crate df-im-gateway) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘</span></pre>
|
||||||
|
|
||||||
|
<h2>三、数据流</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>方向</th><th>路径</th><th>用途</th></tr>
|
||||||
|
<tr><td>下行指令</td><td>IM/Web → df-im-gateway → u-relay → 桌面端</td><td>发消息 / 审批 / 停止</td></tr>
|
||||||
|
<tr><td>上行事件</td><td>桌面端 → u-relay → df-im-gateway → 各端</td><td>流式回复 / 状态广播</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="warn">⚠ 桌面端全程<b>主动出站</b>,不开任何入站端口——NAT 友好,家里/公司网络都能用;云端被攻破也拿不到业务数据(无状态管道)。</div>
|
||||||
|
|
||||||
|
<h2>四、阶段路线</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card phase p1">
|
||||||
|
<h3 style="margin-top:0">阶段1 · IM 远程接入 <span class="tag t-green">进行中·飞书实机</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li>飞书单端 E2E 闭环(握手→事件→卡片流式→审批回调→流式回复)——<span class="ok">代码已全落地,实机验证中</span></li>
|
||||||
|
<li>df-im-gateway 作为 workspace 第 9 crate 承载</li>
|
||||||
|
<li>钉钉/企微/Web = <span class="no">后续扩展,不阻塞</span></li>
|
||||||
|
<li>唯一动 u-relay 的点:Commander 身份注册(配置级)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card phase p2">
|
||||||
|
<h3 style="margin-top:0">阶段2 · devflow-core 抽取 <span class="tag t-orange">设计启动</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li>UI 无关 crate 界定为 devflow-core(工作流引擎/任务管理/存储层)</li>
|
||||||
|
<li>存储 trait 双实现:SQLite(本地)/ PostgreSQL(云端)</li>
|
||||||
|
<li>AI Provider 双模式:key 本机直连(个人版)/ 云端集中托管(团队版)</li>
|
||||||
|
<li>同主机 Nginx 后追加 df-cloud-server,管道层不动</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card phase p3">
|
||||||
|
<h3 style="margin-top:0">阶段3 · 完整云端化 <span class="tag t-purple">预留</span></h3>
|
||||||
|
<ul>
|
||||||
|
<li>云端接管全部业务逻辑,桌面端降级薄客户端</li>
|
||||||
|
<li>多用户 / 多设备 / 团队协作(任务池/审批/知识库共享)</li>
|
||||||
|
<li>可选本地引擎保离线能力</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>五、组件清单与状态</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>组件</th><th>形态</th><th>部署位置</th><th>状态</th></tr>
|
||||||
|
<tr><td>u-relay :9180</td><td>Rust 二进制(已有)</td><td>云端主机,systemd 原子切换</td><td class="ok">✅ 复用零改动</td></tr>
|
||||||
|
<tr><td>df-im-gateway</td><td>Rust crate(新增,方案A先驻 workspace)</td><td>联调期本机 → 常驻期云端主机</td><td class="ok">✅ 飞书端已通,实机验证</td></tr>
|
||||||
|
<tr><td>Nginx :443</td><td>TLS 终结 + 反代(已有)</td><td>云端主机</td><td class="ok">✅ 复用</td></tr>
|
||||||
|
<tr><td>DevFlow 桌面端</td><td>Tauri 2 应用</td><td>用户本机</td><td class="ok">✅ 唯一状态源</td></tr>
|
||||||
|
<tr><td>df-cloud-server</td><td>Rust 服务(规划)</td><td>云端主机,Nginx 后</td><td>阶段2 拍板后立项</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>六、风险与关键点</h2>
|
||||||
|
<div class="card">
|
||||||
|
<ul>
|
||||||
|
<li><span class="tag t-red">风险1</span><b>飞书卡片版本</b>:审批按钮回调仅新版卡片 <code>card.action.trigger</code> 生效,旧版 card 帧被 SDK 直接丢弃——联调最大坑(已知已沉淀)</li>
|
||||||
|
<li><span class="tag t-red">风险2</span><b>假活自愈</b>:断流约 13 分钟由看门狗 notify_waiters 唤醒(45f0560f),云端常驻部署后需关注长连接巡检参数</li>
|
||||||
|
<li><span class="tag t-orange">注意1</span><b>双库分歧</b>:devflow.db vs devflow-dev.db 是刻意设计(cfg! 分支),云化时存储层抽象需保留该模式</li>
|
||||||
|
<li><span class="tag t-orange">注意2</span><b>钉钉/企微降级</b>:P3/P4 保持 todo,不阻塞阶段1 验收与阶段2 并行</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta" style="margin-top:36px;border-top:1px solid var(--border);padding-top:12px">DevFlow 云化架构 · 生成于 2026-10-21 · 依据知识库拍板记录 978a3cf0 · 相关任务 6e13f49b / 711e1390</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ai-chat 上下文管理与信息组织·完善设计(2026-10-18)
|
||||||
|
|
||||||
|
> 用户令:ai-chat agent 上下文管理/信息组织开发完善;回复别太口水话(2026-10-18)。
|
||||||
|
> 证据输入(问题全量论证在彼,此处只引编号不重复):`temp/深查-aichat对话质量复测-2026-10-17.md`(E1-E13)、`temp/深查-aichat信息UX-2026-10-17.md`(F1-F6)。
|
||||||
|
> 现状判定:10-04 三大病(上下文失控/反复压缩/终局缺失)基本治愈;残量集中在**恢复链污染、终局态半闭环、存量脏数据、信息可达性、输出纪律**五个结构性面。
|
||||||
|
|
||||||
|
## 一、信息流模型与缺口定位
|
||||||
|
|
||||||
|
链路:`发起(直发/inject) → 目标提取(pinned_goals) → 上下文组装(system+摘要+近期) → 执行循环 → 压缩(view/cache) → 恢复(recovery) → 投影(轮桶/轨迹卡) → 呈现(桌面/飞书)`
|
||||||
|
|
||||||
|
| 缺口 | 根因(结构性,非散点) | 归并的散点 |
|
||||||
|
|---|---|---|
|
||||||
|
| **G-A 恢复链身份未建模** | 恢复消息无类型标记,兼具「系统信号」与「用户输入」双重身份,被目标提取源再次吸收 | E1 套娃、E3 二次恢复、看板 649 行恢复目标完整性守卫 |
|
||||||
|
| **G-B 终局态半闭环** | boundary_json/loop_state 字段已建、写入闭环未建(填充率 0) | P2-5、E3 竞态窗口 |
|
||||||
|
| **G-C 写入不保数据语义** | 写入方各自为政,全量重写/双写不保留原义 | E11 created_at 重写污染 1750 条、E5 存量目标不迁移、E12 双写 |
|
||||||
|
| **G-D 信息可达性瓶颈** | 呈现层硬上限+折叠体内不可见 | F4-1 会话 50 上限(实测 115 条已击穿)、F1-3+F3-2 注入降级藏审批、F6-1 |
|
||||||
|
| **G-E 输出纪律缺失** | 聚焦准则管「答什么」,不管「怎么答」——主提示词无回复风格条款 | 用户口水话反馈(本次令);摘要提示词已有该纪律可对齐 |
|
||||||
|
| **G-F 阴性结论无守卫** | 工具 0 命中被当事实断言,无复核引导 | E6 假阴性 3 轮错判链 |
|
||||||
|
|
||||||
|
## 二、修复批次(领单视角,M1 先行)
|
||||||
|
|
||||||
|
### M1 · P1 上下文正确性
|
||||||
|
1. **E1+E3 恢复链治本(G-A)**:恢复消息加 source 标记(与看板 961② inject 标记同基建,复用 V52 source 字段);build_recovery_message 过滤 `[系统恢复]` 前缀目标;目标写入侧拒收同前缀。防二次恢复:快照全部目标=completed 时短路,或恢复后 N 分钟窗口禁重复。
|
||||||
|
2. **E11 created_at 保义(G-C)**:message_repo.rs:285 全量重写保留原 created_at(records 读取带出原值回写)。
|
||||||
|
3. **E6 阴性存疑(G-F)**:search_files/grep 0 命中时结果强制附「阴性存疑,建议复核路径/glob」标记(seq 1549 hint 雏形转正)。
|
||||||
|
4. **E4 跑题换装验证**:三源活跃度排序已在树(第五十三段),换装后重放 6a5a3e04 seq1802 场景验证。
|
||||||
|
|
||||||
|
### M2 · P2 上下文域
|
||||||
|
5. **E5 存量目标一次性迁移(G-C)**:active 目标命中「执行/搜索/查看+工具参数」形态或 `[系统恢复]`/`[补充指令]` 前缀 → 降级为非目标或重取首条任务消息。
|
||||||
|
6. **E2 压缩降级透出**:压缩失败兜底时前端状态条「上下文已降级」,禁静默。
|
||||||
|
7. **E13 超时连败收敛**:同命令超时连败 N 次转「换方案/问用户」分支(与看板 B5d 退避策略同族,合并实施)。
|
||||||
|
8. **P2-5 终局闭环(G-B)**:停靠/终局时填充 boundary_json/loop_state,不只落 status。
|
||||||
|
|
||||||
|
### M3 · P2/P3 信息组织域
|
||||||
|
9. **F4-1 会话可达性(G-D)**:列表 offset 游标分页+侧栏「加载更多」;搜索走后端 LIKE(title+消息内容)。
|
||||||
|
10. **F5-1+F5-2 飞书审批闭环(同根一改双收)**:决议词表归一(trim/剥标点/同义词,复用 normalize_halfwidth)+ 订阅 AiToolCallCompleted 推终态卡;pending 为空时决议回「已失效」提示而非静默放行。
|
||||||
|
11. **F1-3+F3-2 折叠体可达(G-D,同根)**:executing 判定改「桶内存在 running/pending 工具」;scrollToFirstPending 前按 data-trace-key 预展开含 pending 的轨迹卡。
|
||||||
|
|
||||||
|
### M4 · 输出纪律+UX 小批
|
||||||
|
12. **G-E 提示词输出纪律**:system_prompt_parts 行为准则追加「回复风格」段——结论先行直接给动作/结果;省略复述用户问题、客套开场、总结性收尾;要点优先于段落;用户没问的不展开。中英双语同步+测试断言(对齐 prompt.rs 既有测试范式)。注意别过度约束:需要解释时照常解释,反的是空话不是内容。
|
||||||
|
13. **UX 确定性小批**:F1-1 i18n 补键 / F1-2+F3-1 主题 token 化 / F2-1 aria-label / F4-2 loaded 标志 / F6-2+F6-3 Esc 关闭 / 看板尾部队列带图项标记(10-18 残留)。
|
||||||
|
|
||||||
|
## 三、验收口径
|
||||||
|
|
||||||
|
- **复测脚本复跑**(复用 `temp/复测-aichat质量-1017/` 脚本,timestamp 列口径):恢复消息套娃=0、created_at 污染增量=0、boundary_json 填充率>0(新停靠会话 100%)。
|
||||||
|
- **可达性**:115 条活跃会话全量可从侧栏到达;审批卡在任何折叠形态下 scrollToFirstPending 均可见。
|
||||||
|
- **输出纪律**:3 个真实会话抽查,开场复述/空收尾/无信息客套=0;结论仍在首屏。
|
||||||
|
- 每批销账附验收命令输出(G4 惯例)。
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 上下文管理对标 u-claw 与大批量并行差距分析(2026-10-19)
|
||||||
|
|
||||||
|
> 用户令(2026-10-18 飞书):①大批量 AI 任务调度/多会话多任务并行是未来常态核心场景;②战略目标=DevFlow 部署 Linux 替代 u-claw+Claude Code(CC 效率不够高、资源消耗太大);③第一门槛=上下文管理三命题:丢记忆、历史信息召回、高效利用 token;④方法=学习 u-claw 历史提交记录补 devflow 场景不足。
|
||||||
|
|
||||||
|
## 一、u-claw 上下文管理能力画像(源自主仓 6 条相关提交,核心是 86e9216)
|
||||||
|
|
||||||
|
| # | 能力 | u-claw 实现 | 要点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **token 水位判据** | 140K≈200K 窗口 70% 主判据(可配),体积判据降兜底 | 治「换代过早」:350KB 体积≈真实水位 20-35%,体积判据致 23 代/日→水位判据 <5/日 |
|
||||||
|
| 2 | **usage 真值采集** | 读流解析 message.usage(input+cache_read+cache_creation),失败静默 0 | 水位判据的数据底座 |
|
||||||
|
| 3 | **大内容外置纪律** | system prompt 处理规则:大结论写盘 out/,回复只给路径+3 行结论 | 治上下文滚雪球(attach/ 86 文件 25MB 实锤) |
|
||||||
|
| 4 | **换代通知推送** | rollover 等会话层事件经渠道真推送(非仅日志) | 用户对会话滚动有感知 |
|
||||||
|
| 5 | **/history 归档检索** | 无参列最近 10 代,关键词 grep summary | 跨代记忆召回入口 |
|
||||||
|
| 6 | **归档检索指引** | sopHint 注明 jsonl 字段与 grep→读 summary 操作路径 | 让 AI 自己会查历史 |
|
||||||
|
|
||||||
|
另:0a4f8dd 重上下文估算(防图片堆积)、a3f28dc 群聊上下文增量注入(按决策价值治理进度卡)。
|
||||||
|
|
||||||
|
## 二、devflow 现状对照(三命题 × 已治/缺口)
|
||||||
|
|
||||||
|
### 命题 1:丢记忆
|
||||||
|
- ✅ 已治:目标语义重构(用户任务原文入 pinned_goals)、恢复链快照([系统恢复]/[补充指令] 产物双侧过滤,10-18 批)、压缩摘要四段式(意图/决策/文件/约束+失败事实保留)、E2 降级透出(10-18)
|
||||||
|
- ❌ 缺口:**boundary/loop_state 填充率 0**(终局态建模半闭环)——「代」的显式语义缺失,会话边界靠消息流推断
|
||||||
|
|
||||||
|
### 命题 2:历史信息召回
|
||||||
|
- ✅ 已治:单会话内压缩可召回(archive jsonl+召回句柄行)、知识路标投影进系统提示词
|
||||||
|
- ❌ 缺口:**跨会话检索无入口**(u-claw /history 等价物缺失)——「上次那个任务怎么弄的」只能靠人翻侧栏;消息全文搜索未做(F4-1 只做了标题+列表分页)
|
||||||
|
|
||||||
|
### 命题 3:高效利用 token
|
||||||
|
- ✅ 已治:压缩 memoization+决策冻结(10-04 病②治愈)、尾部追加摘要保前缀缓存、E13 超时连败收敛
|
||||||
|
- ❌ 缺口:
|
||||||
|
- **usage 真值采集未落地**(6 月 P1 敲定至今暂缓;cache_hit≈0% 说明 cache 口径有列无真值)——水位判据缺数据底座
|
||||||
|
- **压缩触发判据待查**:压缩/请求比 4.8 偏频,疑体积/轮次判据过敏感(对标 u-claw 病灶同型)
|
||||||
|
- **大内容外置纪律缺失**:工具输出有 truncate,但「大结论写盘+回复给路径」没有
|
||||||
|
|
||||||
|
### 大批量并行新命题(u-claw 亦未解,devflow 需原创)
|
||||||
|
- 多会话同时压缩互不感知(各自预算独立,全局 token 消耗无视图)
|
||||||
|
- 并行任务的信息投影(侧栏/仪表盘聚合 N 个会话的进行态)
|
||||||
|
- 调度面:优先级/抢占/停靠恢复已有底座(TaskBoundary/parked),缺**全局调度器**视角
|
||||||
|
|
||||||
|
## 三、补差优先级(P0→P2)
|
||||||
|
|
||||||
|
| 级 | 项 | 移植来源 | 落点 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **P0** | usage 真值采集(stream_options.include_usage 三口径) | u-claw #2 | 6 月 P1 设计直接复活;先有数据再谈判据 |
|
||||||
|
| **P0** | 压缩触发改 token 水位主判据(≈窗口 70%,体积兜底) | u-claw #1 | maybe_auto_compress 判据重构,依赖上项 |
|
||||||
|
| **P1** | 跨会话归档检索:/history 等价(关键词搜消息全文+摘要) | u-claw #5/#6 | ai_messages 全文索引(FTS5 或 LIKE 起步);检索指令+AI 检索指引 |
|
||||||
|
| **P1** | boundary/loop_state 写入闭环(代语义显式化) | — | 停靠/终局时填充;换代通知推送(u-claw #4 同款) |
|
||||||
|
| **P2** | 大内容外置纪律(system prompt 处理规则+out/ 目录约定) | u-claw #3 | prompt.rs 处理规则段+workspace 目录约定 |
|
||||||
|
| **P2** | 并行资源治理:全局 token 预算视图+多会话压缩协防 | 原创 | 调度器域,与 Linux 化路线(看板 10-18 远程容器方向)合流设计 |
|
||||||
|
|
||||||
|
## 四、与 Linux 化战略的衔接
|
||||||
|
|
||||||
|
上下文管理是第一门槛(用户拍板),Linux 化是最终形态。依赖链:**usage 真值 → 水位判据 → 代语义+归档检索 → 并行调度治理 → server 化无头运行**。前三步在现有 Tauri 架构内即可推进(P0 两项纯 Rust 域),后两步与远程容器/df-tunnel 线合流。建议节奏:P0 两项作下一批(devflow agent 领单),P1 随后,P2 与 Linux 化立项合并设计。
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# DevFlow 布局样式大重构设计(2026-10-18)
|
||||||
|
|
||||||
|
> 依据:用户令「布局样式大重构,参考 D:\Downloads\u-agent」+「每页样式 DRY 提取统一定义」
|
||||||
|
> 输入:u-agent设计系统勘察-2026-10-18.md · 样式走查-1018/报告-布局全局审计.md · 报告-配色全局审计.md
|
||||||
|
> 原则:DRY 单一真值 · token 化 · 高密度(u-agent 13px 范式)· 双主题保留 · 紫 accent 品牌保留 · 视觉回归可控(dev 模式实时预览)
|
||||||
|
|
||||||
|
## 0. 现状诊断(三份勘察合并)
|
||||||
|
|
||||||
|
| 维度 | 现状 | u-agent 基准 | 差距 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 基准字号 | html 14px,aichat 已降 9-13,views 仍 11-14 | 13px 全局 | 功能页粗一档,密度断层 |
|
||||||
|
| 页头 | 三套并存(aichat 46px / Dashboard 18px 标题 / 功能页 24px h1 无固定高) | 46px 定高单行 | 全站统一为紧凑单行 |
|
||||||
|
| 侧栏 | 192px | 250-280px | 偏窄,功能页导航拥挤 |
|
||||||
|
| 按钮 | .btn 双基类分叉(global 34px vs settings 30px)+ 图标钮三档 | .cbtn 单基类 | 合一 30px |
|
||||||
|
| 徽章/tag | 圆角 3/4/6/8/10 五档并存;.df-tag 3px 字面量 | 小标签小圆角一档 | 收敛一档入 token |
|
||||||
|
| 圆角 token | 覆盖 75.5%,9 处 var 回退值写错(静默降档) | — | 修回退+字面量 97 处归 token |
|
||||||
|
| 间距 | 76.7% 收敛,7px×8/82px 手算对齐等离群 | 4px 刻度 | 离群归位+魔法数 token 化 |
|
||||||
|
| 字号 | 半像素 24 处(10.5/11.5/12.5)+17px 孤值 | 纯整数谱 | 归整 |
|
||||||
|
| 输入框 | 三档并存(21-22 / 26-27 / 30) | 30px 单档为主 | 行内搜索统一 26px 档 |
|
||||||
|
| 小节头 | 各页自造 | 9-10px uppercase mono 字距 统一范式 | 抽 .df-sec-h |
|
||||||
|
| 公共技法 | 无 | hairline 网格 kv 卡 / 发丝线行 / 染色两档 | 引入公共类 |
|
||||||
|
|
||||||
|
## 1. Token 层扩展(global.css :root,单一真值)
|
||||||
|
|
||||||
|
### 1.1 新增 token
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 字号谱(纯整数,对齐 u-agent) */
|
||||||
|
--df-fs-micro: 9px; /* 小节头/tag/计数 */
|
||||||
|
--df-fs-meta: 10px; /* 徽章/元数据 */
|
||||||
|
--df-fs-aux: 11px; /* 辅助文字/列表次级 */
|
||||||
|
--df-fs-ctrl: 12px; /* 控件文字/小按钮 */
|
||||||
|
--df-fs-body: 13px; /* 正文基准 */
|
||||||
|
--df-fs-title: 16px; /* 页头标题 */
|
||||||
|
|
||||||
|
/* 控件高度档 */
|
||||||
|
--df-h-btn: 30px; /* 主按钮(.btn 合一目标) */
|
||||||
|
--df-h-input: 26px; /* 行内输入/搜索 */
|
||||||
|
--df-h-modal-input: 30px;
|
||||||
|
--df-h-icon-btn: 26px; /* 图标钮统一档(22/26/28 收敛) */
|
||||||
|
|
||||||
|
/* 微内距暗刻度(chip/badge 族) */
|
||||||
|
--df-pad-chip: 1px 6px;
|
||||||
|
--df-pad-badge: 3px 8px;
|
||||||
|
|
||||||
|
/* 图标槽 */
|
||||||
|
--df-icon-gutter: 42px; /* ToolCard 图标槽(38/42 魔法数归一) */
|
||||||
|
|
||||||
|
/* 染色两档(语义色背景/边框透明度统一,双主题各配) */
|
||||||
|
/* 由 --df-*-bg(12% 底)+ 新增 --df-*-bd(30% 边)承担,消除散落 rgba 字面量 */
|
||||||
|
--df-success-bd / --df-warning-bd / --df-danger-bd / --df-info-bd / --df-accent-bd
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 修正既有错误
|
||||||
|
- 9 处 var 回退值写错(lg 8→10 / md 6→8 / xs 3→2):**删回退值**(token 必注入,回退是静默降档源)
|
||||||
|
- .df-tag 圆角 3px 字面量 → var(--df-radius-xs)
|
||||||
|
- body 基准 14px → 13px(line-height 1.5 保持);aichat 已对齐,功能页随页头波次跟进
|
||||||
|
|
||||||
|
## 2. 公共组件层(global.css/components.css 单一来源)
|
||||||
|
|
||||||
|
| 公共类 | 来源 | 定义去向 |
|
||||||
|
|---|---|---|
|
||||||
|
| `.btn` 族 | global+settings 双基类合一 30px/8px 16px/13px | global.css 唯一定义,settings.css 删副本 |
|
||||||
|
| `.df-sec-h` | u-agent 小节头范式(10px uppercase letter-spacing .5px mono tx3) | global.css 新增 |
|
||||||
|
| `.df-kv` / `.df-kv-i/k/v` | hairline 网格档案卡(gap:1px+bd 背景) | components.css 新增 |
|
||||||
|
| `.df-row` | 发丝线行(border-bottom+last-child none) | components.css 新增 |
|
||||||
|
| `.df-note` | 通知条(info/warning/danger 染色) | components.css 新增 |
|
||||||
|
| `.df-pill` | 状态胶囊(全圆+染色底) | components.css 新增 |
|
||||||
|
| `.df-tab` / `.df-tab--active` | 下划线 tab(2px accent 底线+计数 chip) | components.css 新增 |
|
||||||
|
| 既有 `.df-dot/.df-badge/.df-tag/.df-mono/.df-hstat` | 第五十四段已注入 | 保留,随 1.2 修圆角 |
|
||||||
|
|
||||||
|
## 3. 页面适配波(DRY 提取:各页 scoped 重复样式→公共类)
|
||||||
|
|
||||||
|
- **页头统一**:功能页 8 页 + Dashboard 全部改 `.page-header` 紧凑单行(h1 16px/500 + 定高 46px 等价 padding + header-actions gap 8px),删各页自造页头样式
|
||||||
|
- **侧栏 192→220px**(AppSidebar 一处)
|
||||||
|
- **输入框**:GitChanges/Provider 行内 21-22px → var(--df-h-input)
|
||||||
|
- **列表/详情页**:status-tag/status-* 徽章族(components.css 已有 13 个状态色)染边补齐 + 圆角统一;Details 页 kv 区改 .df-kv;tab 行改 .df-tab
|
||||||
|
- **settings 族**:settings.css .btn 副本删除并轨;面板内重复 card/row 样式提取
|
||||||
|
- **ai 族**(已对齐 workbench):仅扫尾——圆角字面量 9px 气泡入注释锚定、7px 代码块归 token、tag 圆角收敛
|
||||||
|
- **离群修复**:7px 间距→6/8、ProviderPanel 82px 手算对齐改结构、26px 图标位 calc、FilePreview outline-name 补 nowrap(确定 bug)、定宽输入/浮层补 max-width
|
||||||
|
|
||||||
|
## 4. 实施波次(文件零交集,逐波门禁 vue-tsc + 目检)
|
||||||
|
|
||||||
|
| 波 | 内容 | 文件面 |
|
||||||
|
|---|---|---|
|
||||||
|
| R0 底座 | token 扩展+回退修正+公共类新增+.btn 合一+body 13px | styles/global.css + components.css + settings/settings.css |
|
||||||
|
| R1 功能页族 | Projects/Tasks/Ideas/Knowledge/AuditLog 页头+密度+tag 收敛 | views/*.vue(5 页可再拆并行) |
|
||||||
|
| R2 详情页族 | ProjectDetail/TaskDetail/IdeaDetail(kv/tab/徽章/页头) | 3 个 Detail.vue 可并行 |
|
||||||
|
| R3 设置族 | settings.css 并轨+面板 DRY 提取 | components/settings/* |
|
||||||
|
| R4 ai 扫尾+杂项 | 圆角/token 扫尾+离群修复+FilePreview bug | components/ai/* + 杂项 |
|
||||||
|
| R5 配色收口 | 按配色审计报告优先级表 10 项实施(报告 temp/样式走查-1018/报告-配色全局审计.md):①幽灵 token 清零(含 --df-bg-secondary 等 6 处声明失效真 bug)②.status-* 徽章系 token 三件套③白叠层 64 处入 token(--df-hover/--df-zebra 浅色覆写黑叠层)④回退值 111 处全删⑤global.css 边框 rgba 改 color-mix⑥--df-neutral-bg⑦views 族 rgba 清零⑧--df-shadow-sm/md⑨alpha 收敛⑩豁免注释 | 按报告分布;R5 先补 token(叠层/中性/阴影)再批量替换 |
|
||||||
|
|
||||||
|
每波:前台同步代理实施 → 主代理 diff 核查 + vue-tsc → dev 窗口目检(用户可实时看)。R0 先行(其他波依赖新 token/类),R1-R4 在 R0 后并行。
|
||||||
|
|
||||||
|
## 5. 拍板点(按推荐值先行,用户可回改)
|
||||||
|
|
||||||
|
1. **基准字号 13px**(功能页降一档,信息密度对齐 u-agent;若觉偏小回 14 仅改一处 token 层 body 声明)
|
||||||
|
2. **侧栏 220px**(192→220 折中,非 250)
|
||||||
|
3. **页头 16px 标题+紧凑单行**(废 24px 大标题)
|
||||||
|
4. **按钮 30px 主档**(对齐 settings 既有 30px 而非 global 34px)
|
||||||
|
5. tag/badge 圆角统一 xs(2px)/sm(4px) 档
|
||||||
|
|
||||||
|
## 6. 单文件拆分轨([补4],与样式波错峰)
|
||||||
|
|
||||||
|
行数实测(2026-10-18):MessageList 3297 / ProjectDetail 1750 / TopBar 1700 / Tasks 1540 / ChatInput 1503 / IdeaDetail 1228 / ConversationSidebar 1226 / TaskDetail 1160 / AiChat 1145 / FilePreview 1144 / ProviderPanel 964 / FileExplorer 932 / ToolResultBody 895。
|
||||||
|
|
||||||
|
**拆分纪律**:
|
||||||
|
- 纯拆分不改行为——样式/逻辑原样搬迁,验收=vue-tsc 0 + 构建过 + dev 窗口目检无视觉回归
|
||||||
|
- 拆分边界按「区块自治」(模板区块+配套样式+相关逻辑成组),优先抽展示型子组件;共享状态经 props/emits 或既有 composable,不新造 store
|
||||||
|
- **与样式波互斥调度**:同一文件的样式适配与拆分不并行(样式先行,拆分在后——拆完再改样式要重新定位锚点)
|
||||||
|
- 拆分批次(样式波收口后启动,每批文件零交集):
|
||||||
|
- C1 ai 族:MessageList(3297→消息项/工具卡区/折叠条带等子件) → ChatInput(1503) → TopBar(1700)
|
||||||
|
- C2 项目族:ProjectDetail(1750) → FilePreview(1144) → FileExplorer(932)
|
||||||
|
- C3 任务/想法族:Tasks(1540) → TaskDetail(1160) → IdeaDetail(1228)
|
||||||
|
- C4 侧栏+设置族:ConversationSidebar(1226) → AiChat(1145) → ProviderPanel(964) → ToolResultBody(895)
|
||||||
|
- 每文件拆分目标:主文件 ≤600 行,子件单一职责
|
||||||
|
|
||||||
|
## 7. 整洁性轨(用户令 2026-10-18:代码整洁性全量处理+样式代码统一抽取,[补1] 共享的样式才抽取)
|
||||||
|
|
||||||
|
探针实测(dead-style-scan.json / dup-style-scan.json,脚本在 temp/样式走查-1018/):
|
||||||
|
- 死样式候选 368 处 / 56 文件(MessageList 34/ToolCard 25/TopBar 20/ActiveProjectsPanel 19 领衔);**假阳性规则**:Vue transition 生成类(xxx-enter/leave/from/to/active 由 `<transition name="xxx">` 隐式消费,非死)、动态拼接类(`'status-' + s`)、子组件透传类——删前须逐处验证
|
||||||
|
- 跨文件共享重复规则 49 条;典型双轨:`.ai-toast-*`(AiChat/ApprovalPopup)与 `.toast-*`(Knowledge/Settings)同义共存;`.confirm-actions`×3;`@keyframes spin/shimmer` 10/3 文件各写一份
|
||||||
|
|
||||||
|
**底座已落**(主代理亲码 global.css):`.df-toast` 四色族+过渡 / `@keyframes df-spin`/`df-shimmer` / `.df-confirm-actions`+过渡 / `.df-diff-pre` 族。
|
||||||
|
|
||||||
|
**D 批分路**(R5 收口后启动,文件族零交集,每路两件事:本族死样式验证删除 + 本族共享类消费替换):
|
||||||
|
- D-A ai 族+根组件(MessageList/ToolCard/TopBar/ConversationSidebar/ChatInput/ToolResultBody/AiChat/ToolCardList):死样式 100+ 处 + ai-toast 族消费 + spin/shimmer keyframes 消费 + .ai-tool-diff-pre 族并轨 .df-diff-pre
|
||||||
|
- D-B views+project 族(Knowledge/ApprovalPopup/Settings/Projects/ProjectDetail/Tasks/AuditLog/FilePreview/FileExplorer/GitChanges/ProjectCard):死样式 100+ 处 + .toast-* 族消费 + confirm-actions 消费
|
||||||
|
- D-C settings+layout+dashboard+knowledge 族(AdvancedSection/AllowedDirsPanel/UpdateBadge/ActiveProjectsPanel/KnowledgeDetail 等):死样式残余 + 确认弹层消费
|
||||||
|
- D4 TS/模板整洁(随各路顺带):未用 import/变量(vue-tsc noUnusedLocals 级)、console.log 残留、死模板分支——纯删不改语义
|
||||||
|
|
||||||
|
**验收纪律**:每路 vue-tsc 0 + grep 证据(死样式删除数/共享类消费数);transition 类假阳性必须 grep `<transition name=` 确认后再删。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 飞书通道 WS 假活:探活只告警不自愈
|
||||||
|
|
||||||
|
> 登记: AI-Ops 2026-10-11 22:15 · 来源: 用户 @ bot 零回应事故排查 · 实施归 devflow agent
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
用户 20:07 后在飞书 @ bot 发消息,零回应。feishu-channel.log 尾部停在 20:07 「打包换装」watcher 终版,其后 2h+ 零「收到消息」事件——消息根本未达应用,非模型/会话层问题。
|
||||||
|
|
||||||
|
## 证据
|
||||||
|
|
||||||
|
- 日志: `~/AppData/Roaming/top.1216.devflow/feishu-channel.log`
|
||||||
|
- 累计 90 条探活告警「660s 无任何事件且无在途会话(疑似假活,持续出现请重启应用重建连接)」,跨 10-05~10-11 多日分布,连接时好时坏
|
||||||
|
- 时间线: 14:00 前持续告警 → 20:04-20:07 恢复(收到 4 条消息正常受理+终版) → 20:07 起再断,至 22:12 仍零入站,进程未重启(devflow.exe PID 14172 同前)
|
||||||
|
- 应用本身健康: trace.log 持续输出, B5 引擎「🟢 健康,空闲」
|
||||||
|
- 对照: u-claw 曾有同款(热重建不关旧连接致僵尸 WS,closeGraceful 须显式 Close),见 u-claw 记忆 uclaw-zombie-ws-connection
|
||||||
|
|
||||||
|
## 根因方向
|
||||||
|
|
||||||
|
WS 长连接假活: 探活任务已检测到「无事件+无在途」但仅打日志,无自动重建连接动作,恢复依赖新消息触发或人工重启。为何 20:04 能自愈一次、20:07 后不能,需读 SDK 连接层确认(是否依赖入站流量触发重连)。
|
||||||
|
|
||||||
|
## 修复建议
|
||||||
|
|
||||||
|
1. 探活告警连续 N 次(如 2 次,约 20 分钟)→ 主动断开并重建 WS 连接(显式 Close 旧连接防僵尸),重建结果写日志
|
||||||
|
2. 重建失败退避重试,持续失败升级通知(桌面通知/卡片)
|
||||||
|
3. 复核 90 条告警的历史分布,确认是否存在更早未被用户察觉的静默窗口
|
||||||
|
|
||||||
|
## 排除项
|
||||||
|
|
||||||
|
- 97「收到消息」vs 47「受理返回」差值: 新旧版本日志口径混杂(受理返回为后加日志),受理守卫拒绝仅 2 条且走旧「AI 正在生成中」路径,无消息丢失
|
||||||
|
- watcher 终版无「回复」行: 新版投递走卡片原地更新(edit_card),非回复消息,属正常
|
||||||
@@ -418,6 +418,10 @@
|
|||||||
- [ ] 913 ✅转待办 验收:auto 模式审批架空语义收敛——auto_takeover_all 触发高危工具(run_command 等)须按 S2/信任分级处置(拦截或旁路留痕);审计表可查 auto 会话审批触发/旁路记录,产品语义写入设计档。
|
- [ ] 913 ✅转待办 验收:auto 模式审批架空语义收敛——auto_takeover_all 触发高危工具(run_command 等)须按 S2/信任分级处置(拦截或旁路留痕);审计表可查 auto 会话审批触发/旁路记录,产品语义写入设计档。
|
||||||
- [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次
|
- [ ] 观察进化系统只写不读:knowledges 1033 条/embedding 全 NULL/83.6% 零复用/knowledge_search 仅 5 次
|
||||||
- [ ] 915 ✅转待办 验收:知识库由「只写不读」转读写闭环——embedding_status 回填(或明确弃用向量路径),≥半数知识进检索候选,knowledge_search 调用与复用率实测上升(复用率 >50%、检索调用非个位数);口径=同库 DB 查询核验。
|
- [ ] 915 ✅转待办 验收:知识库由「只写不读」转读写闭环——embedding_status 回填(或明确弃用向量路径),≥半数知识进检索候选,knowledge_search 调用与复用率实测上升(复用率 >50%、检索调用非个位数);口径=同库 DB 查询核验。
|
||||||
|
- [~] **P0 治理批已落地(2026-10-23,用户拍板「推进落地」)**:①写入防重融合——提炼前按窗口关键词查已有知识 top5 随 prompt 附给 LLM,schema 增 action=new|update+target_id 协议,重复经验覆盖融合(target_id 校验候选集防幻觉 id),knowledge_insert 工具同口径(update_target_id);②淘汰机制——repo.archive_stale(零复用+low+超龄→archived,UPDATE RETURNING 批量)+knowledge_archive_stale command(逐条留痕 status_changed),检索面 status 白名单天然排除 archived,检索排序加 confidence 决胜键(两处 SQL);③向量路径「明确弃用检索用途」已声明(注释+实测 0/1688,阶段3 改职建边引擎)。验收:cargo test -p df-storage 242 passed(含 3 新测:update_content_merged 融合轨迹/archive_stale 三条件筛选/search_keywords 置信决胜)+cargo check -p devflow EXIT 0+knowledge 58 passed+tool_registry 50 passed(工具数未破)。✅ **存量清理批已执行(2026-10-23 用户授权「1」)**:先备份(backup API 快照 1688 条核对一致)后变更,判据校准 low→非high(生产实证 LLM 置信系统性偏高 low 仅 1/1688,原判据命中 0 形同虚设),归档 62 条(published 1550→1488)+knowledge_events 逐条留痕可回滚;**未执行档**:零复用+超龄不看置信 632 条(84% high 已无区分度,规模大待单独拍板)。台账:temp/数据变更台账.md。诊断与终局设计见 temp/知识库治理审查-20261023.md 与 temp/知识中枢终局设计-20261023.md(五阶段路线:0治节点→1连边→2图召回→3自动建边→4中枢共享,百万级换挡表已实测留档)。
|
||||||
|
- [~] **阶段1 连边已落地(2026-10-23 续批,用户「继续」)**:①V54 迁移 knowledge_edges 边表(kind/weight/origin/last_verified_at,双列索引,LATEST_SCHEMA_VERSION 53→54);②repo 边 CRUD——insert_edge(双向去重幂等+触验)、list_related(双向 JOIN+对端展开,archived 不展示);③提炼链 related_ids 协议——prompt 规则 7(new 时列最多 3 个强相关已有条目),解析循环建 similar 边(origin=llm-tag,target 校验候选集防幻觉);④knowledge_insert 工具 related_ids 同口径+knowledge_search 返回补 id 字段(agent 引用已有条目的前提);⑤详情页「关联知识」区块——knowledge_get_detail payload 直带 related,折叠 section+点击跳对端(open-related→selectKnowledge),双语 i18n。验收:cargo test -p df-storage 243 passed(V54 迁移幂等门+边 CRUD 新测)+cargo check -p devflow EXIT 0+vue-tsc --noEmit EXIT 0。阶段2(图召回激活扩散)✅ 2026-10-26 落地:list_neighbors_for_seeds(多种子 1-hop,排除种子/archived,权重降序)+knowledge_search 结果附 related 段(限 5);df-storage 233 绿。
|
||||||
|
- [~] **治理续批(2026-10-24 用户「自主推」+「蒸馏压缩合并/打包换装」)**:①激进档已执行——自主授权(可逆+留痕+备份),归档 570 条零复用超龄(置信不限),累计 archived 632/published 1550→918,备份 devflow.db.bak-知识库淘汰批二-1024,台账已登记;②存量蒸馏合并功能落地——knowledge_distill.rs(archived 按 kind 分组 limit 20→LLM 合订本 candidate→merges 边 origin=distill 连组内全部条目,strip_code_fence/candidate_record_from_item pub(crate) 复用)+repo.list_by_status_kind(created_at 升序最老优先)+knowledge_distill_group command;③版本 0.1.3→0.1.4,打包换装 package-devflow.ps1 -Mode full 执行中。验收:cargo test -p df-storage 244 passed(含 list_by_status_kind 新测)+cargo check EXIT 0。蒸馏批生产执行(app 内按 kind 逐组人工触发)与阶段2图召回待下批。
|
||||||
|
- [~] **渐进披露批(2026-10-24 /swarm 四执行者+主控核查,用户「披露式按需召回加载」+「不牺牲缓存命中」)**:D1 文档探索策略段 11.9K 全文常驻→路标 260 字符常驻+全文按需(URL/文档词触发,fingerprint 含门位,全文进 volatile 侧——缓存安全主控核查通过);D2 技能清单 120条×80字→60条×40字;D3 devflow.md 投影目录化(纪律分区全文保留,其余目录+read_file 按需,总闸 6000→3000);D4 工具 schema 属性级空串 description 清理(81 工具×~4 属性,~10KB/轮);驳回工具两跳披露(冻结集跨轮字节前缀稳定约束>收益)。**附带修复目标切换失忆(用户实证「切换目标两三轮后拉回老目标」)**:G1 钉扎段加仲裁行「用户最近指令>登记目标」——根因=system 级老目标压过对话流新意志。验收:cargo test -p devflow --lib 932 passed+df-storage 244+df-ai 605+cargo check EXIT 0。**换装 0.1.4 完成**(装机 exe 版本实测 0.1.4/PID 存活);⚠️ 登记换装脚本验证缺陷:swap 启动验证只查进程存活+读 mtime,build 期间旧进程复活锁文件致 NSIS 静默安装失败仍报「换装成功」——验证应加装机 exe 版本核对(待修)。新会话提示词静态底座 16.2K→~4.5K 字符+工具载荷省 ~10KB/轮。
|
||||||
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
|
- [ ] 观察多模态未走通:贴图落"[图片已忽略]"占位,实际靠 run_command 外部 vision CLI 绕路
|
||||||
- [ ] 917 ✅转待办 验收:贴图走原生视觉路径不落占位——非视觉模型收图时明确拒绝或切视觉模型,视觉模型直接出识别结果,不再靠 run_command 调外部 vision CLI 绕路;实测贴图会话零"[图片已忽略]"占位。
|
- [ ] 917 ✅转待办 验收:贴图走原生视觉路径不落占位——非视觉模型收图时明确拒绝或切视觉模型,视觉模型直接出识别结果,不再靠 run_command 调外部 vision CLI 绕路;实测贴图会话零"[图片已忽略]"占位。
|
||||||
- [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL
|
- [ ] 观察编排面零使用:workflow/node_executions/ai_plans 等 9 张表全 0 行;四会话分工 origin 89/89 全 NULL
|
||||||
@@ -756,3 +760,33 @@
|
|||||||
961. [~] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。
|
961. [~] **桌面轮次交互定位重定义(2026-10-11 用户拍板,补7 三条)**:①轨迹卡片收敛:除主回复气泡外交互收敛到一张「有动态+可折叠」的轨迹卡(现有轮次折叠类型),**修缺陷:正生成时卡片展开不了**;②轮次语义纠正:现「压缩后即新轮次」定位错——正确定义=**一次信息发起=一个任务轮次,中途补充信息(inject)仍属同一轮,全部完成后新发起才是新轮**;③轮次编号稳定化:现编号不稳定(刚是 5 一会下面又出现 1)——应固定为「该对话的第 1 轮/第 2 轮」持久编号(与消息 seq/轮边界锚点绑定,不随视图压缩/折叠漂移)。
|
||||||
- ✅ **①已修(2026-10-11)**:执行中 step 卡支持点击展开(复用 expandedTraceIds,key 与完成态 trace 同构,执行中展开→完成转 trace 态不丢;展开区实时渲染该轮过程工具卡)。
|
- ✅ **①已修(2026-10-11)**:执行中 step 卡支持点击展开(复用 expandedTraceIds,key 与完成态 trace 同构,执行中展开→完成转 trace 态不丢;展开区实时渲染该轮过程工具卡)。
|
||||||
- **②③设计要点(实施前拍板)**:轮边界判定锚点从「压缩摘要」改为「用户发起消息」——发起=桌面直发/飞书入站的 user 消息,补充=inject_runtime_message 写入的消息(需来源标记,建议复用 V52 source:{channel:"inject"} 与发起区分,归属判定 O(1));轮号=conv 内第 N 条「发起消息」序(数据派生,天然稳定,不随压缩/视图漂移),轨迹卡头/导航条/进度徽标统一取该口径(单一真值来源)。涉及:轮桶分桶逻辑(MessageList genBucketItems 上游)+轮号计算函数收敛+inject 消息 source 标记。
|
- **②③设计要点(实施前拍板)**:轮边界判定锚点从「压缩摘要」改为「用户发起消息」——发起=桌面直发/飞书入站的 user 消息,补充=inject_runtime_message 写入的消息(需来源标记,建议复用 V52 source:{channel:"inject"} 与发起区分,归属判定 O(1));轮号=conv 内第 N 条「发起消息」序(数据派生,天然稳定,不随压缩/视图漂移),轨迹卡头/导航条/进度徽标统一取该口径(单一真值来源)。涉及:轮桶分桶逻辑(MessageList genBucketItems 上游)+轮号计算函数收敛+inject 消息 source 标记。
|
||||||
|
|
||||||
|
# 产品方向定位:执行 agent 运行到远程容器(2026-10-18 用户口述,设计先行未实施)
|
||||||
|
- **定位**:DevFlow 未来形态=执行 agent 跑在远程容器(本机只做控制面/交互面),对齐 u-claw 分身实例形态——桌面端发起任务→远程容器内 agent 全权执行→结果回传。收益:长任务不占本机/环境隔离/多实例并行/24h 无人值守天然成立。
|
||||||
|
- **衔接存量**:df-tunnel/u-relay 线(v3 wire 协议+ws 隧道,已在树)正是传输底座;TaskBoundary 无人值守体系(边界内自主)是权限面底座;两者拼合即远程执行骨架。
|
||||||
|
- **待设计问题**(立项时展开):远程执行体的工具面裁剪(本机路径语义如何映射)/媒体与文件存储归属(远程落盘 vs 回传)/审批与边界在远程侧的执行点/凭据注入方式/断线重连与会话恢复/多容器池调度。
|
||||||
|
- **本条为方向登记,实施等用户令。**
|
||||||
|
- [ ] 队列带图项零标记(2026-10-18 走查残留):队列行渲染在 AiChat.vue:81-115,带图队列项无图标记;与 F3-2(ToolCardList scrollToFirstPending 预展开)同批处理。
|
||||||
|
|
||||||
|
### 💡 2026-10-18 ai-chat 上下文管理与信息组织完善(设计已定,按批领单)
|
||||||
|
> 用户令「上下文管理/信息组织开发完善+回复别太口水话」。蓝图:[02-架构设计/专项设计/ai-chat上下文管理与信息组织完善设计-2026-10-18.md](../02-架构设计/专项设计/ai-chat上下文管理与信息组织完善设计-2026-10-18.md);证据:temp/深查-aichat对话质量复测-2026-10-17.md(E系)+temp/深查-aichat信息UX-2026-10-17.md(F系)。M1 为 P1 先行,条目锚点/修法/验收全在设计文档,此处只列索引。
|
||||||
|
- [x] M1-P1① 恢复链治本:恢复消息 source 标记+[系统恢复]目标双侧过滤+防二次恢复(E1/E3;与 961② 同基建,可并 649 行旧条销账)——✅ 验收:cargo test -p devflow --lib recovery **6 passed**(zero_nesting_with_polluted_goals/all_polluted_falls_back/drops_recovery_artifacts_idempotent 等);conversation.rs:415 双侧过滤+单点拦截在位
|
||||||
|
- [x] M1-P1② message_repo 全量重写保留原 created_at(E11;污染存量 1750 条不回刷,止增量即可)——✅ 验收:cargo test -p df-storage **14 passed** 含 replace_conversation_preserves_created_at ok
|
||||||
|
- [x] M1-P1③ search_files/grep 0 命中「阴性存疑」标记转正(E6)——✅ 22:11 AI-Ops 子代理补齐:file.rs negative_hit_hint 共享函数+grep/search_files 两处接入;验收:negative_hit_hint_states_doubt_and_scope ok
|
||||||
|
- [~] M1-P1④ 跑题修复换装验证(E4;修复在树,换装后重放 6a5a3e04 seq1802 验证)——**已在工作区实施待验收**(prompt.rs list_active_with_activity 三源活跃度排序+指纹缓存对齐;验证方式:跑 dev 后重放场景)
|
||||||
|
- [ ] M2 存量目标一次性迁移/压缩降级前端透出/超时连败收敛(并 B5d)/boundary_json+loop_state 写入闭环(E5/E2/E13/P2-5)
|
||||||
|
- [ ] M3 会话 offset 分页+后端搜索/飞书审批决议词归一+终态卡/executing 桶内活性判定+审批预展开(F4-1/F5-1+2/F1-3+F3-2)
|
||||||
|
- [x] M4-a 提示词回复风格纪律(反口水话,中英双语+测试断言)——✅ 22:11 AI-Ops 子代理补齐:prompt.rs 中英两版行为准则各加「回复风格/Response style」条款(结论先行/省客套复述收尾/要点优先/没问不展开/「省的是空话不是内容」);验收:system_prompt_zh/en_has_response_style_clause 2 passed(共 5 passed)
|
||||||
|
- [ ] M4-b UX 小批(i18n 补键/token 化/aria/loaded/Esc/队列带图标记)——未动,留板
|
||||||
|
- [x] **收尾令(用户 2026-10-18 21:41 飞书下达到 AI-Ops,转登记)**:M1 销账后——①按 G3 提交本批改动并 push origin main(推送令已拍板,限本批范围);②启动 dev 模式运行验证(重点验 M4 反口水话与 M1 恢复链);③完成信息经飞书推送用户。若 AI-Ops 监测到 M1 已销账而本条未执行,AI-Ops 代执行。——✅ 23:0x AI-Ops 执行完毕:四笔分组提交(05d0f89 上下文P1批/302c4bc 飞书闭环/25ec9cf 前端令牌批/fb83339 文档)已 push(3bb662f..fb83339);验收 cargo test 2281 passed 0 failed+vue-tsc EXIT 0;dev 已跑(vite:1420 HTTP 200);完成信息已飞书推送。
|
||||||
|
|
||||||
|
### 💡 2026-10-19 上下文管理对标 u-claw·大批量并行差距(10-21 已批次化,待领单)
|
||||||
|
> 用户战略令(10-18 飞书):DevFlow 上 Linux 替代 u-claw+CC,第一门槛=上下文管理三命题(丢记忆/历史召回/token 高效),大批量多会话并行是常态场景。详情:[02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md](../02-架构设计/专项设计/上下文管理对标u-claw与大批量并行差距分析-2026-10-19.md)。**10-21 勘察批**:分析落 temp/分析-P0A/P0B/P1A/P1B-P2A-2026-10-21.md+temp/待完成六条-汇总方案-2026-10-21.md。
|
||||||
|
- [x] **T1 批 P0-A usage 口径统一**(S-M,无依赖,推荐首批):考古实锤=三口径列已齐(V38 864c696 prompt/completion+V39 a031521 cache_hit/cache_miss/reasoning,ai_messages;V5 ai_conversations 累加),include_usage 已注入——**真缺口=两栈口径漂移**:openai_compat.rs:542-549 缺失全零静默入库(三路走查:85)vs Anthropic 缺失硬失败、anthropic cache 段少记 prompt;修=缺失回退估算+is_estimated 标记+cache 段口径对齐 u-claw 三口径(input/cache_read/cache_creation)。拍板:缺失回退策略确认——✅ **2026-10-22 落地**:anthropic_helpers usage 整段+字段双层 serde default(端点不回 usage 不再炸帧,全零下行走既有估算轨 round_usage_effective 打 is_estimated;openai 臂既有 serde default 同口径零改动)+cache 三口径归一(prompt=input+cache_read+cache_creation 对齐 openai 全量输入语义,apply_anthropic_event 归一点)+round_is_estimated 全链透传(stream_relay→loop→completed_event)。df-ai 603 绿
|
||||||
|
- [x] **T2 批 P0-B 压缩水位判据**(S 级两阶段,Phase1 无硬依赖可首批并行):方案实锤=temp/分析-P0B-压缩水位判据-2026-10-21.md——现行判据 context_lifecycle.rs:117-138(history>0.6×(budget−sys)+保护区 6 条);窗口分母已解决(model_context.rs:31 三级来源,budget 模型感知零新增管道);Phase 1=判据行加分支(KV `df-ai-compress-watermark` 默认关+`-ratio` 默认 0.70,分子维持估算,开关关逐字节等价)/Phase 2=分子换 usage 真值(usage_anchor 锚点,缺失自动回退);与 W1-W4 交互=触发后移缓存净增(W3 可验),红线 trigger+max_round_growth<available;**S1 分批压缩同轴必须合并实施序**(T2 立轴 S1 叠批次);**验收口径修正:「压缩/请求比 4.8」数错对象(是 view-only 视图压缩日志非自动压缩频度,归 P3-7 另治),验收改用「自动压缩成功行数/日」**。拍板 5:分母 budget/0.70 默认/灰度翻转/S1 合并序/PROTECT_COUNT 双源统一(可选)——✅ **2026-10-22 Phase1 落地**:compress_should_trigger 双模式(context_lifecycle.rs:113,开关关=现行 0.6 判据逐字节等价;开=水位主判据 history+sys>ratio×budget 严格大于,替换语义不并联)+parse_watermark_ratio 钳位 [0.3,0.95](prompt.rs:775,保 headroom≥5% budget,debug_assert 契约)+KV 开关默认关+S1 同轴挂点注释钉死实施序;Phase2 usage 真值分子待 T1 数据观察后另批
|
||||||
|
- [x] **T3 批 P1-A 跨会话检索**(核心 2.5d/含前端 3.5d,无依赖可并行):方案已实锤=temp/分析-P1A-跨会话检索-2026-10-21.md(终版含生产库实测)——**实测:ai_messages 32,349 行/117 会话,content 39.5MB,compressed 消息原文完整保留=全量账本,archive jsonl 仅派生副本,检索面盖 ai_messages 即全覆盖,零迁移零回填**;LIKE 实测 113ms/单关键词——LIKE+复用知识库分词(extract_query_keywords,knowledge_inject.rs:115)起步,FTS5 开箱可用(bundled SQLite 3.45)但中文按字切分召回差+仓内两先例拍过不上,升级条件(>50 万行/P95>500ms)留档;**双入口分期**:AI 工具 history_search 先行(两跳式瘦身:首跳每会话 120 字片段/深查二跳 conversation_id,对齐 2K 预算纪律 namespace_store.rs:32;无参降级列最近 10 会话;基线 tool_registry.rs:1520 断言 80 工具+1),前端侧栏升级同批可选;sop 指引 prompt.rs:161-163 guidance 行+:251 父子同源(先例 :385)。拍板 6:①入口分期(AI 先行+前端可选)②两跳式瘦身 ③无参列会话内置降级 ④tool 角色默认排除检索 ⑤无摘要会话摘要降级链 ⑥FTS5 阈值留档——✅ **2026-10-22 AI 工具入口落地**:message_repo 跨会话关键词检索(MessageSearchHit+snippet 命中点前后 200 字截 500,FTS5 升级触发条件注释留档)+idea_repo escape_like 升 pub(crate) 单一实现复用+tools/history.rs 声明式注册 history_search(两跳式/无参降级列最近会话/tool 角色排除)+tool_registry 接线+prompt guidance 双语(严禁 run_command 查 sqlite 代查+压缩史 read_file archive 路径引导)。df-storage 227 绿;前端侧栏入口未做(分期可选,待令)
|
||||||
|
- [x] **T4 批 P1-B 代语义闭环**(M,无依赖):boundary/loop_state 填充率 0 的挂点补齐——四事件挂点=压缩完成/停靠(emit_task_parked agentic/mod.rs:~1464)/恢复链快照/会话清空;换代通知接飞书既有推送通道(审批/停靠通知同范式);DDL 待勘察(T4 分析回收后定 V54+)——✅ **2026-10-22 三件套落地**(零 DDL,status 列 V50 既有):件一 status 终局补写(emit_ai_completed_once 顺写 completed/emit_fatal_error 写 error/ai_chat_clear 事务内归位 loop_state=NULL+status=active+boundary_json=NULL 防复活)+件二 run_generation_notifier 飞书推卡(订阅 AiTaskParked/AiAutoCompressed,停靠去重+换代 30s 聚合)+件三 DELIVERY_GUIDANCE_PROMPT 渠道段注入(cache_on 分流)+watcher 交付缺口补(快任务兜底路径早退漏调 reply_deliveries)。进度=temp/三件套-代语义闭环与飞书推送-进度-2026-10-21.md;devflow 930 绿
|
||||||
|
- [ ] **T5 项 P2-A 大内容外置纪律**(S,轻,可搭车 T3/T4):system prompt 处理规则段(u-claw #3:大结论写盘 out/,回复给路径+3 行结论);write_file 权限语义+out/ 目录约定随批定
|
||||||
|
- [x] ~~P2-B 并行资源治理~~ → **不立项,占位留 Linux 化**(temp/分析-P2B-并行资源治理占位-2026-10-21.md:前置=P0-A 预留 per-conversation 聚合口径+P1-B 挂点不堵死;触发=Linux 化立项启动)
|
||||||
|
|
||||||
|
**批次推进序**:T1+T3 并行首批 → T2(依赖 T1)+T4 → T5 搭车;P2-B 留合流。挂账提醒:T1-T5 开工前先清 6 文件拖拽落盘攒批(等提交令)。
|
||||||
|
|||||||
+18
-1
@@ -5,6 +5,21 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>DevFlow</title>
|
<title>DevFlow</title>
|
||||||
|
<style>
|
||||||
|
/* 启动期加载占位:纯 CSS spinner,Vue mount 后随 #app 内容替换 */
|
||||||
|
.boot-loading {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.boot-spinner {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border: 3px solid rgba(123, 111, 240, 0.2);
|
||||||
|
border-top-color: #7b6ff0;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: boot-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes boot-spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body style="background:#0c0e1a">
|
<body style="background:#0c0e1a">
|
||||||
<script>
|
<script>
|
||||||
@@ -15,7 +30,9 @@
|
|||||||
document.documentElement.setAttribute('data-theme', 'light');
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/src/mocks/tauri-shim.ts"></script>
|
<script type="module" src="/src/mocks/tauri-shim.ts"></script>
|
||||||
<div id="app"></div>
|
<div id="app">
|
||||||
|
<div class="boot-loading"><div class="boot-spinner"></div></div>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 探测本机正在运行的 DevFlow 进程及其版本(只读)
|
||||||
|
$p = Get-Process devflow -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Path
|
||||||
|
if ($p) {
|
||||||
|
$v = (Get-Item $p).VersionInfo
|
||||||
|
Write-Host "PATH=$p"
|
||||||
|
Write-Host "FileVersion=$($v.FileVersion)"
|
||||||
|
Write-Host "ProductVersion=$($v.ProductVersion)"
|
||||||
|
} else {
|
||||||
|
Write-Host "NO_DEVFLOW_PROCESS"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 校验 updater 验签密钥配对(终版): 比对 keyid(KEYNUM 8字节) —— minisign 格式对齐
|
||||||
|
# conf.pubkey blob(42B): SIGALG"Ed"[0..2] + KEYNUM[2..10] + PUBKEY[10..42]
|
||||||
|
# 私钥文件 blob(116B): SIGALG"Rs"[0..2] + KDFALG[2..4] + CKSUM[4..12] + SALT[12..28] + OPS[28..36] + MEM[36..44] + KEYNUM[44..52] + SIG[52..116]
|
||||||
|
$confPath = "E:\wk-lab\devflow\src-tauri\tauri.conf.json"
|
||||||
|
$keyPath = Join-Path $env:USERPROFILE ".tauri\devflow.key"
|
||||||
|
|
||||||
|
# 1. conf 侧: 提取 pubkey → 解码 → 取 KEYNUM
|
||||||
|
$raw = Get-Content $confPath -Raw
|
||||||
|
if ($raw -match '"pubkey"\s*:\s*"([A-Za-z0-9+/=]{40,400})"') { $confPubB64 = $Matches[1] } else { Write-Host "CONF_PUBKEY_NOT_FOUND"; exit 1 }
|
||||||
|
# conf.pubkey 是 base64(minisign .pub 文件文本),需二次解码取末行
|
||||||
|
$confText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($confPubB64))
|
||||||
|
$pubLines = @($confText -split "`n" | Where-Object { $_.Trim() -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
|
||||||
|
if ($pubLines.Count -lt 1) { Write-Host "CONF_PUB_FORMAT_UNEXPECTED"; exit 1 }
|
||||||
|
$confBlob = [Convert]::FromBase64String($pubLines[0])
|
||||||
|
$confKeynum = ($confBlob[2..9] | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||||
|
Write-Host "conf keyid : $confKeynum (blob $($confBlob.Length)B)"
|
||||||
|
|
||||||
|
# 2. key 侧: 私钥文件 = 注释行 + 1 条 b64 blob(KEYNUM 未加密,明文可读)
|
||||||
|
if (-not (Test-Path $keyPath)) { Write-Host "KEY_NOT_FOUND: $keyPath"; exit 1 }
|
||||||
|
$b64Lines = @(Get-Content $keyPath | Where-Object { $_ -and $_ -notmatch 'comment' } | ForEach-Object { $_.Trim() })
|
||||||
|
if ($b64Lines.Count -lt 1) { Write-Host "KEY_FORMAT_UNEXPECTED"; exit 1 }
|
||||||
|
$secBlob = [Convert]::FromBase64String($b64Lines[0])
|
||||||
|
$secKeynum = ($secBlob[44..51] | ForEach-Object { $_.ToString("x2") }) -join ""
|
||||||
|
Write-Host "key keyid : $secKeynum (blob $($secBlob.Length)B)"
|
||||||
|
|
||||||
|
# 3. 配对判定
|
||||||
|
if ($confKeynum -eq $secKeynum) {
|
||||||
|
Write-Host "MATCH - updater 验签密钥配对正确(keyid $confKeynum), B3 安装验签可通过"
|
||||||
|
exit 0
|
||||||
|
} else {
|
||||||
|
Write-Host "MISMATCH - tauri.conf.json 的 pubkey 与签名私钥不配对, B3 安装验签将失败!"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 发布收尾:杀挂起脚本 -> 手动签名 0.1.3 -> latest.json -> 上传 -> 远端验证
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$repo = "E:\wk-lab\devflow"
|
||||||
|
$nsis = "$repo\target\release\bundle\nsis"
|
||||||
|
$exe = "$nsis\devflow_0.1.3_x64-setup.exe"
|
||||||
|
$key = "$env:USERPROFILE\.tauri\devflow.key"
|
||||||
|
$site = "suke-dev"
|
||||||
|
$remoteDir = "/var/www/app-site/download/devflow"
|
||||||
|
$baseUrl = "https://app.1216.top/download/devflow"
|
||||||
|
$notes = "版本更新与自动升级全链路上线:设置页新增「关于」,支持应用内检查/下载/安装更新"
|
||||||
|
|
||||||
|
# 1. 杀挂起的发布脚本与构建残留
|
||||||
|
Get-Process -Id 14328,2008,26208,28276,19412 -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "[1/5] 挂起进程已清理"
|
||||||
|
|
||||||
|
# 2. 手动签名(key 文件内容经 env 传入——-k/ev 传路径会被当 base64 解码,报 Invalid symbol 58)
|
||||||
|
Set-Location $repo
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY = (Get-Content $key -Raw).Trim()
|
||||||
|
$env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = ""
|
||||||
|
cmd /c "cargo tauri signer sign -p `"`" --app-version 0.1.3 `"$exe`" < NUL 2>&1"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 签名"; exit 1 }
|
||||||
|
$sigPath = "$exe.sig"
|
||||||
|
if (-not (Test-Path $sigPath)) { Write-Host "[失败] .sig 未生成"; exit 1 }
|
||||||
|
Write-Host "[2/5] 签名完成: $sigPath"
|
||||||
|
|
||||||
|
# 3. 生成 latest.json(UTF-8 无 BOM,防 PS5.1 BOM 坑)
|
||||||
|
$sig = (Get-Content $sigPath -Raw).Trim()
|
||||||
|
$pubDate = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
$latestJson = [ordered]@{
|
||||||
|
version = "0.1.3"
|
||||||
|
notes = $notes
|
||||||
|
pub_date = $pubDate
|
||||||
|
platforms = [ordered]@{
|
||||||
|
"windows-x86_64" = [ordered]@{
|
||||||
|
signature = $sig
|
||||||
|
url = "$baseUrl/devflow_0.1.3_x64-setup.exe"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json -Depth 5
|
||||||
|
$latestPath = "$nsis\latest.json"
|
||||||
|
[System.IO.File]::WriteAllText($latestPath, $latestJson, (New-Object System.Text.UTF8Encoding($false)))
|
||||||
|
Write-Host "[3/5] latest.json 已生成 (version=0.1.3)"
|
||||||
|
|
||||||
|
# 4. 上传三件套
|
||||||
|
$env:MSYS_NO_PATHCONV = "1"
|
||||||
|
ssh-proxy exec -n $site -c "mkdir -p $remoteDir"
|
||||||
|
foreach ($f in @($exe, $sigPath, $latestPath)) {
|
||||||
|
$name = Split-Path $f -Leaf
|
||||||
|
Write-Host "[4/5] 上传 $name ..."
|
||||||
|
ssh-proxy upload --name $site --src $f --dst "$remoteDir/$name"
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Host "[失败] 上传 $name"; exit 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. 远端验证
|
||||||
|
Write-Host "[5/5] 远端清单验证:"
|
||||||
|
ssh-proxy exec -n $site -c "ls -la $remoteDir | grep 0.1.3; head -c 200 $remoteDir/latest.json"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "[发布完成] v0.1.3 清单地址: $baseUrl/latest.json"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 后台启动 publish-release.ps1(tauri build 10 分钟级,run_command 600s 上限装不下)
|
||||||
|
# 用法: run_command 执行本文件 -> 立即返回 pid -> 之后轮询 release-out.log
|
||||||
|
$repo = 'E:\wk-lab\devflow'
|
||||||
|
$log = "$repo\target\release-out.log"
|
||||||
|
$errl = "$repo\target\release-err.log"
|
||||||
|
|
||||||
|
# 清掉上次 600s 超时可能残留的构建进程,防 cargo 构建锁互等
|
||||||
|
$killed = Get-Process cargo,cargo-tauri,rustc -ErrorAction SilentlyContinue
|
||||||
|
if ($killed) {
|
||||||
|
$killed | Stop-Process -Force
|
||||||
|
Write-Host ("清理残留构建进程: " + (($killed | Select-Object -ExpandProperty Id) -join ','))
|
||||||
|
} else {
|
||||||
|
Write-Host "无残留构建进程"
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item $log,$errl -ErrorAction SilentlyContinue
|
||||||
|
$p = Start-Process -FilePath 'powershell.exe' `
|
||||||
|
-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',"$repo\scripts\publish-release.ps1") `
|
||||||
|
-WorkingDirectory $repo `
|
||||||
|
-NoNewWindow `
|
||||||
|
-RedirectStandardOutput $log `
|
||||||
|
-RedirectStandardError $errl `
|
||||||
|
-PassThru
|
||||||
|
Set-Content -Path "$repo\target\release-pid.txt" -Value $p.Id -Encoding ascii
|
||||||
|
Write-Host "release started, pid=$($p.Id), log=$log"
|
||||||
@@ -59,8 +59,16 @@ Start-Sleep -Seconds 2
|
|||||||
Start-Process $bundle.FullName -ArgumentList "/S" -Wait
|
Start-Process $bundle.FullName -ArgumentList "/S" -Wait
|
||||||
|
|
||||||
if (-not (Test-Path $exe)) { Write-Host "[失败] 安装后未找到 $exe"; exit 1 }
|
if (-not (Test-Path $exe)) { Write-Host "[失败] 安装后未找到 $exe"; exit 1 }
|
||||||
|
# 版本核对(2026-10-25 实证修复):NSIS 静默安装可能因文件锁静默失败,旧进程复活锁文件时
|
||||||
|
# 曾出现「报换装成功但装机 exe 仍是旧版」。装机版本必须与本次构建一致才算换装成功。
|
||||||
|
$wantVer = (Get-Content "$repo\src-tauri\tauri.conf.json" | ConvertFrom-Json).version
|
||||||
|
$gotVer = (Get-Item $exe).VersionInfo.ProductVersion
|
||||||
|
if ($gotVer -ne $wantVer) {
|
||||||
|
Write-Host "[失败] 静默安装未生效:装机 exe 版本 $gotVer != 构建 $wantVer(疑似安装器被文件锁/旧进程拦截),请手工运行安装包后重试"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
$exeTime = (Get-Item $exe).LastWriteTime
|
$exeTime = (Get-Item $exe).LastWriteTime
|
||||||
Write-Host "[4/4] 启动验证(装机 exe 时间: $exeTime)..."
|
Write-Host "[4/4] 启动验证(装机 exe $gotVer,时间: $exeTime)..."
|
||||||
Start-Process $exe
|
Start-Process $exe
|
||||||
Start-Sleep -Seconds 6
|
Start-Sleep -Seconds 6
|
||||||
$proc = Get-Process devflow -ErrorAction SilentlyContinue
|
$proc = Get-Process devflow -ErrorAction SilentlyContinue
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "devflow"
|
name = "devflow"
|
||||||
version = "0.1.2"
|
version = "0.1.5"
|
||||||
description = "DevFlow Desktop App"
|
description = "DevFlow Desktop App"
|
||||||
authors = [""]
|
authors = [""]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "DevFlow default permissions",
|
"description": "DevFlow default permissions",
|
||||||
"windows": ["main", "ai-detached", "ai-detached-*", "fe-detached-*", "approval-popup"],
|
"windows": ["main", "ai-detached", "ai-detached-*", "fe-detached-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:event:default",
|
"core:event:default",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! 当用户长时间未处理待审批时,自动将其转为拒绝状态,防止会话永久卡死。
|
//! 当用户长时间未处理待审批时,自动将其转为拒绝状态,防止会话永久卡死。
|
||||||
//!
|
//!
|
||||||
//! 触发点:[`cleanup_expired_approvals`] 由 [`try_continue_agent_loop`] 入口调用,
|
//! 触发点:[`cleanup_expired_approvals`] 由 [`try_continue_agent_loop`] 入口调用,
|
||||||
//! 覆盖所有审批恢复路径(ai_approve / ai_authorize_dir / ai_continue_loop 等)。
|
//! 覆盖所有审批恢复路径(ai_approve / ai_continue_loop 等)。
|
||||||
//!
|
//!
|
||||||
//! 超时时长来自 AppState.approval_timeout_minutes(Settings KV 持久化,默认 15 分钟),
|
//! 超时时长来自 AppState.approval_timeout_minutes(Settings KV 持久化,默认 15 分钟),
|
||||||
//! 0 表示禁用超时。
|
//! 0 表示禁用超时。
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)。
|
//! 从 `mod.rs::run_agentic_loop` 机械抽取,后经 L0-3 后台化(见下)。
|
||||||
//!
|
//!
|
||||||
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
//! 触发条件(默认):history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||||
|
//! P0-B 水位判据(KV `df-ai-compress-watermark`,默认关=上式逐字节不变;开=替换为水位
|
||||||
|
//! 主判据 history+sys > ratio×budget,见 [`compress_should_trigger`])。
|
||||||
//!
|
//!
|
||||||
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||||
//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
|
//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
|
||||||
@@ -87,6 +89,50 @@ async fn reset_compressing_after_panic(session_arc: &Arc<Mutex<AiSession>>, conv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 压缩触发判据(P0-B Phase 1 纯函数收口,便于单测;设计=temp/分析-P0B-压缩水位判据-2026-10-21)。
|
||||||
|
///
|
||||||
|
/// - **开关关(默认)**:逐字节等价现行判据 `available*6/10 < history`(available=budget−sys,
|
||||||
|
/// 即原内联表达式)——分支前置,不进任何新逻辑。
|
||||||
|
/// - **开关开**:**替换**为水位主判据 `history+sys > ratio×budget`(分子=估算 history+sys,
|
||||||
|
/// 与现行口径同源;分母=budget_limit 模型感知,与 build_for_request 裁剪硬门同轴,免坐标
|
||||||
|
/// 错位;严格大于,恰等于阈值不触发)。0.6 判据仅在开关关时生效;体积兜底=build_for_request
|
||||||
|
/// 硬门(1.0×available 视图裁剪,永在),不在判据层并联。
|
||||||
|
/// - 保护区门(protect_start>0,最近 PROTECT_COUNT 条不参与)与「区外有可压缩消息」门在
|
||||||
|
/// 两种模式下同样生效,不走分支。
|
||||||
|
///
|
||||||
|
/// 红线(分析 §4.3):水位触发点 ratio×budget + 单轮最大增长必须 < available=budget−sys。
|
||||||
|
/// ratio 经 `parse_watermark_ratio` 钳位 [0.3,0.95] 保证 headroom ≥ 5% budget。开关开为替换
|
||||||
|
/// 语义:若口径异常令水位线 ≥ available,自动压缩不再触发,唯一防线=build_for_request 硬门
|
||||||
|
/// (视图裁到 available 内,永在)——水位判据不会越过裁剪门,只是失去提前接住的机会。
|
||||||
|
///
|
||||||
|
/// S1 叠批次挂点:S1 分批压缩(C_high/C_low 批次语义)与本判据同轴同点,后续在本函数
|
||||||
|
/// 水位分支上叠加批次参数,不另起第二处判据(分析 §4.3 实施序);本批不实施 S1。
|
||||||
|
fn compress_should_trigger(
|
||||||
|
watermark_on: bool,
|
||||||
|
ratio: f32,
|
||||||
|
protect_start: usize,
|
||||||
|
history_tokens: u32,
|
||||||
|
budget: u32,
|
||||||
|
sys_tokens: u32,
|
||||||
|
has_compressible: bool,
|
||||||
|
) -> bool {
|
||||||
|
debug_assert!(
|
||||||
|
(super::super::prompt::MIN_WATERMARK_RATIO..=super::super::prompt::MAX_WATERMARK_RATIO)
|
||||||
|
.contains(&ratio),
|
||||||
|
"ratio 须经 parse_watermark_ratio 钳位后传入",
|
||||||
|
);
|
||||||
|
if !watermark_on {
|
||||||
|
// 关:现行判据逐字节等价(触发阈值 0.6*available,available=budget−sys_tokens)。
|
||||||
|
return protect_start > 0
|
||||||
|
&& (budget.saturating_sub(sys_tokens) as u64) * 6 / 10 < history_tokens as u64
|
||||||
|
&& has_compressible;
|
||||||
|
}
|
||||||
|
// 开:水位主判据(总入账 history+sys 超 ratio×budget;严格大于,恰等于不触发)。
|
||||||
|
// 替换语义:0.6 判据仅在开关关时生效,不在判据层并联(体积兜底=build_for_request 硬门)。
|
||||||
|
let watermark = history_tokens as u64 + sys_tokens as u64 > (budget as f32 * ratio) as u64;
|
||||||
|
protect_start > 0 && watermark && has_compressible
|
||||||
|
}
|
||||||
|
|
||||||
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||||
///
|
///
|
||||||
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
|
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
|
||||||
@@ -116,6 +162,12 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
) -> bool {
|
) -> bool {
|
||||||
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
|
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
|
||||||
if !prev_compressing {
|
if !prev_compressing {
|
||||||
|
// P0-B 水位判据配置(KV 双键,锁外读一次;默认关=现行判据逐字节不变,
|
||||||
|
// 对齐 compress_recall 同点零新增锁的读法)。
|
||||||
|
let (wm_on, ratio) = {
|
||||||
|
let st = app_handle.state::<AppState>();
|
||||||
|
super::super::prompt::compress_watermark_cfg(&st).await
|
||||||
|
};
|
||||||
// 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。
|
// 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。
|
||||||
let (should_compress, protect_start, pre_compress_tokens) = {
|
let (should_compress, protect_start, pre_compress_tokens) = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
@@ -129,11 +181,17 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
let mgr = &mgr.messages;
|
let mgr = &mgr.messages;
|
||||||
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
|
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
|
||||||
let history_tokens = mgr.history_tokens();
|
let history_tokens = mgr.history_tokens();
|
||||||
let available = mgr.budget_limit().saturating_sub(sys_tokens);
|
// 触发判据收口 compress_should_trigger:开关关=原 0.6 判据逐字节等价;
|
||||||
// 触发阈值 0.6*available(可用预算=budget-sys_tokens):available*6/10 < history_tokens
|
// 开=水位主判据(ratio×budget,替换原 0.6 判据)。
|
||||||
let should = protect_start > 0
|
let should = compress_should_trigger(
|
||||||
&& (available as u64) * 6 / 10 < history_tokens as u64
|
wm_on,
|
||||||
&& mgr.has_compressible_messages(protect_start);
|
ratio,
|
||||||
|
protect_start,
|
||||||
|
history_tokens,
|
||||||
|
mgr.budget_limit(),
|
||||||
|
sys_tokens,
|
||||||
|
mgr.has_compressible_messages(protect_start),
|
||||||
|
);
|
||||||
(should, protect_start, history_tokens)
|
(should, protect_start, history_tokens)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -270,7 +328,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
);
|
);
|
||||||
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
|
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
|
||||||
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
|
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
|
||||||
keyword_fallback_commit(
|
let degraded = keyword_fallback_commit(
|
||||||
&session_arc,
|
&session_arc,
|
||||||
&conv_id,
|
&conv_id,
|
||||||
protect_start,
|
protect_start,
|
||||||
@@ -281,6 +339,16 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
recall_on.then_some(data_dir.as_path()),
|
recall_on.then_some(data_dir.as_path()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// E2:降级不再静默(实证 76730ce8 seq166 用户被迫原句重发)
|
||||||
|
if degraded {
|
||||||
|
emit_ai_chat_event(
|
||||||
|
&app_handle,
|
||||||
|
AiChatEvent::AiContextDegraded {
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
reason: "压缩 provider 重建失败,已降级为关键词摘要".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -384,7 +452,7 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||||
KEYWORD_FALLBACK_ENABLED,
|
KEYWORD_FALLBACK_ENABLED,
|
||||||
);
|
);
|
||||||
keyword_fallback_commit(
|
let degraded = keyword_fallback_commit(
|
||||||
&session_arc,
|
&session_arc,
|
||||||
&conv_id,
|
&conv_id,
|
||||||
protect_start,
|
protect_start,
|
||||||
@@ -395,6 +463,17 @@ pub(super) async fn maybe_auto_compress(
|
|||||||
recall_on.then_some(data_dir.as_path()),
|
recall_on.then_some(data_dir.as_path()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// E2:降级不再静默——AiError 语义是生成结束,此处 loop 仍在跑,
|
||||||
|
// 用专用事件让前端透出降级状态条/提示(不打断流)
|
||||||
|
if degraded {
|
||||||
|
emit_ai_chat_event(
|
||||||
|
&app_handle,
|
||||||
|
AiChatEvent::AiContextDegraded {
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
reason: "LLM 压缩失败,已降级为关键词摘要".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}) // 闭 catch_unwind 内 async move
|
}) // 闭 catch_unwind 内 async move
|
||||||
@@ -444,11 +523,11 @@ async fn keyword_fallback_commit(
|
|||||||
log_head: &str,
|
log_head: &str,
|
||||||
head_id: &Option<String>,
|
head_id: &Option<String>,
|
||||||
recall: Option<&Path>,
|
recall: Option<&Path>,
|
||||||
) {
|
) -> bool {
|
||||||
let inserted = {
|
let inserted = {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
if !session.per_conv.contains_key(conv_id) {
|
if !session.per_conv.contains_key(conv_id) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
let conv = session.conv(conv_id);
|
let conv = session.conv(conv_id);
|
||||||
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
|
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
|
||||||
@@ -458,7 +537,7 @@ async fn keyword_fallback_commit(
|
|||||||
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
|
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
|
||||||
log_head
|
log_head
|
||||||
);
|
);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
|
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
|
||||||
conv.messages.set_compressing(false);
|
conv.messages.set_compressing(false);
|
||||||
@@ -495,6 +574,7 @@ async fn keyword_fallback_commit(
|
|||||||
log_head
|
log_head
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -616,3 +696,108 @@ mod guard_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P0-B 水位判据契约(纯函数级,对齐 guard_tests 风格):
|
||||||
|
// ①开关关=现行判据逐值等价 ②开=水位边界 ±1 token(严格大于才触发)③替换语义:开档
|
||||||
|
// 0.6 判据不再生效(触发点后移)④保护区/可压缩门两模式同效。
|
||||||
|
// ratio 钳位契约见 prompt.rs tests(parse_watermark_ratio)。
|
||||||
|
#[cfg(test)]
|
||||||
|
mod watermark_tests {
|
||||||
|
use super::compress_should_trigger;
|
||||||
|
|
||||||
|
const BUDGET: u32 = 102_000; // 128K 窗口:budget=(128K−8K)×0.85
|
||||||
|
const SYS: u32 = 8_192;
|
||||||
|
|
||||||
|
/// ①开关关:全输入域网格遍历,与现行内联表达式逐值同价(零行为变化的回归证明)。
|
||||||
|
#[test]
|
||||||
|
fn watermark_off_equals_legacy_expression() {
|
||||||
|
for budget in [0u32, 1_000, BUDGET, u32::MAX] {
|
||||||
|
for sys in [0u32, 100, SYS, u32::MAX] {
|
||||||
|
for history in [0u32, 999, 56_000, u32::MAX] {
|
||||||
|
for protect in [0usize, 1, 6] {
|
||||||
|
for compressible in [false, true] {
|
||||||
|
let available = budget.saturating_sub(sys);
|
||||||
|
let legacy = protect > 0
|
||||||
|
&& (available as u64) * 6 / 10 < history as u64
|
||||||
|
&& compressible;
|
||||||
|
assert_eq!(
|
||||||
|
compress_should_trigger(
|
||||||
|
false, 0.70, protect, history, budget, sys, compressible,
|
||||||
|
),
|
||||||
|
legacy,
|
||||||
|
"关分支必须与现行判据逐值等价(b={budget},sys={sys},h={history})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ②开关开:水位边界 ±1 token——恰等于阈值不触发(严格大于),超 1 token 触发。
|
||||||
|
#[test]
|
||||||
|
fn watermark_on_boundary_exact_no_over_yes() {
|
||||||
|
// ratio=0.5 在 f32 精确(阈值=500,无浮点歧义),硬编码锁语义。
|
||||||
|
let (budget, sys) = (1_000u32, 100u32);
|
||||||
|
assert!(
|
||||||
|
!compress_should_trigger(true, 0.5, 6, 400, budget, sys, true),
|
||||||
|
"恰等于阈值(400+100=500)不应触发"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
compress_should_trigger(true, 0.5, 6, 401, budget, sys, true),
|
||||||
|
"超 1 token(401+100=501)应触发"
|
||||||
|
);
|
||||||
|
// 默认 0.70:阈值与实现同式计算(自洽),典型 sys=8K 配置直接锁边界
|
||||||
|
// (替换语义下无 0.6 兜底干扰,水位线即触发下界)。
|
||||||
|
let threshold = (BUDGET as f32 * 0.70) as u64;
|
||||||
|
let at = (threshold - SYS as u64) as u32;
|
||||||
|
assert!(!compress_should_trigger(true, 0.70, 6, at, BUDGET, SYS, true));
|
||||||
|
assert!(compress_should_trigger(true, 0.70, 6, at + 1, BUDGET, SYS, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ③替换语义:开关开时原 0.6 判据不再生效——历史超 0.6 线但未到水位线不触发
|
||||||
|
/// (即设计「触发点后移」的行为差;0.6 判据只在开关关时生效)。
|
||||||
|
#[test]
|
||||||
|
fn watermark_on_replaces_legacy_criterion() {
|
||||||
|
// history=60_000:> 0.6 线 56284(关档此处已触发);< 水位线(71400−8192=63208)。
|
||||||
|
let history = 60_000u32;
|
||||||
|
assert!(
|
||||||
|
compress_should_trigger(false, 0.70, 6, history, BUDGET, SYS, true),
|
||||||
|
"前提自检:同点位关档按 0.6 已触发"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!compress_should_trigger(true, 0.70, 6, history, BUDGET, SYS, true),
|
||||||
|
"开档 0.6 判据不再并联,未到水位线不触发"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ③补:sys 占比大时水位线早于 0.6 兜底线——水位分支真正改变决策的点位
|
||||||
|
/// (同点位开关关不触发,证两分支可分)。
|
||||||
|
#[test]
|
||||||
|
fn watermark_on_fires_where_legacy_would_not() {
|
||||||
|
let (sys, history) = (60_000u32, 20_000u32); // 水位线≈11.4K history < 20K < 兜底线 25.2K
|
||||||
|
let watermark = history as u64 + sys as u64 > (BUDGET as f32 * 0.70) as u64;
|
||||||
|
let legacy = ((BUDGET - sys) as u64) * 6 / 10 < history as u64;
|
||||||
|
assert!(watermark && !legacy, "前提自检:该点位水位触发而 0.6 未触发");
|
||||||
|
assert!(compress_should_trigger(true, 0.70, 6, history, BUDGET, sys, true));
|
||||||
|
assert!(
|
||||||
|
!compress_should_trigger(false, 0.70, 6, history, BUDGET, sys, true),
|
||||||
|
"同点位关=现行判据不触发"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ④保护区与可压缩门:两种模式下同样生效(不走分支,任何 token 量都拦得住)。
|
||||||
|
#[test]
|
||||||
|
fn protect_and_compressible_gates_hold_in_both_modes() {
|
||||||
|
for wm in [false, true] {
|
||||||
|
assert!(
|
||||||
|
!compress_should_trigger(wm, 0.70, 0, u32::MAX, BUDGET, 0, true),
|
||||||
|
"保护区门(protect_start=0)必须生效"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!compress_should_trigger(wm, 0.70, 6, u32::MAX, BUDGET, 0, false),
|
||||||
|
"可压缩门(区外无可压缩)必须生效"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ use super::conv_state::ConvState;
|
|||||||
|
|
||||||
/// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。
|
/// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。
|
||||||
///
|
///
|
||||||
/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning /
|
/// 抽自同构骨架(insert_destructive_loop_warning / insert_stall_warning /
|
||||||
/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning):
|
/// insert_tool_call_over_limit_warning 等;原 audit::cache insert_listing_bypass_warning 已退役):
|
||||||
/// 「lock → per_conv.contains_key 守卫 → messages.push(system) → tracing::info」。
|
/// 「lock → per_conv.contains_key 守卫 → messages.push(system) → tracing::info」。
|
||||||
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
|
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -180,20 +180,22 @@ pub const MAX_GOALS: usize = 5;
|
|||||||
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
|
/// system_prompt 零变化,退回改动前行为(单点回退,不影响 augmentation 的 / 联想)。
|
||||||
pub const SKILL_LIST_INJECT_ENABLED: bool = true;
|
pub const SKILL_LIST_INJECT_ENABLED: bool = true;
|
||||||
|
|
||||||
/// 技能清单注入最大条数(默认 120)。
|
/// 技能清单注入最大条数(默认 60)。
|
||||||
///
|
///
|
||||||
/// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。
|
/// 防本机大量技能全量进 system_prompt 占 sys_tokens 预算;取前 N 条(name 去重序),截断描述。
|
||||||
/// 自动触发(方案3):本机实扫 38 home skills + 1 command + 72 plugins ≈ 111 个技能,
|
/// 自动触发(方案3):本机实扫 38 home skills + 1 command + 72 plugins ≈ 111 个技能,
|
||||||
/// 原上限 20 会把 90+ 个技能对 LLM 完全隐藏(publish-moyu-uat 之后全部不可见),
|
/// 原上限 20 会把 90+ 个技能对 LLM 完全隐藏(publish-moyu-uat 之后全部不可见),
|
||||||
/// "任务命中技能 → AI 自主 read_skill"的自动触发链路因清单不可见而断裂。
|
/// "任务命中技能 → AI 自主 read_skill"的自动触发链路因清单不可见而断裂。
|
||||||
/// 清单单条为「名称+80字描述」索引卡片(约 30 token/条),120 条 ≈ 3.6K token,
|
/// 渐进披露(D2):清单只做导航,单条「名称+40字描述」索引卡片(约 20 token/条),
|
||||||
/// 且注入位于 system 稳定段(跨轮缓存命中,只付一次成本),120 上限兼顾覆盖与预算。
|
/// 60 条 ≈ 1.2K token,注入位于 system 稳定段(跨轮缓存命中,只付一次成本);
|
||||||
pub const SKILL_LIST_MAX_ITEMS: usize = 120;
|
/// 未列出的技能仍可经 read_skill 按名按需读取,不因清单减半而不可用。
|
||||||
|
pub const SKILL_LIST_MAX_ITEMS: usize = 60;
|
||||||
|
|
||||||
/// 技能清单单条描述截断长度(默认 80 字符)。
|
/// 技能清单单条描述截断长度(默认 40 字符)。
|
||||||
///
|
///
|
||||||
/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号。
|
/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号;
|
||||||
pub const SKILL_DESC_MAX_CHARS: usize = 80;
|
/// 全文按需经 read_skill 读取(渐进披露:清单导航,详情懒取)。
|
||||||
|
pub const SKILL_DESC_MAX_CHARS: usize = 40;
|
||||||
|
|
||||||
/// 派工纪律母本(N1 通用能力内置沉淀):主 agent 派工纪律 8 行,措辞可微调不可删项(用户拍板)。
|
/// 派工纪律母本(N1 通用能力内置沉淀):主 agent 派工纪律 8 行,措辞可微调不可删项(用户拍板)。
|
||||||
///
|
///
|
||||||
@@ -538,6 +540,7 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
.find(|m| {
|
.find(|m| {
|
||||||
matches!(m.role, df_ai::provider::MessageRole::User)
|
matches!(m.role, df_ai::provider::MessageRole::User)
|
||||||
&& !m.content.starts_with("[系统恢复]")
|
&& !m.content.starts_with("[系统恢复]")
|
||||||
|
&& !m.content.starts_with("[补充指令]")
|
||||||
&& m.content.chars().count() >= 4
|
&& m.content.chars().count() >= 4
|
||||||
})
|
})
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
@@ -2463,12 +2466,12 @@ async fn process_round_tools(
|
|||||||
let round_tool_call_count = tool_calls_acc.len();
|
let round_tool_call_count = tool_calls_acc.len();
|
||||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent)
|
// 处理工具调用(Low 自动执行 / Medium+High 待审批;主对话恒 Normal 策略,子循环走 SubAgent)
|
||||||
let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal, None).await;
|
let pending_count = process_tool_calls(session_arc, tool_calls_acc, tools_arc, db, app_handle, conv_id, ToolExecPolicy::Normal, None).await;
|
||||||
// DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug,
|
// 挂起审批统计诊断(原 DIRAUTH eprintln 降级为 tracing::debug,
|
||||||
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
|
// 避免污染 stderr,保留排障能力(RUST_LOG=debug 可见)。
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
pending_count,
|
pending_count,
|
||||||
"[AI-DIRAUTH-DIAG] agentic loop 收到 pending"
|
"[AI-PENDING-DIAG] agentic loop 收到 pending"
|
||||||
);
|
);
|
||||||
|
|
||||||
// F1 并发 epoch:工具执行期间被新 loop 接管(force_send)后立即退出,
|
// F1 并发 epoch:工具执行期间被新 loop 接管(force_send)后立即退出,
|
||||||
@@ -3134,6 +3137,16 @@ pub(crate) async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, erro
|
|||||||
stop_kind: Some("error".to_string()),
|
stop_kind: Some("error".to_string()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// V50 终局态(P1-B 方案甲,2026-10-21):Fatal=错误终局,补落 status=error——此前
|
||||||
|
// error 全仓零写点(与 completed 同属半闭环缺口)。事件侧 AiAgentFinished 已广播
|
||||||
|
// status=error,持久层在此对齐,完成侧对称写点在 emit_ai_completed_once。
|
||||||
|
// 未落库会话影响 0 行属预期(懒创建),失败仅记日志。
|
||||||
|
{
|
||||||
|
let app_state = app_handle.state::<AppState>();
|
||||||
|
if let Err(e) = app_state.ai_conversations.set_status(conv_id, "error").await {
|
||||||
|
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] Fatal 写 status=error 失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
|
// P1 bg 化:失败系统通知(条件发送,失败静默)。正文截断防长堆栈撑爆系统气泡。
|
||||||
let brief: String = error.chars().take(160).collect();
|
let brief: String = error.chars().take(160).collect();
|
||||||
crate::commands::ai::notify::notify_session_event(
|
crate::commands::ai::notify::notify_session_event(
|
||||||
@@ -3199,6 +3212,11 @@ pub(crate) async fn emit_ai_completed_once(
|
|||||||
}
|
}
|
||||||
// V49:正常收敛出口清理 loop_state(fire-and-forget 独立 spawn,不与上面的锁段耦合;
|
// V49:正常收敛出口清理 loop_state(fire-and-forget 独立 spawn,不与上面的锁段耦合;
|
||||||
// 进程内 loop 死亡的残留 running 由启动扫描兜底)。
|
// 进程内 loop 死亡的残留 running 由启动扫描兜底)。
|
||||||
|
// V50 收尾单点接驳(P1-B 方案甲,2026-10-21):本 helper 是全部正常出口统一 emit 单点,
|
||||||
|
// 此处顺写 status=completed —— 此前 V50 只写 active/incomplete 两态,completed 全仓零
|
||||||
|
// 写点=半闭环(终局态建模只完成一半)。MidStream 保文/截断、用户手动 stop 均经本出口,
|
||||||
|
// 都算「会话如何结束」的完成侧收束(V50 语义注释 migrations.rs migrate_v50:status 管
|
||||||
|
// 「会话如何结束」)。错误侧对称写点在 emit_fatal_error(status=error)。
|
||||||
{
|
{
|
||||||
let app_state = app_handle.state::<AppState>();
|
let app_state = app_handle.state::<AppState>();
|
||||||
let db2 = app_state.db.clone();
|
let db2 = app_state.db.clone();
|
||||||
@@ -3206,6 +3224,10 @@ pub(crate) async fn emit_ai_completed_once(
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let repo = df_storage::crud::AiConversationRepo::new(&db2);
|
let repo = df_storage::crud::AiConversationRepo::new(&db2);
|
||||||
let _ = repo.set_loop_state(&cid, None).await;
|
let _ = repo.set_loop_state(&cid, None).await;
|
||||||
|
// 未落库会话影响 0 行属预期(懒创建,首插 save 随行带默认 active),失败仅记日志
|
||||||
|
if let Err(e) = repo.set_status(&cid, "completed").await {
|
||||||
|
tracing::warn!(conv_id = %cid, error = %e, "[ai] 收敛出口写 status=completed 失败");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// B5 无人值守续作引擎收敛回读钩子:引擎在途会话到达本出口即清其标志并唤醒轮询器
|
// B5 无人值守续作引擎收敛回读钩子:引擎在途会话到达本出口即清其标志并唤醒轮询器
|
||||||
@@ -3302,7 +3324,13 @@ fn completed_event(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 六段拼接 ──
|
// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 段序拼接 ──
|
||||||
|
//
|
||||||
|
// **段序=缓存友好原则(2026-10-23 拍板)**:按稳定性递减排列——恒静段(环境/定位/纪律/技能,
|
||||||
|
// 进程级)→会话恒定段(devflow.md 投影)→loop 级段(目标钉扎/工作流 DAG)→最易变段(工作上下文,
|
||||||
|
// 每条任务指令重建)压尾。依据=前缀缓存连续匹配:易变段插中间会使其后恒静段+全部历史陪葬
|
||||||
|
// (实证:新任务后首条命中率跌至 16-33%,25K-38K token 全价重写/次,见
|
||||||
|
// temp/分析-提示词段序缓存友好性-2026-10-23.md)。重排只动拼接次序,各段内容逐字未变。
|
||||||
//
|
//
|
||||||
// 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。
|
// 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。
|
||||||
// N1 增量:新增派工纪律段(DISPATCH_DISCIPLINE 母本内置,拼在 behavior 后),非原五段内容。
|
// N1 增量:新增派工纪律段(DISPATCH_DISCIPLINE 母本内置,拼在 behavior 后),非原五段内容。
|
||||||
@@ -3342,44 +3370,6 @@ async fn build_runtime_prompt(
|
|||||||
|
|
||||||
let mut system_prompt = base_prompt;
|
let mut system_prompt = base_prompt;
|
||||||
|
|
||||||
// G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。
|
|
||||||
if GOAL_PIN_ENABLED {
|
|
||||||
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
|
||||||
if !active_goals.is_empty() {
|
|
||||||
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
|
||||||
let trimmed = g.text.trim();
|
|
||||||
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
|
||||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
|
||||||
format!("{}…", truncated)
|
|
||||||
} else {
|
|
||||||
truncated
|
|
||||||
};
|
|
||||||
format!("{}. {}", i + 1, truncated)
|
|
||||||
}).collect();
|
|
||||||
let goals_text = goal_lines.join("\n");
|
|
||||||
system_prompt = if GOAL_INJECT_BANNER {
|
|
||||||
format!(
|
|
||||||
"{}\n\n## 当前目标(全程锚定,所有动作须服务于它们)\n{}",
|
|
||||||
system_prompt, goals_text
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!("{}\n\n{}", system_prompt, goals_text)
|
|
||||||
};
|
|
||||||
tracing::info!(
|
|
||||||
conv_id = %conv_id,
|
|
||||||
count = goals.len(),
|
|
||||||
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
|
||||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
|
||||||
goals.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。
|
|
||||||
if !working_injection.is_empty() {
|
|
||||||
system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnvSnapshot 环境感知:detect() 是 OnceLock 全局缓存(启动探一次,后续零开销),与 G1 一样是
|
// EnvSnapshot 环境感知:detect() 是 OnceLock 全局缓存(启动探一次,后续零开销),与 G1 一样是
|
||||||
// loop 不变量,与目标钉扎拼接次序无强约束(放其后,语义自然)。无锁需求。
|
// loop 不变量,与目标钉扎拼接次序无强约束(放其后,语义自然)。无锁需求。
|
||||||
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
|
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
|
||||||
@@ -3392,7 +3382,7 @@ async fn build_runtime_prompt(
|
|||||||
"- 所有操作都通过工具完成,用户不参与执行\n",
|
"- 所有操作都通过工具完成,用户不参与执行\n",
|
||||||
"- 优先使用开发工具 IPC,非必要不写独立脚本\n",
|
"- 优先使用开发工具 IPC,非必要不写独立脚本\n",
|
||||||
"- 脚本需要审批通过才执行,会拖慢工作流\n",
|
"- 脚本需要审批通过才执行,会拖慢工作流\n",
|
||||||
"- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n",
|
"- 已有 80+ 工具覆盖绝大多数场景,先查工具列表再决定\n",
|
||||||
"- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n",
|
"- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具\n",
|
||||||
"- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n",
|
"- **直接回答用户问题,不要以评论开头**——严禁用好问题等无信息量的开场白,直接输出答案或执行操作\n",
|
||||||
"- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作",
|
"- **及时收敛**:工具执行完目标达成后立即输出结果,不要继续调工具做无关操作",
|
||||||
@@ -3439,6 +3429,51 @@ async fn build_runtime_prompt(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 项目规则文件投影(devflow.md/CLAUDE.md):会话级恒定段 —— 内容经 devflow_md 模块
|
||||||
|
// 按会话缓存(同会话只读一次盘)。段序按缓存友好原则排列:恒静段(环境/定位/纪律/技能)
|
||||||
|
// 之后、loop 级易变段(目标/DAG/工作上下文)之前——跨任务指令时目标/工作上下文重建,
|
||||||
|
// 失效面止于它们自身,本段及之前的全部前缀照常命中;工作目录无规则文件时原样返回
|
||||||
|
//(缺失不影响既有组装)。
|
||||||
|
let mut system_prompt =
|
||||||
|
crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await;
|
||||||
|
|
||||||
|
// G1 目标钉扎:仅 active 目标拼进 system_prompt(completed 不干扰 LLM 注意力)。
|
||||||
|
if GOAL_PIN_ENABLED {
|
||||||
|
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
||||||
|
if !active_goals.is_empty() {
|
||||||
|
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
||||||
|
let trimmed = g.text.trim();
|
||||||
|
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
||||||
|
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||||
|
format!("{}…", truncated)
|
||||||
|
} else {
|
||||||
|
truncated
|
||||||
|
};
|
||||||
|
format!("{}. {}", i + 1, truncated)
|
||||||
|
}).collect();
|
||||||
|
let goals_text = goal_lines.join("\n");
|
||||||
|
system_prompt = if GOAL_INJECT_BANNER {
|
||||||
|
// 目标切换仲裁(2026-10-24 用户实证「切换目标两三轮后失忆回老目标」):
|
||||||
|
// 钉扎是 system 级常驻,优先级天然压过对话流——用户中途口头换向后,
|
||||||
|
// 老目标会持续把模型拉回。仲裁行显式声明「最新对话意志 > 登记目标」,
|
||||||
|
// 保住钉扎防漂移价值的同时不压制用户切换权。
|
||||||
|
format!(
|
||||||
|
"{}\n\n## 当前目标(登记的活跃任务,全程锚定,所有动作须服务于它们;若用户最近消息已明确表达目标切换或新方向,以用户最新指令为准并顺势推进,勿回拉旧目标)\n{}",
|
||||||
|
system_prompt, goals_text
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("{}\n\n{}", system_prompt, goals_text)
|
||||||
|
};
|
||||||
|
tracing::info!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
count = goals.len(),
|
||||||
|
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
||||||
|
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||||
|
goals.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt。
|
// T4: 工作流 DAG 注入 — 当会话关联工作流时,将活跃路径注入 system prompt。
|
||||||
if let Some(ref dag_summary) = workflow_dag_summary {
|
if let Some(ref dag_summary) = workflow_dag_summary {
|
||||||
system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary);
|
system_prompt = format!("{}[工作流]\n{}\n", system_prompt, dag_summary);
|
||||||
@@ -3449,11 +3484,11 @@ async fn build_runtime_prompt(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 项目规则文件投影(devflow.md/CLAUDE.md):稳定尾段纯追加 —— 拼在全部既有段之后、
|
// T5: WorkingContext L1 注入(dirty_window=3,空 injection 跳过)。压尾:每条任务指令
|
||||||
// 返回之前。内容经 devflow_md 模块按会话缓存(同会话只读一次盘),loop 内为不变量,
|
// 重建,排最后使前缀缓存失效面止于本段,不拖累恒静段/会话段/目标/DAG。
|
||||||
// 不击穿 provider prompt 缓存前缀;工作目录无规则文件时原样返回(缺失不影响既有组装)。
|
if !working_injection.is_empty() {
|
||||||
let system_prompt =
|
system_prompt = format!("{}\n\n## 工作上下文\n{}", system_prompt, working_injection);
|
||||||
crate::commands::ai::devflow_md::append_projection(system_prompt, work_dir, conv_id).await;
|
}
|
||||||
|
|
||||||
(system_prompt, goals)
|
(system_prompt, goals)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -828,3 +828,43 @@ mod midstream_reentry_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P0-A 口径统一单测:回退轨 round_usage_effective 两态钉死——端点缺 usage(累加器 None →
|
||||||
|
// 全零)走估算打标不静默零;真值(含三口径分项)原样透传不误标。两臂流式主链共用此单点派生。
|
||||||
|
#[cfg(test)]
|
||||||
|
mod usage_caliber_tests {
|
||||||
|
use super::round_usage_effective;
|
||||||
|
use df_ai::provider::TokenUsage;
|
||||||
|
|
||||||
|
/// 缺失回退(openai 臂缺 usage 形态,老端点/网关剥 include_usage):全零 TokenUsage →
|
||||||
|
/// prompt 走 estimated_prompt 兜底 + is_estimated=true(消息级落库标注),completion 不估算。
|
||||||
|
#[test]
|
||||||
|
fn round_usage_effective_missing_usage_falls_back_to_estimate() {
|
||||||
|
let (usage, estimated) = round_usage_effective(&TokenUsage::default(), 1234);
|
||||||
|
assert!(estimated, "全零 usage 必须打估算标,不静默零入库");
|
||||||
|
assert_eq!(usage.prompt_tokens, 1234, "prompt 走估算兜底");
|
||||||
|
assert_eq!(usage.completion_tokens, 0, "completion 不估算(拍板点3,标注已如实)");
|
||||||
|
assert_eq!(usage.total_tokens, 1234, "total = 估算 prompt + completion");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真值透传(三口径映射,两臂同轨):端点回真值 → prompt(全量输入,含缓存)/hit/miss/
|
||||||
|
/// reasoning 原样透传落库,is_estimated=false 不误标。
|
||||||
|
#[test]
|
||||||
|
fn round_usage_effective_real_usage_passes_through_three_caliber() {
|
||||||
|
let real = TokenUsage {
|
||||||
|
prompt_tokens: 100,
|
||||||
|
completion_tokens: 30,
|
||||||
|
total_tokens: 130,
|
||||||
|
prompt_cache_hit_tokens: 64,
|
||||||
|
prompt_cache_miss_tokens: 36,
|
||||||
|
reasoning_tokens: 12,
|
||||||
|
};
|
||||||
|
let (usage, estimated) = round_usage_effective(&real, 9999);
|
||||||
|
assert!(!estimated, "真值不误标估算");
|
||||||
|
assert_eq!(usage.prompt_tokens, 100, "真值 prompt 不被估算覆盖");
|
||||||
|
assert_eq!(usage.prompt_cache_hit_tokens, 64, "hit 分项透传");
|
||||||
|
assert_eq!(usage.prompt_cache_miss_tokens, 36, "miss 分项透传");
|
||||||
|
assert_eq!(usage.reasoning_tokens, 12, "reasoning 分项透传");
|
||||||
|
assert_eq!(usage.total_tokens, 130, "total 原样");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, Manager};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||||
@@ -16,6 +16,7 @@ use df_storage::crud::AiToolExecutionRepo;
|
|||||||
use df_storage::db::Database;
|
use df_storage::db::Database;
|
||||||
|
|
||||||
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for};
|
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for};
|
||||||
|
use super::approval_expires_at_millis;
|
||||||
use super::super::event_bus::emit_ai_chat_event;
|
use super::super::event_bus::emit_ai_chat_event;
|
||||||
use super::diff::build_write_file_diff;
|
use super::diff::build_write_file_diff;
|
||||||
use super::reason::build_approval_reason;
|
use super::reason::build_approval_reason;
|
||||||
@@ -205,6 +206,12 @@ pub(super) async fn insert_pending_approval(
|
|||||||
}
|
}
|
||||||
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
||||||
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||||
|
// 审批超时收敛单一真值=后端(arm_approval_timeout_timers + cleanup 惰性兜底,前端本地
|
||||||
|
// 自动拒绝定时器已退役 2026-10-25):到期时刻随事件透传,前端倒计时只读不算。
|
||||||
|
let expires_at_millis = approval_expires_at_millis(
|
||||||
|
app_handle.state::<crate::state::AppState>().inner(),
|
||||||
|
std::time::SystemTime::now(),
|
||||||
|
);
|
||||||
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
|
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
|
||||||
let ev = AiChatEvent::AiApprovalRequired {
|
let ev = AiChatEvent::AiApprovalRequired {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
@@ -213,6 +220,7 @@ pub(super) async fn insert_pending_approval(
|
|||||||
reason,
|
reason,
|
||||||
diff: approval_diff,
|
diff: approval_diff,
|
||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
expires_at_millis,
|
||||||
};
|
};
|
||||||
emit_ai_chat_event(&app_handle, ev);
|
emit_ai_chat_event(&app_handle, ev);
|
||||||
// P1 bg 化:审批挂起需用户介入,主窗隐藏到托盘时系统通知补位(条件发送,失败静默)。
|
// P1 bg 化:审批挂起需用户介入,主窗隐藏到托盘时系统通知补位(条件发送,失败静默)。
|
||||||
|
|||||||
@@ -399,155 +399,9 @@ fn sort_object_keys(v: &mut serde_json::Value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// AC-2 ③ 目录列举绕行检测/警告已退役(2026-10-25):仅告警无动作,噪音>价值,
|
||||||
// AC-2 ③:run_command 目录列举绕行检测(2026-08-08 最保守版,只警告不硬拒)
|
// 误报(正常 ls 用途)反误导 LLM。原 detect_listing_bypass/insert_listing_bypass_warning
|
||||||
//
|
// 整链删除;F-05 高危同参缓存复用(本文件其余部分)不受影响。
|
||||||
// 实证 09e7abfa:LLM list_directory 列某目录后,又用 run_command `Get-ChildItem`/`ls`
|
|
||||||
// 再探同一目录,绕过 ② 的目录级去重。需解析 run_command 命令内容判定,**易误判**
|
|
||||||
// (ls/Get-ChildItem 也有正常用途),故本批只做最保守版:
|
|
||||||
// - 仅当命令含列举类命令(Get-ChildItem/gci/ls) **且** 命令/working_dir 引用了会话中
|
|
||||||
// 已用 list_directory 列过的目录路径(精确子串) 才判绕行;
|
|
||||||
// - 只 insert 一条 system 警告(非静默),不硬拒——命令照常执行/审批,
|
|
||||||
// 误报最多多一条提示,不阻断工作流;
|
|
||||||
// - 未命中 → None,零行为变更。
|
|
||||||
// 已列目录路径来自消息历史扫描(listed_directory_paths),与 find_cached_readonly_result
|
|
||||||
// 同源(读 assistant tool_calls 的 list_directory args.path)。
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/// 词边界子串匹配(纯函数):needle 在 haystack 中出现且前后均非字母数字。
|
|
||||||
///
|
|
||||||
/// 用字节扫描避免引入 regex 依赖;注意 "Get-ChildItem" 含连字符,不能简单按非字母数字
|
|
||||||
/// 分词(会把 get-childitem 拆成 get/childitem),故按整词含连字符匹配。
|
|
||||||
fn contains_word(haystack: &str, needle: &str) -> bool {
|
|
||||||
let bytes = haystack.as_bytes();
|
|
||||||
let n = needle.as_bytes();
|
|
||||||
if n.is_empty() || n.len() > bytes.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let max_start = bytes.len() - n.len();
|
|
||||||
let mut i = 0;
|
|
||||||
while i <= max_start {
|
|
||||||
if &bytes[i..i + n.len()] == n {
|
|
||||||
let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
|
|
||||||
let next_idx = i + n.len();
|
|
||||||
let next_ok = next_idx >= bytes.len() || !bytes[next_idx].is_ascii_alphanumeric();
|
|
||||||
if prev_ok && next_ok {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 列举类命令检测(纯函数,词边界匹配防误判)。
|
|
||||||
///
|
|
||||||
/// 覆盖 PowerShell `Get-ChildItem`/`gci` 与类 Unix `ls`。`dir` 别名歧义大
|
|
||||||
/// (PowerShell 里既列举又是路径常见词)排除,靠「命令引用已列目录」精化过滤。
|
|
||||||
fn contains_listing_command(command: &str) -> bool {
|
|
||||||
let lower = command.to_ascii_lowercase();
|
|
||||||
contains_word(&lower, "get-childitem")
|
|
||||||
|| contains_word(&lower, "gci")
|
|
||||||
|| contains_word(&lower, "ls")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 扫描会话历史,取所有目录级缓存工具(cache_path_key 声明者,现即 list_directory)
|
|
||||||
/// 已列过的目录路径(去重,保留原始大小写)。
|
|
||||||
///
|
|
||||||
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 args.path,
|
|
||||||
/// 忽略递归/深度参数(② 同语义:同目录即算已列过)。工具判定查注册元数据
|
|
||||||
/// (cache_path_key 维度,DRY 债 #5 收敛),与目录级缓存 key 同一判据。
|
|
||||||
async fn listed_directory_paths(
|
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
|
||||||
conv_id: &str,
|
|
||||||
) -> Vec<String> {
|
|
||||||
use df_ai::provider::MessageRole;
|
|
||||||
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
|
|
||||||
let session = session_arc.lock().await;
|
|
||||||
let Some(conv) = session.conv_read(conv_id) else { return Vec::new() };
|
|
||||||
let mut dirs: Vec<String> = Vec::new();
|
|
||||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
for m in conv.messages.iter() {
|
|
||||||
if !matches!(m.role, MessageRole::Assistant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(tcs) = m.tool_calls.as_ref() else { continue };
|
|
||||||
for tc in tcs {
|
|
||||||
if !df_ai::ai_tools::cache_path_key_only(&tc.function.name) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(p) = args.get("path").and_then(|v| v.as_str()) else { continue };
|
|
||||||
if seen.insert(p.to_string()) {
|
|
||||||
dirs.push(p.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dirs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AC-2 ③:检测 run_command 是否用目录列举命令绕行 list_directory 去重。
|
|
||||||
///
|
|
||||||
/// 返回 Some(已列过的目录路径) 当命令含列举类命令且引用了该目录;None = 无绕行。
|
|
||||||
/// 只检测**先前轮次**已列的目录(当前轮同一批 list_directory 尚未写入消息,不覆盖
|
|
||||||
/// 同轮先列后 ls 的极端形态——属边缘场景,主要绕行形态跨轮已覆盖)。
|
|
||||||
pub(crate) async fn detect_listing_bypass(
|
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
|
||||||
conv_id: &str,
|
|
||||||
args: &serde_json::Value,
|
|
||||||
) -> Option<String> {
|
|
||||||
// 1) 取命令文本 + working_dir(绕行路径通常出现在二者之一)。
|
|
||||||
let command = args.get("command").and_then(|v| v.as_str())?.trim();
|
|
||||||
if command.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// 2) 命令须含列举类命令(Get-ChildItem/gci/ls)。
|
|
||||||
if !contains_listing_command(command) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let working_dir = args.get("working_dir").and_then(|v| v.as_str()).unwrap_or("");
|
|
||||||
// 3) 扫描会话已列过的目录路径。
|
|
||||||
let listed_dirs = listed_directory_paths(session_arc, conv_id).await;
|
|
||||||
if listed_dirs.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// 4) 命令或 working_dir 引用已列过的目录 → 绕行(精确子串,不模糊匹配)。
|
|
||||||
for dir in &listed_dirs {
|
|
||||||
if dir.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if command.contains(dir.as_str())
|
|
||||||
|| (!working_dir.is_empty() && working_dir.contains(dir.as_str()))
|
|
||||||
{
|
|
||||||
return Some(dir.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// AC-2 ③:目录列举绕行警告注入(system 消息首位)。
|
|
||||||
///
|
|
||||||
/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。
|
|
||||||
/// 插入骨架经 agentic::helpers::insert_system_notice 复用(DRY #7,与 G2/AC-2① 同一实现)。
|
|
||||||
pub(crate) async fn insert_listing_bypass_warning(
|
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
|
||||||
conv_id: &str,
|
|
||||||
dir: &str,
|
|
||||||
) {
|
|
||||||
let warn_text = format!(
|
|
||||||
"⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。",
|
|
||||||
dir
|
|
||||||
);
|
|
||||||
crate::commands::ai::agentic::helpers::insert_system_notice(
|
|
||||||
session_arc,
|
|
||||||
conv_id,
|
|
||||||
&warn_text,
|
|
||||||
&format!("AC-2 目录列举绕行:注入警告(只警告不硬拒) dir={}", dir),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
@@ -625,30 +479,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn listing_command_detects_powershell_and_unix() {
|
|
||||||
assert!(contains_listing_command("Get-ChildItem -Recurse"));
|
|
||||||
assert!(contains_listing_command("gci -Force"));
|
|
||||||
assert!(contains_listing_command("ls -la /tmp"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn listing_command_avoids_false_positives() {
|
|
||||||
assert!(!contains_listing_command("git log"));
|
|
||||||
assert!(!contains_listing_command("else"));
|
|
||||||
assert!(!contains_listing_command("glass"));
|
|
||||||
assert!(!contains_listing_command("cd /tmp"));
|
|
||||||
assert!(!contains_listing_command("Get-ChildItem2"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn contains_word_boundary_behavior() {
|
|
||||||
// "ls" 不应命中 "else"/"glass"(词边界);"Get-ChildItem" 含连字符仍应整词命中
|
|
||||||
assert!(contains_word("get-childitem -recurse", "get-childitem"));
|
|
||||||
assert!(!contains_word("else", "ls"));
|
|
||||||
assert!(!contains_word("glass", "ls"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── cache_hit_warning 目录专用提示 ──
|
// ── cache_hit_warning 目录专用提示 ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ pub(crate) async fn audit_finalize(
|
|||||||
rec.status = status.to_string();
|
rec.status = status.to_string();
|
||||||
rec.decided_by = Some("human".to_string());
|
rec.decided_by = Some("human".to_string());
|
||||||
rec.executed_at = Some(now_millis());
|
rec.executed_at = Some(now_millis());
|
||||||
// V50 纯执行耗时:审批路径由调用方(ai_approve/ai_authorize_dir)在真执行窗口测量后传入,
|
// V50 纯执行耗时:审批路径由调用方(ai_approve)在真执行窗口测量后传入,
|
||||||
// 与「requested_at→executed_at」(含等待人工批准)解耦。拒绝/超时未执行传 None。
|
// 与「requested_at→executed_at」(含等待人工批准)解耦。拒绝/超时未执行传 None。
|
||||||
rec.duration_ms = duration_ms;
|
rec.duration_ms = duration_ms;
|
||||||
if let Some(r) = result {
|
if let Some(r) = result {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use df_storage::db::Database;
|
|||||||
|
|
||||||
use super::event_bus::emit_ai_chat_event;
|
use super::event_bus::emit_ai_chat_event;
|
||||||
use crate::state::{SettingsNorm, log_slow_lock};
|
use crate::state::{SettingsNorm, log_slow_lock};
|
||||||
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
use super::{AiChatEvent, AiSession, ToolCallDraft};
|
||||||
use super::tools::entity_resolve;
|
use super::tools::entity_resolve;
|
||||||
use crate::commands::ai::is_destructive_command;
|
use crate::commands::ai::is_destructive_command;
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ mod diff;
|
|||||||
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||||||
// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
|
// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
|
||||||
pub(crate) mod path_auth;
|
pub(crate) mod path_auth;
|
||||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass, PATH_AUTH_BYPASS_KEY};
|
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass};
|
||||||
// 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志
|
// 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志
|
||||||
pub(super) use path_auth::is_blacklist_bypassed;
|
pub(super) use path_auth::is_blacklist_bypassed;
|
||||||
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
|
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
|
||||||
@@ -45,7 +45,7 @@ pub(super) use path_auth::{set_blacklist_bypass, BLACKLIST_BYPASS_TEST_LOCK};
|
|||||||
// approval(audit/approval.rs):审批门控逻辑。
|
// approval(audit/approval.rs):审批门控逻辑。
|
||||||
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
|
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
|
||||||
mod approval;
|
mod approval;
|
||||||
use approval::{detect_retry_count, handle_approval_tool};
|
use approval::handle_approval_tool;
|
||||||
|
|
||||||
// record(audit/record.rs):审计记录写入 + 历史 DTO + 审计面板查询 IPC。
|
// record(audit/record.rs):审计记录写入 + 历史 DTO + 审计面板查询 IPC。
|
||||||
// 第六批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls /
|
// 第六批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls /
|
||||||
@@ -82,7 +82,7 @@ pub(crate) use finalize::audit_finalize;
|
|||||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||||
// 第三批从本文件抽离,行为零变更。
|
// 第三批从本文件抽离,行为零变更。
|
||||||
mod cache;
|
mod cache;
|
||||||
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key};
|
pub(super) use cache::{cache_hit_warning, find_cached_readonly_result, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key};
|
||||||
|
|
||||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||||
@@ -171,7 +171,7 @@ async fn deny_sub_agent_tool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
|
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
|
||||||
/// 不 insert pending_approvals、不 emit AiApprovalRequired/AiDirAuthRequired——边界会话
|
/// 不 insert pending_approvals、不 emit AiApprovalRequired——边界会话
|
||||||
/// 零新挂起(P1-1 治本面:挂起等输入是无人值守的主要敌人),LLM 从 envelope 的
|
/// 零新挂起(P1-1 治本面:挂起等输入是无人值守的主要敌人),LLM 从 envelope 的
|
||||||
/// denial_layer + 行动提示自纠(换路径/拆低风险步骤/写入报告未尽事项),fail-closed
|
/// denial_layer + 行动提示自纠(换路径/拆低风险步骤/写入报告未尽事项),fail-closed
|
||||||
/// 同构子代理拒绝协议。decided_by 与 denial_layer 同值留痕(boundary-dirs/boundary-risk,
|
/// 同构子代理拒绝协议。decided_by 与 denial_layer 同值留痕(boundary-dirs/boundary-risk,
|
||||||
@@ -312,7 +312,7 @@ fn upgrade_blocked_hint(blocked: &str) -> String {
|
|||||||
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
||||||
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
|
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
|
||||||
///
|
///
|
||||||
/// 审批执行窗口(ai_approve / ai_authorize_dir)复用本函数:审批暂停时主 loop 已 return、
|
/// 审批执行窗口(ai_approve)复用本函数:审批暂停时主 loop 已 return、
|
||||||
/// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建)
|
/// 其 heartbeat_loop 随作用域退出,慢工具(大文件 patch / spawn_agent / run_command 长构建)
|
||||||
/// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话
|
/// 整窗无心跳 → 前端看门狗误杀。复用同时获得 run_command 实时流式 sink、spawn_agent 父会话
|
||||||
/// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。
|
/// task-local 注入与 outer_secs 分档超时(spawn_agent=挂钟预算+60 / run_command=timeout_secs clamp)。
|
||||||
@@ -445,7 +445,7 @@ pub(crate) async fn execute_with_heartbeat(
|
|||||||
|
|
||||||
// ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
|
// ── 提前退出监视(防空转):会话已删 / 审批窗口失效时中止工具执行 ──
|
||||||
// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
|
// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
|
||||||
// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
|
// ai_approve 审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
|
||||||
// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
|
// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
|
||||||
// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
|
// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
|
||||||
// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
|
// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
|
||||||
@@ -613,7 +613,7 @@ pub(crate) async fn execute_with_heartbeat(
|
|||||||
/// 工厂形态:同步路径与后台 grace 路径各消费一个实例(同一快照语义)。
|
/// 工厂形态:同步路径与后台 grace 路径各消费一个实例(同一快照语义)。
|
||||||
///
|
///
|
||||||
/// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
|
/// 进入快照:该 tool_call_id 是否仍在 pending_approvals。现网 4 个调用点(chat.rs ai_approve /
|
||||||
/// ai_authorize_dir 两个审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
|
/// ai_approve 审批执行窗口均「先 remove 后 execute」,本文件 low/trust 两条自动执行
|
||||||
/// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
|
/// 路径从未 insert),进入时 pending 必无此 id——若按字面「不在 pending 即退出」实现,会把
|
||||||
/// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
|
/// 所有已批准执行立即腰斩。故审批移除监视仅在「进入时仍在 pending」的调用形态下武装
|
||||||
/// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
|
/// (现网不触发,为未来调用形态兜底);会话已删监视对所有调用点生效:执行中删除会话,
|
||||||
@@ -661,6 +661,26 @@ fn abort_when_invalid(
|
|||||||
static ARMED_TIMEOUT_IDS: std::sync::OnceLock<std::sync::Mutex<HashSet<String>>> =
|
static ARMED_TIMEOUT_IDS: std::sync::OnceLock<std::sync::Mutex<HashSet<String>>> =
|
||||||
std::sync::OnceLock::new();
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// 审批挂起到期时刻(ms Unix 时间戳):`approval_timeout_minutes` 配置换算,前端倒计时的单一真值
|
||||||
|
/// (前端本地自动拒绝定时器已退役 2026-10-25,超时收敛只有后端 arm_approval_timeout_timers +
|
||||||
|
/// cleanup_expired_approvals 惰性兜底)。mins=0(禁用超时)→ None,前端不显倒计时。
|
||||||
|
pub(crate) fn approval_expires_at_millis(
|
||||||
|
state: &crate::state::AppState,
|
||||||
|
created_at: std::time::SystemTime,
|
||||||
|
) -> Option<i64> {
|
||||||
|
let mins = state
|
||||||
|
.approval_timeout_minutes
|
||||||
|
.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
if mins == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ms = created_at
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()?
|
||||||
|
.as_millis() as i64;
|
||||||
|
Some(ms + (mins as i64) * 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
/// 为当前所有待审批武装「到点主动收敛」定时任务。
|
/// 为当前所有待审批武装「到点主动收敛」定时任务。
|
||||||
///
|
///
|
||||||
/// 幂等:ARMED_TIMEOUT_IDS 去重,同一 tool_call_id 只 spawn 一次。
|
/// 幂等:ARMED_TIMEOUT_IDS 去重,同一 tool_call_id 只 spawn 一次。
|
||||||
@@ -956,26 +976,21 @@ pub(crate) async fn process_tool_calls(
|
|||||||
|
|
||||||
// ── 文件工具路径授权预校验 ──
|
// ── 文件工具路径授权预校验 ──
|
||||||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
// 逐条预校验(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||||
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
|
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
|
||||||
// - 任一路径未命中白名单且非黑名单 → NeedsAuth:挂起 loop(insert pending + emit AiDirAuthRequired),
|
|
||||||
// 等用户经 ai_authorize_dir IPC 决定"仅本次/未来都允许/拒绝"后恢复(Phase B)
|
|
||||||
// - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程
|
// - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程
|
||||||
// 预校验通过的 drafts 留在原 Vec 继续走原流程;NeedsAuth/Denied 的 drafts 从 Vec 移除单独处理。
|
// path_auth 白名单挂起链(原 NeedsAuth → AiDirAuthRequired → ai_authorize_dir)已退役
|
||||||
|
// (2026-10-25):bypass 恒开使 NeedsAuth 不可达,仅保留防御性硬拒兜底。
|
||||||
let allowed_snapshot = app_handle
|
let allowed_snapshot = app_handle
|
||||||
.state::<crate::state::AppState>()
|
.state::<crate::state::AppState>()
|
||||||
.allowed_dirs
|
.allowed_dirs
|
||||||
.clone();
|
.clone();
|
||||||
let allowed_guard = allowed_snapshot.read().await.clone();
|
let allowed_guard = allowed_snapshot.read().await.clone();
|
||||||
// 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起
|
// 授权目录白名单机制退役为默认全放行(2026-10-25 用户拍板),原机制见 git 历史。
|
||||||
// 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。
|
// 存量 KV(df-ai-path-auth-bypass,含显式 false)一律忽略,bypass 恒开:
|
||||||
// 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。
|
// 主会话全放行,子代理仍走 KeepBlacklist(黑名单安全线不因退役解除)。
|
||||||
// get_bool_norm 归一比对(前端 JSON.stringify 落库带引号),默认 false 由调用方显式传参。
|
// PATH_AUTH_BYPASS_KEY 壳保留在 path_auth.rs 作恢复点,前端管理入口已撤。
|
||||||
let path_auth_bypass = app_handle
|
let path_auth_bypass = true;
|
||||||
.state::<crate::state::AppState>()
|
|
||||||
.settings
|
|
||||||
.get_bool_norm(PATH_AUTH_BYPASS_KEY, false)
|
|
||||||
.await;
|
|
||||||
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
|
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
|
||||||
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
|
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
|
||||||
path_auth::set_blacklist_bypass(path_auth_bypass);
|
path_auth::set_blacklist_bypass(path_auth_bypass);
|
||||||
@@ -987,15 +1002,14 @@ pub(crate) async fn process_tool_calls(
|
|||||||
} else {
|
} else {
|
||||||
PathBypass::Off
|
PathBypass::Off
|
||||||
};
|
};
|
||||||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
// 收集 Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
|
||||||
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||||
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)。
|
// SubAgent 策略:须授权/黑名单拒绝统一收集,后续就地拒绝(绝不挂起)。
|
||||||
// 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
|
// 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
|
||||||
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
|
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
|
||||||
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new();
|
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new();
|
||||||
// 任务边界会话:白名单外待授权目录的拒绝收集(结构化拒绝,不挂起,见下方处理)
|
// 任务边界会话:白名单外待授权目录的拒绝收集(结构化拒绝,不挂起,见下方处理)
|
||||||
let mut boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
let boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||||
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||||
for (idx, draft, args) in drafts {
|
for (idx, draft, args) in drafts {
|
||||||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||||||
@@ -1003,33 +1017,14 @@ pub(crate) async fn process_tool_calls(
|
|||||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_bypass) {
|
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_bypass) {
|
||||||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||||||
FileToolAuthOutcome::NeedsAuth(req) => {
|
FileToolAuthOutcome::NeedsAuth(req) => {
|
||||||
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
|
// path_auth 白名单挂起链已退役(2026-10-25):bypass 恒开(All/KeepBlacklist)
|
||||||
|
// 下本分支不可达。防御性硬拒兜底(bypass 若回退为 Off,未授权路径拒绝而非静默放行)。
|
||||||
|
let _ = (idx, args, req);
|
||||||
|
let reason = "路径授权机制已退役,未授权路径拒绝".to_string();
|
||||||
if is_sub_agent {
|
if is_sub_agent {
|
||||||
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
|
sub_agent_path_denied.push((draft, reason, "needs-auth"));
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 任务边界优先(无人值守,设计 §3.1②):req.dirs 是三档白名单外的待授权
|
|
||||||
// 父目录集合,边界白名单全覆盖 → 直接放行(免弹 AiDirAuthRequired);
|
|
||||||
// 未全覆盖 → 结构化拒绝(boundary-dirs),不挂起——边界会话零新挂起。
|
|
||||||
if let Some(b) = &boundary {
|
|
||||||
if crate::commands::ai::boundary::covers_dirs(b, &req.dirs) {
|
|
||||||
authorized_drafts.push((idx, draft, args));
|
|
||||||
} else {
|
} else {
|
||||||
let uncovered: Vec<String> = req.dirs.iter()
|
path_denied.push((draft, reason));
|
||||||
.filter(|d| !crate::commands::ai::boundary::dir_covered(&b.dir_allowlist, d))
|
|
||||||
.map(|d| d.to_string_lossy().to_string())
|
|
||||||
.collect();
|
|
||||||
boundary_dir_denied.push((draft, format!("待授权目录 {} 不在任务白名单内", uncovered.join(", "))));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
|
|
||||||
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
|
|
||||||
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
|
|
||||||
if auto_exec_mode == "all" {
|
|
||||||
authorized_drafts.push((idx, draft, args));
|
|
||||||
} else {
|
|
||||||
path_auth_pending.push((draft, args, req));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FileToolAuthOutcome::Denied(reason) => {
|
FileToolAuthOutcome::Denied(reason) => {
|
||||||
@@ -1092,86 +1087,8 @@ pub(crate) async fn process_tool_calls(
|
|||||||
).await;
|
).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
// Phase B(原 NeedsAuth → AiDirAuthRequired → ai_authorize_dir 挂起链)已随 path_auth
|
||||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
// 退役整体删除(2026-10-25):pending_approvals 单表现在只有 Risk kind 挂起。
|
||||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
|
||||||
//
|
|
||||||
// detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
|
|
||||||
// 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。
|
|
||||||
for (draft, args, req) in path_auth_pending {
|
|
||||||
pending_count += 1;
|
|
||||||
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
|
|
||||||
// ai_authorize_dir 消费时遍历所有 dirs 写白名单。
|
|
||||||
let dir_str = req.dirs.first()
|
|
||||||
.map(|d| d.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
|
||||||
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
|
||||||
let retry_count = detect_retry_count(&audit_repo, conv_id, &draft.name, &draft.args).await;
|
|
||||||
if retry_count >= 1 {
|
|
||||||
let skip_msg = format!(
|
|
||||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
|
||||||
draft.id
|
|
||||||
);
|
|
||||||
// 短 lock 段:push tool_result(纯写)
|
|
||||||
{
|
|
||||||
let __lock_t = std::time::Instant::now();
|
|
||||||
let mut session = session_arc.lock().await;
|
|
||||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
|
||||||
log_slow_lock(__lock_t, "process_tool_calls:305");
|
|
||||||
}
|
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
|
||||||
id: draft.id.clone(),
|
|
||||||
result: serde_json::Value::String(skip_msg.clone()),
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
};
|
|
||||||
emit_ai_chat_event(&app_handle, ev);
|
|
||||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
|
||||||
audit_tool_call(
|
|
||||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
|
||||||
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
|
|
||||||
current_message_id, None,
|
|
||||||
).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 短 lock 段:pending_approvals.insert + 占位 tool_result push(两处纯写,无 await)
|
|
||||||
{
|
|
||||||
let __lock_t = std::time::Instant::now();
|
|
||||||
let mut session = session_arc.lock().await;
|
|
||||||
session.pending_approvals.insert(
|
|
||||||
draft.id.clone(),
|
|
||||||
PendingApproval {
|
|
||||||
tool_call_id: draft.id.clone(),
|
|
||||||
tool_name: draft.name.clone(),
|
|
||||||
arguments: args.clone(),
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
recovered: false,
|
|
||||||
// 路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
|
||||||
kind: ApprovalKind::Path(req),
|
|
||||||
retry_count,
|
|
||||||
created_at: Some(std::time::SystemTime::now()),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
|
||||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
|
||||||
log_slow_lock(__lock_t, "process_tool_calls:325");
|
|
||||||
}
|
|
||||||
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
|
||||||
let ev = AiChatEvent::AiDirAuthRequired {
|
|
||||||
id: draft.id.clone(),
|
|
||||||
tool: draft.name.clone(),
|
|
||||||
path: path_str,
|
|
||||||
dir: dir_str,
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
};
|
|
||||||
emit_ai_chat_event(&app_handle, ev);
|
|
||||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
|
||||||
audit_tool_call(
|
|
||||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
|
||||||
"pending", risk_level, None, None,
|
|
||||||
current_message_id, None,
|
|
||||||
).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
|
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
|
||||||
//
|
//
|
||||||
@@ -1273,15 +1190,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
|
// AC-2 ③ 目录列举绕行警告已退役(2026-10-25):仅告警无动作,噪音>价值(误报反误导 LLM)。
|
||||||
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
|
|
||||||
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
|
|
||||||
// 执行/审批(不硬拒,防误判阻断正常命令)。易误判详见 cache.rs detect_listing_bypass 文档。
|
|
||||||
if draft.name == "run_command" {
|
|
||||||
if let Some(dir) = detect_listing_bypass(session_arc, conv_id, &args).await {
|
|
||||||
insert_listing_bypass_warning(session_arc, conv_id, &dir).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handle_approval_tool(
|
handle_approval_tool(
|
||||||
draft, args, risk_level, &auto_exec_mode,
|
draft, args, risk_level, &auto_exec_mode,
|
||||||
session_arc, conv_id, tools_arc, &audit_repo,
|
session_arc, conv_id, tools_arc, &audit_repo,
|
||||||
@@ -1700,19 +1609,13 @@ pub(crate) async fn process_tool_calls(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
// 单表 pending_approvals 统计 pending 总数(path_auth 退役后单 Risk kind)。
|
||||||
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
||||||
let (path_count, risk_count, pending_approvals_len) = {
|
let pending_approvals_len = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
let len = session.pending_approvals.len();
|
session.pending_approvals.len()
|
||||||
let (p, r) = session.pending_approvals.values()
|
|
||||||
.fold((0usize, 0usize), |(p, r), a| match a.kind {
|
|
||||||
ApprovalKind::Path(_) => (p + 1, r),
|
|
||||||
ApprovalKind::Risk { .. } => (p, r + 1),
|
|
||||||
});
|
|
||||||
(p, r, len)
|
|
||||||
};
|
};
|
||||||
tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len, path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)");
|
tracing::debug!(pending_count, pending_approvals_len, "process_tool_calls 返回(挂起审批统计)");
|
||||||
pending_count
|
pending_count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1724,7 +1627,7 @@ mod tests {
|
|||||||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||||||
///
|
///
|
||||||
/// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
/// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||||||
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
|
/// NeedsAuth 申请(对齐 read_file),不像 search_files 被硬拒。
|
||||||
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
||||||
#[test]
|
#[test]
|
||||||
fn test_grep_unauthorized_path_triggers_auth_not_denied() {
|
fn test_grep_unauthorized_path_triggers_auth_not_denied() {
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
//! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。
|
//! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。
|
||||||
//! 测试模块通过 `super::check_file_tool_auth` 引用。
|
//! 测试模块通过 `super::check_file_tool_auth` 引用。
|
||||||
|
|
||||||
use super::PathAuthRequest;
|
use crate::commands::ai::PathAuthRequest;
|
||||||
|
|
||||||
/// 「信任所有目录」总开关 KV key(app_settings,bool,默认关)。
|
/// 「信任所有目录」总开关 KV key(app_settings)。
|
||||||
/// 开启后白名单外路径不再挂起授权弹窗,黑名单拦截也一并放行(产品拍板:体验优先,
|
/// 【已退役】授权目录白名单机制退役为默认全放行(2026-10-25 用户拍板),存量 KV 一律忽略,
|
||||||
/// 用户知情选择,后期再收敛)。前端设置页读写同一 key。
|
/// audit 层 bypass 恒开不再读本键。壳保留作恢复点,前端设置页管理入口已撤。
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass";
|
pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass";
|
||||||
|
|
||||||
/// 黑名单放行的进程级标志。validate_path(run_command 等)所在调用链无 bypass 参数,
|
/// 黑名单放行的进程级标志。validate_path(run_command 等)所在调用链无 bypass 参数,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_multi_conv_restore {
|
mod tests_multi_conv_restore {
|
||||||
use super::super::{ApprovalKind, PendingApproval};
|
use crate::commands::ai::{ApprovalKind, PendingApproval};
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::commands::ai::AiSession;
|
use crate::commands::ai::AiSession;
|
||||||
|
|
||||||
@@ -173,14 +173,7 @@ mod tests_multi_conv_restore {
|
|||||||
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
||||||
// 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
|
// 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
|
||||||
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
|
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
|
||||||
// 构造期恒等式:上方 pending_rows 构造的 4 条 PendingApproval 全部 kind=Risk(本测试构造
|
// path_auth 退役(2026-10-25):ApprovalKind::Path 变体已删,恢复侧构造恒 kind=Risk。
|
||||||
// 时未插入任何 Path(_)),故 filter Path(_) 计数必为 0。这是构造恒等式而非外部不变量——
|
|
||||||
// 对齐真实 restore_pending_approvals 的构造(实现 L78 一律 kind=Risk,无 Path 分支)。
|
|
||||||
// 语义:首次 restore 无历史(Path 审批是会话级状态不恢复,恢复侧构造时本就不产出 Path)。
|
|
||||||
let path_restored = session.pending_approvals.values()
|
|
||||||
.filter(|a| matches!(a.kind, ApprovalKind::Path(_)))
|
|
||||||
.count();
|
|
||||||
assert_eq!(path_restored, 0, "构造期恒等式:恢复侧构造全部 kind=Risk,无 Path(首次 restore 无历史)");
|
|
||||||
let conv_a_pending: Vec<_> = session
|
let conv_a_pending: Vec<_> = session
|
||||||
.pending_approvals
|
.pending_approvals
|
||||||
.values()
|
.values()
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ use super::super::augmentation::build_augmentation_segment;
|
|||||||
use super::super::augmentation::sanitize;
|
use super::super::augmentation::sanitize;
|
||||||
use super::super::conversation::save_conversation;
|
use super::super::conversation::save_conversation;
|
||||||
use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
|
use super::super::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
|
||||||
use super::super::prompt::{build_system_prompt_parts, cache_enabled, cache_split_provider_type};
|
use super::super::prompt::{
|
||||||
|
build_system_prompt_parts_with_user, cache_enabled, cache_split_provider_type,
|
||||||
|
};
|
||||||
|
|
||||||
use super::super::{AiChatEvent, ApprovalKind, SessionState};
|
use super::super::{AiChatEvent, SessionState};
|
||||||
|
|
||||||
/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
|
/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
|
||||||
/// "需要用户审批,等待确认")就地替换为终态文本。
|
/// "需要用户审批,等待确认")就地替换为终态文本。
|
||||||
@@ -386,8 +388,24 @@ pub async fn ai_regenerate(
|
|||||||
let conv_id = conversation_id.clone();
|
let conv_id = conversation_id.clone();
|
||||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
||||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||||
|
// 渐进披露(D1)门信号:regenerate 重跑末条 user,以其文本判文档策略全文是否附带
|
||||||
|
// (取法同 knowledge_inject 末条 active user 口径:反向扫描 + is_active 过滤)。
|
||||||
|
let doc_user_text = {
|
||||||
|
let session = state.ai_session.lock().await;
|
||||||
|
session
|
||||||
|
.conv_read(&conv_id)
|
||||||
|
.and_then(|c| {
|
||||||
|
c.messages
|
||||||
|
.all_messages_clone()
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.find(|m| matches!(m.role, df_ai::provider::MessageRole::User) && m.is_active())
|
||||||
|
})
|
||||||
|
.map(|m| m.content)
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
let (mut system_prompt, mut volatile) =
|
let (mut system_prompt, mut volatile) =
|
||||||
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
|
build_system_prompt_parts_with_user(&state, &lang, &[], &[], &doc_user_text, cache_on).await;
|
||||||
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
|
// 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
|
||||||
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
|
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
|
||||||
// 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
|
// 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
|
||||||
@@ -492,6 +510,49 @@ pub async fn ai_is_generating(
|
|||||||
Ok(state.conv_states.is_active(&target))
|
Ok(state.conv_states.is_active(&target))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 拖拽/粘贴的非图片文件落盘(2026-10-19 用户反馈「发送文件变成一堆文本」根治):
|
||||||
|
/// WebView2 原生行为会把拖入 textarea 的文本文件内容直接插入输入框——前端接管全部
|
||||||
|
/// 文件 drop/paste 后,非图片文件经此 IPC 落盘 attachments/(hash 去重),返回本机
|
||||||
|
/// 路径供前端插引用行,AI 经 read_file 按需读取(与飞书文件入站同范式,体验一致)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ai_save_dropped_file(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
file_name: String,
|
||||||
|
data: Vec<u8>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if data.is_empty() {
|
||||||
|
return Err("文件内容为空".to_string());
|
||||||
|
}
|
||||||
|
// 对齐飞书入站文件上限:防超大件烧磁盘
|
||||||
|
const MAX_ATTACHMENT_BYTES: usize = 20 * 1024 * 1024;
|
||||||
|
if data.len() > MAX_ATTACHMENT_BYTES {
|
||||||
|
return Err(format!("文件超过 20MB 上限({} 字节)", data.len()));
|
||||||
|
}
|
||||||
|
let name = sanitize_file_name(&file_name);
|
||||||
|
let store = crate::media_store::MediaStore::new(state.data_dir.clone(), None);
|
||||||
|
let path = store.store_attachment(&data, &name).await?;
|
||||||
|
Ok(path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件名清洗:去路径穿越与非法字符(对齐 media_store::sanitize_name 语义,本地复用
|
||||||
|
/// 防跨模块私有依赖;空名兜底"unnamed")。
|
||||||
|
fn sanitize_file_name(name: &str) -> String {
|
||||||
|
let cleaned: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
c if c.is_control() => '_',
|
||||||
|
c => c,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = cleaned.trim().trim_matches('.').to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"unnamed".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// F-260801 Phase2:anthropic 协议 url→base64 预拉
|
// F-260801 Phase2:anthropic 协议 url→base64 预拉
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -965,7 +1026,7 @@ pub(crate) async fn spawn_agentic_session(
|
|||||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
||||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||||
let (mut system_prompt, mut volatile) =
|
let (mut system_prompt, mut volatile) =
|
||||||
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
|
build_system_prompt_parts_with_user(&state, &lang, &excl_proj, &excl_task, &message, cache_on).await;
|
||||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
|
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
|
||||||
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
|
// 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
|
||||||
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
|
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
|
||||||
@@ -993,6 +1054,20 @@ pub(crate) async fn spawn_agentic_session(
|
|||||||
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
|
system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 飞书渠道交付引导(#958 生产端配套,2026-10-21):出站回传消费端在渠道侧
|
||||||
|
// (extract_delivery_paths/reply_deliveries),但 prompt 层此前零处教 AI 写 [交付] 标记行,
|
||||||
|
// AI 不会自发写标记,链路空转(用户实证「不会用飞书发文件」)。落点=spawn 链渠道标识
|
||||||
|
// 透传判定(source.channel,#958 溯源同参),零新管道;拼装与 aug_seg 同构——缓存模式并
|
||||||
|
// 入 volatile 尾、非缓存直拼 system,均在稳定段之后,BE3 指纹键不含此段零缓存失效。
|
||||||
|
// 文案本体归渠道模块单一事实源(DELIVERY_GUIDANCE_PROMPT,与解析器/回传/拦截同文件演化)。
|
||||||
|
if source.as_ref().map(|s| s.channel.as_str()) == Some(crate::feishu_channel::SOURCE_CHANNEL_FEISHU) {
|
||||||
|
let guidance = crate::feishu_channel::DELIVERY_GUIDANCE_PROMPT;
|
||||||
|
if cache_on {
|
||||||
|
volatile = append_volatile(volatile, guidance);
|
||||||
|
} else {
|
||||||
|
system_prompt = format!("{}\n\n---\n{}", system_prompt, guidance);
|
||||||
|
}
|
||||||
|
}
|
||||||
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
|
let volatile_tail = if cache_on && !volatile.is_empty() { Some(volatile) } else { None };
|
||||||
|
|
||||||
// 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。
|
// 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。
|
||||||
@@ -1235,9 +1310,8 @@ pub async fn ai_approve(
|
|||||||
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => {
|
None => {
|
||||||
// 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。
|
// 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove。
|
||||||
// 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表,
|
// 无挂起则查审计表(幂等,已处理返成功)。
|
||||||
// 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。
|
|
||||||
// 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
|
// 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
|
||||||
// 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
|
// 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
|
||||||
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
|
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
|
||||||
@@ -1272,16 +1346,6 @@ pub async fn ai_approve(
|
|||||||
return Err(format!("未找到挂起的审批: {} ({})", tool_call_id, audit_status));
|
return Err(format!("未找到挂起的审批: {} ({})", tool_call_id, audit_status));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 阶段3a 决策层分离:ai_approve 只消费 kind==Risk(普通 Med/High 审批,一次性 approve/reject)。
|
|
||||||
// 若前端误调 ai_approve 消费 kind==Path 挂起(应走 ai_authorize_dir)→ Err 防误调,
|
|
||||||
// 避免把路径授权挂起当 Risk 审批执行(会跳过白名单写入,语义错乱)。
|
|
||||||
// 兜底/回退:误调返回 Err 前不回滚 insert(前端拿 Err 自行刷新 pending,等价原「未找到」语义)。
|
|
||||||
if !matches!(approval.kind, ApprovalKind::Risk { .. }) {
|
|
||||||
return Err(format!(
|
|
||||||
"tool_call_id={} 非普通审批(路径授权请用 ai_authorize_dir)",
|
|
||||||
tool_call_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
|
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
|
||||||
let _recovered = approval.recovered;
|
let _recovered = approval.recovered;
|
||||||
|
|
||||||
@@ -1495,324 +1559,17 @@ pub async fn ai_approve(
|
|||||||
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
||||||
|
|
||||||
|
|
||||||
/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。
|
|
||||||
///
|
|
||||||
/// 仅做词法判断(取前两字符 `<drive>:`),不做 canonicalize(避免 IO 卡死——这正是本批
|
|
||||||
/// 防卡死预检的目标)。UNC 路径 `\\server\share` 返 None(跨盘语义不适用)。
|
|
||||||
fn drive_letter(path: &str) -> Option<String> {
|
|
||||||
let bytes = path.as_bytes();
|
|
||||||
if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
|
|
||||||
let drive = (bytes[0] as char).to_ascii_uppercase();
|
|
||||||
return Some(format!("{}:", drive));
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
|
|
||||||
/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
|
|
||||||
fn workspace_drive() -> Option<String> {
|
|
||||||
std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
|
|
||||||
///
|
|
||||||
/// 仅对涉及文件移动/删除的工具(delete_file / rename_file)有意义——这些工具的目标位
|
|
||||||
/// (.trash 或 rename 的 to)锚定 workspace_root,源在另一盘时走 copy+remove 降级。
|
|
||||||
/// 其他文件工具(read_file/write_file)无跨盘移动语义,不提示。
|
|
||||||
fn cross_drive_hint(tool_name: &str, raw_paths: &[String]) -> Option<String> {
|
|
||||||
// 仅文件移动/删除类工具涉及跨盘(源在 A 盘 → 目标 .trash/to 在 workspace 盘)
|
|
||||||
if !matches!(tool_name, "delete_file" | "rename_file" | "move_file") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let ws_drive = workspace_drive()?;
|
|
||||||
let cross_drives: Vec<&String> = raw_paths.iter()
|
|
||||||
.filter(|p| drive_letter(p).map_or(false, |d| d != ws_drive))
|
|
||||||
.collect();
|
|
||||||
if cross_drives.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(format!(
|
|
||||||
"⚠ 跨盘提示:路径 {:?} 与工作区({})不同盘,工具内部走 copy+remove 降级(非原子,大文件较慢,失败可能留残,已内置回滚)",
|
|
||||||
cross_drives, ws_drive
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 把跨盘提示追加到 tool_result(保留原结果,仅追加提示行)。
|
|
||||||
///
|
|
||||||
/// - String 结果:追加 `\n<提示>`。
|
|
||||||
/// - Object 结果:追加 `cross_drive_note` 字段(不覆盖原字段)。
|
|
||||||
/// - 其他类型:包成 Object 的 `result` + `cross_drive_note` 双字段。
|
|
||||||
fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json::Value {
|
|
||||||
match result {
|
|
||||||
serde_json::Value::String(s) => serde_json::Value::String(format!("{}\n{}", s, note)),
|
|
||||||
serde_json::Value::Object(mut map) => {
|
|
||||||
map.insert("cross_drive_note".to_string(), serde_json::Value::String(note.to_string()));
|
|
||||||
serde_json::Value::Object(map)
|
|
||||||
}
|
|
||||||
other => serde_json::json!({
|
|
||||||
"result": other,
|
|
||||||
"cross_drive_note": note,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起的 pending)。
|
|
||||||
///
|
|
||||||
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
|
|
||||||
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
|
|
||||||
///
|
|
||||||
/// `decision`(三授权档 + 拒):
|
|
||||||
/// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复
|
|
||||||
/// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复
|
|
||||||
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复
|
|
||||||
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
|
|
||||||
///
|
|
||||||
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
|
|
||||||
/// emit Completed + ApprovalResult → try_continue_agent_loop 恢复 loop(同 ai_approve)。
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn ai_authorize_dir(
|
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
tool_call_id: String,
|
|
||||||
decision: String,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
// F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug,
|
|
||||||
// 避免污染 stderr(用户可见)。
|
|
||||||
tracing::debug!(
|
|
||||||
tool_call_id = %tool_call_id,
|
|
||||||
decision = %decision,
|
|
||||||
"[AI-AUTHZ] ai_authorize_dir 入口"
|
|
||||||
);
|
|
||||||
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
|
|
||||||
let approval = {
|
|
||||||
let __lock_t864 = std::time::Instant::now();
|
|
||||||
let mut session = state.ai_session.lock().await;
|
|
||||||
let v = match session.pending_approvals.remove(&tool_call_id) {
|
|
||||||
Some(a) => a,
|
|
||||||
None => {
|
|
||||||
// 内存无此挂起。释放锁后查审计表(避免持锁 await DB)。
|
|
||||||
drop(session);
|
|
||||||
// 关闭/拒绝无效根因修复(2026-08,对齐 ai_approve None 分支同款修复):
|
|
||||||
// path 类审批同样存在"已处理但前端未收敛"场景(对话 pending_approvals JSON 列残留
|
|
||||||
// 陈旧 path 条目 / 跨会话恢复竞态)。原此处直接 return Err 不 emit → 前端
|
|
||||||
// pendingApprovals(kind='path')永不清 → 卡死"关不了"。修复:审计已落定时
|
|
||||||
// emit AiApprovalResult{approved:false} 让前端收敛(幂等)。
|
|
||||||
match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
|
|
||||||
Ok(Some(rec)) if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" => {
|
|
||||||
let ev = AiChatEvent::AiApprovalResult {
|
|
||||||
id: tool_call_id.clone(),
|
|
||||||
approved: false,
|
|
||||||
conversation_id: rec.conversation_id.clone(),
|
|
||||||
};
|
|
||||||
emit_ai_chat_event(&app, ev);
|
|
||||||
return Ok(format!("已处理({})", rec.status));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
return Err(format!("未找到路径授权挂起: {}", tool_call_id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
log_slow_lock(__lock_t864, "ai_authorize_dir:864");
|
|
||||||
v
|
|
||||||
};
|
|
||||||
// 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
|
|
||||||
// 提取 PathAuthRequest(kind 下沉原 path_auth 字段),非 Path(误调普通 RiskLevel 审批)→ Err。
|
|
||||||
// 兜底/回退:Err 前 re-insert 回 pending_approvals 保持挂起态(原「未找到」会丢挂起致卡死,
|
|
||||||
// re-insert 保守兜底防误调导致 pending 丢失 + loop 永挂)。
|
|
||||||
let req = match approval.kind.clone() {
|
|
||||||
ApprovalKind::Path(req) => req,
|
|
||||||
ApprovalKind::Risk { .. } => {
|
|
||||||
// 误调:re-insert 回单表保持挂起,返 Err(前端据 Err 提示改调 ai_approve)。
|
|
||||||
let __lock_t878 = std::time::Instant::now();
|
|
||||||
let mut session = state.ai_session.lock().await;
|
|
||||||
session.pending_approvals.insert(tool_call_id.clone(), approval);
|
|
||||||
log_slow_lock(__lock_t878, "ai_authorize_dir:878");
|
|
||||||
return Err(format!(
|
|
||||||
"tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
|
|
||||||
tool_call_id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// L1 补丁:req.dirs 含所有未授权父目录(rename_file from/to 不同目录时多项)。
|
|
||||||
// once/always 遍历全部写入白名单,确保工具执行时 handler 内 resolve 全放行。
|
|
||||||
let dir_strs: Vec<String> = req.dirs.iter()
|
|
||||||
.map(|d| d.to_string_lossy().to_string())
|
|
||||||
.collect();
|
|
||||||
let conv_id = approval.conversation_id.clone();
|
|
||||||
let args = approval.arguments.clone();
|
|
||||||
|
|
||||||
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
|
|
||||||
if decision == "deny" {
|
|
||||||
// BUG-2026-07-07/拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
|
||||||
let err_msg = serde_json::json!({
|
|
||||||
"status": "rejected",
|
|
||||||
"reason": "path_auth_denied",
|
|
||||||
"message": "用户拒绝路径授权"
|
|
||||||
}).to_string();
|
|
||||||
{
|
|
||||||
let __lock_t903 = std::time::Instant::now();
|
|
||||||
let mut session = state.ai_session.lock().await;
|
|
||||||
if let Some(ref cid) = conv_id {
|
|
||||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
|
|
||||||
}
|
|
||||||
log_slow_lock(__lock_t903, "ai_authorize_dir:903");
|
|
||||||
}
|
|
||||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
|
||||||
id: tool_call_id.clone(),
|
|
||||||
result: serde_json::Value::String(err_msg.clone()),
|
|
||||||
conversation_id: conv_id.clone(),
|
|
||||||
};
|
|
||||||
emit_ai_chat_event(&app, ev_completed);
|
|
||||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
|
||||||
id: tool_call_id.clone(),
|
|
||||||
approved: false,
|
|
||||||
conversation_id: conv_id.clone(),
|
|
||||||
};
|
|
||||||
emit_ai_chat_event(&app, ev_approval);
|
|
||||||
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg), None).await;
|
|
||||||
if let Some(ref cid) = conv_id {
|
|
||||||
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
|
|
||||||
}
|
|
||||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
|
||||||
let start_iter = {
|
|
||||||
let __lock_t930 = std::time::Instant::now();
|
|
||||||
let session = state.ai_session.lock().await;
|
|
||||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
|
||||||
log_slow_lock(__lock_t930, "ai_authorize_dir:930");
|
|
||||||
v
|
|
||||||
};
|
|
||||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
|
||||||
return Ok("rejected".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)──
|
|
||||||
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。
|
|
||||||
// deny 已在上方早 return,此处仅 once/session/always。
|
|
||||||
match decision.as_str() {
|
|
||||||
"once" => {
|
|
||||||
for d in &dir_strs {
|
|
||||||
state.add_once_allowed_dir(d.clone()).await;
|
|
||||||
}
|
|
||||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)");
|
|
||||||
}
|
|
||||||
"session" => {
|
|
||||||
for d in &dir_strs {
|
|
||||||
state.add_session_allowed_dir(d.clone()).await;
|
|
||||||
}
|
|
||||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)");
|
|
||||||
}
|
|
||||||
"always" => {
|
|
||||||
for d in &dir_strs {
|
|
||||||
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
|
|
||||||
tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e);
|
|
||||||
// 降级:写入会话临时授权,保本路径本会话可用
|
|
||||||
state.add_session_allowed_dir(d.clone()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)");
|
|
||||||
}
|
|
||||||
_ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
|
|
||||||
// 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute
|
|
||||||
tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
|
|
||||||
// 防工具卡死:超时兜底已收敛进 execute_with_heartbeat 的 outer_secs 分档,
|
|
||||||
// execute 不返回时超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
|
|
||||||
// V50 纯执行耗时:只测真执行窗口,不含等待人工批准。
|
|
||||||
let exec_started = std::time::Instant::now();
|
|
||||||
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
|
|
||||||
super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
|
|
||||||
} else {
|
|
||||||
// 授权执行窗口复用 execute_with_heartbeat(对齐 ai_approve,详见其执行段注释):
|
|
||||||
// 授权挂起期主 loop 心跳已停,慢文件工具(如跨盘 copy+remove 降级)整窗静默
|
|
||||||
// → 前端看门狗误杀;附带补齐 run_command 实时流式与超时 clamp(原恒 60s)。
|
|
||||||
execute_with_heartbeat(
|
|
||||||
&state.ai_tools, &approval.tool_name, args.clone(),
|
|
||||||
&app, conv_id.as_deref().unwrap_or_default(), &tool_call_id,
|
|
||||||
).await
|
|
||||||
};
|
|
||||||
let exec_ms = exec_started.elapsed().as_millis() as i64;
|
|
||||||
tracing::debug!(
|
|
||||||
ok = exec_result.is_ok(),
|
|
||||||
status = if exec_result.is_ok() { "completed" } else { "failed" },
|
|
||||||
"[AI-AUTHZ] execute 后"
|
|
||||||
);
|
|
||||||
let (audit_status, mut result_val) = match &exec_result {
|
|
||||||
Ok(val) => ("completed", val.clone()),
|
|
||||||
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
|
|
||||||
};
|
|
||||||
// 阶段4(容错/恢复):跨盘预检 → tool_result 提示。
|
|
||||||
// 检测授权路径(req.raw_paths 规范化前原值)与 workspace_root 是否跨盘(Windows 不同盘符,
|
|
||||||
// 如 C盘授权路径 → E盘 workspace)。跨盘时文件工具(delete_file→.trash / rename_file from→to)
|
|
||||||
// 内部走 copy+remove 降级(非原子,大文件慢),提示 LLM 知晓成本 + 失败留残风险。
|
|
||||||
// 提示追加到 result_val(tool_result),不阻断执行(降级已能处理,仅告知)。
|
|
||||||
if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) {
|
|
||||||
result_val = append_cross_drive_note(result_val, ¬e);
|
|
||||||
}
|
|
||||||
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string()), Some(exec_ms)).await;
|
|
||||||
if exec_result.is_ok() {
|
|
||||||
emit_data_changed(&app, &approval.tool_name);
|
|
||||||
// MED-1(审查): path 绑定工具(Medium)审批执行后 reload 白名单。
|
|
||||||
// bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
|
|
||||||
// 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
|
|
||||||
if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
|
|
||||||
state.reload_allowed_dirs().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。
|
|
||||||
// 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。
|
|
||||||
state.clear_once_allowed_dirs().await;
|
|
||||||
|
|
||||||
// 替换占位 tool_result 为真实结果
|
|
||||||
{
|
|
||||||
let __lock_t1018 = std::time::Instant::now();
|
|
||||||
let mut session = state.ai_session.lock().await;
|
|
||||||
if let Some(ref cid) = conv_id {
|
|
||||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
|
|
||||||
}
|
|
||||||
log_slow_lock(__lock_t1018, "ai_authorize_dir:1018");
|
|
||||||
}
|
|
||||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
|
||||||
id: tool_call_id.clone(),
|
|
||||||
result: result_val.clone(),
|
|
||||||
conversation_id: conv_id.clone(),
|
|
||||||
};
|
|
||||||
emit_ai_chat_event(&app, ev_completed);
|
|
||||||
let ev_approval = AiChatEvent::AiApprovalResult {
|
|
||||||
id: tool_call_id.clone(),
|
|
||||||
approved: true,
|
|
||||||
conversation_id: conv_id.clone(),
|
|
||||||
};
|
|
||||||
emit_ai_chat_event(&app, ev_approval);
|
|
||||||
if let Some(ref cid) = conv_id {
|
|
||||||
save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
|
|
||||||
}
|
|
||||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
|
||||||
let start_iter = {
|
|
||||||
let __lock_t1044 = std::time::Instant::now();
|
|
||||||
let session = state.ai_session.lock().await;
|
|
||||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
|
||||||
log_slow_lock(__lock_t1044, "ai_authorize_dir:1044");
|
|
||||||
v
|
|
||||||
};
|
|
||||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
|
||||||
Ok(audit_status.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
|
/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
|
||||||
///
|
|
||||||
/// 阶段4:`kind` 字段透传(`risk` / `path`),前端 switchConversation 恢复 pendingApprovals 时
|
|
||||||
/// 按 kind 渲染——risk 类显 approve/reject,path 类显 once/always/deny(对齐阶段3b 统一审批模型)。
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct PendingToolCallInfo {
|
pub struct PendingToolCallInfo {
|
||||||
pub tool_call_id: String,
|
pub tool_call_id: String,
|
||||||
pub conversation_id: Option<String>,
|
pub conversation_id: Option<String>,
|
||||||
/// 阶段4:审批类型,前端按 kind 渲染不同决策按钮。
|
/// 审批类型,恒 'risk'(path_auth 已退役,2026-10-25)。字段保留作前端协议兼容。
|
||||||
/// 'risk' = 普通 RiskLevel 审批(approve/reject);'path' = 路径授权(once/always/deny)。
|
|
||||||
#[serde(rename = "kind")]
|
#[serde(rename = "kind")]
|
||||||
pub kind: &'static str,
|
pub kind: &'static str,
|
||||||
|
/// 后端超时收敛到期时刻(ms Unix 时间戳,前端倒计时单一真值);None=禁用超时/异常无 created_at。
|
||||||
|
#[serde(rename = "expires_at_millis")]
|
||||||
|
pub expires_at_millis: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 查询某对话积压的待审批工具(前端 switchConversation 后恢复 toolCard 的 pending_approval 态)
|
/// 查询某对话积压的待审批工具(前端 switchConversation 后恢复 toolCard 的 pending_approval 态)
|
||||||
@@ -1823,21 +1580,17 @@ pub async fn ai_pending_tool_calls(
|
|||||||
) -> Result<Vec<PendingToolCallInfo>, String> {
|
) -> Result<Vec<PendingToolCallInfo>, String> {
|
||||||
let __lock_t1071 = std::time::Instant::now();
|
let __lock_t1071 = std::time::Instant::now();
|
||||||
let session = state.ai_session.lock().await;
|
let session = state.ai_session.lock().await;
|
||||||
// 阶段3a 单真相源合并:单 pending_approvals 表按 conversation_id 过滤,kind 字段透传给前端。
|
// 单 pending_approvals 表按 conversation_id 过滤;kind 恒 'risk'(path_auth 已退役)。
|
||||||
// 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
|
|
||||||
// (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。
|
|
||||||
// (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。)
|
|
||||||
let list = session
|
let list = session
|
||||||
.pending_approvals
|
.pending_approvals
|
||||||
.values()
|
.values()
|
||||||
.filter(|a| a.conversation_id.as_deref() == Some(conversation_id.as_str()))
|
.filter(|a| a.conversation_id.as_deref() == Some(conversation_id.as_str()))
|
||||||
.map(|a| PendingToolCallInfo {
|
.map(|a| PendingToolCallInfo {
|
||||||
|
expires_at_millis: a.created_at
|
||||||
|
.and_then(|c| crate::commands::ai::audit::approval_expires_at_millis(state.inner(), c)),
|
||||||
tool_call_id: a.tool_call_id.clone(),
|
tool_call_id: a.tool_call_id.clone(),
|
||||||
conversation_id: a.conversation_id.clone(),
|
conversation_id: a.conversation_id.clone(),
|
||||||
kind: match a.kind {
|
kind: "risk",
|
||||||
ApprovalKind::Risk { .. } => "risk",
|
|
||||||
ApprovalKind::Path(_) => "path",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
log_slow_lock(__lock_t1071, "ai_pending_tool_calls:1071");
|
log_slow_lock(__lock_t1071, "ai_pending_tool_calls:1071");
|
||||||
@@ -1900,6 +1653,11 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
|||||||
// pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。
|
// pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。
|
||||||
finalize_pending_placeholders(&mut *session, id, "会话已清除");
|
finalize_pending_placeholders(&mut *session, id, "会话已清除");
|
||||||
session.conv(id).messages.clear();
|
session.conv(id).messages.clear();
|
||||||
|
// V45 边界随代同亡(P1-B 代史收尾,2026-10-21):硬清空=旧任务代终局,内存边界同步清。
|
||||||
|
// per_conv.boundary 是真相源(save 每轮随行覆写 DB),不清则下轮 save 会把 DB 侧刚在
|
||||||
|
// clear_conversation_atomic 归位的 boundary_json=NULL 重新覆写成旧边界,旧任务约束
|
||||||
|
// (风险上限/目录白名单/禁破坏性)泄漏给同壳新任务。
|
||||||
|
session.conv(id).boundary = None;
|
||||||
// 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。
|
// 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。
|
||||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(id.as_str()));
|
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(id.as_str()));
|
||||||
}
|
}
|
||||||
@@ -2215,7 +1973,7 @@ pub async fn ai_chat_edit(
|
|||||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,知识→aug 原顺序保留)。
|
||||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||||
let (mut system_prompt, mut volatile) =
|
let (mut system_prompt, mut volatile) =
|
||||||
build_system_prompt_parts(&state, &lang, &[], &[], cache_on).await;
|
build_system_prompt_parts_with_user(&state, &lang, &[], &[], &new_message, cache_on).await;
|
||||||
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
|
// 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
|
||||||
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
|
// DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
|
||||||
// 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
|
// 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
|
||||||
@@ -2485,7 +2243,7 @@ pub async fn ai_chat_force_send(
|
|||||||
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
// prompt caching 拆分模式组装(anthropic 显式缓存 / openai_compat 前缀稳定;开关关 → 与旧路径逐字等价,aug→知识原顺序保留)。
|
||||||
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
let cache_on = cache_enabled(&state).await && cache_split_provider_type(&provider_config.provider_type);
|
||||||
let (mut system_prompt, mut volatile) =
|
let (mut system_prompt, mut volatile) =
|
||||||
build_system_prompt_parts(&state, &lang, &excl_proj, &excl_task, cache_on).await;
|
build_system_prompt_parts_with_user(&state, &lang, &excl_proj, &excl_task, &message, cache_on).await;
|
||||||
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
|
// Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
|
||||||
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
|
let aug_seg = resolve_and_inject(&app, &state, &conv_id, &provider_config, &skill, &mention_spans, &lang).await;
|
||||||
if !aug_seg.is_empty() {
|
if !aug_seg.is_empty() {
|
||||||
|
|||||||
@@ -232,14 +232,24 @@ pub async fn ai_conversation_create(
|
|||||||
///
|
///
|
||||||
/// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖
|
/// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖
|
||||||
/// include_archived=true,见 useAiConversations.loadConversations)。
|
/// include_archived=true,见 useAiConversations.loadConversations)。
|
||||||
|
///
|
||||||
|
/// F4-1(2026-10-17)分页:active_offset/archived_offset 按组跳过前 N 条(默认 0=第一页,
|
||||||
|
/// 旧调用方零改动)。治「会话列表+搜索硬上限 50」——实测 115 条活跃主会话 57% 在桌面
|
||||||
|
/// 端零入口;侧栏首屏仍一页(50/组)保启动性能,「加载更多」按 offset 续拉,搜索前前端
|
||||||
|
/// 逐页拉满(搜索跑在已加载列表上,截断列表=搜索失灵)。offset 超组长度返回该组空页
|
||||||
|
/// (自然收敛,无需显式 has_more 字段:页内某组满额 limit 即可能还有下一页)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn ai_conversation_list(
|
pub async fn ai_conversation_list(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
include_archived: Option<bool>,
|
include_archived: Option<bool>,
|
||||||
|
active_offset: Option<usize>,
|
||||||
|
archived_offset: Option<usize>,
|
||||||
) -> Result<Vec<serde_json::Value>, String> {
|
) -> Result<Vec<serde_json::Value>, String> {
|
||||||
let limit = limit.unwrap_or(50);
|
let limit = limit.unwrap_or(50);
|
||||||
let include_archived = include_archived.unwrap_or(false);
|
let include_archived = include_archived.unwrap_or(false);
|
||||||
|
let active_offset = active_offset.unwrap_or(0);
|
||||||
|
let archived_offset = archived_offset.unwrap_or(0);
|
||||||
let records = state.ai_conversations.list_all().await.map_err(err_str)?;
|
let records = state.ai_conversations.list_all().await.map_err(err_str)?;
|
||||||
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit。
|
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit。
|
||||||
// origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。
|
// origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。
|
||||||
@@ -273,20 +283,25 @@ pub async fn ai_conversation_list(
|
|||||||
"loop_state": r.loop_state,
|
"loop_state": r.loop_state,
|
||||||
})
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
// 分组截断:活跃/归档各自独立取 limit,防归档项挤占活跃名额(前端侧栏活跃列表被截空)。
|
Ok(paginate_by_group(summaries, limit, active_offset, archived_offset))
|
||||||
// 原单 take(limit) 在 include_archived=true 时归档项混排会占满 50 名额。
|
}
|
||||||
let mut active = Vec::with_capacity(limit);
|
|
||||||
let mut archived = Vec::with_capacity(limit);
|
/// F4-1:摘要按组独立分页——活跃/归档各自 skip(offset)+take(limit),活跃在前归档在后,
|
||||||
for s in summaries {
|
/// 组内保持传入原序(created_at DESC)。offset=0 与旧「分组截断」口径逐字节等价
|
||||||
if s.get("archived").and_then(|v| v.as_bool()) == Some(true) {
|
/// (回归保护见 paginate_first_page_matches_legacy_grouped_truncation)。
|
||||||
if archived.len() < limit { archived.push(s); }
|
/// 抽纯函数供单测:分组正确性/offset 续拉/越界空页/归档不挤占活跃名额。
|
||||||
} else if active.len() < limit {
|
fn paginate_by_group(
|
||||||
active.push(s);
|
summaries: Vec<serde_json::Value>,
|
||||||
}
|
limit: usize,
|
||||||
}
|
active_offset: usize,
|
||||||
// 活跃在前(侧栏主区),归档在后(归档分组);各自保持 created_at DESC 原序。
|
archived_offset: usize,
|
||||||
active.extend(archived);
|
) -> Vec<serde_json::Value> {
|
||||||
Ok(active)
|
let (active, archived): (Vec<_>, Vec<_>) = summaries
|
||||||
|
.into_iter()
|
||||||
|
.partition(|s| s.get("archived").and_then(|v| v.as_bool()) != Some(true));
|
||||||
|
let mut page: Vec<serde_json::Value> = active.into_iter().skip(active_offset).take(limit).collect();
|
||||||
|
page.extend(archived.into_iter().skip(archived_offset).take(limit));
|
||||||
|
page
|
||||||
}
|
}
|
||||||
|
|
||||||
/// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。
|
/// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。
|
||||||
@@ -391,11 +406,37 @@ pub(crate) async fn recover_parked_inner(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 恢复产物前缀:恢复链自身生成的引导消息以它开头(恢复产物非用户意图,任何情况下
|
||||||
|
/// 都不该作为有效目标回放——2026-10-15 目标语义重构约定)。递归套娃根因:恢复消息
|
||||||
|
/// 经 begin_turn(chat.rs:950/2461)被记为 WorkingContext Active 目标落库,下次恢复
|
||||||
|
/// 回放时被当有效目标嵌套进新恢复消息,逐层加深(实证 conv 76730ce8 seq 250:
|
||||||
|
/// 目标文本=上一条恢复消息原文截 200 字符)。防线收敛在恢复链单点:
|
||||||
|
/// parse_pinned_goals 反解剔除 + build_recovery_message 回放剔除,同判据。
|
||||||
|
pub(crate) const RECOVERY_ARTIFACT_PREFIX: &str = "[系统恢复]";
|
||||||
|
|
||||||
|
/// 恢复产物判定:目标文本以恢复前缀开头即恢复链自产物,不作为有效目标。
|
||||||
|
fn is_recovery_artifact(text: &str) -> bool {
|
||||||
|
text.starts_with(RECOVERY_ARTIFACT_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 注入产物判定(E5):[补充指令] 是运行中用户插话的通道标记,非任务意图本身
|
||||||
|
/// (实证 434c8c40 active goal=「[补充指令] 123 hi」占位)。与恢复产物同治:
|
||||||
|
/// 载入侧滤出内存,下轮 save 重写即自愈落库。
|
||||||
|
fn is_injection_artifact(text: &str) -> bool {
|
||||||
|
text.starts_with("[补充指令]")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 引导产物总判定(恢复+注入),双侧(载入/回放)统一用。
|
||||||
|
fn is_guide_artifact(text: &str) -> bool {
|
||||||
|
is_recovery_artifact(text) || is_injection_artifact(text)
|
||||||
|
}
|
||||||
|
|
||||||
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
|
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
|
||||||
///
|
///
|
||||||
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
|
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
|
||||||
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活。
|
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活;
|
||||||
/// 快照全空(旧会话无快照)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
|
/// 恢复产物([系统恢复] 前缀)剔除——回放它即递归套娃,历史污染目标在此单点拦截。
|
||||||
|
/// 快照全空(旧会话无快照/全被过滤)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
|
||||||
/// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。
|
/// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。
|
||||||
pub(crate) fn build_recovery_message(
|
pub(crate) fn build_recovery_message(
|
||||||
working_context: &df_ai::context_helpers::WorkingContext,
|
working_context: &df_ai::context_helpers::WorkingContext,
|
||||||
@@ -405,12 +446,18 @@ pub(crate) fn build_recovery_message(
|
|||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
let mut active: Vec<&str> = Vec::new();
|
let mut active: Vec<&str> = Vec::new();
|
||||||
for g in &working_context.goals {
|
for g in &working_context.goals {
|
||||||
if matches!(g.status, GoalStatus::Active) && seen.insert(g.text.clone()) {
|
if matches!(g.status, GoalStatus::Active)
|
||||||
|
&& !is_guide_artifact(&g.text)
|
||||||
|
&& seen.insert(g.text.clone())
|
||||||
|
{
|
||||||
active.push(&g.text);
|
active.push(&g.text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for g in pinned_goals {
|
for g in pinned_goals {
|
||||||
if matches!(g.status, crate::commands::ai::GoalStatus::Active) && seen.insert(g.text.clone()) {
|
if matches!(g.status, crate::commands::ai::GoalStatus::Active)
|
||||||
|
&& !is_guide_artifact(&g.text)
|
||||||
|
&& seen.insert(g.text.clone())
|
||||||
|
{
|
||||||
active.push(&g.text);
|
active.push(&g.text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,7 +489,8 @@ pub(crate) fn build_recovery_message(
|
|||||||
|
|
||||||
/// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status,
|
/// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status,
|
||||||
/// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与
|
/// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与
|
||||||
/// parked 恢复共用(DRY,单一事实源)。
|
/// parked 恢复共用(DRY,单一事实源)。恢复产物([系统恢复] 前缀)剔除——存量
|
||||||
|
/// 污染目标不进内存,switch 载入后下轮 save 重写 pinned_goals 即自愈落库。
|
||||||
pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> {
|
pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> {
|
||||||
let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok());
|
let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok());
|
||||||
match parsed {
|
match parsed {
|
||||||
@@ -468,6 +516,7 @@ pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::
|
|||||||
Some(crate::commands::ai::GoalEntry::new(text))
|
Some(crate::commands::ai::GoalEntry::new(text))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.filter(|e| !is_guide_artifact(&e.text))
|
||||||
.collect(),
|
.collect(),
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
@@ -656,7 +705,7 @@ pub async fn ai_conversation_switch(
|
|||||||
//
|
//
|
||||||
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
||||||
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
||||||
// 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
// 审批处理时(ai_approve)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
||||||
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
||||||
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
||||||
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
||||||
@@ -1092,17 +1141,17 @@ pub async fn ai_conversation_set_pinned(
|
|||||||
///
|
///
|
||||||
/// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。
|
/// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。
|
||||||
/// goals 是完整替换(非增量),前端增/删后传全量 new Vec。
|
/// goals 是完整替换(非增量),前端增/删后传全量 new Vec。
|
||||||
|
// 跨端修复(走查批E):签名 Vec<String> → Vec<GoalEntry>。原实现把前端载荷逐条
|
||||||
|
// GoalEntry::new 重置 active,已完成目标(status="completed")的完成态被抹。
|
||||||
|
// 前端 GoalsPanel 同步改传完整 GoalEntry[](text/status,snake_case),反序列化缺
|
||||||
|
// status 时 serde default 补 active(见 mod.rs GoalEntry 定义),旧载荷兼容。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn ai_update_conversation_goals(
|
pub async fn ai_update_conversation_goals(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
conversation_id: String,
|
conversation_id: String,
|
||||||
goals: Vec<String>,
|
goals: Vec<crate::commands::ai::GoalEntry>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// 前端传回的 string[] 转为 GoalEntry(假设全部 active)
|
let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
||||||
let goal_entries: Vec<crate::commands::ai::GoalEntry> = goals.into_iter()
|
|
||||||
.map(|g| crate::commands::ai::GoalEntry::new(g))
|
|
||||||
.collect();
|
|
||||||
let goals_json = serde_json::to_string(&goal_entries).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
|
||||||
// 写 DB
|
// 写 DB
|
||||||
state.ai_conversations
|
state.ai_conversations
|
||||||
.update_field(&conversation_id, "pinned_goals", &goals_json)
|
.update_field(&conversation_id, "pinned_goals", &goals_json)
|
||||||
@@ -1111,7 +1160,7 @@ pub async fn ai_update_conversation_goals(
|
|||||||
// 同步内存 per_conv(若已加载)
|
// 同步内存 per_conv(若已加载)
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
|
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
|
||||||
conv.pinned_goals = goal_entries;
|
conv.pinned_goals = goals;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1477,6 +1526,73 @@ mod tests {
|
|||||||
assert_eq!(back.timestamp, Some(123));
|
assert_eq!(back.timestamp, Some(123));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// F4-1 会话列表分页(paginate_by_group:活跃/归档各自 offset+limit)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn conv_val(id: &str, archived: bool) -> serde_json::Value {
|
||||||
|
serde_json::json!({ "id": id, "archived": archived })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首页(offset=0)与旧「分组截断」口径等价:各组前 limit 条,活跃在前归档在后。
|
||||||
|
#[test]
|
||||||
|
fn paginate_first_page_matches_legacy_grouped_truncation() {
|
||||||
|
// 60 活跃 + 60 归档,limit 50:旧口径各取 50(归档不挤占活跃名额)
|
||||||
|
let summaries: Vec<_> = (0..60).map(|i| conv_val(&format!("a{i}"), false))
|
||||||
|
.chain((0..60).map(|i| conv_val(&format!("r{i}"), true)))
|
||||||
|
.collect();
|
||||||
|
let page = paginate_by_group(summaries, 50, 0, 0);
|
||||||
|
assert_eq!(page.len(), 100, "各组独立取满 50");
|
||||||
|
assert!(page.iter().take(50).all(|s| s["archived"] == false), "前 50 全活跃");
|
||||||
|
assert!(page.iter().skip(50).all(|s| s["archived"] == true), "后 50 全归档");
|
||||||
|
assert_eq!(page[0]["id"], "a0");
|
||||||
|
assert_eq!(page[49]["id"], "a49");
|
||||||
|
assert_eq!(page[50]["id"], "r0");
|
||||||
|
assert_eq!(page[99]["id"], "r49");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// offset 续拉:第二页从各组 offset 处继续,两组独立推进互不影响
|
||||||
|
/// (115 活跃场景:第一页 a0..a49,第二页 active_offset=50 拉 a50..a99)。
|
||||||
|
#[test]
|
||||||
|
fn paginate_offset_advances_each_group_independently() {
|
||||||
|
let summaries: Vec<_> = (0..115).map(|i| conv_val(&format!("a{i}"), false))
|
||||||
|
.chain((0..3).map(|i| conv_val(&format!("r{i}"), true)))
|
||||||
|
.collect();
|
||||||
|
// 第二页:活跃 offset 50(满页 50 条),归档 offset 0(仅 3 条全回)
|
||||||
|
let page2 = paginate_by_group(summaries.clone(), 50, 50, 0);
|
||||||
|
assert_eq!(page2.len(), 53);
|
||||||
|
assert_eq!(page2[0]["id"], "a50");
|
||||||
|
assert_eq!(page2[49]["id"], "a99");
|
||||||
|
assert_eq!(page2[50]["id"], "r0", "归档组从 0 起独立分页,不被活跃 offset 影响");
|
||||||
|
// 第三页:活跃 offset 100(尾页 15 条),归档 offset 3(越界空)
|
||||||
|
let page3 = paginate_by_group(summaries, 50, 100, 3);
|
||||||
|
assert_eq!(page3.len(), 15);
|
||||||
|
assert_eq!(page3[0]["id"], "a100");
|
||||||
|
assert_eq!(page3[14]["id"], "a114");
|
||||||
|
assert!(page3.iter().all(|s| s["archived"] == false), "归档越界返回该组空页");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// offset 超组长度的组返回空页(自然收敛);两组同时越界返回空 Vec。
|
||||||
|
#[test]
|
||||||
|
fn paginate_offset_beyond_length_yields_empty() {
|
||||||
|
let summaries = vec![conv_val("a0", false), conv_val("r0", true)];
|
||||||
|
assert!(paginate_by_group(summaries.clone(), 50, 5, 0).iter().all(|s| s["archived"] == true));
|
||||||
|
assert!(paginate_by_group(summaries.clone(), 50, 0, 5).iter().all(|s| s["archived"] == false));
|
||||||
|
assert!(paginate_by_group(summaries, 50, 5, 5).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 输入混排(归档穿插在活跃间,list_all 全量 DESC 实际形态)时 partition 仍按组归位,
|
||||||
|
/// 组内保持传入原序。
|
||||||
|
#[test]
|
||||||
|
fn paginate_partition_from_interleaved_input() {
|
||||||
|
let summaries = vec![
|
||||||
|
conv_val("a0", false), conv_val("r0", true), conv_val("a1", false), conv_val("r1", true),
|
||||||
|
];
|
||||||
|
let page = paginate_by_group(summaries, 50, 0, 0);
|
||||||
|
let ids: Vec<&str> = page.iter().map(|s| s["id"].as_str().unwrap()).collect();
|
||||||
|
assert_eq!(ids, vec!["a0", "a1", "r0", "r1"], "活跃全量在前(原序),归档全量在后(原序)");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原)
|
// F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1789,4 +1905,86 @@ mod tests {
|
|||||||
assert!(parse_pinned_goals(Some("not-json")).is_empty());
|
assert!(parse_pinned_goals(Some("not-json")).is_empty());
|
||||||
assert!(parse_pinned_goals(None).is_empty());
|
assert!(parse_pinned_goals(None).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 恢复产物过滤(递归套娃防线,conv 76730ce8 seq 250 实证) ──
|
||||||
|
|
||||||
|
/// 构造一条上一代恢复消息全文(截 200 字符,复刻 begin_turn 误记目标的现场形态)。
|
||||||
|
fn prev_recovery_text() -> String {
|
||||||
|
let full = format!(
|
||||||
|
"{} 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务:\n\n有效目标:\n- 检查 各个 u-claw 实例状态\n\n若快照不足以判断进度,先向用户确认已完成步骤再继续。",
|
||||||
|
RECOVERY_ARTIFACT_PREFIX
|
||||||
|
);
|
||||||
|
full.chars().take(200).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_message_zero_nesting_with_polluted_goals() {
|
||||||
|
use df_ai::context_helpers::GoalStatus;
|
||||||
|
// 嵌套恢复现场:WorkingContext.goals 与 pinned_goals 都躺着上一条恢复消息全文,
|
||||||
|
// 两侧 Active——未过滤时它会被当有效目标嵌进新恢复消息(套娃逐层加深)。
|
||||||
|
let wc = wc_with_goals(&[
|
||||||
|
(&prev_recovery_text(), GoalStatus::Active),
|
||||||
|
("交付 v2 登录接口", GoalStatus::Active),
|
||||||
|
]);
|
||||||
|
let pinned = vec![
|
||||||
|
crate::commands::ai::GoalEntry::new(prev_recovery_text()),
|
||||||
|
crate::commands::ai::GoalEntry::new("补齐单测覆盖".to_string()),
|
||||||
|
];
|
||||||
|
let msg = build_recovery_message(&wc, &pinned);
|
||||||
|
// 输出只允许保留消息头一处恢复前缀(零套娃),正常用户目标不受影响
|
||||||
|
assert_eq!(
|
||||||
|
msg.matches(RECOVERY_ARTIFACT_PREFIX).count(),
|
||||||
|
1,
|
||||||
|
"恢复产物目标必须被剔除,输出仅消息头一处前缀: {msg}"
|
||||||
|
);
|
||||||
|
assert!(msg.contains("交付 v2 登录接口"), "正常用户目标须保留: {msg}");
|
||||||
|
assert!(msg.contains("补齐单测覆盖"), "正常钉扎目标须保留: {msg}");
|
||||||
|
assert!(
|
||||||
|
!msg.contains("有效目标:\n- [系统恢复]"),
|
||||||
|
"上一代恢复消息不得作为目标行回放: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_message_all_polluted_falls_back_to_constant() {
|
||||||
|
use df_ai::context_helpers::GoalStatus;
|
||||||
|
// 快照只剩恢复产物(历史最深套娃态):全部过滤后回退常量,套娃链在此截断
|
||||||
|
let wc = wc_with_goals(&[(&prev_recovery_text(), GoalStatus::Active)]);
|
||||||
|
let pinned = vec![crate::commands::ai::GoalEntry::new(prev_recovery_text())];
|
||||||
|
assert_eq!(
|
||||||
|
build_recovery_message(&wc, &pinned),
|
||||||
|
PARKED_RECOVERY_MESSAGE,
|
||||||
|
"全污染快照过滤后按空快照回退常量"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_pinned_goals_drops_recovery_artifacts_idempotent() {
|
||||||
|
// 存量污染反解剔除:恢复前缀条目不进内存(幂等——清洗后再解析结果不变)
|
||||||
|
let polluted = serde_json::to_string(&vec![
|
||||||
|
crate::commands::ai::GoalEntry::new(prev_recovery_text()),
|
||||||
|
crate::commands::ai::GoalEntry::new("正常目标保留".to_string()),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let parsed = parse_pinned_goals(Some(&polluted));
|
||||||
|
assert_eq!(parsed.len(), 1, "恢复产物条目须剔除: {parsed:?}");
|
||||||
|
assert_eq!(parsed[0].text, "正常目标保留");
|
||||||
|
// 幂等:清洗结果重解析不变
|
||||||
|
let cleaned = serde_json::to_string(&parsed).unwrap();
|
||||||
|
assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_pinned_goals_drops_injection_artifacts() {
|
||||||
|
// E5:[补充指令] 是通道标记非任务意图(434c8c40「[补充指令] 123 hi」占位实证),
|
||||||
|
// 与恢复产物同治——载入侧剔除,下轮 save 重写自愈。
|
||||||
|
let polluted = serde_json::to_string(&vec![
|
||||||
|
crate::commands::ai::GoalEntry::new("[补充指令] 123 hi".to_string()),
|
||||||
|
crate::commands::ai::GoalEntry::new("升级工具链到最新版".to_string()),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
let parsed = parse_pinned_goals(Some(&polluted));
|
||||||
|
assert_eq!(parsed.len(), 1, "注入产物条目须剔除: {parsed:?}");
|
||||||
|
assert_eq!(parsed[0].text, "升级工具链到最新版");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
//! - [`config`] — skills + config 域 4 个 IPC(ai_list_skills + ai_set_concurrency_config
|
//! - [`config`] — skills + config 域 4 个 IPC(ai_list_skills + ai_set_concurrency_config
|
||||||
//! + ai_set_agent_max_iterations + ai_set_agent_max_retries);`ai_list_skills` 复用
|
//! + ai_set_agent_max_iterations + ai_set_agent_max_retries);`ai_list_skills` 复用
|
||||||
//! `super::super::skills::{skills_cached, SkillInfo}`(skills.rs 缓存+扫盘,config 仅 IPC 包装)
|
//! `super::super::skills::{skills_cached, SkillInfo}`(skills.rs 缓存+扫盘,config 仅 IPC 包装)
|
||||||
|
//! - [`search`] — search 域 1 个 IPC(search_messages 跨会话消息检索,P1-A)+ 共享内核
|
||||||
|
//! (keyword_search / recent_conversation_briefs,AI 工具 history_search 同源复用)
|
||||||
//!
|
//!
|
||||||
//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation,provider,config}::*`
|
//! re-export 链:`commands/mod.rs` → `pub use self::{chat,conversation,provider,config,search}::*`
|
||||||
//! 把各域 IPC 拉到 `commands::*` → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*`
|
//! 把各域 IPC 拉到 `commands::*` → `ai/mod.rs` 的 `pub use self::commands::*` 透传到 `commands::ai::*`
|
||||||
//! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
|
//! → `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
|
||||||
//!
|
//!
|
||||||
@@ -32,3 +34,6 @@ pub use self::provider::*;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use self::config::*;
|
pub use self::config::*;
|
||||||
|
pub mod search;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use self::search::*;
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
//! search 域 IPC — 跨会话消息检索(P1-A,对标 u-claw #5)
|
||||||
|
//!
|
||||||
|
//! `search_messages` IPC 与 AI 工具 `history_search`(tools/history.rs)共用本文件内核
|
||||||
|
//! ([`keyword_search`] / [`recent_conversation_briefs`]):同一 Repo 查询两条薄包装,
|
||||||
|
//! DRY 无双轨(设计:分析-P1A-跨会话检索-2026-10-21 §三③)。
|
||||||
|
//!
|
||||||
|
//! 依赖方向:分词在 command 层完成(extract_query_keywords,与 knowledge_search 同源),
|
||||||
|
//! 关键词数组传 Repo——df-storage 在依赖下层,不能反向依赖 src-tauri。
|
||||||
|
//! LIKE 起步 + role 过滤 user+assistant+system(tool 结果默认排除,拍板④);
|
||||||
|
//! FTS5 升级条件留档在 message_repo.rs 搜索方法头(拍板⑥,>50 万行或 P95>500ms 再评估)。
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use df_storage::crud::{AiConversationRepo, AiMessageRepo, MessageSearchHit};
|
||||||
|
use df_storage::db::Database;
|
||||||
|
|
||||||
|
use crate::commands::ai::audit::truncate_chars_ellipsis;
|
||||||
|
use crate::commands::ai::knowledge_inject::extract_query_keywords;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// IPC 分页:默认条数与上限(分析 §4.2:默认 20,帽 50)。
|
||||||
|
const SEARCH_DEFAULT_LIMIT: usize = 20;
|
||||||
|
const SEARCH_MAX_LIMIT: usize = 50;
|
||||||
|
|
||||||
|
/// 会话概要截断长度(拍板⑤降级链产物统一口径:截 200 字)。
|
||||||
|
const CONVERSATION_SUMMARY_CHARS: usize = 200;
|
||||||
|
|
||||||
|
/// 关键词检索命中 DTO:会话摘要元信息(id/title/updated_at)+ 命中片段(Repo 层已截 500 字)。
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct MessageSearchDto {
|
||||||
|
pub conversation_id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub updated_at: String,
|
||||||
|
/// 子代理轨迹标记(sub_agent);主会话为 null
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub archived: bool,
|
||||||
|
pub message_id: String,
|
||||||
|
pub seq: i64,
|
||||||
|
pub role: String,
|
||||||
|
pub timestamp: Option<i64>,
|
||||||
|
pub snippet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话概要 DTO(无参列最近会话)。
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ConversationBrief {
|
||||||
|
pub id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// 摘要降级链产物(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截 200 字;空会话为空串
|
||||||
|
pub summary: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub archived: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关键词检索内核(IPC 与 AI 工具共用):分词 → Repo LIKE 检索 → 会话元信息补全。
|
||||||
|
///
|
||||||
|
/// query 分词后无有效关键词(纯停用词/纯标点)返空 Vec,调用方按空结果给指引文案
|
||||||
|
/// (禁裸空数组,tools/knowledge.rs 同惯例)。
|
||||||
|
pub(crate) async fn keyword_search(
|
||||||
|
db: &Arc<Database>,
|
||||||
|
query: &str,
|
||||||
|
conversation_id: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
offset: usize,
|
||||||
|
) -> Result<Vec<MessageSearchDto>, String> {
|
||||||
|
let keywords = extract_query_keywords(query);
|
||||||
|
if keywords.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let hits = AiMessageRepo::new(db)
|
||||||
|
.search_keywords(&keywords, conversation_id, limit, offset)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
enrich_with_conversations(db, hits).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 命中列表补会话元信息:按去重后的会话 id 逐个 PK 查询(hit 上限几十条、会话数更少,
|
||||||
|
/// 子毫秒级 PK lookup,不值得为此加批量 Repo 面)。会话已删的孤儿消息按空元信息兜底。
|
||||||
|
async fn enrich_with_conversations(
|
||||||
|
db: &Arc<Database>,
|
||||||
|
hits: Vec<MessageSearchHit>,
|
||||||
|
) -> Result<Vec<MessageSearchDto>, String> {
|
||||||
|
let conv_repo = AiConversationRepo::new(db);
|
||||||
|
// 元信息三元组:(title, updated_at, origin, archived)
|
||||||
|
let mut metas: HashMap<String, (Option<String>, String, Option<String>, bool)> = HashMap::new();
|
||||||
|
let conv_ids: HashSet<String> = hits.iter().map(|h| h.conversation_id.clone()).collect();
|
||||||
|
for conv_id in conv_ids {
|
||||||
|
let record = conv_repo.get_by_id(&conv_id).await.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(r) = record {
|
||||||
|
metas.insert(conv_id, (r.title, r.updated_at, r.origin, r.archived));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| {
|
||||||
|
let (title, updated_at, origin, archived) = metas
|
||||||
|
.get(&h.conversation_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or((None, String::new(), None, false));
|
||||||
|
MessageSearchDto {
|
||||||
|
conversation_id: h.conversation_id,
|
||||||
|
title,
|
||||||
|
updated_at,
|
||||||
|
origin,
|
||||||
|
archived,
|
||||||
|
message_id: h.message_id,
|
||||||
|
seq: h.seq,
|
||||||
|
role: h.role,
|
||||||
|
timestamp: h.timestamp,
|
||||||
|
snippet: h.snippet,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最近会话概要列表(无参降级数据源):与 ai_conversation_list 同源 list_all,
|
||||||
|
/// 排除子代理轨迹(origin='sub_agent',对齐侧栏口径),按 updated_at DESC 取前 limit。
|
||||||
|
///
|
||||||
|
/// 摘要走降级链(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截
|
||||||
|
/// [`CONVERSATION_SUMMARY_CHARS`] 字;两级皆空(空会话)为空串。
|
||||||
|
pub(crate) async fn recent_conversation_briefs(
|
||||||
|
db: &Arc<Database>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<ConversationBrief>, String> {
|
||||||
|
let records = AiConversationRepo::new(db)
|
||||||
|
.list_all()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut mains: Vec<_> = records.into_iter().filter(|r| r.origin.is_none()).collect();
|
||||||
|
// updated_at 毫秒串数值降序(「最近」=最近活跃,非最近创建);脏数据按 0 沉底
|
||||||
|
mains.sort_by_key(|r| std::cmp::Reverse(r.updated_at.parse::<u64>().unwrap_or(0)));
|
||||||
|
mains.truncate(limit);
|
||||||
|
let sources = AiMessageRepo::new(db)
|
||||||
|
.summary_sources(&mains.iter().map(|r| r.id.clone()).collect::<Vec<_>>())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(mains
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let summary = sources
|
||||||
|
.get(&r.id)
|
||||||
|
.and_then(|s| match (&s.last_system, &s.first_user) {
|
||||||
|
(Some(sys), _) if !sys.trim().is_empty() => Some(sys.clone()),
|
||||||
|
(_, Some(usr)) if !usr.trim().is_empty() => Some(usr.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.map(|s| truncate_chars_ellipsis(&s, CONVERSATION_SUMMARY_CHARS))
|
||||||
|
.unwrap_or_default();
|
||||||
|
ConversationBrief {
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
summary,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
origin: r.origin,
|
||||||
|
archived: r.archived,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跨会话消息全文检索(P1-A 后端搜索面,F4-1 前端侧栏升级的预留 IPC)。
|
||||||
|
///
|
||||||
|
/// 命中片段 Repo 层已截 500 字防全文回灌;role 默认排除 tool(拍板④);
|
||||||
|
/// query 分词后无有效关键词返空 results(前端空态自理)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_messages(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
query: String,
|
||||||
|
conversation_id: Option<String>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
offset: Option<usize>,
|
||||||
|
) -> Result<Vec<MessageSearchDto>, String> {
|
||||||
|
keyword_search(
|
||||||
|
&state.db,
|
||||||
|
&query,
|
||||||
|
conversation_id.as_deref(),
|
||||||
|
limit.unwrap_or(SEARCH_DEFAULT_LIMIT).min(SEARCH_MAX_LIMIT),
|
||||||
|
offset.unwrap_or(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use df_storage::crud::AiMessageRepo;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
use df_storage::models::{AiConversationRecord, AiMessageRecord};
|
||||||
|
|
||||||
|
async fn test_db() -> Arc<Database> {
|
||||||
|
Arc::new(Database::open_in_memory().await.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ins_conv(db: &Database, id: &str, title: Option<&str>, origin: Option<&str>, updated_at: &str) {
|
||||||
|
AiConversationRepo::new(db)
|
||||||
|
.insert(AiConversationRecord {
|
||||||
|
id: id.into(),
|
||||||
|
title: title.map(|s| s.into()),
|
||||||
|
messages: "[]".into(),
|
||||||
|
provider_id: None,
|
||||||
|
model: None,
|
||||||
|
models: None,
|
||||||
|
archived: false,
|
||||||
|
pinned: false,
|
||||||
|
prompt_tokens: None,
|
||||||
|
completion_tokens: None,
|
||||||
|
pinned_goals: None,
|
||||||
|
pending_approvals: None,
|
||||||
|
created_at: "100".into(),
|
||||||
|
updated_at: updated_at.into(),
|
||||||
|
origin: origin.map(|s| s.into()),
|
||||||
|
boundary_json: None,
|
||||||
|
loop_state: None,
|
||||||
|
status: "active".into(),
|
||||||
|
working_context_json: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn msg(conv: &str, seq: i64, role: &str, content: &str, timestamp: i64) -> AiMessageRecord {
|
||||||
|
AiMessageRecord {
|
||||||
|
id: format!("m_{conv}_{seq}"),
|
||||||
|
conversation_id: conv.into(),
|
||||||
|
seq,
|
||||||
|
role: role.into(),
|
||||||
|
content: content.into(),
|
||||||
|
parts: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
tool_calls: None,
|
||||||
|
model: None,
|
||||||
|
status: "active".into(),
|
||||||
|
reasoning_content: None,
|
||||||
|
timestamp: Some(timestamp),
|
||||||
|
created_at: "100".into(),
|
||||||
|
prompt_tokens: None,
|
||||||
|
completion_tokens: None,
|
||||||
|
prompt_cache_hit_tokens: None,
|
||||||
|
prompt_cache_miss_tokens: None,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
is_estimated: None,
|
||||||
|
source: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关键词检索内核:分词命中 + 会话元信息(title/origin/archived)补全;
|
||||||
|
/// tool role 不入检索面;纯停用词 query 返空。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keyword_search_enriches_conversation_meta() {
|
||||||
|
let db = test_db().await;
|
||||||
|
ins_conv(&db, "c1", Some("部署踩坑复盘"), None, "200").await;
|
||||||
|
ins_conv(&db, "c2", None, Some("sub_agent"), "300").await;
|
||||||
|
AiMessageRepo::new(&db)
|
||||||
|
.insert_batch(vec![
|
||||||
|
msg("c1", 0, "user", "上次部署服务踩了端口占用的坑", 100),
|
||||||
|
msg("c1", 1, "tool", "部署 tool 结果正文", 200),
|
||||||
|
msg("c2", 0, "assistant", "子代理轨迹里的部署结论", 300),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let hits = keyword_search(&db, "帮我看看部署相关的历史", None, 20, 0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// tool 结果排除(user+assistant 命中,tool 不入),两会话各一条
|
||||||
|
assert_eq!(hits.len(), 2, "tool 应排除: {:?}", hits.iter().map(|h| &h.role).collect::<Vec<_>>());
|
||||||
|
let c1 = hits.iter().find(|h| h.conversation_id == "c1").unwrap();
|
||||||
|
assert_eq!(c1.title.as_deref(), Some("部署踩坑复盘"), "命中应补会话标题");
|
||||||
|
assert!(c1.snippet.contains("部署"), "片段应含命中词: {}", c1.snippet);
|
||||||
|
// 子代理轨迹会话同样可检索(origin 透出供 AI 识别轨迹)
|
||||||
|
let c2 = hits.iter().find(|h| h.conversation_id == "c2").unwrap();
|
||||||
|
assert_eq!(c2.origin.as_deref(), Some("sub_agent"));
|
||||||
|
|
||||||
|
// 纯停用词/客套话 query → 分词后无有效关键词 → 空集(调用方给指引)
|
||||||
|
let empty = keyword_search(&db, "帮我看看一下这个", None, 20, 0).await.unwrap();
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无参列最近会话:摘要降级链(最近 system 压缩摘要 > 首条 user)、updated_at DESC、
|
||||||
|
/// 子代理轨迹排除、limit 截断。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recent_briefs_summary_fallback_chain() {
|
||||||
|
let db = test_db().await;
|
||||||
|
// c_old:更新最早(100);c_user:无 system,走首条 user 兜底(200);
|
||||||
|
// c_sum:有压缩摘要,优先于 user(300);c_sub:子代理轨迹排除(400);c_tail:超 limit 沉底(50)
|
||||||
|
ins_conv(&db, "c_old", Some("最早会话"), None, "100").await;
|
||||||
|
ins_conv(&db, "c_user", Some("无摘要会话"), None, "200").await;
|
||||||
|
ins_conv(&db, "c_sum", Some("有摘要会话"), None, "300").await;
|
||||||
|
ins_conv(&db, "c_sub", Some("子代理轨迹"), Some("sub_agent"), "400").await;
|
||||||
|
ins_conv(&db, "c_tail", Some("超limit沉底"), None, "50").await;
|
||||||
|
let repo = AiMessageRepo::new(&db);
|
||||||
|
repo.insert_batch(vec![
|
||||||
|
msg("c_old", 0, "user", "c_old 首条", 1),
|
||||||
|
msg("c_user", 0, "user", "c_user 的首条用户消息,应作摘要兜底", 2),
|
||||||
|
msg("c_sum", 0, "user", "c_sum 首条不应入选", 3),
|
||||||
|
msg("c_sum", 1, "system", "## 意图\n- c_sum 压缩摘要应优先", 4),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let briefs = recent_conversation_briefs(&db, 4).await.unwrap();
|
||||||
|
let ids: Vec<&str> = briefs.iter().map(|b| b.id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["c_sum", "c_user", "c_old", "c_tail"],
|
||||||
|
"updated_at DESC 且子代理轨迹排除: {ids:?}"
|
||||||
|
);
|
||||||
|
let c_sum = briefs.iter().find(|b| b.id == "c_sum").unwrap();
|
||||||
|
assert!(c_sum.summary.contains("c_sum 压缩摘要应优先"), "system 摘要优先: {}", c_sum.summary);
|
||||||
|
assert!(!c_sum.summary.contains("不应入选"), "user 消息不得越过 system 摘要");
|
||||||
|
let c_user = briefs.iter().find(|b| b.id == "c_user").unwrap();
|
||||||
|
assert!(c_user.summary.contains("首条用户消息"), "无 system 时首条 user 兜底: {}", c_user.summary);
|
||||||
|
let c_old = briefs.iter().find(|b| b.id == "c_old").unwrap();
|
||||||
|
// c_old 只有一条 user?其摘要也应来自首条 user(链路兜底)
|
||||||
|
assert!(c_old.summary.contains("c_old 首条"), "仅 user 消息时同样兜底: {}", c_old.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 摘要截断:超 200 字的 system 摘要截断加省略号(拍板⑤统一口径)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recent_briefs_truncates_long_summary() {
|
||||||
|
let db = test_db().await;
|
||||||
|
ins_conv(&db, "c_long", Some("长摘要会话"), None, "100").await;
|
||||||
|
let long_summary = format!("## 意图\n- {}", "长".repeat(300));
|
||||||
|
AiMessageRepo::new(&db)
|
||||||
|
.insert_batch(vec![msg("c_long", 0, "system", &long_summary, 1)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let briefs = recent_conversation_briefs(&db, 10).await.unwrap();
|
||||||
|
assert_eq!(briefs.len(), 1);
|
||||||
|
let summary_chars = briefs[0].summary.chars().count();
|
||||||
|
assert_eq!(
|
||||||
|
summary_chars, CONVERSATION_SUMMARY_CHARS + 1,
|
||||||
|
"截 200 字 + 省略号,实际 {summary_chars}"
|
||||||
|
);
|
||||||
|
assert!(briefs[0].summary.ends_with('…'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。
|
//! devflow.md / CLAUDE.md 项目认知投影(项目规则文件 + 知识库路标 → system prompt 稳定尾段)。
|
||||||
//!
|
//!
|
||||||
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
|
//! 定位:对标 CLAUDE.md 的"启动读一遍静态规则文件",本土化为双文件并读——
|
||||||
//! - `devflow.md`:agent 生态自己的项目认知文件,v1 取两个分区(规范纪律 / 领单);
|
//! - `devflow.md`:agent 生态自己的项目认知文件,目录式渐进披露——纪律类分区
|
||||||
|
//! (标题含 纪律/规范/必读/铁律)全文保留,其余分区收敛为单行目录;
|
||||||
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
|
//! - `CLAUDE.md`:既有通用约定,同目录存在则并入(增强非依赖)。
|
||||||
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
|
//! 两文件并存时 devflow.md 优先(段内排序在前 + 显式冲突注明)。
|
||||||
//!
|
//!
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空,
|
//! 海纳川流:两文件缺失 / 读取失败 / 内容为空,且知识库无合格条目 → 投影段为空,
|
||||||
//! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。
|
//! prompt 与不接本模块逐字一致(规则文件与路标都是增强输入,缺失不影响内生运作)。
|
||||||
//!
|
//!
|
||||||
//! 裁剪纪律:注入的 token 才是成本——规则分区按字符上限截断,路标条目数被查询
|
//! 裁剪纪律:注入的 token 才是成本——分区按字符上限截断 + 总闸 3000 收口,路标条目数被查询
|
||||||
//! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断
|
//! LIMIT 封顶 + 行内标题截断,整体体量有界。超限按 Unicode 字符截断
|
||||||
//! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
|
//! (中文一字一符,按字节会截断 UTF-8 序列产生乱码)并注明。
|
||||||
|
|
||||||
@@ -35,16 +36,19 @@ use crate::state::AllowedDirs;
|
|||||||
|
|
||||||
// ── 裁剪上限(字符数,中文一字一符) ──
|
// ── 裁剪上限(字符数,中文一字一符) ──
|
||||||
//
|
//
|
||||||
// 分区上限兜住单分区体量,总上限兜住整段体量;取值按"规范纪律最厚、领单次之、
|
// 分区上限兜住单分区体量,总上限兜住整段体量。纪律类分区全文保留(完整性优先),
|
||||||
// CLAUDE.md 与纪律区同量级"的内容形态分配,叠加后总上限仍可能触发二次截断。
|
// 其余分区收敛为单行目录后体量可忽略;总闸 3000 为实际生效的预算闸,分区上限是
|
||||||
|
// 叠加前的第一道结构兜底。
|
||||||
/// 规范纪律分区上限
|
/// 规范纪律分区上限
|
||||||
const DISCIPLINE_MAX_CHARS: usize = 4000;
|
const DISCIPLINE_MAX_CHARS: usize = 4000;
|
||||||
/// 领单分区上限
|
/// 目录行首行摘要上限
|
||||||
const CLAIM_MAX_CHARS: usize = 2000;
|
const INDEX_SUMMARY_MAX_CHARS: usize = 100;
|
||||||
/// CLAUDE.md 并入段上限
|
/// CLAUDE.md 并入段上限
|
||||||
const CLAUDE_MD_MAX_CHARS: usize = 4000;
|
const CLAUDE_MD_MAX_CHARS: usize = 4000;
|
||||||
/// 投影段总上限(各分区叠加后的二次硬闸)
|
/// 投影段总上限(各分区叠加后的二次硬闸)
|
||||||
const SECTION_MAX_CHARS: usize = 6000;
|
const SECTION_MAX_CHARS: usize = 3000;
|
||||||
|
/// 目录块尾部总引导(≤60 字符):提示被收敛分区的全文获取方式
|
||||||
|
const INDEX_TAIL_HINT: &str = "以上收敛分区全文用 read_file 读 devflow.md 原文件获取";
|
||||||
|
|
||||||
/// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空
|
/// 投影段缓存上限(条目)。会话粒度缓存,长跑进程防内存膨胀;超限整体清空
|
||||||
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
|
/// (清空后下次组装重读盘,内容只会更新不会错,正确性不受损)。
|
||||||
@@ -61,9 +65,9 @@ const WAYMARK_GLOBAL_MAX: usize = 10;
|
|||||||
/// 路标行标题上限(字符):超长截断防长标题破坏紧凑行形态
|
/// 路标行标题上限(字符):超长截断防长标题破坏紧凑行形态
|
||||||
const WAYMARK_TITLE_MAX_CHARS: usize = 60;
|
const WAYMARK_TITLE_MAX_CHARS: usize = 60;
|
||||||
|
|
||||||
/// devflow.md 规范分区识别的标题关键字(领单优先判,防"领单纪律"被纪律关键字截胡)
|
/// 纪律类分区识别的标题关键字(命中 → 全文保留,禁收敛;其余分区收敛为目录行)。
|
||||||
const CLAIM_HEADING_KEY: &str = "领单";
|
/// contains 直判不引正则;中文关键字无大小写,ASCII 侧统一小写后比对(大小写不敏感)。
|
||||||
const DISCIPLINE_HEADING_KEYS: [&str; 2] = ["纪律", "规范"];
|
const DISCIPLINE_HEADING_KEYS: [&str; 4] = ["纪律", "规范", "必读", "铁律"];
|
||||||
|
|
||||||
static SECTION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
static SECTION_CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
|
||||||
|
|
||||||
@@ -116,6 +120,10 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Ar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let dir = work_dir.map(PathBuf::from);
|
let dir = work_dir.map(PathBuf::from);
|
||||||
|
// devflow.md 绝对路径:目录行 read_file 提示的定位锚(先取串,dir 随后移入闭包读盘)
|
||||||
|
let devflow_path = dir
|
||||||
|
.as_ref()
|
||||||
|
.map(|d| d.join("devflow.md").to_string_lossy().replace('\\', "/"));
|
||||||
let conv = conv_id.to_string();
|
let conv = conv_id.to_string();
|
||||||
// 规则文件读盘(阻塞线程池,失败按缺失处理)与知识路标查库相互独立,并发取
|
// 规则文件读盘(阻塞线程池,失败按缺失处理)与知识路标查库相互独立,并发取
|
||||||
let (files, waymark) = tokio::join!(
|
let (files, waymark) = tokio::join!(
|
||||||
@@ -128,11 +136,12 @@ async fn projection_section(work_dir: Option<&str>, conv_id: &str, kb: Option<Ar
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
let (devflow, claude) = files.unwrap_or_else(|_| (None, None));
|
let (devflow, claude) = files.unwrap_or_else(|_| (None, None));
|
||||||
let (discipline, claiming) = devflow
|
// 目录式分类:纪律类分区全文,其余收敛为单行目录(渐进披露)
|
||||||
.as_deref()
|
let parts = devflow.as_deref().map(extract_partitions).unwrap_or_default();
|
||||||
.map(extract_partitions)
|
let section = combine_sections(
|
||||||
.unwrap_or((String::new(), String::new()));
|
build_section(&parts, claude.as_deref(), devflow_path.as_deref()),
|
||||||
let section = combine_sections(build_section(&discipline, &claiming, claude.as_deref()), waymark);
|
waymark,
|
||||||
|
);
|
||||||
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
|
let mut cache = section_cache().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
if cache.len() >= SECTION_CACHE_MAX {
|
if cache.len() >= SECTION_CACHE_MAX {
|
||||||
cache.clear();
|
cache.clear();
|
||||||
@@ -209,16 +218,28 @@ fn read_trimmed(path: &Path) -> Option<String> {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 拼装规则文件投影段(纯函数):分区截取、上限截断、优先级注明都在这里定形。
|
/// 拼装规则文件投影段(纯函数):分区分类(纪律全文 / 其余目录行)、上限截断、
|
||||||
|
/// 优先级注明都在这里定形。CLAUDE.md 侧恒全文并入(目录式只作用于 devflow.md 侧)。
|
||||||
/// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。
|
/// 三路输入全空 → None(路标子块在场时由 combine_sections 单独成段)。
|
||||||
fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Option<String> {
|
fn build_section(parts: &[Partition], claude: Option<&str>, devflow_path: Option<&str>) -> Option<String> {
|
||||||
let claude = claude.map(str::trim).filter(|c| !c.is_empty());
|
let claude = claude.map(str::trim).filter(|c| !c.is_empty());
|
||||||
if discipline.is_empty() && claiming.is_empty() && claude.is_none() {
|
// 分类:标题空(扁平回退)或命中纪律关键字 → 全文保留;其余收敛为目录行
|
||||||
|
let mut discipline: Vec<&str> = Vec::new();
|
||||||
|
let mut index: Vec<String> = Vec::new();
|
||||||
|
for p in parts {
|
||||||
|
if p.title.is_empty() || is_discipline_heading(&p.title) {
|
||||||
|
discipline.push(p.body.as_str());
|
||||||
|
} else {
|
||||||
|
index.push(render_index_line(p, devflow_path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let discipline = discipline.join("\n");
|
||||||
|
if discipline.is_empty() && index.is_empty() && claude.is_none() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let has_devflow = !discipline.is_empty() || !claiming.is_empty();
|
let has_devflow = !discipline.is_empty() || !index.is_empty();
|
||||||
let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)");
|
let mut sec = String::from("## 项目认知投影\n以下规则来自项目仓库文件(会话启动时读取)");
|
||||||
// 优先级只在双文件并存时注明,单文件无冲突可注明
|
// 优先级只在双文件并存时注明,单文件无冲突不注明
|
||||||
if has_devflow && claude.is_some() {
|
if has_devflow && claude.is_some() {
|
||||||
sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准");
|
sec.push_str(",devflow.md 与 CLAUDE.md 并存,冲突以 devflow.md 为准");
|
||||||
}
|
}
|
||||||
@@ -226,18 +247,22 @@ fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Opti
|
|||||||
|
|
||||||
if !discipline.is_empty() {
|
if !discipline.is_empty() {
|
||||||
sec.push_str("\n\n### 规范纪律(devflow.md)\n");
|
sec.push_str("\n\n### 规范纪律(devflow.md)\n");
|
||||||
sec.push_str(&cap_chars(discipline, DISCIPLINE_MAX_CHARS));
|
sec.push_str(&cap_chars(&discipline, DISCIPLINE_MAX_CHARS));
|
||||||
}
|
}
|
||||||
if !claiming.is_empty() {
|
if !index.is_empty() {
|
||||||
sec.push_str("\n\n### 领单纪律(devflow.md)\n");
|
sec.push_str("\n\n### 其他分区目录(devflow.md)\n");
|
||||||
sec.push_str(&cap_chars(claiming, CLAIM_MAX_CHARS));
|
for line in &index {
|
||||||
|
sec.push_str(line);
|
||||||
|
sec.push('\n');
|
||||||
|
}
|
||||||
|
sec.push_str(INDEX_TAIL_HINT);
|
||||||
}
|
}
|
||||||
if let Some(cla) = claude {
|
if let Some(cla) = claude {
|
||||||
sec.push_str("\n\n### 项目规范(CLAUDE.md)\n");
|
sec.push_str("\n\n### 项目规范(CLAUDE.md)\n");
|
||||||
sec.push_str(&cap_chars(cla, CLAUDE_MD_MAX_CHARS));
|
sec.push_str(&cap_chars(cla, CLAUDE_MD_MAX_CHARS));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 总上限二次硬闸:各分区上限叠加后仍可能超,整段再截一次
|
// 总上限二次硬闸:各分区叠加后仍可能超,整段再截一次
|
||||||
if sec.chars().count() > SECTION_MAX_CHARS {
|
if sec.chars().count() > SECTION_MAX_CHARS {
|
||||||
let cut: String = sec.chars().take(SECTION_MAX_CHARS).collect();
|
let cut: String = sec.chars().take(SECTION_MAX_CHARS).collect();
|
||||||
return Some(format!("{}\n…(投影段超出总上限已截断)", cut));
|
return Some(format!("{}\n…(投影段超出总上限已截断)", cut));
|
||||||
@@ -245,6 +270,22 @@ fn build_section(discipline: &str, claiming: &str, claude: Option<&str>) -> Opti
|
|||||||
Some(sec)
|
Some(sec)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 目录行(单行收敛):`## 标题` + 首行摘要(≤100 字符) + read_file 按需全文提示。
|
||||||
|
/// 标题不截断——它是 read_file 定位原文件标题的锚点,截断会破坏按标题检索。
|
||||||
|
fn render_index_line(p: &Partition, devflow_path: Option<&str>) -> String {
|
||||||
|
let first = p.body.lines().next().unwrap_or("").trim();
|
||||||
|
let count = first.chars().count();
|
||||||
|
let mut summary: String = first.chars().take(INDEX_SUMMARY_MAX_CHARS).collect();
|
||||||
|
if count > INDEX_SUMMARY_MAX_CHARS {
|
||||||
|
summary.push('…');
|
||||||
|
}
|
||||||
|
let mut line = format!("## {} | {summary}", p.title);
|
||||||
|
if let Some(path) = devflow_path {
|
||||||
|
line.push_str(&format!(" (全文按需: read_file {path} 定位「{}」)", p.title));
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
/// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。
|
/// 按字符数截断并注明(纯函数):超 `max` 截断追加标注,否则原样返回。
|
||||||
fn cap_chars(s: &str, max: usize) -> String {
|
fn cap_chars(s: &str, max: usize) -> String {
|
||||||
if s.chars().count() <= max {
|
if s.chars().count() <= max {
|
||||||
@@ -256,15 +297,17 @@ fn cap_chars(s: &str, max: usize) -> String {
|
|||||||
|
|
||||||
// ── devflow.md 分区解析 ──
|
// ── devflow.md 分区解析 ──
|
||||||
//
|
//
|
||||||
// v1 取两个分区:规范纪律 / 领单。识别方式 = markdown 标题关键字命中,命中块的
|
// 目录式投影:每个 markdown 标题块取为一个分区(块内更深层子标题视为块内子结构
|
||||||
// 更深层子标题视为块内子结构一并保留;未命中关键标题的块不注入(状态/警示类
|
// 并入正文)。分类 = 标题含纪律关键字(纪律/规范/必读/铁律)→ 全文保留(完整性
|
||||||
// 分区不属 v1 范围,天然被裁掉)。全文无命中分区的扁平写法回退为整文件按
|
// 优先,agent 行为底线禁收敛);其余分区收敛为「标题 + 首行摘要 + read_file 定位」
|
||||||
// 规范纪律处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。
|
// 单行目录。更深层纪律关键字标题在非纪律容器内强制开新块(防容器吞并);首个
|
||||||
|
// 命中标题前的前言不属任何分区,裁掉;全文无标题(扁平写法)回退为整文件按规范纪律
|
||||||
|
// 处理——规则文件形态与 CLAUDE.md 同构,不因没写小节标题而失传。
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
/// devflow.md 单个分区:标题 + 正文(trim 后,不含起始标题行;标题空 = 扁平回退块)
|
||||||
enum Partition {
|
struct Partition {
|
||||||
Discipline,
|
title: String,
|
||||||
Claim,
|
body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。
|
/// 识别 markdown 标题行,返回 (级别, 标题文本);非标题 / 纯装饰线 → None。
|
||||||
@@ -281,30 +324,32 @@ fn heading_of(line: &str) -> Option<(usize, &str)> {
|
|||||||
Some((level, text))
|
Some((level, text))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_heading(text: &str) -> Option<Partition> {
|
/// 标题是否命中纪律关键字(命中 → 全文保留,禁收敛)。
|
||||||
if text.contains(CLAIM_HEADING_KEY) {
|
fn is_discipline_heading(text: &str) -> bool {
|
||||||
return Some(Partition::Claim);
|
let lower = text.to_lowercase();
|
||||||
}
|
DISCIPLINE_HEADING_KEYS.iter().any(|k| lower.contains(k))
|
||||||
if DISCIPLINE_HEADING_KEYS.iter().any(|k| text.contains(k)) {
|
|
||||||
return Some(Partition::Discipline);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 devflow.md 全文提取 (规范纪律, 领单) 两个分区正文(纯函数,未截断)。
|
/// 从 devflow.md 全文提取分区列表(纯函数,未截断):每个标题开启一个分区,
|
||||||
fn extract_partitions(content: &str) -> (String, String) {
|
/// 块内更深层子标题并入正文;空白正文的标题块跳过。
|
||||||
let mut discipline: Vec<String> = Vec::new();
|
fn extract_partitions(content: &str) -> Vec<Partition> {
|
||||||
let mut claiming: Vec<String> = Vec::new();
|
let mut parts: Vec<Partition> = Vec::new();
|
||||||
// 当前块:(分区, 起始标题级别, 正文);正文不含起始标题行(投影段会重加小标题)
|
// 当前块:(起始标题级别, 标题, 正文);正文不含起始标题行(投影段会重加小标题)
|
||||||
let mut cur: Option<(Partition, usize, String)> = None;
|
let mut cur: Option<(usize, String, String)> = None;
|
||||||
|
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
let heading = heading_of(line);
|
let heading = heading_of(line);
|
||||||
// 块边界:块内遇到不深于起始层级的标题 → 收块,由新标题决定是否开下一块
|
// 块边界:块内遇到不深于起始层级的标题收块;更深层纪律关键字标题仅在
|
||||||
let boundary = matches!((&cur, &heading), (Some((_, lv, _)), Some((h_lv, _))) if h_lv <= lv);
|
// 非纪律容器块内强制开新块(防浅层容器标题吞并后续纪律分区),纪律块内
|
||||||
|
// 的更深层纪律子标题(如「### 提交规范」)仍属块内子结构,连行保留
|
||||||
|
let boundary = matches!(
|
||||||
|
(&cur, &heading),
|
||||||
|
(Some((lv, cur_title, _)), Some((h_lv, text)))
|
||||||
|
if *h_lv <= *lv
|
||||||
|
|| (h_lv > lv && is_discipline_heading(text) && !is_discipline_heading(cur_title))
|
||||||
|
);
|
||||||
if boundary {
|
if boundary {
|
||||||
let (kind, _, body) = cur.take().expect("boundary 判定保证 cur 存在");
|
push_block(cur.take(), &mut parts);
|
||||||
push_block(kind, body, &mut discipline, &mut claiming);
|
|
||||||
} else if let Some((_, _, body)) = cur.as_mut() {
|
} else if let Some((_, _, body)) = cur.as_mut() {
|
||||||
// 更深层级标题属块内子结构,连行保留
|
// 更深层级标题属块内子结构,连行保留
|
||||||
body.push_str(line);
|
body.push_str(line);
|
||||||
@@ -312,31 +357,29 @@ fn extract_partitions(content: &str) -> (String, String) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some((lv, text)) = heading {
|
if let Some((lv, text)) = heading {
|
||||||
if let Some(kind) = classify_heading(text) {
|
cur = Some((lv, text.to_string(), String::new()));
|
||||||
cur = Some((kind, lv, String::new()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
push_block(cur.take(), &mut parts);
|
||||||
if let Some((kind, _, body)) = cur.take() {
|
|
||||||
push_block(kind, body, &mut discipline, &mut claiming);
|
|
||||||
}
|
|
||||||
|
|
||||||
let dev = discipline.join("\n").trim().to_string();
|
if parts.is_empty() {
|
||||||
let claim = claiming.join("\n").trim().to_string();
|
// 扁平回退:全文无任何标题 → 整文件按规范纪律(标题空串,渲染时归纪律区)
|
||||||
if dev.is_empty() && claim.is_empty() {
|
let flat = content.trim();
|
||||||
return (content.trim().to_string(), String::new());
|
if !flat.is_empty() {
|
||||||
|
return vec![Partition { title: String::new(), body: flat.to_string() }];
|
||||||
}
|
}
|
||||||
(dev, claim)
|
}
|
||||||
|
parts
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_block(kind: Partition, body: String, discipline: &mut Vec<String>, claiming: &mut Vec<String>) {
|
/// 收块入分区列表(纯函数):空白正文跳过(渲染不出可读内容的块不入段)。
|
||||||
|
fn push_block(block: Option<(usize, String, String)>, parts: &mut Vec<Partition>) {
|
||||||
|
if let Some((_, title, body)) = block {
|
||||||
let trimmed = body.trim();
|
let trimmed = body.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match kind {
|
parts.push(Partition { title, body: trimmed.to_string() });
|
||||||
Partition::Discipline => discipline.push(trimmed.to_string()),
|
|
||||||
Partition::Claim => claiming.push(trimmed.to_string()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,41 +430,46 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明,双内容齐备) ──
|
// ── 双文件并存 → 优先级正确(devflow.md 在前 + 冲突注明;纪律全文,领单收敛目录行) ──
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn both_files_devflow_priority() {
|
async fn both_files_devflow_priority() {
|
||||||
let dir = temp_project_dir();
|
let dir = temp_project_dir();
|
||||||
write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单纪律\n- 领单后改 [→]");
|
write_file(&dir, "devflow.md", "## 规范纪律\n- 禁 sed 改代码\n\n## 领单\n- 领单后改 [→]");
|
||||||
write_file(&dir, "CLAUDE.md", "# 旧约定\n- 旧规则条目");
|
write_file(&dir, "CLAUDE.md", "# 旧约定\n- 旧规则条目");
|
||||||
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
|
let out = append_projection("BASE".to_string(), Some(dir.to_str().unwrap()), &format!("conv-{}", unique_tag())).await;
|
||||||
let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须注入");
|
let dev_pos = out.find("禁 sed 改代码").expect("devflow.md 纪律正文须全文注入");
|
||||||
let claim_pos = out.find("领单后改").expect("领单分区须注入");
|
let idx_pos = out.find("## 领单 |").expect("领单分区须收敛为目录行");
|
||||||
let claude_pos = out.find("旧规则条目").expect("CLAUDE.md 正文须并入");
|
let claude_pos = out.find("旧规则条目").expect("CLAUDE.md 正文须并入");
|
||||||
assert!(dev_pos < claude_pos && claim_pos < claude_pos, "devflow.md 内容必须排在 CLAUDE.md 之前(优先级)");
|
assert!(dev_pos < idx_pos && idx_pos < claude_pos, "顺序:纪律全文 → 目录行 → CLAUDE.md");
|
||||||
assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级");
|
assert!(out.contains("冲突以 devflow.md 为准"), "双文件并存须注明优先级");
|
||||||
assert!(out.contains("### 规范纪律(devflow.md)"));
|
assert!(out.contains("### 规范纪律(devflow.md)"));
|
||||||
assert!(out.contains("### 领单纪律(devflow.md)"));
|
assert!(out.contains("### 其他分区目录(devflow.md)"));
|
||||||
|
assert!(out.contains("read_file"), "目录行须带 read_file 定位提示");
|
||||||
assert!(out.contains("### 项目规范(CLAUDE.md)"));
|
assert!(out.contains("### 项目规范(CLAUDE.md)"));
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 超长裁剪:分区上限 + 总上限生效并注明 ──
|
// ── 超长裁剪:总闸 3000 收口生效;分区上限与摘要上限机制仍在 ──
|
||||||
#[test]
|
#[test]
|
||||||
fn caps_truncate_with_marker() {
|
fn caps_truncate_with_marker() {
|
||||||
let long = "纪".repeat(DISCIPLINE_MAX_CHARS + 100);
|
// 总闸:纪律 4000 压线 + CLAUDE.md 超量,叠加必超 3000 总闸
|
||||||
let section = build_section(&long, "", None).expect("非空输入必有段");
|
let discipline = Partition { title: "规范纪律".to_string(), body: "a".repeat(DISCIPLINE_MAX_CHARS) };
|
||||||
let discipline_part = section.split("### 规范纪律(devflow.md)\n").nth(1).unwrap_or("");
|
|
||||||
assert!(discipline_part.contains("超上限已截断"), "分区超限须注明");
|
|
||||||
let kept = discipline_part.split("\n…(超上限已截断)").next().unwrap();
|
|
||||||
assert_eq!(kept.chars().count(), DISCIPLINE_MAX_CHARS, "分区保留长度须恰为上限");
|
|
||||||
|
|
||||||
// 总上限:各分区压线 + CLAUDE.md 超量,叠加必超总闸
|
|
||||||
let discipline = "a".repeat(DISCIPLINE_MAX_CHARS);
|
|
||||||
let claiming = "b".repeat(CLAIM_MAX_CHARS);
|
|
||||||
let claude = "c".repeat(CLAUDE_MD_MAX_CHARS);
|
let claude = "c".repeat(CLAUDE_MD_MAX_CHARS);
|
||||||
let section = build_section(&discipline, &claiming, Some(&claude)).expect("非空输入必有段");
|
let section = build_section(&[discipline], Some(&claude), None).expect("非空输入必有段");
|
||||||
assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len());
|
assert!(section.chars().count() <= SECTION_MAX_CHARS + "…(投影段超出总上限已截断)".len());
|
||||||
assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明");
|
assert!(section.ends_with("…(投影段超出总上限已截断)"), "总超限须整段截断并注明");
|
||||||
|
|
||||||
|
// 分区上限机制(cap_chars 纯函数):超限截断并注明
|
||||||
|
let capped = cap_chars(&"a".repeat(50), 10);
|
||||||
|
assert!(capped.contains("超上限已截断"), "分区超限须注明");
|
||||||
|
|
||||||
|
// 目录行摘要上限:只取首行,超 100 字符截断注省略号
|
||||||
|
let body = format!("- {}\n- 第二行不进摘要", "词".repeat(101));
|
||||||
|
let line = render_index_line(&Partition { title: "领单".to_string(), body }, None);
|
||||||
|
assert!(line.contains('…'), "摘要超限须截断注省略号");
|
||||||
|
assert!(!line.contains("第二行"), "目录行只取首行摘要");
|
||||||
|
let summary = line.split(" | ").nth(1).unwrap_or("");
|
||||||
|
assert!(summary.chars().count() <= INDEX_SUMMARY_MAX_CHARS + 1, "摘要 ≤100 字符(+省略号)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 稳定尾段:前段逐字保留,投影段独占尾部 ──
|
// ── 稳定尾段:前段逐字保留,投影段独占尾部 ──
|
||||||
@@ -454,14 +502,12 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 分区解析:关键字命中 / 子标题保留 / 未命中块裁掉 / 扁平回退 ──
|
// ── 分区解析:全标题块入分区 / 子标题保留 / 纪律恒开新块 / 扁平回退 ──
|
||||||
#[test]
|
#[test]
|
||||||
fn partition_extraction() {
|
fn partition_extraction() {
|
||||||
let content = "\
|
let content = "\
|
||||||
# 项目认知
|
# 项目认知
|
||||||
|
|
||||||
前言段落应被裁掉。
|
|
||||||
|
|
||||||
## 规范纪律
|
## 规范纪律
|
||||||
- 纪律条目一
|
- 纪律条目一
|
||||||
|
|
||||||
@@ -469,26 +515,41 @@ mod tests {
|
|||||||
- 子标题下的纪律条目
|
- 子标题下的纪律条目
|
||||||
|
|
||||||
## 看板摘要
|
## 看板摘要
|
||||||
- 未命中关键字的块应被裁掉
|
- 看板条目应收敛为目录行
|
||||||
|
|
||||||
## 领单纪律
|
## 领单
|
||||||
- 领单条目一
|
- 领单条目一
|
||||||
";
|
";
|
||||||
let (dev, claim) = extract_partitions(content);
|
let parts = extract_partitions(content);
|
||||||
assert!(dev.contains("纪律条目一"), "规范纪律块须命中");
|
assert_eq!(parts.len(), 3, "空白正文标题块跳过,三个实体分区入场");
|
||||||
assert!(dev.contains("子标题下的纪律条目"), "块内子标题结构须保留");
|
assert_eq!(parts[0].title, "规范纪律");
|
||||||
assert!(!dev.contains("看板摘要") && !dev.contains("前言段落"), "未命中块与前言须裁掉");
|
assert!(parts[0].body.contains("纪律条目一"), "纪律分区正文命中");
|
||||||
assert_eq!(claim, "- 领单条目一", "领单分区须独立提取");
|
assert!(parts[0].body.contains("子标题下的纪律条目"), "块内子标题结构须保留");
|
||||||
|
assert!(!parts[0].body.contains("看板条目"), "后续分区不得并入纪律正文");
|
||||||
|
assert!(is_discipline_heading(&parts[0].title), "纪律关键字命中 → 全文类");
|
||||||
|
assert_eq!((parts[1].title.as_str(), parts[2].title.as_str()), ("看板摘要", "领单"));
|
||||||
|
assert!(
|
||||||
|
!is_discipline_heading("领单") && !is_discipline_heading("看板摘要"),
|
||||||
|
"非关键字分区 → 目录类"
|
||||||
|
);
|
||||||
|
|
||||||
// 扁平文件(无命中标题)回退:整文件按规范纪律
|
// 扁平文件(无任何标题)回退:整文件按规范纪律(标题空串)
|
||||||
let (dev, claim) = extract_partitions("- 扁平规则一\n- 扁平规则二\n");
|
let parts = extract_partitions("- 扁平规则一\n- 扁平规则二\n");
|
||||||
assert_eq!(dev, "- 扁平规则一\n- 扁平规则二");
|
assert_eq!(parts.len(), 1);
|
||||||
assert!(claim.is_empty());
|
assert!(parts[0].title.is_empty());
|
||||||
|
assert_eq!(parts[0].body, "- 扁平规则一\n- 扁平规则二");
|
||||||
|
|
||||||
// 深层标题起始的块:同级才截断
|
// 深层标题起始的块:同级才截断
|
||||||
let (dev, claim) = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n");
|
let parts = extract_partitions("### 领单\n- 深层领单条目\n\n## 其他\n- 其他块内容\n");
|
||||||
assert_eq!(claim, "- 深层领单条目", "深层起始块到同级标题截断");
|
assert_eq!(parts.len(), 2, "深层起始块到同级标题截断,各自成块");
|
||||||
assert!(!dev.contains("其他块内容"));
|
assert_eq!((parts[0].title.as_str(), parts[1].title.as_str()), ("领单", "其他"));
|
||||||
|
|
||||||
|
// 纪律块防吞并:浅层非纪律容器标题不得吞并后续纪律分区(全文底线);
|
||||||
|
// 纪律块内的更深纪律子标题仍属子结构(见首例「### 提交规范」)
|
||||||
|
let parts = extract_partitions("# 总标题\n前言一句\n\n## 规范纪律\n- 纪律正文\n");
|
||||||
|
assert_eq!(parts.len(), 2, "容器块与纪律块各自独立");
|
||||||
|
assert_eq!((parts[0].title.as_str(), parts[1].title.as_str()), ("总标题", "规范纪律"));
|
||||||
|
assert_eq!(parts[1].body, "- 纪律正文", "纪律正文不得被容器吞并");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ──
|
// ── 工作目录:排序取首稳定(HashSet 迭代序不定,不排序结果不可复现) ──
|
||||||
@@ -502,15 +563,34 @@ mod tests {
|
|||||||
assert_eq!(work_dir_of(&empty), None, "无授权目录返 None");
|
assert_eq!(work_dir_of(&empty), None, "无授权目录返 None");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 构建纯函数:全空 None / 单区缺省不产生空小节 ──
|
// ── 构建纯函数:全空 None / 目录行形态与总引导 / 纪律全文直入 / 空区不产生空小节 ──
|
||||||
#[test]
|
#[test]
|
||||||
fn build_section_edge_cases() {
|
fn build_section_edge_cases() {
|
||||||
assert!(build_section("", "", None).is_none(), "三路全空须 None");
|
assert!(build_section(&[], None, None).is_none(), "三路全空须 None");
|
||||||
assert!(build_section("", "", Some(" ")).is_none(), "空白 CLAUDE.md 视同缺失");
|
assert!(build_section(&[], Some(" "), None).is_none(), "空白 CLAUDE.md 视同缺失");
|
||||||
let only_claim = build_section("", "- 领单规则", None).expect("单分区非空须有段");
|
let only_idx = build_section(
|
||||||
assert!(only_claim.contains("### 领单纪律(devflow.md)"));
|
&[Partition { title: "领单".to_string(), body: "- 领单规则".to_string() }],
|
||||||
assert!(!only_claim.contains("### 规范纪律"), "空分区不得产生空小节");
|
None,
|
||||||
assert!(!only_claim.contains("冲突以"), "单文件不得注明双文件优先级");
|
Some("E:/p/devflow.md"),
|
||||||
|
)
|
||||||
|
.expect("目录分区非空须有段");
|
||||||
|
assert!(only_idx.contains("### 其他分区目录(devflow.md)"));
|
||||||
|
assert!(
|
||||||
|
only_idx.contains("## 领单 | - 领单规则 (全文按需: read_file E:/p/devflow.md 定位「领单」)"),
|
||||||
|
"目录行形态:标题 + 首行摘要 + read_file 定位"
|
||||||
|
);
|
||||||
|
assert!(only_idx.ends_with(INDEX_TAIL_HINT), "收敛在场须带尾部总引导");
|
||||||
|
assert!(!only_idx.contains("### 规范纪律"), "空纪律区不得产生空小节");
|
||||||
|
assert!(!only_idx.contains("冲突以"), "单文件不得注明双文件优先级");
|
||||||
|
|
||||||
|
let disc = build_section(
|
||||||
|
&[Partition { title: "自主修复纪律".to_string(), body: "- 明确 bug 直接修".to_string() }],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("纪律分区非空须有段");
|
||||||
|
assert!(disc.contains("### 规范纪律(devflow.md)\n- 明确 bug 直接修"), "纪律正文全文直入");
|
||||||
|
assert!(!disc.contains(INDEX_TAIL_HINT), "无收敛分区时不得出现总引导");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ mod tests {
|
|||||||
let events = vec![
|
let events = vec![
|
||||||
serde_json::json!({"type": "approval_required", "conversation_id": "c1"}),
|
serde_json::json!({"type": "approval_required", "conversation_id": "c1"}),
|
||||||
serde_json::json!({"type": "compressed", "conversation_id": "c1"}),
|
serde_json::json!({"type": "compressed", "conversation_id": "c1"}),
|
||||||
serde_json::json!({"type": "dir_auth_required", "conversation_id": "c1"}),
|
serde_json::json!({"type": "max_rounds", "conversation_id": "c1"}),
|
||||||
serde_json::json!({"type": "context_cleared", "conversation_id": "c1"}),
|
serde_json::json!({"type": "context_cleared", "conversation_id": "c1"}),
|
||||||
];
|
];
|
||||||
for event in &events {
|
for event in &events {
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
//! 存量知识蒸馏合并(知识库治理 P0 深化,2026-10-24 用户指令「历史知识内容蒸馏压缩合并」)
|
||||||
|
//!
|
||||||
|
//! 与写入防重(P0,治增量)互补:本模块治存量——已归档长尾(淘汰批 632 条)按 kind 分组,
|
||||||
|
//! LLM 把一组同主题旧知识蒸馏成一条「合订本」(candidate 入库,原条目保持 archived 不删),
|
||||||
|
//! 建 merges 边(合订本→组内每条,origin=distill)保留可溯关系。
|
||||||
|
//!
|
||||||
|
//! 蒸馏语义:合订本是「组内知识的浓缩替代视图」,不是权威合并——原条目仍在库(archived)
|
||||||
|
//! 可随时人工复核;合订本走正常 candidate→复用晋升通道,被人用过才算立住。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||||
|
use df_ai::router::{select_model_id, Modality, TaskRequirements};
|
||||||
|
use df_storage::crud::KnowledgeRepo;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
use df_storage::models::AiProviderRecord;
|
||||||
|
|
||||||
|
use crate::commands::ai::knowledge_inject::{candidate_record_from_item, strip_code_fence};
|
||||||
|
use crate::state::LlmConcurrency;
|
||||||
|
|
||||||
|
use super::secret::build_provider_for;
|
||||||
|
|
||||||
|
/// 单批蒸馏条数上限:每组送 LLM 的条目数。20 条 × 均 218 字 ≈ 4.4K 字符输入,
|
||||||
|
/// 输出单条合订本 2K token 内;更多条分组蒸馏质量劣化(模型记不住开头),宁多批。
|
||||||
|
const DISTILL_GROUP_LIMIT: usize = 20;
|
||||||
|
|
||||||
|
/// 合订本 content 里单条原知识的摘要截断(蒸馏输入侧)
|
||||||
|
const DISTILL_ITEM_SNIPPET: usize = 300;
|
||||||
|
|
||||||
|
const DISTILL_SYSTEM_PROMPT: &str = "你是知识蒸馏引擎。下面是知识库中同一主题下的一组已归档旧知识条目,\
|
||||||
|
它们多为多次对话对同一经验的重复沉淀。请把它们蒸馏成**一条**合并后的知识:\n\
|
||||||
|
- content 融合全组信息,去重、合并同类点、保留每条的独有细节,输出完整可复用的浓缩版\n\
|
||||||
|
- title 概括整组共同主题\n\
|
||||||
|
- tags 取全组标签的并集(最多 5 个)\n\
|
||||||
|
- reasoning 说明蒸馏取舍(丢了什么、为何可丢)\n\n\
|
||||||
|
输出严格的 JSON 对象(不要 markdown 包裹):\n\
|
||||||
|
{\"kind\": \"<沿用组内 kind>\", \"title\": \"...\", \"content\": \"...\", \"tags\": [\"...\"], \"confidence\": \"medium\", \"reasoning\": \"...\"}\n\n\
|
||||||
|
无可蒸馏内容(条目互相无重叠且各自完整)时输出 {\"skip\": true}。只输出 JSON。";
|
||||||
|
|
||||||
|
/// 蒸馏一个已归档分组:LLM 合订本 → candidate 落库 → merges 边连组内全部条目。
|
||||||
|
///
|
||||||
|
/// 返回合订本 id;组内不足 2 条(无可合并)或模型输出 skip 时返回 Ok(None)。
|
||||||
|
/// 节奏控制:重复调用同 kind 会再取同批条目(蒸馏批不做已处理标记,P0 由前端按钮
|
||||||
|
/// 人工控频;merges 边幂等,重复产出合订本可人工在收件箱驳回)。
|
||||||
|
pub async fn distill_archived_group(
|
||||||
|
db: &Arc<Database>,
|
||||||
|
provider_config: &AiProviderRecord,
|
||||||
|
llm_concurrency: &LlmConcurrency,
|
||||||
|
kind: &str,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> anyhow::Result<Option<String>> {
|
||||||
|
let repo = KnowledgeRepo::new(db);
|
||||||
|
let group = repo
|
||||||
|
.list_by_status_kind("archived", kind, limit.unwrap_or(DISTILL_GROUP_LIMIT).min(50))
|
||||||
|
.await?;
|
||||||
|
if group.len() < 2 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// 组装蒸馏输入:编号列表(id 供溯源,正文截断)
|
||||||
|
let mut conv_text = format!("主题分组: {kind}\n\n");
|
||||||
|
for (i, k) in group.iter().enumerate() {
|
||||||
|
let truncated: String;
|
||||||
|
let content = if k.content.chars().count() > DISTILL_ITEM_SNIPPET {
|
||||||
|
truncated = k.content.chars().take(DISTILL_ITEM_SNIPPET).collect();
|
||||||
|
truncated.as_str()
|
||||||
|
} else {
|
||||||
|
k.content.as_str()
|
||||||
|
};
|
||||||
|
conv_text.push_str(&format!("[{}] id={} | {}\n{}\n\n", i + 1, k.id, k.title, content));
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = vec![
|
||||||
|
ChatMessage::system(DISTILL_SYSTEM_PROMPT),
|
||||||
|
ChatMessage::user(&conv_text),
|
||||||
|
];
|
||||||
|
let req = TaskRequirements {
|
||||||
|
modalities: vec![Modality::Text],
|
||||||
|
needs_tool_use: false,
|
||||||
|
estimated_context: 0,
|
||||||
|
tier: None,
|
||||||
|
};
|
||||||
|
let model = select_model_id(&req, &provider_config.model_configs)
|
||||||
|
.unwrap_or_else(|| provider_config.default_model.clone());
|
||||||
|
let request = CompletionRequest {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
temperature: Some(0.2),
|
||||||
|
max_tokens: Some(2048),
|
||||||
|
stream: false,
|
||||||
|
tools: None,
|
||||||
|
tool_choice: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
};
|
||||||
|
let provider: Box<dyn LlmProvider> = build_provider_for(provider_config)
|
||||||
|
.map_err(|e| anyhow::anyhow!("provider 密钥不可用: {e}"))?;
|
||||||
|
let _global = llm_concurrency.acquire_global().await;
|
||||||
|
let resp = provider.complete(request).await?;
|
||||||
|
let json_str = strip_code_fence(resp.text.trim());
|
||||||
|
let item: serde_json::Value = serde_json::from_str(json_str)
|
||||||
|
.map_err(|e| anyhow::anyhow!("蒸馏输出 JSON 解析失败: {e} | 原始: {}", resp.text))?;
|
||||||
|
if item.get("skip").and_then(|v| v.as_bool()) == Some(true) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合订本落库:candidate 信任级(source_ref=distill:{kind}),溯源与自动提炼同风格
|
||||||
|
let batch_ref = format!("distill:{}", kind);
|
||||||
|
let record = candidate_record_from_item(&item, None, Some(batch_ref.clone()))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("蒸馏产出字段残缺(kind/title/content 缺失)"))?;
|
||||||
|
repo.insert(record.clone()).await?;
|
||||||
|
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db);
|
||||||
|
timeline
|
||||||
|
.record_extracted(
|
||||||
|
&record.id,
|
||||||
|
"distill-batch",
|
||||||
|
&format!("蒸馏合订本({kind})"),
|
||||||
|
record.reasoning.as_deref().unwrap_or(""),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// merges 边:合订本 → 组内每条(insert_edge 双向去重幂等,单边失败非阻断)
|
||||||
|
let mut linked = 0usize;
|
||||||
|
for k in &group {
|
||||||
|
if repo.insert_edge(&record.id, &k.id, "merges", "distill").await.is_ok() {
|
||||||
|
linked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"[knowledge-distill] {kind} 组蒸馏完成: 合订本 {} ← {linked} 条边(group={})",
|
||||||
|
record.id,
|
||||||
|
group.len()
|
||||||
|
);
|
||||||
|
Ok(Some(record.id))
|
||||||
|
}
|
||||||
@@ -50,6 +50,12 @@ const KNOWLEDGE_EXTRACT_WINDOW: usize = 40;
|
|||||||
///
|
///
|
||||||
/// 取代原「每会话仅提炼一次」的 bool 硬去重——长会话后半程的新经验此前永不提炼。
|
/// 取代原「每会话仅提炼一次」的 bool 硬去重——长会话后半程的新经验此前永不提炼。
|
||||||
const KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS: usize = 10;
|
const KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS: usize = 10;
|
||||||
|
/// 提炼查重候选数:随 prompt 附给 LLM 的已有知识条数(P0 写入防重,2026-10-23)
|
||||||
|
///
|
||||||
|
/// 5 条 × 500 字截断 ≈ 2.5K 字符,足以覆盖本轮对话主题的已有知识面;更多会稀释
|
||||||
|
/// 提炼输入预算与 LLM 对单条的注意力。与注入 limit=3 同量级但不等值(注入防膨胀,
|
||||||
|
/// 查重要盖住高频重复区)。
|
||||||
|
const KNOWLEDGE_DEDUP_CANDIDATES: usize = 5;
|
||||||
|
|
||||||
/// 自动提炼频次水位(conv_id → 上次提炼进度),见 [`EXTRACT_WATERMARKS`]。
|
/// 自动提炼频次水位(conv_id → 上次提炼进度),见 [`EXTRACT_WATERMARKS`]。
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -235,6 +241,11 @@ async fn resolve_embed_provider(
|
|||||||
/// 生成文本嵌入(向量检索用)
|
/// 生成文本嵌入(向量检索用)
|
||||||
///
|
///
|
||||||
/// 用配置指定的 embedding provider(必须 openai_compat 类型),失败返回 None(降级 LIKE)。
|
/// 用配置指定的 embedding provider(必须 openai_compat 类型),失败返回 None(降级 LIKE)。
|
||||||
|
///
|
||||||
|
/// ⚠️ P0 定位变更(2026-10-23 知识库治理审查拍板):向量路径的「检索用途」明确弃用——
|
||||||
|
/// 生产实证 0/1688 条有向量(provider 从未配成,本函数返回 None 即天然跳过,降级 LIKE
|
||||||
|
/// 已是唯一实际路径)。检索独走分词 LIKE;本链路保留不删,阶段 3(知识中枢设计 2026-10-23)
|
||||||
|
/// 改职复活为「自动建边引擎」(相似度>阈值=similar 边候选),届时不再做检索排序。
|
||||||
async fn generate_embedding(
|
async fn generate_embedding(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
text: &str,
|
text: &str,
|
||||||
@@ -894,13 +905,20 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
|
|||||||
输出严格的 JSON 数组(不要 markdown 代码块包裹),每个元素 schema:\n\
|
输出严格的 JSON 数组(不要 markdown 代码块包裹),每个元素 schema:\n\
|
||||||
{\"kind\": \"review_rule|prompt_template|pitfall|architecture_pattern|diagnosis|deployment_note|workflow_optimization\", \
|
{\"kind\": \"review_rule|prompt_template|pitfall|architecture_pattern|diagnosis|deployment_note|workflow_optimization\", \
|
||||||
\"title\": \"简短标题\", \"content\": \"完整可复用内容\", \
|
\"title\": \"简短标题\", \"content\": \"完整可复用内容\", \
|
||||||
\"tags\": [\"标签\"], \"confidence\": \"high|medium|low\", \"reasoning\": \"为何值得沉淀\"}\n\n\
|
\"tags\": [\"标签\"], \"confidence\": \"high|medium|low\", \"reasoning\": \"为何值得沉淀\", \
|
||||||
|
\"action\": \"new|update\", \"target_id\": \"已有条目id(仅 update 时)\", \
|
||||||
|
\"related_ids\": [\"强相关的已有条目id\"](可选,最多3个,仅 action=new 时)}\n\n\
|
||||||
规则:\n\
|
规则:\n\
|
||||||
1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\
|
1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\
|
||||||
2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\
|
2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\
|
||||||
3. 无可提炼内容时返回空数组 []\n\
|
3. 无可提炼内容时返回空数组 []\n\
|
||||||
4. 输出纯 JSON,无任何额外文字\n\
|
4. 输出纯 JSON,无任何额外文字\n\
|
||||||
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
|
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)\n\
|
||||||
|
6. 查重融合:若本次经验与「已有知识库条目」中某条重复或强相关,必须输出 action=\"update\" \
|
||||||
|
且 target_id=该条 id,content 给出融合两方后的完整新版(不要重复旧文已有信息),\
|
||||||
|
禁止同义新增造成知识重复;确属新经验才 action=\"new\"(可省略 action 字段)\n\
|
||||||
|
7. 关联标注:action=\"new\" 时,若与已有条目存在强相关(同一主题的互补/前提/对照),\
|
||||||
|
在 related_ids 数组里列出最多 3 个相关条目 id,供知识关联网络建边;无强相关则省略该字段";
|
||||||
|
|
||||||
/// 终态提炼提示词 — 在常规提示词上加终态边界(设计 §3.1):产出限跨会话可复用经验,
|
/// 终态提炼提示词 — 在常规提示词上加终态边界(设计 §3.1):产出限跨会话可复用经验,
|
||||||
/// 未完成事项/待办是会话任务态不是知识,归 L4 目标快照与停靠说明承接,不进知识表。
|
/// 未完成事项/待办是会话任务态不是知识,归 L4 目标快照与停靠说明承接,不进知识表。
|
||||||
@@ -1356,7 +1374,7 @@ fn conv_title_or_placeholder(title: Option<String>) -> String {
|
|||||||
/// LLM 产出单条 → candidate 知识记录(常规/终态提炼共用的落库统一口径)。
|
/// LLM 产出单条 → candidate 知识记录(常规/终态提炼共用的落库统一口径)。
|
||||||
/// kind 缺失或标题/正文为空返回 None(调用方跳过该条);status 恒 candidate(LLM 写入
|
/// kind 缺失或标题/正文为空返回 None(调用方跳过该条);status 恒 candidate(LLM 写入
|
||||||
/// 不自动发布,与工具沉淀同信任级);归属与溯源 source_ref 由调用方按统一口径给定。
|
/// 不自动发布,与工具沉淀同信任级);归属与溯源 source_ref 由调用方按统一口径给定。
|
||||||
fn candidate_record_from_item(
|
pub(crate) fn candidate_record_from_item(
|
||||||
item: &serde_json::Value,
|
item: &serde_json::Value,
|
||||||
source_project: Option<&str>,
|
source_project: Option<&str>,
|
||||||
source_ref: Option<String>,
|
source_ref: Option<String>,
|
||||||
@@ -1417,8 +1435,19 @@ async fn run_extraction_core(
|
|||||||
Some(mid) => format!("conv_msg:{}", mid),
|
Some(mid) => format!("conv_msg:{}", mid),
|
||||||
None => format!("conv:{}", conv_id),
|
None => format!("conv:{}", conv_id),
|
||||||
});
|
});
|
||||||
|
// P0 写入防重(2026-10-23):提炼前按窗口关键词查已有知识 top5 随 prompt 附给 LLM,
|
||||||
|
// 重复经验走 update 融合而非同义新增——治理审查实测零查重是 10 月 695 条膨胀的主因。
|
||||||
|
// 全局检索(project=None):知识重复不分项目。检索失败非阻断(降级为无候选,行为回旧)。
|
||||||
|
let existing = match extract_existing_candidates(db, conv_text).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[knowledge] 查重候选检索失败,降级无查重提炼(非阻断): {e}");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let system_full = append_existing_block(system_prompt, &existing);
|
||||||
let extract_messages = vec![
|
let extract_messages = vec![
|
||||||
ChatMessage::system(system_prompt),
|
ChatMessage::system(&system_full),
|
||||||
ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)),
|
ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)),
|
||||||
];
|
];
|
||||||
// F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。
|
// F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。
|
||||||
@@ -1498,7 +1527,45 @@ async fn run_extraction_core(
|
|||||||
tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目");
|
tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目");
|
||||||
}
|
}
|
||||||
let mut inserted = 0;
|
let mut inserted = 0;
|
||||||
|
let mut updated = 0;
|
||||||
|
// update 分派的合法性锚:target_id 必须命中本轮附给 LLM 的候选集(防幻觉 id 误改他条)
|
||||||
|
let existing_ids: std::collections::HashSet<&str> =
|
||||||
|
existing.iter().map(|r| r.id.as_str()).collect();
|
||||||
for item in &items {
|
for item in &items {
|
||||||
|
// P0 查重融合路径:action=update 且 target_id 命中候选集 → 覆盖已有条目而非新建。
|
||||||
|
// content 缺失/空白与 target 未命中都降级走新建(内容不丢,与旧行为一致)。
|
||||||
|
let update_target = (item.get("action").and_then(|v| v.as_str()) == Some("update"))
|
||||||
|
.then(|| item.get("target_id").and_then(|v| v.as_str()))
|
||||||
|
.flatten()
|
||||||
|
.filter(|tid| existing_ids.contains(*tid));
|
||||||
|
if let Some(tid) = update_target {
|
||||||
|
let Some(new_content) = item
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let reasoning_line = format!(
|
||||||
|
"update@{}: {}",
|
||||||
|
chrono::Utc::now().format("%Y-%m-%d"),
|
||||||
|
item.get("reasoning").and_then(|v| v.as_str()).unwrap_or("融合重复经验")
|
||||||
|
);
|
||||||
|
match knowledge_repo
|
||||||
|
.update_content_merged(tid, new_content, &reasoning_line)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => {
|
||||||
|
updated += 1;
|
||||||
|
tracing::info!("AI 提炼知识融合更新: {tid}(重复经验并入已有条目)");
|
||||||
|
timeline.record_updated(tid, &["content"], source_ref.as_deref().unwrap_or("extract")).await;
|
||||||
|
}
|
||||||
|
Ok(false) => tracing::warn!("知识融合更新未命中(id 已消失?),降级新建: {tid}"),
|
||||||
|
Err(e) => tracing::warn!("知识融合更新失败(非阻断,降级新建): {e}"),
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Some(record) = candidate_record_from_item(item, source_project.as_deref(), source_ref.clone()) else {
|
let Some(record) = candidate_record_from_item(item, source_project.as_deref(), source_ref.clone()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -1520,18 +1587,78 @@ async fn run_extraction_core(
|
|||||||
last_assistant_msg_id,
|
last_assistant_msg_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// 阶段1 连边:related_ids ∩ 候选集 → similar 边(方向=新条目→相关已有条目)。
|
||||||
|
// 幂等(insert_edge 双向去重),单边失败非阻断只 warn。
|
||||||
|
let related = item
|
||||||
|
.get("related_ids")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| arr.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for rid in related.into_iter().take(3) {
|
||||||
|
if !existing_ids.contains(rid) || rid == record.id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = knowledge_repo.insert_edge(&record.id, rid, "similar", "llm-tag").await {
|
||||||
|
tracing::warn!("知识关联边建立失败(非阻断): {}→{}: {e}", record.id, rid);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("知识候选插入失败(非阻断): {}", e),
|
Err(e) => tracing::warn!("知识候选插入失败(非阻断): {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if inserted > 0 {
|
// 水位语义「有产出才推进」:update 与 new 同为产出(0 new + N update 不再误判无产出)
|
||||||
tracing::info!("知识提炼完成: 对话 {} 产出 {} 条 candidate", conv_id, inserted);
|
if inserted + updated > 0 {
|
||||||
|
tracing::info!(
|
||||||
|
"知识提炼完成: 对话 {conv_id} 新建 {inserted} 条 / 融合更新 {updated} 条"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Ok(inserted)
|
Ok(inserted + updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提炼查重候选检索:按窗口关键词查已有知识 top5(P0 写入防重,2026-10-23)
|
||||||
|
///
|
||||||
|
/// 走与注入同源的 [`extract_query_keywords`] 分词 + [`KnowledgeRepo::search_keywords`]
|
||||||
|
/// (include_candidates=true 与检索面同口径)。降级链:分词空词/0 命中 → 空表(LLM 收不到
|
||||||
|
/// 已有块,全部走 new,行为回旧)。
|
||||||
|
async fn extract_existing_candidates(
|
||||||
|
db: &Arc<Database>,
|
||||||
|
conv_text: &str,
|
||||||
|
) -> anyhow::Result<Vec<KnowledgeRecord>> {
|
||||||
|
let keywords = extract_query_keywords(conv_text);
|
||||||
|
if keywords.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let repo = KnowledgeRepo::new(db);
|
||||||
|
Ok(repo.search_keywords(&keywords, true, KNOWLEDGE_DEDUP_CANDIDATES).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把已有知识候选块追加到提炼 system prompt(action=update 协议的上下文载体)
|
||||||
|
///
|
||||||
|
/// 每条带 id/title/content 截断(500 字,LLM 判断重复足够,不撑提炼输入预算);
|
||||||
|
/// 候选为空返回原 prompt(零开销)。
|
||||||
|
fn append_existing_block(system_prompt: &str, existing: &[KnowledgeRecord]) -> String {
|
||||||
|
if existing.is_empty() {
|
||||||
|
return system_prompt.to_owned();
|
||||||
|
}
|
||||||
|
let mut block = String::from("\n\n已有知识库条目(判断查重用):\n");
|
||||||
|
for r in existing {
|
||||||
|
let truncated: String;
|
||||||
|
let content = if r.content.chars().count() > 500 {
|
||||||
|
truncated = r.content.chars().take(500).collect();
|
||||||
|
truncated.as_str()
|
||||||
|
} else {
|
||||||
|
r.content.as_str()
|
||||||
|
};
|
||||||
|
block.push_str(&format!(
|
||||||
|
"- id={} | {} | {}\n",
|
||||||
|
r.id, r.title, content
|
||||||
|
));
|
||||||
|
}
|
||||||
|
format!("{system_prompt}{block}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 剥离 LLM 输出可能的 ```json ... ``` 代码块包裹
|
/// 剥离 LLM 输出可能的 ```json ... ``` 代码块包裹
|
||||||
fn strip_code_fence(s: &str) -> &str {
|
pub(crate) fn strip_code_fence(s: &str) -> &str {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
if let Some(rest) = s.strip_prefix("```json") {
|
if let Some(rest) = s.strip_prefix("```json") {
|
||||||
return rest.trim().trim_end_matches("```").trim();
|
return rest.trim().trim_end_matches("```").trim();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub mod fetch_search;
|
|||||||
pub mod download_file;
|
pub mod download_file;
|
||||||
pub(crate) mod generate_image;
|
pub(crate) mod generate_image;
|
||||||
pub mod get_app_config;
|
pub mod get_app_config;
|
||||||
|
pub mod knowledge_distill;
|
||||||
pub mod knowledge_inject;
|
pub mod knowledge_inject;
|
||||||
// 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知
|
// 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知
|
||||||
pub mod lease;
|
pub mod lease;
|
||||||
@@ -178,7 +179,11 @@ pub enum AiChatEvent {
|
|||||||
/// 需要人工审批
|
/// 需要人工审批
|
||||||
/// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff),
|
/// diff(AE-2025-03,路径 B):仅 write_file 注入(旧文件 vs 新内容行级 unified diff),
|
||||||
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
|
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
|
||||||
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option<String>, conversation_id: Option<String> },
|
AiApprovalRequired { id: String, name: String, args: serde_json::Value, reason: String, diff: Option<String>, conversation_id: Option<String>,
|
||||||
|
/// 后端超时收敛到期时刻(ms Unix 时间戳)。前端审批倒计时单一真值(前端已无本地自动拒绝定时器,2026-10-25)。
|
||||||
|
/// None = 超时被禁用(approval_timeout_minutes=0),前端不显倒计时。
|
||||||
|
#[serde(default)]
|
||||||
|
expires_at_millis: Option<i64> },
|
||||||
/// 审批结果
|
/// 审批结果
|
||||||
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
|
AiApprovalResult { id: String, approved: bool, conversation_id: Option<String> },
|
||||||
/// AI 响应完成
|
/// AI 响应完成
|
||||||
@@ -288,19 +293,6 @@ pub enum AiChatEvent {
|
|||||||
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
|
/// 每次重试前 emit,前端可在错误气泡内显示「重试 n/m」。
|
||||||
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。
|
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1),max_attempts = max_retries + 1。
|
||||||
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
|
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
|
||||||
/// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,挂起 loop 等用户决定)。
|
|
||||||
///
|
|
||||||
/// 触发:resolve_workspace_path 预校验路径未命中 persistent/session 白名单(且非黑名单)。
|
|
||||||
/// 复用审批挂起架构(PendingApproval.kind=Path 标记路径授权挂起,阶段3a 单真相源合并),
|
|
||||||
/// 用户经 ai_authorize_dir IPC 选择"仅本次/未来都允许/拒绝",恢复 loop。
|
|
||||||
/// `dir`:规范化后的待授权目录(父目录,目录粒度对齐 session_trust)。
|
|
||||||
AiDirAuthRequired {
|
|
||||||
id: String,
|
|
||||||
tool: String,
|
|
||||||
path: String,
|
|
||||||
dir: String,
|
|
||||||
conversation_id: Option<String>,
|
|
||||||
},
|
|
||||||
/// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
|
/// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
|
||||||
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。
|
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。
|
||||||
AiContextCleared { conversation_id: Option<String> },
|
AiContextCleared { conversation_id: Option<String> },
|
||||||
@@ -317,6 +309,11 @@ pub enum AiChatEvent {
|
|||||||
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
|
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
|
||||||
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||||
AiAutoCompressed { conversation_id: Option<String>, summary: String },
|
AiAutoCompressed { conversation_id: Option<String>, summary: String },
|
||||||
|
/// E2 压缩失败降级透出(2026-10-18):LLM 压缩失败走关键词摘要兜底时,loop 仍在继续
|
||||||
|
/// (不发 AiError——其 case 无条件 setStreaming(false) 会误判生成结束),但用户须知晓
|
||||||
|
/// 上下文已降级(实证 76730ce8 seq166:静默降级→AI 失忆→用户被迫原句重发)。
|
||||||
|
/// 前端弹一次性状态提示,不中断流。
|
||||||
|
AiContextDegraded { conversation_id: Option<String>, reason: String },
|
||||||
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||||
///
|
///
|
||||||
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
|
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
|
||||||
@@ -693,16 +690,11 @@ pub struct AiSession {
|
|||||||
pub active_conversation_id: Option<String>,
|
pub active_conversation_id: Option<String>,
|
||||||
/// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
|
/// 活跃对话创建时间(懒创建:首条消息落库前仅存内存,upsert 时用作 created_at)
|
||||||
pub active_conv_created_at: Option<String>,
|
pub active_conv_created_at: Option<String>,
|
||||||
/// 挂起的审批(阶段3a 单真相源合并:原 path_auth_pending + risk_pending 双 HashMap)。
|
/// 挂起的审批(单真相源单表,键=tool_call_id,`conversation_id` 是业务语义)。
|
||||||
///
|
///
|
||||||
/// 决策1(状态层合 + 决策层分):状态层把两类挂起合并进单 HashMap,
|
/// path_auth 审批链已退役(2026-10-25):原 Path/Risk 双 kind 合并设计回退为单 Risk kind,
|
||||||
/// `PendingApproval.kind` 字段区分语义(`ApprovalKind::Path` vs `ApprovalKind::Risk`);
|
/// `ai_approve` 是唯一决策入口。`ApprovalKind::Path` 变体已删,DB pending_approvals JSON 列
|
||||||
/// 决策层仍双轨 —— `ai_authorize_dir` 只消费 `kind==Path`(once/always 写持久白名单),
|
/// 残留的 `kind:Path` 老行反序列化失败即整体跳过(切会话侧 warn 非阻断,等价挂起丢弃)。
|
||||||
/// `ai_approve` 只消费 `kind==Risk`(一次性 approve/reject),入口 kind 校验防误调。
|
|
||||||
/// 键为 `tool_call_id`(路由键不变),`conversation_id` 是业务语义(哪个对话产生)。
|
|
||||||
///
|
|
||||||
/// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。
|
|
||||||
/// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。
|
|
||||||
pub pending_approvals: HashMap<String, PendingApproval>,
|
pub pending_approvals: HashMap<String, PendingApproval>,
|
||||||
/// 多会话并发架构 — 会话级状态按 conversation_id 切分,全部调用方经
|
/// 多会话并发架构 — 会话级状态按 conversation_id 切分,全部调用方经
|
||||||
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)` 读写,per_conv 是唯一真相源。
|
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)` 读写,per_conv 是唯一真相源。
|
||||||
@@ -1309,10 +1301,8 @@ impl Default for PerConvState {
|
|||||||
|
|
||||||
/// 待审批的工具调用
|
/// 待审批的工具调用
|
||||||
///
|
///
|
||||||
/// 阶段3a(单真相源合并):新增 `kind: ApprovalKind` 字段区分两类挂起(Path/Risk),
|
/// `kind: ApprovalKind` 保留字段壳:原 Path/Risk 双 kind 设计随 path_auth 退役
|
||||||
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
|
/// (2026-10-25)回退为单 Risk kind,字段保留作 serde 兼容与未来扩展。
|
||||||
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
|
|
||||||
/// 各只消费自己 kind,入口校验防误调)。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PendingApproval {
|
pub struct PendingApproval {
|
||||||
pub tool_call_id: String,
|
pub tool_call_id: String,
|
||||||
@@ -1325,8 +1315,7 @@ pub struct PendingApproval {
|
|||||||
/// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。
|
/// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub created_at: Option<std::time::SystemTime>,
|
pub created_at: Option<std::time::SystemTime>,
|
||||||
/// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。
|
/// 审批类型。path_auth 退役后仅剩 `ApprovalKind::Risk` 单变体(Path 变体已删)。
|
||||||
/// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。
|
|
||||||
pub kind: ApprovalKind,
|
pub kind: ApprovalKind,
|
||||||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):同 tc_id 重试计数。
|
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):同 tc_id 重试计数。
|
||||||
///
|
///
|
||||||
@@ -1344,23 +1333,18 @@ pub struct PendingApproval {
|
|||||||
pub retry_count: u32,
|
pub retry_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 阶段3a:审批类型枚举(决策层分离,kind 区分 ai_approve vs ai_authorize_dir 消费)。
|
/// 审批类型枚举。path_auth 退役(2026-10-25)后仅剩 Risk 单变体,
|
||||||
///
|
/// 原 `Path(PathAuthRequest)` 变体已删(路径授权挂起链整体退役)。
|
||||||
/// - `Path(req)`:F-260619-03 Phase B 路径授权挂起,由 `ai_authorize_dir` 消费(once/always/deny)。
|
|
||||||
/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。
|
|
||||||
/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览;
|
|
||||||
/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum ApprovalKind {
|
pub enum ApprovalKind {
|
||||||
/// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。
|
/// 普通 RiskLevel 审批(Med/High 风险工具),由 `ai_approve` 消费(一次性 approve/reject)。
|
||||||
Path(PathAuthRequest),
|
|
||||||
/// 普通 RiskLevel 审批(Med/High 风险工具)。
|
|
||||||
/// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。
|
/// diff 仅 write_file 挂起审批前预读注入;其余工具 / recovered 审批为 None。
|
||||||
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·)·cargo Rust never-read 误报
|
|
||||||
Risk { diff: Option<String> },
|
Risk { diff: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。
|
/// 路径授权挂起请求载荷。【已退役】ApprovalKind::Path 变体已删,本结构仅作
|
||||||
|
/// path_auth.rs 壳(恢复点)的 NeedsAuth 载荷类型保留,运行时无消费者。
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PathAuthRequest {
|
pub struct PathAuthRequest {
|
||||||
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
|
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
|
||||||
|
|||||||
@@ -127,6 +127,9 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
- {}\n\
|
- {}\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
|
- {}\n\
|
||||||
|
- Response style: lead with the conclusion and the action/result; omit restating the user's question, pleasantries, and summary closers; prefer bullet points over paragraphs; do not expand on what was not asked. Explain whenever explanation is needed — cut filler, not substance.\n\
|
||||||
|
- Never estimate or mention work-hours/duration/man-days — this is an AI-coding product where human + AI ship together, so traditional effort estimates are meaningless; express workload as change surface (files/lines/batches) instead.\n\
|
||||||
## Focus\n\
|
## Focus\n\
|
||||||
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
|
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
|
||||||
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
|
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
|
||||||
@@ -136,6 +139,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
duplicate_call_guidance(lang),
|
duplicate_call_guidance(lang),
|
||||||
file_reading_guidance(lang),
|
file_reading_guidance(lang),
|
||||||
verification_fast_check_guidance(lang),
|
verification_fast_check_guidance(lang),
|
||||||
|
history_search_guidance(lang),
|
||||||
),
|
),
|
||||||
"\n## Current Projects\n",
|
"\n## Current Projects\n",
|
||||||
"\n## Current Tasks\n",
|
"\n## Current Tasks\n",
|
||||||
@@ -160,6 +164,9 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
- {}\n\
|
- {}\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
|
- {}\n\
|
||||||
|
- 回复风格:结论先行,直接给动作/结果;不复述用户问题,不做客套开场和总结性收尾;要点优先于段落;用户没问的不展开。需要解释时照常解释——省的是空话,不是内容。\n\
|
||||||
|
- 禁止预估或提及工时/耗时/人天——本产品是 AI Coding 场景,人与 AI 协作完成开发,传统工时估算无意义;需要表达工作量时用改动面(涉及文件数/代码行数/拆几批)描述。\n\
|
||||||
## 聚焦准则\n\
|
## 聚焦准则\n\
|
||||||
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
||||||
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
||||||
@@ -169,6 +176,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
duplicate_call_guidance(lang),
|
duplicate_call_guidance(lang),
|
||||||
file_reading_guidance(lang),
|
file_reading_guidance(lang),
|
||||||
verification_fast_check_guidance(lang),
|
verification_fast_check_guidance(lang),
|
||||||
|
history_search_guidance(lang),
|
||||||
),
|
),
|
||||||
"\n## 当前项目\n",
|
"\n## 当前项目\n",
|
||||||
"\n## 当前任务\n",
|
"\n## 当前任务\n",
|
||||||
@@ -195,6 +203,10 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
|
|||||||
/// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与
|
/// 段体拆为两块:核心纪律(URL 三工具分工/探索流程/禁臆造 URL/render=true 铁律)与
|
||||||
/// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段
|
/// obscura 安装引导(涉用户交互与 run_command,父侧专用)。子代理工具纪律段
|
||||||
/// ([`sub_agent_tool_discipline_section`])只拼核心块。
|
/// ([`sub_agent_tool_discipline_section`])只拼核心块。
|
||||||
|
///
|
||||||
|
/// **渐进披露(D1)后角色变更**:本函数 = 完整段,不再无条件常驻 system prompt——
|
||||||
|
/// 常驻改拼路标短段([`doc_strategy_signpost_section`]),完整段仅当
|
||||||
|
/// [`doc_strategy_needed`] 命中时按需附到易变侧(全文 ~12K 字符按需才进上下文)。
|
||||||
fn doc_exploration_strategy_section(lang: &str) -> String {
|
fn doc_exploration_strategy_section(lang: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{}{}",
|
"{}{}",
|
||||||
@@ -203,6 +215,44 @@ fn doc_exploration_strategy_section(lang: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判定本轮 user 文本是否需要附带完整文档探索策略(渐进披露门,纯函数无 IO)。
|
||||||
|
///
|
||||||
|
/// 命中任一即附带:含 URL(http:// / https:// / www.)或文档关键词
|
||||||
|
/// (「文档」「docs」「documentation」)。ASCII 一律不分大小写(抹平 Docs/DOCS/WWW.
|
||||||
|
/// 等书写差异);「官方文档」不必单查——含「官方文档」必含「文档」,单查子串即等价覆盖。
|
||||||
|
/// 放近使用处(prompt.rs 组装链),不抽 trait 不过度抽象。
|
||||||
|
pub(crate) fn doc_strategy_needed(user_text: &str) -> bool {
|
||||||
|
let lower = user_text.to_ascii_lowercase();
|
||||||
|
lower.contains("http://")
|
||||||
|
|| lower.contains("https://")
|
||||||
|
|| lower.contains("www.")
|
||||||
|
|| user_text.contains("文档")
|
||||||
|
|| lower.contains("docs")
|
||||||
|
|| lower.contains("documentation")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文档探索策略路标段(中/英,常驻 system prompt 的收敛短段,≤1200 字符)。
|
||||||
|
///
|
||||||
|
/// 渐进披露(D1):原完整段 ~12K 字符逐轮常驻烧上下文,而多数轮次并不涉及文档任务。
|
||||||
|
/// 常驻只留路标——触发场景(URL/文档站/外部文档类问题)+ 三个工具名(fetch_url /
|
||||||
|
/// fetch_search / obscura)+「完整策略按需自动附带」一句引导,让 LLM 知道策略存在、
|
||||||
|
/// 用对工具;完整段([`doc_exploration_strategy_section`])由组装链按
|
||||||
|
/// [`doc_strategy_needed`] 命中附到易变侧。策略正文零改动,只动拼接结构。
|
||||||
|
fn doc_strategy_signpost_section(lang: &str) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
"en" => "\n\
|
||||||
|
## Documentation Exploration Strategy (signpost)\n\
|
||||||
|
When the user message contains a URL, a docs site link, or an external-docs/integration question, you are in a documentation exploration scenario: pick the right URL tool first, never blind-fire requests at raw URLs.\n\
|
||||||
|
Tool entries: `fetch_url` reads a URL into markdown (`render=true` goes through the obscura JS renderer); `fetch_search` queries a search engine for unknown URLs; obscura is the JS-render backend — when not installed, `fetch_url` silently falls back to static and returns an install `hint` field.\n\
|
||||||
|
For documentation tasks, follow the full documentation exploration strategy (attached on demand): URL tool division rules, sniff → identify → extract → verify → apply flow, never fabricate URLs, switch tactics after 2 `render=true` failures.\n\n",
|
||||||
|
_ => "\n\
|
||||||
|
## 文档探索策略(路标)\n\
|
||||||
|
用户消息含 URL、文档站链接或外部文档/接入类问题时,即进入文档探索场景:先选对 URL 工具再动手,严禁对原始 URL 盲发请求。\n\
|
||||||
|
工具入口:`fetch_url` 读 URL 转 markdown(`render=true` 走 obscura JS 渲染);`fetch_search` 未知 URL 先搜索引擎拿候选;obscura 是 JS 渲染后端——未安装时 `fetch_url` 静态回退并返回安装引导 `hint` 字段。\n\
|
||||||
|
遇到文档类任务,遵循完整文档探索策略(按需自动附带):URL 三工具分工铁律、嗅探→识别→提取→验证→应用流程、严禁臆造 URL、`render=true` 失败 2 次必换方案。\n\n",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段
|
/// read_symbol 优先指引(中/英,不含行首 "- ")。父会话能力清单与子代理工具纪律段
|
||||||
/// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。
|
/// 同源拼接(DRY,治指引真空:工具下发了纪律没下发,实测翻车坑在子代理原样复发)。
|
||||||
fn read_symbol_guidance(lang: &str) -> &'static str {
|
fn read_symbol_guidance(lang: &str) -> &'static str {
|
||||||
@@ -240,9 +290,21 @@ fn verification_fast_check_guidance(lang: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跨会话历史检索指引(中/英,不含行首 "- ")。P1-A(u-claw #6 等价):教 AI 跨会话
|
||||||
|
/// 查「上次/之前怎么弄的」用 history_search,严禁 run_command 查 sqlite 代替(查配置
|
||||||
|
/// 绕行的同款失败模式,先例 app_config_query_guidance_section 段式:正向指令+禁令+替代路径)。
|
||||||
|
/// 父会话能力清单与子代理工具纪律段同源拼接(同 read_symbol_guidance 模式)。
|
||||||
|
fn history_search_guidance(lang: &str) -> &'static str {
|
||||||
|
match lang {
|
||||||
|
"en" => "**Cross-session history search**: when the user refers to a previous task, conclusion, or approach (\"last time\", \"earlier\", \"history\"), use history_search to search past conversations (auto keyword extraction; the first hop returns one snippet per conversation, pass conversation_id for a deep dive; omit query to list recent conversations). Never use run_command against the sqlite database file as a substitute; compressed early history of a hit conversation can be read back via read_file from the archive path (app data dir, context-archive/<conversation_id>-archive.jsonl)",
|
||||||
|
_ => "**跨会话历史检索**:用户提到「上次/之前/历史」的任务、结论或做法时,用 history_search 检索历史会话(自动分词;首跳返回每会话一条命中片段,深读再传 conversation_id;query 省略时列最近会话概览)。严禁用 run_command 查 sqlite 数据库文件代替本工具;命中会话被压缩的早期原文可按存档路径(应用数据目录 context-archive/<conversation_id>-archive.jsonl)用 read_file 回读",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
|
/// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
|
||||||
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
|
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
|
||||||
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具,
|
/// 2 次必换方案 / 查配置用 get_app_config / 跨会话查历史用 history_search。
|
||||||
|
/// 子代理白名单缺省下发全量 Low 工具,
|
||||||
/// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。
|
/// 纪律必须随工具同源下发,否则父会话实测蒸馏的防坑经验在子循环原样复发。
|
||||||
/// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与
|
/// 裁剪不适用条款:obscura 安装引导(涉用户交互,子代理无交互面)、聚焦准则与
|
||||||
/// 用户交互类行为准则(执行前说明意图/向用户提问)。
|
/// 用户交互类行为准则(执行前说明意图/向用户提问)。
|
||||||
@@ -253,11 +315,13 @@ pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String {
|
|||||||
- {}\n\
|
- {}\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
- {}\n\
|
- {}\n\
|
||||||
|
- {}\n\
|
||||||
{}{}",
|
{}{}",
|
||||||
read_symbol_guidance(lang),
|
read_symbol_guidance(lang),
|
||||||
duplicate_call_guidance(lang),
|
duplicate_call_guidance(lang),
|
||||||
file_reading_guidance(lang),
|
file_reading_guidance(lang),
|
||||||
verification_fast_check_guidance(lang),
|
verification_fast_check_guidance(lang),
|
||||||
|
history_search_guidance(lang),
|
||||||
doc_strategy_core_section(lang),
|
doc_strategy_core_section(lang),
|
||||||
app_config_query_guidance_section(lang),
|
app_config_query_guidance_section(lang),
|
||||||
)
|
)
|
||||||
@@ -445,13 +509,19 @@ async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<Str
|
|||||||
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
|
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
|
||||||
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
|
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
|
||||||
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
|
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
|
||||||
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。
|
/// - 活跃 project/task 指纹:task 取 id+updated_at;project 取 id+活跃时间(三源取大:
|
||||||
|
/// tasks.updated_at 最新/project_events 最新 created_at/projects.updated_at,仅未归档未删除,
|
||||||
|
/// 与「当前项目」清单的 list_active_with_activity 排序输入对齐)——任务更新/归档变化均
|
||||||
|
/// 反映到指纹,清单排序变了缓存串必失效。
|
||||||
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
|
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
|
||||||
|
/// - 文档策略全文门(D1):完整段按 user 文本条件附带,命中与否改变 system 内容,
|
||||||
|
/// 不入指纹则同指纹下「无全文的缓存串」会被文档类轮次错误复用(陈旧命中)。
|
||||||
async fn system_prompt_fingerprint(
|
async fn system_prompt_fingerprint(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
lang: &str,
|
lang: &str,
|
||||||
excl_proj: &[String],
|
excl_proj: &[String],
|
||||||
excl_task: &[String],
|
excl_task: &[String],
|
||||||
|
doc_full: bool,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||||
let custom = state
|
let custom = state
|
||||||
@@ -463,7 +533,11 @@ async fn system_prompt_fingerprint(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let proj_fp = entity_fingerprint(
|
let proj_fp = entity_fingerprint(
|
||||||
&state.db,
|
&state.db,
|
||||||
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL",
|
"SELECT p.id, MAX(\
|
||||||
|
IFNULL((SELECT MAX(t.updated_at) FROM tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL), 0), \
|
||||||
|
IFNULL((SELECT MAX(e.created_at) FROM project_events e WHERE e.project_id = p.id), 0), \
|
||||||
|
IFNULL(p.updated_at, 0)\
|
||||||
|
) FROM projects p WHERE p.deleted_at IS NULL AND p.archived = 0",
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let task_fp = entity_fingerprint(
|
let task_fp = entity_fingerprint(
|
||||||
@@ -480,6 +554,7 @@ async fn system_prompt_fingerprint(
|
|||||||
key.push_str("|t:");
|
key.push_str("|t:");
|
||||||
key.push_str(id);
|
key.push_str(id);
|
||||||
}
|
}
|
||||||
|
key.push_str(if doc_full { "|d:1" } else { "|d:0" });
|
||||||
Some(key)
|
Some(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +577,8 @@ async fn system_prompt_fingerprint(
|
|||||||
/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。
|
/// (文档/文档注释多处引用,后续非缓存场景调用点回迁即用),标 allow 不盲删。
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
|
pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String {
|
||||||
build_system_prompt_with_excluded(state, lang, &[], &[]).await
|
// 无 user 文本上下文的预留 API:门判定退化为不命中(仅路标常驻,全文不附)。
|
||||||
|
build_system_prompt_with_excluded(state, lang, &[], &[], "").await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。
|
/// 构建系统提示词(可去重版本)——主路径 chat.rs send / force_send 调用。
|
||||||
@@ -518,14 +594,21 @@ pub(crate) async fn build_system_prompt(state: &AppState, lang: &str) -> String
|
|||||||
///
|
///
|
||||||
/// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。
|
/// - 空排除列表(无 mention 的调用)与 [`build_system_prompt`] 行为完全等价(仅多一行注明语)。
|
||||||
/// - 注入克制策略不变:仍各 take(20)。
|
/// - 注入克制策略不变:仍各 take(20)。
|
||||||
|
///
|
||||||
|
/// **渐进披露(D1)**:`user_text` 为本轮用户文本,过 [`doc_strategy_needed`] 门——
|
||||||
|
/// 常驻只拼路标短段,命中时完整段紧随其后(本函数 = 缓存关单串形态,无 stable/volatile
|
||||||
|
/// 拆分,全文随串整体逐轮重建,无缓存污染问题;BE3 会话内缓存指纹已纳入命中位)。
|
||||||
|
/// 传空串(调用方无 user 上下文)等价于不命中:仅路标,全文不附。
|
||||||
pub(crate) async fn build_system_prompt_with_excluded(
|
pub(crate) async fn build_system_prompt_with_excluded(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
lang: &str,
|
lang: &str,
|
||||||
excluded_project_ids: &[String],
|
excluded_project_ids: &[String],
|
||||||
excluded_task_ids: &[String],
|
excluded_task_ids: &[String],
|
||||||
|
user_text: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
|
let doc_full = doc_strategy_needed(user_text);
|
||||||
// BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。
|
// BE3:先算指纹查缓存,命中直接复用已拼好的 system 串(省查库拼清单)。
|
||||||
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids).await;
|
let fp_key = system_prompt_fingerprint(state, lang, excluded_project_ids, excluded_task_ids, doc_full).await;
|
||||||
if let Some(key) = &fp_key {
|
if let Some(key) = &fp_key {
|
||||||
let cache = system_prompt_cache();
|
let cache = system_prompt_cache();
|
||||||
if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) {
|
if let Some(cached) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(key) {
|
||||||
@@ -535,8 +618,11 @@ pub(crate) async fn build_system_prompt_with_excluded(
|
|||||||
let (prefix, _, _) = system_prompt_parts(lang);
|
let (prefix, _, _) = system_prompt_parts(lang);
|
||||||
let mut prompt = env_profile_line();
|
let mut prompt = env_profile_line();
|
||||||
prompt.push_str(&prefix);
|
prompt.push_str(&prefix);
|
||||||
// 文档探索策略段(URL→fetch_url 嗅探→识别→提取→验证→应用,治盲目 http_request 瞎试)
|
// 文档探索策略段(渐进披露 D1):路标常驻;命中门时完整段紧随(治盲目 http_request 瞎试)
|
||||||
|
prompt.push_str(doc_strategy_signpost_section(lang));
|
||||||
|
if doc_full {
|
||||||
prompt.push_str(&doc_exploration_strategy_section(lang));
|
prompt.push_str(&doc_exploration_strategy_section(lang));
|
||||||
|
}
|
||||||
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
|
// 查 DevFlow 自身配置引导(治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
|
||||||
prompt.push_str(app_config_query_guidance_section(lang));
|
prompt.push_str(app_config_query_guidance_section(lang));
|
||||||
|
|
||||||
@@ -586,24 +672,25 @@ async fn build_context_lists(
|
|||||||
) -> String {
|
) -> String {
|
||||||
let (_, proj_label, task_label) = system_prompt_parts(lang);
|
let (_, proj_label, task_label) = system_prompt_parts(lang);
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
// 附加当前数据上下文
|
// 附加当前数据上下文。项目段按最近活跃排序(任务活跃为主信号),治创建序误导项目绑定
|
||||||
if let Ok(projects) = state.projects.list_active().await {
|
// (创建序会把早停摆项目排在清单首位,AI 误当最高优先项目锚点)。
|
||||||
|
if let Ok(projects) = state.projects.list_active_with_activity().await {
|
||||||
if !projects.is_empty() {
|
if !projects.is_empty() {
|
||||||
out.push_str(proj_label);
|
out.push_str(proj_label);
|
||||||
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
|
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
|
||||||
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
|
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
|
||||||
for p in projects.iter().take(20) {
|
for p in projects.iter().take(20) {
|
||||||
if excluded_project_ids.iter().any(|id| id == &p.id) {
|
if excluded_project_ids.iter().any(|id| id == &p.record.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"- {} (id: {}) ({}): {}\n",
|
"- {} (id: {}) ({}): {}\n",
|
||||||
p.name,
|
p.record.name,
|
||||||
p.id,
|
p.record.id,
|
||||||
p.status.as_str(),
|
p.record.status.as_str(),
|
||||||
truncate_desc(&p.description, CONTEXT_DESC_MAX_CHARS)
|
truncate_desc(&p.record.description, CONTEXT_DESC_MAX_CHARS)
|
||||||
));
|
));
|
||||||
if let Some(ref dir) = p.path {
|
if let Some(ref dir) = p.record.path {
|
||||||
out.push_str(&format!(" 目录: {}\n", dir));
|
out.push_str(&format!(" 目录: {}\n", dir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,6 +810,46 @@ pub(crate) async fn compress_recall_enabled(state: &AppState) -> bool {
|
|||||||
!(v == "false" || v == "0")
|
!(v == "false" || v == "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// P0-B 压缩水位判据配置(KV 双键,对齐 audit/bg_run.rs 惯例:bool 开关+数值参数,默认关)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 水位判据开关(`df-ai-compress-watermark`,默认关):关=现行 0.6 体积判据逐字节不变;
|
||||||
|
/// 开=替换为水位主判据(history+sys > ratio×budget),体积兜底=build_for_request 硬门。
|
||||||
|
/// 判据消费点=context_lifecycle.rs::compress_should_trigger。
|
||||||
|
const WATERMARK_SWITCH_KEY: &str = "df-ai-compress-watermark";
|
||||||
|
/// 水位阈值(`df-ai-compress-watermark-ratio`,默认 "0.70")。
|
||||||
|
const WATERMARK_RATIO_KEY: &str = "df-ai-compress-watermark-ratio";
|
||||||
|
|
||||||
|
/// 缺省水位阈值(u-claw 同款 70%)。
|
||||||
|
const DEFAULT_WATERMARK_RATIO: f32 = 0.70;
|
||||||
|
/// ratio 钳位下限:再低会显著早于现行 0.6 判据触发(过度压缩,与治偏频目标反向)。
|
||||||
|
pub(crate) const MIN_WATERMARK_RATIO: f32 = 0.3;
|
||||||
|
/// ratio 钳位上限:headroom 红线(分析 §4.3)——触发点+单轮增长须 < available,
|
||||||
|
/// 0.95 保 ≥5% budget 余量。
|
||||||
|
pub(crate) const MAX_WATERMARK_RATIO: f32 = 0.95;
|
||||||
|
|
||||||
|
/// 水位阈值解析:缺失/解析失败/非正数(含 0、负值、NaN/inf)归默认 0.70
|
||||||
|
/// (对齐 bg_run.rs parse_grace「0 视为配置错误」);合法值钳位 [0.3, 0.95] 防误配。
|
||||||
|
fn parse_watermark_ratio(raw: Option<String>) -> f32 {
|
||||||
|
raw.and_then(|s| s.trim().parse::<f32>().ok())
|
||||||
|
.filter(|r| r.is_finite() && *r > 0.0)
|
||||||
|
.map_or(DEFAULT_WATERMARK_RATIO, |r| {
|
||||||
|
r.clamp(MIN_WATERMARK_RATIO, MAX_WATERMARK_RATIO)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取压缩水位判据配置(KV 双键一次读齐):`(开关, 阈值)`。
|
||||||
|
/// 默认 `(false, 0.70)`=现行判据逐字节不变(灰度:默认关,狗粮自开 KV 观察后再拍翻转)。
|
||||||
|
pub(crate) async fn compress_watermark_cfg(state: &AppState) -> (bool, f32) {
|
||||||
|
let on = state
|
||||||
|
.settings
|
||||||
|
.get_bool_norm(WATERMARK_SWITCH_KEY, false)
|
||||||
|
.await;
|
||||||
|
let ratio = parse_watermark_ratio(state.settings.get_string_norm(WATERMARK_RATIO_KEY).await);
|
||||||
|
(on, ratio)
|
||||||
|
}
|
||||||
|
|
||||||
/// 读取缓存友好上下文总开关(AppState settings KV `df-ai-cache-friendly-context`,默认开)。
|
/// 读取缓存友好上下文总开关(AppState settings KV `df-ai-cache-friendly-context`,默认开)。
|
||||||
///
|
///
|
||||||
/// 值语义同 [`parse_cache_flag`]:缺省/空/"true"/"1" → 开;"false"/"0"/杂值 → 关。
|
/// 值语义同 [`parse_cache_flag`]:缺省/空/"true"/"1" → 开;"false"/"0"/杂值 → 关。
|
||||||
@@ -752,32 +879,64 @@ pub(crate) async fn cache_friendly_context_enabled(state: &AppState) -> bool {
|
|||||||
///
|
///
|
||||||
/// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
|
/// 注意:调用方须先按「开关开 AND 当前 provider 支持缓存拆分(anthropic 系/openai_compat)」
|
||||||
/// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
|
/// gate 再传 `cache_enabled`,保证开关关时全部 provider 零影响(逐字等价旧路径)。
|
||||||
|
///
|
||||||
|
/// **渐进披露(D1)**:本签名保留给**拿不到本轮 user 文本**的调用方(如 agentic 续跑
|
||||||
|
/// agentic/helpers.rs,本任务禁改该文件)——门判定退化为不命中:路标常驻、完整段不附。
|
||||||
|
/// 有 user 文本的调用方(chat.rs send/regenerate/edit/force_send)改走
|
||||||
|
/// [`build_system_prompt_parts_with_user`]。
|
||||||
pub(crate) async fn build_system_prompt_parts(
|
pub(crate) async fn build_system_prompt_parts(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
lang: &str,
|
lang: &str,
|
||||||
excluded_project_ids: &[String],
|
excluded_project_ids: &[String],
|
||||||
excluded_task_ids: &[String],
|
excluded_task_ids: &[String],
|
||||||
cache_enabled: bool,
|
cache_enabled: bool,
|
||||||
|
) -> (String, String) {
|
||||||
|
build_system_prompt_parts_with_user(state, lang, excluded_project_ids, excluded_task_ids, "", cache_enabled).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`build_system_prompt_parts`] 的 user 感知版(渐进披露 D1 主入口)。
|
||||||
|
///
|
||||||
|
/// 差异仅在文档策略完整段的归属:`doc_strategy_needed(user_text)` 命中时完整段
|
||||||
|
/// **附到易变段**(不进 system 稳定段)——全文随本轮用户消息而变,留稳定段会逐轮失效
|
||||||
|
/// system 缓存;进易变尾段与知识/augmentation 同理(拼消息流末尾,分段符 `\n\n---\n`
|
||||||
|
/// 对齐 chat.rs `append_volatile` 风格)。开关关单串路径委托
|
||||||
|
/// [`build_system_prompt_with_excluded`](全文紧随路标,见其文档注释)。
|
||||||
|
pub(crate) async fn build_system_prompt_parts_with_user(
|
||||||
|
state: &AppState,
|
||||||
|
lang: &str,
|
||||||
|
excluded_project_ids: &[String],
|
||||||
|
excluded_task_ids: &[String],
|
||||||
|
user_text: &str,
|
||||||
|
cache_enabled: bool,
|
||||||
) -> (String, String) {
|
) -> (String, String) {
|
||||||
if !cache_enabled {
|
if !cache_enabled {
|
||||||
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids).await;
|
let full = build_system_prompt_with_excluded(state, lang, excluded_project_ids, excluded_task_ids, user_text).await;
|
||||||
return (full, String::new());
|
return (full, String::new());
|
||||||
}
|
}
|
||||||
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索 + 查配置引导 + 自定义),
|
// 开关开:稳定段留 system(OS/shell 执行姿势 + 语言前缀 + 文档探索路标 + 查配置引导 + 自定义),
|
||||||
// 易变段(日期 + 项目/任务清单)挪到消息流末尾,不失效 system 缓存。
|
// 易变段(日期 + 项目/任务清单 + 按需的文档策略全文)挪到消息流末尾,不失效 system 缓存。
|
||||||
let (prefix, _, _) = system_prompt_parts(lang);
|
let (prefix, _, _) = system_prompt_parts(lang);
|
||||||
let mut stable = env_profile_stable_body();
|
let mut stable = env_profile_stable_body();
|
||||||
stable.push_str(&prefix);
|
stable.push_str(&prefix);
|
||||||
stable.push_str(&doc_exploration_strategy_section(lang));
|
stable.push_str(doc_strategy_signpost_section(lang));
|
||||||
stable.push_str(app_config_query_guidance_section(lang));
|
stable.push_str(app_config_query_guidance_section(lang));
|
||||||
stable.push_str(&build_custom_section(state).await);
|
stable.push_str(&build_custom_section(state).await);
|
||||||
|
|
||||||
let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await;
|
let lists = build_context_lists(state, excluded_project_ids, excluded_task_ids, lang).await;
|
||||||
let volatile = if lists.is_empty() {
|
let mut volatile = if lists.is_empty() {
|
||||||
env_profile_date_volatile()
|
env_profile_date_volatile()
|
||||||
} else {
|
} else {
|
||||||
format!("{}\n\n{}", env_profile_date_volatile(), lists)
|
format!("{}\n\n{}", env_profile_date_volatile(), lists)
|
||||||
};
|
};
|
||||||
|
// 文档策略全文按需附易变侧(D1):命中才带,非命中轮次省 ~12K 字符上下文。
|
||||||
|
if doc_strategy_needed(user_text) {
|
||||||
|
let full = doc_exploration_strategy_section(lang);
|
||||||
|
volatile = if volatile.is_empty() {
|
||||||
|
full
|
||||||
|
} else {
|
||||||
|
format!("{}\n\n---\n{}", volatile, full)
|
||||||
|
};
|
||||||
|
}
|
||||||
(stable, volatile)
|
(stable, volatile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,6 +1043,39 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// 渐进披露(D1)门判定:URL 三形态 + 文档关键词命中;纯闲聊不命中;ASCII 不分大小写
|
||||||
|
#[test]
|
||||||
|
fn doc_strategy_needed_gate() {
|
||||||
|
assert!(doc_strategy_needed("帮我接入 https://docs.example.com/v1/api"));
|
||||||
|
assert!(doc_strategy_needed("看下 http://example.com 这页写了啥"));
|
||||||
|
assert!(doc_strategy_needed("抓取 WWW.EXAMPLE.COM 首页")); // www. 大写命中
|
||||||
|
assert!(doc_strategy_needed("按照官方文档配置 provider")); // 「文档」子串覆盖「官方文档」
|
||||||
|
assert!(doc_strategy_needed("read the DOCS before coding")); // DOCS 大写命中
|
||||||
|
assert!(doc_strategy_needed("check the Documentation page"));
|
||||||
|
assert!(!doc_strategy_needed("把任务标题改成部署清单"));
|
||||||
|
assert!(!doc_strategy_needed("fix the failing unit test"));
|
||||||
|
assert!(!doc_strategy_needed("")); // 空 user 文本(无上下文调用方)= 不命中
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渐进披露(D1)路标段规格:≤1200 字符 + 三工具名 + 「按需附带」引导句(中/英)
|
||||||
|
#[test]
|
||||||
|
fn doc_strategy_signpost_section_shape() {
|
||||||
|
for lang in ["zh-CN", "en"] {
|
||||||
|
let s = doc_strategy_signpost_section(lang);
|
||||||
|
assert!(
|
||||||
|
s.chars().count() <= 1200,
|
||||||
|
"路标段超 1200 字符({}): {}",
|
||||||
|
lang,
|
||||||
|
s.chars().count()
|
||||||
|
);
|
||||||
|
assert!(s.contains("fetch_url"));
|
||||||
|
assert!(s.contains("fetch_search"));
|
||||||
|
assert!(s.contains("obscura"));
|
||||||
|
}
|
||||||
|
assert!(doc_strategy_signpost_section("zh-CN").contains("按需自动附带"));
|
||||||
|
assert!(doc_strategy_signpost_section("en").contains("attached on demand"));
|
||||||
|
}
|
||||||
|
|
||||||
// 改进1 系统提示聚焦段:中文版含「聚焦准则」独立段
|
// 改进1 系统提示聚焦段:中文版含「聚焦准则」独立段
|
||||||
#[test]
|
#[test]
|
||||||
fn system_prompt_zh_has_focus_section() {
|
fn system_prompt_zh_has_focus_section() {
|
||||||
@@ -921,6 +1113,32 @@ mod tests {
|
|||||||
assert!(prefix.contains("聚焦准则"));
|
assert!(prefix.contains("聚焦准则"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M4 回复风格纪律:中文版行为准则含「回复风格」条款(反口水话,不反内容)
|
||||||
|
#[test]
|
||||||
|
fn system_prompt_zh_has_response_style_clause() {
|
||||||
|
let (prefix, _, _) = system_prompt_parts("zh-CN");
|
||||||
|
assert!(
|
||||||
|
prefix.contains("回复风格"),
|
||||||
|
"中文 system prompt 应含回复风格条款,实际: {}",
|
||||||
|
prefix
|
||||||
|
);
|
||||||
|
// 核心条款抽样:结论先行 + 「省的是空话,不是内容」的度(需要解释时照常解释)
|
||||||
|
assert!(prefix.contains("结论先行"));
|
||||||
|
assert!(prefix.contains("省的是空话,不是内容"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// M4 回复风格纪律:英文版 Guidelines 含 Response style 条款
|
||||||
|
#[test]
|
||||||
|
fn system_prompt_en_has_response_style_clause() {
|
||||||
|
let (prefix, _, _) = system_prompt_parts("en");
|
||||||
|
assert!(
|
||||||
|
prefix.contains("Response style"),
|
||||||
|
"英文 system prompt 应含 Response style 条款,实际: {}",
|
||||||
|
prefix
|
||||||
|
);
|
||||||
|
assert!(prefix.contains("cut filler, not substance"));
|
||||||
|
}
|
||||||
|
|
||||||
// 文档探索策略段(中/英)存在且含关键引导
|
// 文档探索策略段(中/英)存在且含关键引导
|
||||||
#[test]
|
#[test]
|
||||||
fn doc_exploration_strategy_section_present_zh_en() {
|
fn doc_exploration_strategy_section_present_zh_en() {
|
||||||
@@ -1082,6 +1300,35 @@ mod tests {
|
|||||||
assert!(en.contains("Do **NOT**"));
|
assert!(en.contains("Do **NOT**"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1-A 跨会话历史检索指引:中/英双语在位(工具名/禁令/两跳用法/压缩原文回读路径),
|
||||||
|
// 且父会话 prefix 与子代理工具纪律段同源(工具下发与纪律下发不脱钩)。
|
||||||
|
#[test]
|
||||||
|
fn history_search_guidance_present_zh_en_and_shared_with_sub_agent() {
|
||||||
|
let zh = history_search_guidance("zh-CN");
|
||||||
|
assert!(zh.contains("history_search"));
|
||||||
|
assert!(zh.contains("跨会话历史检索"));
|
||||||
|
assert!(zh.contains("严禁用 run_command 查 sqlite"), "禁令必须在位: {zh}");
|
||||||
|
assert!(zh.contains("conversation_id"), "两跳用法必须在位: {zh}");
|
||||||
|
assert!(zh.contains("context-archive/<conversation_id>-archive.jsonl"), "压缩原文回读路径必须在位: {zh}");
|
||||||
|
|
||||||
|
let en = history_search_guidance("en");
|
||||||
|
assert!(en.contains("history_search"));
|
||||||
|
assert!(en.contains("Cross-session history search"));
|
||||||
|
assert!(en.contains("Never use run_command against the sqlite database file"));
|
||||||
|
assert!(en.contains("conversation_id"));
|
||||||
|
|
||||||
|
// 父会话 prefix(中/英)与子代理工具纪律段同源拼接
|
||||||
|
let (prefix_zh, _, _) = system_prompt_parts("zh-CN");
|
||||||
|
assert!(prefix_zh.contains(history_search_guidance("zh-CN")), "父会话行为准则须含指引");
|
||||||
|
let (prefix_en, _, _) = system_prompt_parts("en");
|
||||||
|
assert!(prefix_en.contains(history_search_guidance("en")), "EN prefix must carry the guidance");
|
||||||
|
let sub_zh = sub_agent_tool_discipline_section("zh-CN");
|
||||||
|
assert!(
|
||||||
|
sub_zh.contains(history_search_guidance("zh-CN")),
|
||||||
|
"子代理工具纪律段须与父会话同源(工具下发了纪律必须同源下发)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置),
|
// P1-1 子代理工具纪律段:与父会话同源(read_symbol/重复调用检测/URL 纪律/查配置),
|
||||||
// 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。
|
// 且裁剪掉用户交互条款(obscura 安装引导,子代理无交互面)。
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1163,4 +1410,30 @@ mod tests {
|
|||||||
assert_eq!(got120.chars().count(), 121);
|
assert_eq!(got120.chars().count(), 121);
|
||||||
assert!(got120.ends_with('…'));
|
assert!(got120.ends_with('…'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P0-B 水位阈值解析:缺失/非法/非正数归默认 0.70(对齐 parse_grace「0 视为配置错误」)
|
||||||
|
#[test]
|
||||||
|
fn watermark_ratio_defaults_on_missing_or_invalid() {
|
||||||
|
assert_eq!(parse_watermark_ratio(None), 0.70);
|
||||||
|
assert_eq!(parse_watermark_ratio(Some(String::new())), 0.70);
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("abc".to_string())), 0.70);
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("0".to_string())), 0.70, "0 视为配置错误归默认");
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("-0.5".to_string())), 0.70);
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("NaN".to_string())), 0.70);
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("inf".to_string())), 0.70);
|
||||||
|
}
|
||||||
|
|
||||||
|
// P0-B 水位阈值钳位:合法值收进 [0.3, 0.95] 防误配,界内原样采纳
|
||||||
|
#[test]
|
||||||
|
fn watermark_ratio_clamps_into_safe_range() {
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("0.1".to_string())), 0.3, "过低钳到下限");
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("0.99".to_string())), 0.95, "过高钳到上限");
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("2".to_string())), 0.95);
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("0.5".to_string())), 0.5);
|
||||||
|
assert_eq!(parse_watermark_ratio(Some("0.85".to_string())), 0.85);
|
||||||
|
assert_eq!(parse_watermark_ratio(Some(" 0.8 ".to_string())), 0.8, "容忍首尾空白");
|
||||||
|
// 常量绑定:钳位边界即红线边界(防止常量被误改无人知)
|
||||||
|
assert_eq!(MIN_WATERMARK_RATIO, 0.3);
|
||||||
|
assert_eq!(MAX_WATERMARK_RATIO, 0.95);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
//! | `stop` | `ai_chat_stop` | conversation_id? |
|
//! | `stop` | `ai_chat_stop` | conversation_id? |
|
||||||
//! | `regenerate` | `ai_regenerate` | conversation_id, language?, model_override? |
|
//! | `regenerate` | `ai_regenerate` | conversation_id, language?, model_override? |
|
||||||
//! | `approve` | `ai_approve` | tool_call_id, approved |
|
//! | `approve` | `ai_approve` | tool_call_id, approved |
|
||||||
//! | `authorize_dir` | `ai_authorize_dir` | tool_call_id, decision |
|
//! | `authorize_dir` | (已退役,path_auth 链下线) | — |
|
||||||
//! | `continue_loop` | `ai_continue_loop` | conversation_id |
|
//! | `continue_loop` | `ai_continue_loop` | conversation_id |
|
||||||
//! | `stop_loop` | `ai_stop_loop` | conversation_id |
|
//! | `stop_loop` | `ai_stop_loop` | conversation_id |
|
||||||
//! | `switch_conversation` | (无,MVP 不处理) | — |
|
//! | `switch_conversation` | (无,MVP 不处理) | — |
|
||||||
@@ -63,7 +63,7 @@ use crate::state::AppState;
|
|||||||
// 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取),
|
// 构造 State 入参传给 handle_remote_command。本模块自身不调 Manager(签名收 State 而非自取),
|
||||||
// 故 import 不含 Manager(避免 unused)。
|
// 故 import 不含 Manager(避免 unused)。
|
||||||
use crate::commands::ai::{
|
use crate::commands::ai::{
|
||||||
ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
|
ai_approve, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit,
|
||||||
ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop,
|
ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop,
|
||||||
ai_list_skills, ai_regenerate, ai_stop_loop, event_bus::emit_ai_chat_event,
|
ai_list_skills, ai_regenerate, ai_stop_loop, event_bus::emit_ai_chat_event,
|
||||||
record_to_message, AiChatEvent, ApprovalKind, ConvSummary, ModelInfo, PendingApproval,
|
record_to_message, AiChatEvent, ApprovalKind, ConvSummary, ModelInfo, PendingApproval,
|
||||||
@@ -240,22 +240,7 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── authorize_dir:ai_authorize_dir(tool_call_id, decision) ──
|
// ── authorize_dir:【已退役】path_auth 审批链下线(2026-10-25),命令静默忽略 ──
|
||||||
"authorize_dir" => {
|
|
||||||
match (
|
|
||||||
args_get_string(&command.args, "tool_call_id"),
|
|
||||||
args_get_string(&command.args, "decision"),
|
|
||||||
) {
|
|
||||||
(Some(tool_call_id), Some(decision)) => {
|
|
||||||
let _ = ai_authorize_dir(app, state, tool_call_id, decision).await;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
tracing::warn!(
|
|
||||||
"[remote_bridge] authorize_dir 缺 tool_call_id 或 decision 参数,忽略"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── continue_loop:ai_continue_loop(conversation_id) ──
|
// ── continue_loop:ai_continue_loop(conversation_id) ──
|
||||||
"continue_loop" => {
|
"continue_loop" => {
|
||||||
@@ -344,7 +329,7 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── sync_pending:重连/初始连接恢复 conv 挂起审批(device 重推审批事件) ──
|
// ── sync_pending:重连/初始连接恢复 conv 挂起审批(device 重推审批事件) ──
|
||||||
// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired,
|
// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired,
|
||||||
// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,
|
// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,
|
||||||
// device 读 AiSession.pending_approvals(按 conv 过滤)逐条重发审批事件,
|
// device 读 AiSession.pending_approvals(按 conv 过滤)逐条重发审批事件,
|
||||||
// miniapp handleEvent 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦)。
|
// miniapp handleEvent 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦)。
|
||||||
@@ -523,7 +508,7 @@ async fn route_load_messages(state: &State<'_, AppState>, conversation_id: Strin
|
|||||||
|
|
||||||
/// `sync_pending` 路由 —— 重连/初始连接时 miniapp 请求当前 conv 的挂起审批快照。
|
/// `sync_pending` 路由 —— 重连/初始连接时 miniapp 请求当前 conv 的挂起审批快照。
|
||||||
///
|
///
|
||||||
/// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired/AiDirAuthRequired 事件,
|
/// 治「断网重连审批卡丢失」:miniapp 断网期间可能错过 AiApprovalRequired 事件,
|
||||||
/// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,device 读
|
/// 或桌面端已处理致本地 pendingApprovals 陈旧。重连后 miniapp 清本地 pending + 发本命令,device 读
|
||||||
/// AiSession.pending_approvals(按 conversation_id 过滤)逐条重发审批事件,miniapp handleEvent
|
/// AiSession.pending_approvals(按 conversation_id 过滤)逐条重发审批事件,miniapp handleEvent
|
||||||
/// 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦避重连竞态)。
|
/// 重建 pendingApprovals(审批卡从 pendingApprovals 面板渲染,与 messages 解耦避重连竞态)。
|
||||||
@@ -551,6 +536,9 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
|
|||||||
|
|
||||||
for p in approvals {
|
for p in approvals {
|
||||||
let conv_id = Some(conversation_id.clone());
|
let conv_id = Some(conversation_id.clone());
|
||||||
|
// 到期时刻透传(前端倒计时单一真值=后端);created_at 缺失/禁用超时 → None 不显倒计时。
|
||||||
|
let expires_at_millis = p.created_at.and_then(|c| super::audit::approval_expires_at_millis(state.inner(), c));
|
||||||
|
// path_auth 退役(2026-10-25):单 Risk kind,直接重推 AiApprovalRequired。
|
||||||
let ev = match &p.kind {
|
let ev = match &p.kind {
|
||||||
ApprovalKind::Risk { diff } => AiChatEvent::AiApprovalRequired {
|
ApprovalKind::Risk { diff } => AiChatEvent::AiApprovalRequired {
|
||||||
id: p.tool_call_id,
|
id: p.tool_call_id,
|
||||||
@@ -559,17 +547,7 @@ async fn route_sync_pending(state: &State<'_, AppState>, conversation_id: String
|
|||||||
reason: "等待审批(重连恢复)".to_string(),
|
reason: "等待审批(重连恢复)".to_string(),
|
||||||
diff: diff.clone(),
|
diff: diff.clone(),
|
||||||
conversation_id: conv_id,
|
conversation_id: conv_id,
|
||||||
},
|
expires_at_millis,
|
||||||
ApprovalKind::Path(req) => AiChatEvent::AiDirAuthRequired {
|
|
||||||
id: p.tool_call_id,
|
|
||||||
tool: p.tool_name,
|
|
||||||
path: req.raw_paths.first().cloned().unwrap_or_default(),
|
|
||||||
dir: req
|
|
||||||
.dirs
|
|
||||||
.first()
|
|
||||||
.map(|d| d.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
conversation_id: conv_id,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let _ = state.ai_event_bus.publish_event(ev);
|
let _ = state.ai_event_bus.publish_event(ev);
|
||||||
@@ -1203,7 +1181,7 @@ fn args_get_parts(args: &Value, key: &str) -> Option<Vec<ContentPart>> {
|
|||||||
// 覆盖:
|
// 覆盖:
|
||||||
// 1. MiniCommand 反序列化(标准结构 / args 缺省 / 非 Object payload 失败)
|
// 1. MiniCommand 反序列化(标准结构 / args 缺省 / 非 Object payload 失败)
|
||||||
// 2. match 各 cmd 臂的路由分支(send_message R1 拒绝 / stop / regenerate / approve /
|
// 2. match 各 cmd 臂的路由分支(send_message R1 拒绝 / stop / regenerate / approve /
|
||||||
// authorize_dir / continue_loop / stop_loop / switch_conversation 忽略 / 未知 cmd 忽略)
|
// (authorize_dir 已退役) continue_loop / stop_loop / switch_conversation 忽略 / 未知 cmd 忽略)
|
||||||
// 3. R1 拒绝路径(generating=true 时 emit AiError 不调 ai_chat_send)
|
// 3. R1 拒绝路径(generating=true 时 emit AiError 不调 ai_chat_send)
|
||||||
//
|
//
|
||||||
// 注:完整 Tauri command 调用链(需 AppHandle + State 真实构造)不在单元测试范围,
|
// 注:完整 Tauri command 调用链(需 AppHandle + State 真实构造)不在单元测试范围,
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ struct FailureEntry {
|
|||||||
last_error: String,
|
last_error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 工具级超时连败计数(E13):治「同工具跨签名调参重试」逃逸——run_command 换 timeout
|
||||||
|
/// 档位即换 key,签名级拦截不触发,8 连败照烧轮次(434c8c40 实证)。此处按工具聚合:
|
||||||
|
/// 连续超时(错误特征匹配)≥3 次 → 该工具下次执行前注入软收敛警告(不硬拦,防误杀
|
||||||
|
/// 同工具的轻量调用),信息给足引导 LLM 换方案/问用户。该工具任一次成功即清零。
|
||||||
|
static TOOL_TIMEOUT_STREAKS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// 超时连败软收敛阈值(跨签名聚合口径)。
|
||||||
|
pub const TIMEOUT_STREAK_WARN_AFTER: u32 = 3;
|
||||||
|
|
||||||
|
fn timeout_streaks() -> &'static Mutex<HashMap<String, u32>> {
|
||||||
|
TOOL_TIMEOUT_STREAKS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误摘要是否属超时族(中英特征,大小写不敏感)。
|
||||||
|
fn is_timeout_error(error: &str) -> bool {
|
||||||
|
let lower = error.to_lowercase();
|
||||||
|
lower.contains("超时") || lower.contains("timeout") || lower.contains("timed out")
|
||||||
|
}
|
||||||
|
|
||||||
/// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。
|
/// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。
|
||||||
static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new();
|
static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new();
|
||||||
|
|
||||||
@@ -70,34 +89,50 @@ fn sort_json(v: &serde_json::Value) -> serde_json::Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行前检查:同 key 连败达标 → 拦截,返回结构化失败提示(LLM 可读换路引导)。
|
/// 执行前检查:同 key 连败达标 → 拦截;工具级超时连败达标 → 软收敛警告(不拦执行)。
|
||||||
/// 未达标/无记忆 → None 放行。
|
/// 两者都未达标/无记忆 → None 放行。
|
||||||
pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> {
|
pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> {
|
||||||
let key = memory_key(tool, args);
|
let key = memory_key(tool, args);
|
||||||
let mem = memory().lock().unwrap_or_else(|p| p.into_inner());
|
let mem = memory().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
let Some(entry) = mem.get(&key) else { return None };
|
if let Some(entry) = mem.get(&key) {
|
||||||
if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES {
|
if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES {
|
||||||
Some(format!(
|
return Some(format!(
|
||||||
"{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}",
|
"{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}",
|
||||||
entry.consecutive,
|
entry.consecutive,
|
||||||
entry.last_error.replace('\"', "'"),
|
entry.last_error.replace('\"', "'"),
|
||||||
entry.consecutive
|
entry.consecutive
|
||||||
))
|
));
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// E13 工具级超时连败(跨签名聚合):换参数/换档位逃不出工具维度。
|
||||||
|
let streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
if let Some(streak) = streaks.get(tool) {
|
||||||
|
if *streak >= TIMEOUT_STREAK_WARN_AFTER {
|
||||||
|
return Some(format!(
|
||||||
|
"{{\"status\":\"timeout_streak_warning\",\"tool\":\"{tool}\",\"timeout_streak\":{},\"hint\":\"该工具已连续超时 {} 次(不同参数/档位都超)。继续原路大概率仍超时:①把任务拆小分步执行 ②换更轻量的方式(降低数据量/改后台执行) ③若确实需要长时间运行,向用户说明并确认后再继续。\"}}",
|
||||||
|
streak, streak
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行后回写:成功清零删除,失败递增并记最近错误。
|
/// 执行后回写:成功清零删除(签名级+工具级超时计数),失败递增并记最近错误;
|
||||||
|
/// 失败属超时族时工具级超时连败 +1。
|
||||||
pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) {
|
pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) {
|
||||||
let key = memory_key(tool, args);
|
let key = memory_key(tool, args);
|
||||||
let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner());
|
let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
let mut streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
|
||||||
if ok {
|
if ok {
|
||||||
mem.remove(&key);
|
mem.remove(&key);
|
||||||
|
streaks.remove(tool);
|
||||||
} else {
|
} else {
|
||||||
let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() });
|
let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() });
|
||||||
e.consecutive += 1;
|
e.consecutive += 1;
|
||||||
e.last_error = error.unwrap_or_default().chars().take(200).collect();
|
e.last_error = error.unwrap_or_default().chars().take(200).collect();
|
||||||
|
if is_timeout_error(&e.last_error) {
|
||||||
|
*streaks.entry(tool.to_string()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +141,22 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// 进程级静态 memory() 被全部测试共享:并行运行时 A 测试的 record/clear 会清掉
|
||||||
|
/// B 测试刚记录的计数(实测 precheck_blocks 偶发 unwrap None)。全 mod 测试统一
|
||||||
|
/// 持本锁串行化,消除竞态。
|
||||||
|
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
|
||||||
fn clear() {
|
fn clear() {
|
||||||
memory().lock().unwrap().clear();
|
memory().lock().unwrap().clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serial() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn precheck_blocks_after_two_failures() {
|
fn precheck_blocks_after_two_failures() {
|
||||||
|
let _serial = serial();
|
||||||
clear();
|
clear();
|
||||||
let args = json!({"path": "/tmp/x.rs"});
|
let args = json!({"path": "/tmp/x.rs"});
|
||||||
assert!(precheck("read_file", &args).is_none());
|
assert!(precheck("read_file", &args).is_none());
|
||||||
@@ -131,6 +176,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn memory_key_normalizes_irrelevant_fields() {
|
fn memory_key_normalizes_irrelevant_fields() {
|
||||||
|
let _serial = serial();
|
||||||
let a = json!({"path": "/a.rs", "limit": 10});
|
let a = json!({"path": "/a.rs", "limit": 10});
|
||||||
let b = json!({"path": "/a.rs", "limit": 999});
|
let b = json!({"path": "/a.rs", "limit": 999});
|
||||||
assert_eq!(memory_key("read_file", &a), memory_key("read_file", &b));
|
assert_eq!(memory_key("read_file", &a), memory_key("read_file", &b));
|
||||||
@@ -142,7 +188,44 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn run_command_key_is_command_itself() {
|
fn run_command_key_is_command_itself() {
|
||||||
|
let _serial = serial();
|
||||||
let a = json!({"command": "cargo test", "working_dir": "E:/x"});
|
let a = json!({"command": "cargo test", "working_dir": "E:/x"});
|
||||||
assert!(memory_key("run_command", &a).contains("cmd=cargo test"));
|
assert!(memory_key("run_command", &a).contains("cmd=cargo test"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timeout_streak_aggregates_across_signatures_and_soft_warns() {
|
||||||
|
let _serial = serial();
|
||||||
|
clear();
|
||||||
|
timeout_streaks().lock().unwrap().clear();
|
||||||
|
// 换档位=换签名(E13 逃逸路径:超时档拼在命令里即不同 key):签名级各 1 次均不拦;
|
||||||
|
// 工具级 streak 在第 3 次超时发生后达 3,同工具再调用(含当次迭代)即软警告
|
||||||
|
for (i, timeout) in ["60", "300", "600"].iter().enumerate() {
|
||||||
|
let args = json!({"command": format!("cargo build --timeout {timeout}")});
|
||||||
|
record("run_command", &args, false, Some(format!("命令执行超时({timeout}s)")));
|
||||||
|
if i < 2 {
|
||||||
|
assert!(precheck("run_command", &args).is_none(), "签名级 1 次且 streak<3 不拦");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 新签名(第 4 档):工具级超时连败 ≥3 → 软收敛警告(放行执行但注入换路引导)
|
||||||
|
let args4 = json!({"command": "cargo build --timeout 900"});
|
||||||
|
let warn = precheck("run_command", &args4).unwrap();
|
||||||
|
assert!(warn.contains("timeout_streak_warning"));
|
||||||
|
assert!(warn.contains("\"timeout_streak\":3"));
|
||||||
|
// 同工具任一成功 → 工具级清零
|
||||||
|
record("run_command", &json!({"command": "ls"}), true, None);
|
||||||
|
assert!(precheck("run_command", &args4).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_timeout_failures_do_not_aggregate_to_timeout_streak() {
|
||||||
|
let _serial = serial();
|
||||||
|
clear();
|
||||||
|
timeout_streaks().lock().unwrap().clear();
|
||||||
|
for path in ["/a.rs", "/b.rs", "/c.rs"] {
|
||||||
|
record("read_file", &json!({"path": path}), false, Some("文件不存在".into()));
|
||||||
|
}
|
||||||
|
// 3 连败但非超时族 → 无工具级警告
|
||||||
|
assert!(precheck("read_file", &json!({"path": "/d.rs"})).is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> {
|
|||||||
anyhow::bail!("禁止访问敏感系统目录");
|
anyhow::bail!("禁止访问敏感系统目录");
|
||||||
}
|
}
|
||||||
// AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
|
// AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
|
||||||
// AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog
|
// AppData 原走白名单流程(已随 path_auth 白名单机制退役,2026-10-25,现为默认放行)。
|
||||||
// → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
|
// 真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
|
||||||
// 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治——
|
// 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治——
|
||||||
// 用户授权 = 用户同意,AI 不越权也不一刀切挡用户数据。
|
// 用户授权 = 用户同意,AI 不越权也不一刀切挡用户数据。
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -559,9 +559,20 @@ pub fn build_ai_tool_registry(
|
|||||||
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
|
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
|
||||||
register_uclaw_tool(&mut registry, db);
|
register_uclaw_tool(&mut registry, db);
|
||||||
super::lease::register_lease_tool(&mut registry);
|
super::lease::register_lease_tool(&mut registry);
|
||||||
|
register_history_tool(&mut registry, db);
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// history_search AI 工具注册(1 个:跨会话历史检索,P1-A,u-claw #5/#6 等价)。
|
||||||
|
///
|
||||||
|
/// 工具职责:LLM 调 history_search(query, conversation_id?, limit?) 检索全部历史会话
|
||||||
|
/// 消息正文(含已压缩原文,ai_messages=全量账本),两跳式瘦身(首跳每会话一条 120 字
|
||||||
|
/// 片段,深查二跳传 conversation_id);query 空降级列最近 10 会话。风险 Low(纯只读)。
|
||||||
|
/// 查询内核与 IPC search_messages 同源(commands/search.rs),声明式注册在 tools/history.rs。
|
||||||
|
fn register_history_tool(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
|
super::tools::history::register(registry, db);
|
||||||
|
}
|
||||||
|
|
||||||
/// ask_uclaw AI 工具注册(1 个:委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议)。
|
/// ask_uclaw AI 工具注册(1 个:委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议)。
|
||||||
///
|
///
|
||||||
/// 工具职责:LLM 调 ask_uclaw(prompt) 把任务下发给 u-claw(channel 级单链粘性会话),
|
/// 工具职责:LLM 调 ask_uclaw(prompt) 把任务下发给 u-claw(channel 级单链粘性会话),
|
||||||
@@ -609,7 +620,7 @@ fn register_read_skill_tool(registry: &mut AiToolRegistry) {
|
|||||||
/// tool_registry 拆分:http_request 迁声明式(tools/http.rs),handler body 逐字照搬原
|
/// tool_registry 拆分:http_request 迁声明式(tools/http.rs),handler body 逐字照搬原
|
||||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||||
/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||||
///
|
///
|
||||||
/// 2026-08-01 output_file 参数:响应可落盘,路径走文件路径校验,故透传 allowed_dirs 给
|
/// 2026-08-01 output_file 参数:响应可落盘,路径走文件路径校验,故透传 allowed_dirs 给
|
||||||
/// tools::http::register(由其闭包捕获,handler 内 read lock clone 传 execute_http_request)。
|
/// tools::http::register(由其闭包捕获,handler 内 read lock clone 传 execute_http_request)。
|
||||||
@@ -750,7 +761,7 @@ fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
/// tool_registry 拆分:8 个项目工具全部迁声明式(tools/list_projects.rs 试点 +
|
/// tool_registry 拆分:8 个项目工具全部迁声明式(tools/list_projects.rs 试点 +
|
||||||
/// tools/project.rs 剩余 7 个),handler body 逐字照搬原 async move 块(逻辑零变更),
|
/// tools/project.rs 剩余 7 个),handler body 逐字照搬原 async move 块(逻辑零变更),
|
||||||
/// 仅闭包包装改由 `declare_tool!` 宏生成。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
/// 仅闭包包装改由 `declare_tool!` 宏生成。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||||
fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
super::tools::list_projects::register(registry, db);
|
super::tools::list_projects::register(registry, db);
|
||||||
super::tools::project::register(registry, db);
|
super::tools::project::register(registry, db);
|
||||||
@@ -762,7 +773,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
///
|
///
|
||||||
/// tool_registry 拆分:6 个任务工具全部迁声明式(tools/task.rs),
|
/// tool_registry 拆分:6 个任务工具全部迁声明式(tools/task.rs),
|
||||||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||||||
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。
|
||||||
fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
super::tools::task::register(registry, db);
|
super::tools::task::register(registry, db);
|
||||||
}
|
}
|
||||||
@@ -783,7 +794,7 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
/// tool_registry 拆分:10 个知识图谱/工程系统工具全部迁声明式(tools/task_graph.rs),
|
/// tool_registry 拆分:10 个知识图谱/工程系统工具全部迁声明式(tools/task_graph.rs),
|
||||||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||||||
/// 本函数改 thin 委托 super::tools::task_graph::register(...)。基线测试
|
/// 本函数改 thin 委托 super::tools::task_graph::register(...)。基线测试
|
||||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。
|
||||||
fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
super::tools::task_graph::register(registry, db);
|
super::tools::task_graph::register(registry, db);
|
||||||
}
|
}
|
||||||
@@ -797,7 +808,7 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
|||||||
/// (exec_git/run_git_status/run_git_diff/run_git_log)原样搬入该文件(仅 git 工具用,不暴露)。
|
/// (exec_git/run_git_status/run_git_diff/run_git_log)原样搬入该文件(仅 git 工具用,不暴露)。
|
||||||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||||||
/// 本函数改 thin 委托 super::tools::git::register(...)。基线测试
|
/// 本函数改 thin 委托 super::tools::git::register(...)。基线测试
|
||||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。
|
||||||
fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
super::tools::git::register(registry, db);
|
super::tools::git::register(registry, db);
|
||||||
}
|
}
|
||||||
@@ -808,7 +819,7 @@ fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
/// tool_registry 拆分:run_workflow 迁声明式(tools/workflow.rs),handler body 逐字照搬原
|
/// tool_registry 拆分:run_workflow 迁声明式(tools/workflow.rs),handler body 逐字照搬原
|
||||||
/// async move 块(逻辑零变更,仅返 Err 防御兜底),仅闭包包装改由 `declare_tool!` 宏生成。
|
/// async move 块(逻辑零变更,仅返 Err 防御兜底),仅闭包包装改由 `declare_tool!` 宏生成。
|
||||||
/// 本函数改 thin 委托 super::tools::workflow::register(...)。基线测试
|
/// 本函数改 thin 委托 super::tools::workflow::register(...)。基线测试
|
||||||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 81 总量 + 工具名集合稳定。
|
||||||
fn register_workflow_tools(registry: &mut AiToolRegistry) {
|
fn register_workflow_tools(registry: &mut AiToolRegistry) {
|
||||||
super::tools::workflow::register(registry);
|
super::tools::workflow::register(registry);
|
||||||
}
|
}
|
||||||
@@ -819,7 +830,7 @@ fn register_workflow_tools(registry: &mut AiToolRegistry) {
|
|||||||
/// tool_registry 拆分:2 个灵感工具全部迁声明式(tools/idea.rs),handler body 逐字照搬原
|
/// tool_registry 拆分:2 个灵感工具全部迁声明式(tools/idea.rs),handler body 逐字照搬原
|
||||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||||
/// super::tools::idea::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
/// super::tools::idea::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||||
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
super::tools::idea::register(registry, db);
|
super::tools::idea::register(registry, db);
|
||||||
}
|
}
|
||||||
@@ -845,7 +856,7 @@ fn register_knowledge_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
/// tool_registry 拆分:list_trash 迁声明式(tools/trash.rs),handler body 逐字照搬原
|
/// tool_registry 拆分:list_trash 迁声明式(tools/trash.rs),handler body 逐字照搬原
|
||||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||||
/// super::tools::trash::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
/// super::tools::trash::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||||
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
super::tools::trash::register(registry, db);
|
super::tools::trash::register(registry, db);
|
||||||
}
|
}
|
||||||
@@ -860,7 +871,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
/// tool_registry 拆分:13 个文件工具全部迁声明式(tools/file.rs),handler body 逐字照搬原
|
/// tool_registry 拆分:13 个文件工具全部迁声明式(tools/file.rs),handler body 逐字照搬原
|
||||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||||
/// super::tools::file::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
/// super::tools::file::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
/// 仍断言 81 总量 + 工具名集合稳定。
|
||||||
fn register_file_tools(
|
fn register_file_tools(
|
||||||
registry: &mut AiToolRegistry,
|
registry: &mut AiToolRegistry,
|
||||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||||
@@ -1516,10 +1527,14 @@ mod tests {
|
|||||||
// W-h1 FileLeaseRegistry 兄弟感知入口,写类文件工具跨会话互斥的可视面,
|
// W-h1 FileLeaseRegistry 兄弟感知入口,写类文件工具跨会话互斥的可视面,
|
||||||
// 注册在 lease.rs register_lease_tool)。
|
// 注册在 lease.rs register_lease_tool)。
|
||||||
// 80 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease。
|
// 80 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease。
|
||||||
|
// history_search(2026-10-21): history 层 0→1(P1-A 跨会话历史检索,只读 Low,
|
||||||
|
// u-claw #5/#6 等价:关键词检索全部会话消息正文+无参列最近会话,两跳式瘦身,
|
||||||
|
// 查询内核与 IPC search_messages 同源,声明式注册 tools/history.rs)。
|
||||||
|
// 81 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease + 1 history。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.len(),
|
registry.len(),
|
||||||
80,
|
81,
|
||||||
"工具总数应为 80(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease),实际 {}", registry.len()
|
"工具总数应为 81(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease + 1 history),实际 {}", registry.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||||
@@ -1595,6 +1610,9 @@ mod tests {
|
|||||||
"ask_uclaw",
|
"ask_uclaw",
|
||||||
// ── lease 层 (1) ──(W-h2 文件租约查询,只读 Low,注册在 lease.rs)
|
// ── lease 层 (1) ──(W-h2 文件租约查询,只读 Low,注册在 lease.rs)
|
||||||
"query_leases",
|
"query_leases",
|
||||||
|
// ── history 层 (1) ──(P1-A 跨会话历史检索,只读 Low,u-claw #5/#6 等价,
|
||||||
|
// 注册在 tools/history.rs)
|
||||||
|
"history_search",
|
||||||
];
|
];
|
||||||
expected.sort_unstable();
|
expected.sort_unstable();
|
||||||
|
|
||||||
|
|||||||
@@ -1002,7 +1002,7 @@ pub fn register(
|
|||||||
registry,
|
registry,
|
||||||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||||
"grep",
|
"grep",
|
||||||
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行",
|
||||||
RiskLevel::Low,
|
RiskLevel::Low,
|
||||||
meta: ToolMeta {
|
meta: ToolMeta {
|
||||||
path_args: &["path"],
|
path_args: &["path"],
|
||||||
@@ -1098,7 +1098,7 @@ pub fn register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// output_mode 分派返回结构
|
// output_mode 分派返回结构
|
||||||
let result = match output_mode {
|
let mut result = match output_mode {
|
||||||
"files_with_matches" => {
|
"files_with_matches" => {
|
||||||
// 仅返命中文件路径列表(去重,顺序保留首次命中)
|
// 仅返命中文件路径列表(去重,顺序保留首次命中)
|
||||||
let files: Vec<String> = matches_out.iter()
|
let files: Vec<String> = matches_out.iter()
|
||||||
@@ -1183,6 +1183,11 @@ pub fn register(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// 0 命中「阴性存疑」提示:路径/glob 写法/大小写任一不对都静默 0 命中,
|
||||||
|
// LLM 拿空结果易直接当「不存在」下结论(实测假阴性被当事实断言 3 轮),强制附一行复核提示。
|
||||||
|
if total == 0 {
|
||||||
|
result["hint"] = serde_json::json!(negative_hit_hint("路径/glob 写法/大小写 -i"));
|
||||||
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1228,10 +1233,13 @@ pub fn register(
|
|||||||
let mut out = serde_json::json!({
|
let mut out = serde_json::json!({
|
||||||
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
|
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
|
||||||
});
|
});
|
||||||
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
|
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)+ 阴性存疑提示(0 命中≠不存在)。
|
||||||
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
|
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
|
out["hint"] = serde_json::json!(format!(
|
||||||
|
"{}\n未找到匹配文件。可尝试: ① 补 recursive=true 搜子目录(不传只搜当前层); ② 放宽关键词(更短/字符更少); ③ 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配",
|
||||||
|
negative_hit_hint("路径/recursive")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -1265,6 +1273,15 @@ pub fn register(
|
|||||||
let command = args["command"].as_str()
|
let command = args["command"].as_str()
|
||||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
|
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
// 执行前最后闸(2026-10-25 走查补位):all 档/信任豁免下唯一保留硬线,与 approval/boundary 判定同源。
|
||||||
|
// 审批链是前置闸(会话信任 check_trust_hits/boundary all 档都已各自拦截破坏性命令),
|
||||||
|
// 但兜底放行路径(如未来新增 auto 通道)若漏拦,此处是命令真正 spawn 前的最后防线。
|
||||||
|
if crate::commands::ai::is_destructive_command(&command) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"破坏性命令已拦截(执行层硬线):`{}` 属不可逆破坏类操作,拒绝执行。请改用安全方案",
|
||||||
|
command
|
||||||
|
));
|
||||||
|
}
|
||||||
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
|
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
|
||||||
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
|
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
|
||||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||||
@@ -1675,6 +1692,16 @@ fn file_not_found_error(path: &str) -> anyhow::Error {
|
|||||||
anyhow::anyhow!(msg)
|
anyhow::anyhow!(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 搜索工具(search_files/grep)0 命中「阴性存疑」提示。
|
||||||
|
///
|
||||||
|
/// 治勘察假阴性被当事实断言:search_files 未传 recursive 只搜根目录,
|
||||||
|
/// 0 命中被 LLM 直接当「功能未实施」连续断言 3 轮。路径/glob/recursive 等范围
|
||||||
|
/// 参数写错时同样静默 0 命中,故空结果强制附本提示让 LLM 复核后再下结论。
|
||||||
|
/// 措辞克制一行;`scope` 按工具传入各自的复核点。
|
||||||
|
fn negative_hit_hint(scope: &str) -> String {
|
||||||
|
format!("⚠ 0 命中不一定代表不存在——请复核搜索范围({scope})后再下结论")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2003,6 +2030,23 @@ mod tests {
|
|||||||
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
|
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
|
||||||
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
|
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// negative_hit_hint — 搜索工具 0 命中阴性存疑提示(治假阴性被当事实断言)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 两搜索工具 0 命中提示共用句式:声明「0 命中≠不存在」+ 按工具各自的复核点。
|
||||||
|
#[test]
|
||||||
|
fn negative_hit_hint_states_doubt_and_scope() {
|
||||||
|
// grep 侧复核点:路径/glob 写法/大小写
|
||||||
|
let grep_hint = negative_hit_hint("路径/glob 写法/大小写 -i");
|
||||||
|
assert!(grep_hint.contains("0 命中不一定代表不存在"), "应声明阴性存疑,实际: {grep_hint}");
|
||||||
|
assert!(grep_hint.contains("复核搜索范围"), "应引导复核,实际: {grep_hint}");
|
||||||
|
assert!(grep_hint.contains("glob"), "grep 侧应含 glob 复核点,实际: {grep_hint}");
|
||||||
|
// search_files 侧复核点:recursive(实测根因是未传 recursive 只搜根目录)
|
||||||
|
let search_hint = negative_hit_hint("路径/recursive");
|
||||||
|
assert!(search_hint.contains("recursive"), "search_files 侧应含 recursive 复核点,实际: {search_hint}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
//! history_search AI 工具声明式注册(跨会话历史检索,devflow 等价 u-claw #5/#6)。
|
||||||
|
//!
|
||||||
|
//! 背景:P1-A 跨会话检索(分析:temp/分析-P1A-跨会话检索-2026-10-21.md)。ai_messages
|
||||||
|
//! 是全量账本(compressed 消息原文完整保留),检索面覆盖即全覆盖,零迁移零回填。
|
||||||
|
//! 查询内核与 IPC search_messages 同源(commands/search.rs::keyword_search /
|
||||||
|
//! recent_conversation_briefs),本工具是 AI 通道的薄包装(DRY 无双轨)。
|
||||||
|
//!
|
||||||
|
//! ## 两跳式瘦身(拍板②)
|
||||||
|
//! 首跳(不带 conversation_id)= 每会话一条 120 字片段 + 会话元信息的会话级概览;
|
||||||
|
//! 二跳传 conversation_id+query 深查该会话逐条命中(片段 300 字)。
|
||||||
|
//! 预算纪律:工具结果整体对齐 2K 收敛(namespace_store.rs:32 NAMESPACE_BYTE_THRESHOLD=
|
||||||
|
//! 2048,超限由 namespace 引用化机制兜底转引用);首跳默认 5 会话 × 120 字天然在预算内,
|
||||||
|
//! 深跳若超预算走 namespace 引用化属机制设计路径,不做人为硬截。
|
||||||
|
//!
|
||||||
|
//! ## 无参降级(拍板③)
|
||||||
|
//! query 空 = 列最近 10 会话概览(标题+摘要+时间,一工具两语义,u-claw #5 同行为);
|
||||||
|
//! 摘要走降级链(拍板⑤):最近一条 system 压缩摘要 > 首条 user 消息,截 200 字。
|
||||||
|
//!
|
||||||
|
//! 风险:Low(纯只读),免审批,knowledge_search 同级。
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||||
|
use df_ai::declare_tool;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
|
||||||
|
use crate::commands::ai::audit::truncate_chars_ellipsis;
|
||||||
|
use crate::commands::ai::commands::search::{keyword_search, recent_conversation_briefs};
|
||||||
|
use crate::commands::ai::knowledge_inject::extract_query_keywords;
|
||||||
|
|
||||||
|
/// 首跳单会话片段长度(会话级概览,两跳式瘦身核心参数)。
|
||||||
|
const FIRST_HOP_SNIPPET_CHARS: usize = 120;
|
||||||
|
/// 深跳逐条命中片段长度(单跳备选方案 limit5×300 字口径)。
|
||||||
|
const DEEP_HOP_SNIPPET_CHARS: usize = 300;
|
||||||
|
/// 无参降级列最近会话条数(u-claw #5 口径)。
|
||||||
|
const RECENT_CONVS_COUNT: usize = 10;
|
||||||
|
/// 工具 limit 默认与上限(与 knowledge_search 同手感)。
|
||||||
|
const TOOL_DEFAULT_LIMIT: usize = 5;
|
||||||
|
const TOOL_MAX_LIMIT: usize = 10;
|
||||||
|
/// 首跳为凑满 limit 个会话多取的消息数(单会话多命中会挤占名额,6 倍起步封顶 60)。
|
||||||
|
const FIRST_HOP_FETCH_CAP: usize = 60;
|
||||||
|
|
||||||
|
/// 命中会话压缩原文回读指引(存档路径约定,与 archive.rs 召回句柄同款文案)。
|
||||||
|
const ARCHIVE_RECALL_HINT: &str =
|
||||||
|
"被压缩的早期原文可按存档路径回读:应用数据目录 context-archive/<conversation_id>-archive.jsonl(read_file)。";
|
||||||
|
|
||||||
|
/// 注册 history_search 工具。
|
||||||
|
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
|
declare_tool!(
|
||||||
|
registry,
|
||||||
|
db: Arc<Database>,
|
||||||
|
"history_search",
|
||||||
|
"跨会话历史检索:在全部历史会话的消息正文(user/assistant/system,含已压缩原文)中按关键词检索。使用时机:用户提到「上次/之前/历史」的任务、结论或做法时调用,如「上次那个部署怎么弄的」「之前说过的 XX 约定」。两跳式:首跳不带 conversation_id,返回每会话一条命中片段(会话级概览,含 conversation_id);需要某会话更多上下文时二跳传 conversation_id 深查逐条命中。参数:query=关键词或自然语言(可省略,省略时降级列最近会话概览:标题+摘要+时间);conversation_id=限定会话深查(可选,须与 query 同传);limit=返回会话/消息条数(可选,默认 5,上限 10)",
|
||||||
|
RiskLevel::Low,
|
||||||
|
schema: object_schema(vec![
|
||||||
|
("query", "string", false),
|
||||||
|
("conversation_id", "string", false),
|
||||||
|
("limit", "integer", false),
|
||||||
|
]),
|
||||||
|
args => {
|
||||||
|
let query = args["query"].as_str().unwrap_or("").trim().to_string();
|
||||||
|
let conversation_id = args["conversation_id"]
|
||||||
|
.as_str()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let limit = args["limit"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap_or(TOOL_DEFAULT_LIMIT as u64)
|
||||||
|
.clamp(1, TOOL_MAX_LIMIT as u64) as usize;
|
||||||
|
|
||||||
|
// 无参降级(拍板③):query 空 → 列最近会话概览。conversation_id 与空 query
|
||||||
|
// 同传语义不明,显式报错防 LLM 猜。
|
||||||
|
if query.is_empty() {
|
||||||
|
if conversation_id.is_some() {
|
||||||
|
anyhow::bail!("conversation_id 深查须与 query 同传;列最近会话请省略 query");
|
||||||
|
}
|
||||||
|
let briefs = recent_conversation_briefs(&db, RECENT_CONVS_COUNT)
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"mode": "recent_conversations",
|
||||||
|
"conversations": briefs,
|
||||||
|
"hint": "以上为最近会话概览(标题+摘要+时间)。按关键词检索全部历史消息请传 query;深查某会话传 conversation_id+query。",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
match conversation_id {
|
||||||
|
Some(conv_id) => {
|
||||||
|
// 二跳深查:会话内逐条命中(片段 300 字,超 2K 由 namespace 引用化兜底)
|
||||||
|
let hits = keyword_search(&db, &query, Some(&conv_id), limit, 0)
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
if hits.is_empty() {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"mode": "messages",
|
||||||
|
"conversation_id": conv_id,
|
||||||
|
"results": [],
|
||||||
|
"hint": "该会话内无命中。可换关键词重试,或去掉 conversation_id 全局检索定位会话。",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let title = hits[0].title.clone();
|
||||||
|
let results: Vec<serde_json::Value> = hits
|
||||||
|
.into_iter()
|
||||||
|
.map(|h| {
|
||||||
|
serde_json::json!({
|
||||||
|
"seq": h.seq,
|
||||||
|
"role": h.role,
|
||||||
|
"timestamp": h.timestamp,
|
||||||
|
"snippet": truncate_chars_ellipsis(&h.snippet, DEEP_HOP_SNIPPET_CHARS),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"mode": "messages",
|
||||||
|
"conversation_id": conv_id,
|
||||||
|
"title": title,
|
||||||
|
"results": results,
|
||||||
|
"hint": ARCHIVE_RECALL_HINT,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// 首跳:每会话一条首命中片段的会话级概览(拍板②两跳式瘦身)。
|
||||||
|
// 命中已按 timestamp DESC,保序按会话去重即「每会话最新一条」。
|
||||||
|
let fetch = (limit * 6).clamp(TOOL_DEFAULT_LIMIT * 6, FIRST_HOP_FETCH_CAP);
|
||||||
|
let hits = keyword_search(&db, &query, None, fetch, 0)
|
||||||
|
.await
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
if hits.is_empty() {
|
||||||
|
// 分词后无有效关键词与「真无命中」分开给指引(前者换词,后者换法)
|
||||||
|
let hint = if extract_query_keywords(&query).is_empty() {
|
||||||
|
"query 分词后无有效关键词(全是停用词/标点),请换更具体的关键词(任务名/文件名/技术词)。"
|
||||||
|
} else {
|
||||||
|
"历史会话中无命中。可换关键词重试;或省略 query 列最近会话概览。"
|
||||||
|
};
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"mode": "conversations",
|
||||||
|
"results": [],
|
||||||
|
"hint": hint,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let mut seen: HashSet<String> = HashSet::new();
|
||||||
|
let results: Vec<serde_json::Value> = hits
|
||||||
|
.into_iter()
|
||||||
|
.filter(|h| seen.insert(h.conversation_id.clone()))
|
||||||
|
.take(limit)
|
||||||
|
.map(|h| {
|
||||||
|
serde_json::json!({
|
||||||
|
"conversation_id": h.conversation_id,
|
||||||
|
"title": h.title,
|
||||||
|
"updated_at": h.updated_at,
|
||||||
|
"origin": h.origin,
|
||||||
|
"snippet": truncate_chars_ellipsis(&h.snippet, FIRST_HOP_SNIPPET_CHARS),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"mode": "conversations",
|
||||||
|
"results": results,
|
||||||
|
"hint": format!("以上为每会话一条首命中片段(会话级概览)。需某会话更多上下文时传 conversation_id+query 深查逐条命中;{ARCHIVE_RECALL_HINT}"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use df_storage::crud::{AiConversationRepo, AiMessageRepo};
|
||||||
|
use df_storage::models::{AiConversationRecord, AiMessageRecord};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
async fn test_db() -> Arc<Database> {
|
||||||
|
Arc::new(Database::open_in_memory().await.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ins_conv(db: &Database, id: &str, title: &str, updated_at: &str) {
|
||||||
|
AiConversationRepo::new(db)
|
||||||
|
.insert(AiConversationRecord {
|
||||||
|
id: id.into(),
|
||||||
|
title: Some(title.into()),
|
||||||
|
messages: "[]".into(),
|
||||||
|
provider_id: None,
|
||||||
|
model: None,
|
||||||
|
models: None,
|
||||||
|
archived: false,
|
||||||
|
pinned: false,
|
||||||
|
prompt_tokens: None,
|
||||||
|
completion_tokens: None,
|
||||||
|
pinned_goals: None,
|
||||||
|
pending_approvals: None,
|
||||||
|
created_at: "100".into(),
|
||||||
|
updated_at: updated_at.into(),
|
||||||
|
origin: None,
|
||||||
|
boundary_json: None,
|
||||||
|
loop_state: None,
|
||||||
|
status: "active".into(),
|
||||||
|
working_context_json: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn msg(conv: &str, seq: i64, role: &str, content: &str, timestamp: i64) -> AiMessageRecord {
|
||||||
|
AiMessageRecord {
|
||||||
|
id: format!("m_{conv}_{seq}"),
|
||||||
|
conversation_id: conv.into(),
|
||||||
|
seq,
|
||||||
|
role: role.into(),
|
||||||
|
content: content.into(),
|
||||||
|
parts: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
tool_calls: None,
|
||||||
|
model: None,
|
||||||
|
status: "active".into(),
|
||||||
|
reasoning_content: None,
|
||||||
|
timestamp: Some(timestamp),
|
||||||
|
created_at: "100".into(),
|
||||||
|
prompt_tokens: None,
|
||||||
|
completion_tokens: None,
|
||||||
|
prompt_cache_hit_tokens: None,
|
||||||
|
prompt_cache_miss_tokens: None,
|
||||||
|
reasoning_tokens: None,
|
||||||
|
is_estimated: None,
|
||||||
|
source: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首跳瘦身(拍板②):单会话多命中只出一条(会话级去重),片段截 120 字,
|
||||||
|
/// 带 conversation_id 供二跳深查;空结果给指引非裸空数组。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_hop_groups_per_conversation_with_slim_snippet() {
|
||||||
|
let db = test_db().await;
|
||||||
|
ins_conv(&db, "c1", "部署踩坑复盘", "200").await;
|
||||||
|
ins_conv(&db, "c2", "端口约定", "300").await;
|
||||||
|
AiMessageRepo::new(&db)
|
||||||
|
.insert_batch(vec![
|
||||||
|
msg("c1", 0, "user", "部署第一次尝试失败", 100),
|
||||||
|
msg("c1", 1, "assistant", "部署第二次改用代理成功", 200),
|
||||||
|
msg("c2", 0, "user", "部署统一走 3307 端口", 300),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut registry = AiToolRegistry::new();
|
||||||
|
register(&mut registry, &db);
|
||||||
|
let out = registry
|
||||||
|
.execute("history_search", json!({"query": "部署", "limit": 5}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out["mode"], "conversations");
|
||||||
|
let results = out["results"].as_array().unwrap();
|
||||||
|
assert_eq!(results.len(), 2, "两会话各一条,单会话多命中去重: {out}");
|
||||||
|
let ids: Vec<&str> = results.iter().map(|r| r["conversation_id"].as_str().unwrap()).collect();
|
||||||
|
assert_eq!(ids, vec!["c2", "c1"], "timestamp DESC,c2 最新在前");
|
||||||
|
for r in results {
|
||||||
|
let snippet = r["snippet"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
snippet.chars().count() <= FIRST_HOP_SNIPPET_CHARS + 1,
|
||||||
|
"首跳片段应截 120 字(+省略号),实际 {}: {snippet}",
|
||||||
|
snippet.chars().count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let hint = out["hint"].as_str().unwrap();
|
||||||
|
assert!(hint.contains("conversation_id"), "首跳应提示可二跳深查: {hint}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 二跳深查:conversation_id+query 返回该会话逐条命中(mode=messages)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deep_hop_returns_message_level_hits() {
|
||||||
|
let db = test_db().await;
|
||||||
|
ins_conv(&db, "c1", "部署复盘", "200").await;
|
||||||
|
AiMessageRepo::new(&db)
|
||||||
|
.insert_batch(vec![
|
||||||
|
msg("c1", 0, "user", "部署怎么弄", 100),
|
||||||
|
msg("c1", 1, "assistant", "部署分三步:构建/上传/重启", 200),
|
||||||
|
msg("c1", 2, "tool", "部署工具输出", 300),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut registry = AiToolRegistry::new();
|
||||||
|
register(&mut registry, &db);
|
||||||
|
let out = registry
|
||||||
|
.execute(
|
||||||
|
"history_search",
|
||||||
|
json!({"query": "部署", "conversation_id": "c1"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out["mode"], "messages");
|
||||||
|
assert_eq!(out["title"], "部署复盘");
|
||||||
|
let results = out["results"].as_array().unwrap();
|
||||||
|
assert_eq!(results.len(), 2, "深跳逐条命中但 tool role 仍排除: {out}");
|
||||||
|
assert_eq!(results[0]["seq"], 1, "timestamp DESC,assistant 在前");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无参降级(拍板③+⑤):query 空列最近会话,摘要走「system 压缩摘要 > 首条 user」
|
||||||
|
/// 降级链;conversation_id 与空 query 同传显式报错。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_param_falls_back_to_recent_conversations_with_summary_chain() {
|
||||||
|
let db = test_db().await;
|
||||||
|
ins_conv(&db, "c_sum", "有摘要", "200").await;
|
||||||
|
ins_conv(&db, "c_user", "无摘要", "300").await;
|
||||||
|
AiMessageRepo::new(&db)
|
||||||
|
.insert_batch(vec![
|
||||||
|
msg("c_sum", 0, "user", "c_sum 的用户消息不应入选", 1),
|
||||||
|
msg("c_sum", 1, "system", "## 意图\n- c_sum 压缩摘要优先", 2),
|
||||||
|
msg("c_user", 0, "user", "c_user 首条消息作摘要兜底", 3),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut registry = AiToolRegistry::new();
|
||||||
|
register(&mut registry, &db);
|
||||||
|
let out = registry.execute("history_search", json!({})).await.unwrap();
|
||||||
|
assert_eq!(out["mode"], "recent_conversations");
|
||||||
|
let convs = out["conversations"].as_array().unwrap();
|
||||||
|
assert_eq!(convs.len(), 2, "c_user(300)在前 c_sum(200)在后: {out}");
|
||||||
|
let c_sum = convs.iter().find(|c| c["id"] == "c_sum").unwrap();
|
||||||
|
assert!(c_sum["summary"].as_str().unwrap().contains("压缩摘要优先"), "摘要走 system 优先: {out}");
|
||||||
|
let c_user = convs.iter().find(|c| c["id"] == "c_user").unwrap();
|
||||||
|
assert!(c_user["summary"].as_str().unwrap().contains("首条消息"), "无 system 走首条 user 兜底: {out}");
|
||||||
|
|
||||||
|
// conversation_id 与空 query 同传 → 明确报错,不静默当列表用
|
||||||
|
let err = registry
|
||||||
|
.execute("history_search", json!({"conversation_id": "c1"}))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("query"), "报错应指引带 query: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 空命中给可执行指引(非裸空数组);分词无有效关键词与真无命中分开指引。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_results_carry_actionable_hint() {
|
||||||
|
let db = test_db().await;
|
||||||
|
ins_conv(&db, "c1", "无关会话", "200").await;
|
||||||
|
AiMessageRepo::new(&db)
|
||||||
|
.insert_batch(vec![msg("c1", 0, "user", "完全无关的内容", 1)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut registry = AiToolRegistry::new();
|
||||||
|
register(&mut registry, &db);
|
||||||
|
// 真无命中
|
||||||
|
let out = registry
|
||||||
|
.execute("history_search", json!({"query": "量子纠缠实验"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(out["results"].as_array().unwrap().len(), 0);
|
||||||
|
assert!(out["hint"].as_str().unwrap().contains("换关键词"), "真无命中指引: {out}");
|
||||||
|
// 分词后无有效关键词(纯停用词)
|
||||||
|
let out2 = registry
|
||||||
|
.execute("history_search", json!({"query": "帮我看看一下这个"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(out2["hint"].as_str().unwrap().contains("关键词"), "分词空指引: {out2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|k| {
|
.map(|k| {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
|
"id": k.id,
|
||||||
"title": k.title,
|
"title": k.title,
|
||||||
"kind": k.kind,
|
"kind": k.kind,
|
||||||
"content": truncate_chars_ellipsis(&k.content, SEARCH_CONTENT_SNIPPET_LEN),
|
"content": truncate_chars_ellipsis(&k.content, SEARCH_CONTENT_SNIPPET_LEN),
|
||||||
@@ -100,22 +101,46 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(serde_json::json!({ "results": results }))
|
// 图召回·激活扩散(阶段2,2026-10-26):命中集沿边扩一跳,把「连着的知识」带出
|
||||||
|
// 检索面——排除已命中与 archived,按边权重降序限 5 条;related 不占 limit 配额,
|
||||||
|
// 空数组=无关联(语义清晰,LLM 可忽略)。
|
||||||
|
let seed_ids: Vec<String> = hits.iter().map(|k| k.id.clone()).collect();
|
||||||
|
let related: Vec<serde_json::Value> = repo
|
||||||
|
.list_neighbors_for_seeds(&seed_ids, 5)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.map(|n| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": n.knowledge.id,
|
||||||
|
"title": n.knowledge.title,
|
||||||
|
"kind": n.knowledge.kind,
|
||||||
|
"edge_kind": n.edge.kind,
|
||||||
|
"direction": n.direction,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(serde_json::json!({ "results": results, "related": related }))
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// 沉淀工具:用户说「记住/记一下/以后注意」时把要点落为候选知识
|
// 沉淀工具:用户说「记住/记一下/以后注意」时把要点落为候选知识。
|
||||||
|
// P0 写入防重(2026-10-23):工具无「再问一次 LLM」的查重机会,防重靠描述引导
|
||||||
|
// (先 search 后写) + update_target_id 融合口径——agent 检索发现重复时传已有条目
|
||||||
|
// id 走融合更新,禁同义新增。
|
||||||
declare_tool!(
|
declare_tool!(
|
||||||
registry,
|
registry,
|
||||||
db: Arc<Database>,
|
db: Arc<Database>,
|
||||||
"knowledge_insert",
|
"knowledge_insert",
|
||||||
"把一条经验/约定/踩坑要点沉淀进本地知识库(写入为候选知识,经复用或人工审核后转正,不会立即进入检索)。使用时机:用户说「记住/记一下/以后注意/这是我踩过的坑」时调用,先提炼要点再写入。参数:title=一句话概括(必填),content=要点详情(必填),kind=知识类型(可选,常用:pitfall 踩坑/architecture_pattern 架构模式/diagnosis 诊断/review_rule 评审规则/prompt_template 提示词模板/deployment_note 部署要点/workflow_optimization 流程优化,默认 pitfall),tags=标签数组(可选)。回答前查已有知识请用 knowledge_search",
|
"把一条经验/约定/踩坑要点沉淀进本地知识库(写入为候选知识,经复用或人工审核后转正,不会立即进入检索)。使用时机:用户说「记住/记一下/以后注意/这是我踩过的坑」时调用,先提炼要点再写入。写入前必须先 knowledge_search 查重:与已有条目重复或强相关时不要新增,改传 update_target_id=该条 ID 做融合更新(此时 content 给融合后的完整内容,title 忽略),禁止同义新增造成知识重复。参数:title=一句话概括(新增时必填),content=要点详情(必填),kind=知识类型(可选,常用:pitfall 踩坑/architecture_pattern 架构模式/diagnosis 诊断/review_rule 评审规则/prompt_template 提示词模板/deployment_note 部署要点/workflow_optimization 流程优化,默认 pitfall),tags=标签数组(可选),update_target_id=已有知识ID(可选,融合更新而非新增)",
|
||||||
RiskLevel::Low,
|
RiskLevel::Low,
|
||||||
schema: object_schema(vec![
|
schema: object_schema(vec![
|
||||||
("title", "string", true),
|
("title", "string", true),
|
||||||
("content", "string", true),
|
("content", "string", true),
|
||||||
("kind", "string", false),
|
("kind", "string", false),
|
||||||
("tags", "array", false),
|
("tags", "array", false),
|
||||||
|
("update_target_id", "string", false),
|
||||||
|
("related_ids", "array", false),
|
||||||
]),
|
]),
|
||||||
args => {
|
args => {
|
||||||
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||||||
@@ -123,6 +148,28 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
if title.trim().is_empty() || content.trim().is_empty() {
|
if title.trim().is_empty() || content.trim().is_empty() {
|
||||||
anyhow::bail!("title 和 content 不能为空");
|
anyhow::bail!("title 和 content 不能为空");
|
||||||
}
|
}
|
||||||
|
let repo = df_storage::crud::KnowledgeRepo::new(&db);
|
||||||
|
// P0 融合更新路径:update_target_id 命中已有条目 → 覆盖 content 并留轨迹;
|
||||||
|
// 未命中(false=条目已消失)向 agent 明确报错,由其降级重新调用(不带该参数)
|
||||||
|
if let Some(tid) = args["update_target_id"].as_str().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
let reasoning_line = format!(
|
||||||
|
"update@{}: agent 对话沉淀融合(来源 {})",
|
||||||
|
chrono::Utc::now().format("%Y-%m-%d"),
|
||||||
|
caller_conv().map(|c| format!("conv:{c}")).unwrap_or_else(|| "ai-chat".into())
|
||||||
|
);
|
||||||
|
let merged = repo
|
||||||
|
.update_content_merged(tid, content.trim(), &reasoning_line)
|
||||||
|
.await?;
|
||||||
|
if !merged {
|
||||||
|
anyhow::bail!("update_target_id={tid} 的知识不存在(可能已删除),请不带该参数重新调用以新增");
|
||||||
|
}
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"id": tid,
|
||||||
|
"content": content,
|
||||||
|
"action": "update",
|
||||||
|
"note": "已融合更新已有知识,未新增条目"
|
||||||
|
}));
|
||||||
|
}
|
||||||
// 非空即透传不做硬白名单(见模块注释:snippet 先例,过严校验制造假失败)
|
// 非空即透传不做硬白名单(见模块注释:snippet 先例,过严校验制造假失败)
|
||||||
let kind = match args["kind"].as_str() {
|
let kind = match args["kind"].as_str() {
|
||||||
Some(k) if !k.trim().is_empty() => k.trim().to_string(),
|
Some(k) if !k.trim().is_empty() => k.trim().to_string(),
|
||||||
@@ -169,11 +216,29 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
let kind_out = record.kind.clone();
|
let kind_out = record.kind.clone();
|
||||||
let repo = df_storage::crud::KnowledgeRepo::new(&db);
|
let repo = df_storage::crud::KnowledgeRepo::new(&db);
|
||||||
repo.insert(record).await?;
|
repo.insert(record).await?;
|
||||||
|
// 阶段1 连边:related_ids(知识检索结果里的已有条目)逐个校验存在后建 similar 边,
|
||||||
|
// 幂等且非阻断(单边失败只 warn,不回滚主写入)
|
||||||
|
let mut linked = 0usize;
|
||||||
|
if let Some(arr) = args["related_ids"].as_array() {
|
||||||
|
for rid in arr.iter().filter_map(|v| v.as_str()).take(3) {
|
||||||
|
if rid == id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if repo.get_by_id(rid).await.unwrap_or(None).is_none() {
|
||||||
|
tracing::warn!("related_id 不存在,跳过建边: {rid}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if repo.insert_edge(&id, rid, "similar", "llm-tag").await.is_ok() {
|
||||||
|
linked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"kind": kind_out,
|
"kind": kind_out,
|
||||||
"status": "candidate",
|
"status": "candidate",
|
||||||
|
"linked": linked,
|
||||||
"note": "已记录为候选知识,复用或审核后转正"
|
"note": "已记录为候选知识,复用或审核后转正"
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ pub mod get_app_config;
|
|||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
pub mod idea;
|
pub mod idea;
|
||||||
pub mod knowledge;
|
pub mod knowledge;
|
||||||
|
// history_search 跨会话历史检索(P1-A,u-claw #5/#6 等价;查询内核与 IPC search_messages
|
||||||
|
// 同源在 commands/search.rs,本文件是 AI 通道薄包装)。
|
||||||
|
pub mod history;
|
||||||
pub mod trash;
|
pub mod trash;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod download_file;
|
pub mod download_file;
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
//! CI 检查状态查询命令 — 读取 Gitea commit 检查状态(对标 GitHub commit status API)
|
|
||||||
//!
|
|
||||||
//! 调用 Gitea `{api_base}/api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` 拉取该
|
|
||||||
//! commit 的 CI 检查项列表,转成 CheckStatus 返回前端(供 commit 详情面板展示 ✓/✗/⏳)。
|
|
||||||
//!
|
|
||||||
//! 设计取舍:
|
|
||||||
//! - **容错优先**:网络错误 / URL 解析失败 / Gitea 异常 → 返回空 vec,**不阻断工作流**
|
|
||||||
//! (CI 状态属于辅助信息,失败不应阻塞 Git 浏览等主功能)。
|
|
||||||
//! - URL 解析:从 repo_url(如 `https://gitea.1216.top/lxy/DevFlow`)拆出
|
|
||||||
//! `{scheme}://{host}` 作 api_base,路径段尾两段作 owner/repo。
|
|
||||||
//! - 字段映射:Gitea status.state 取值 `pending/success/failure/error/...`,
|
|
||||||
//! error/warning 归一为 failure 展示;`context` 作检查名,`target_url` 作详情链接。
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tauri::State;
|
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// 前端展示用的 CI 检查状态(归一化后)
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct CheckStatus {
|
|
||||||
/// 检查名(如 "ci/cargo-test"、"continuous-integration/drone/push")
|
|
||||||
pub name: String,
|
|
||||||
/// 归一状态:"pending" | "success" | "failure"
|
|
||||||
pub status: String,
|
|
||||||
/// 详情链接(Gitea target_url,可能为空)
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gitea API 原始返回项(仅取关心的字段,其余忽略)
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct GiteaStatus {
|
|
||||||
/// 检查上下文名(Gitea 字段名 context)
|
|
||||||
context: Option<String>,
|
|
||||||
/// 原始状态:pending / success / failure / error / warning / ...
|
|
||||||
state: Option<String>,
|
|
||||||
/// 详情链接
|
|
||||||
target_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从 repo_url 解析 (api_base, owner, repo)。
|
|
||||||
///
|
|
||||||
/// 例:`https://gitea.1216.top/lxy/DevFlow` →
|
|
||||||
/// (`https://gitea.1216.top`, `lxy`, `DevFlow`)
|
|
||||||
///
|
|
||||||
/// `.git` 后缀容忍;路径段不足两段返回 None。
|
|
||||||
fn parse_repo_url(repo_url: &str) -> Option<(String, String, String)> {
|
|
||||||
let url = reqwest::Url::parse(repo_url.trim()).ok()?;
|
|
||||||
if !matches!(url.scheme(), "http" | "https") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let host = url.host_str()?;
|
|
||||||
// port_or_known_default() 对 https 返 Some(443)、http 返 Some(80),
|
|
||||||
// 致 authority 总是带默认端口(:443/:80),base URL 形如 "https://host:443" 而非规范
|
|
||||||
// "https://host"。下游 Gitea API 调用虽大多容忍,但测试断言与可读性受影响。
|
|
||||||
// 修复:仅当端口非 scheme 默认端口时才拼入 authority(显式非默认端口才保留)。
|
|
||||||
let port = url.port(); // 仅取显式端口(未在 URL 写出的不返)
|
|
||||||
let authority = match port {
|
|
||||||
Some(p) => format!("{}:{}", host, p),
|
|
||||||
None => host.to_string(),
|
|
||||||
};
|
|
||||||
let api_base = format!("{}://{}", url.scheme(), authority);
|
|
||||||
|
|
||||||
// 路径段:去空 / 去 .git 后缀 → 取末两段 owner / repo
|
|
||||||
// (url::Url::path_segments() 返 Option<Split>,Split 不实现 Default,需手动兜底)
|
|
||||||
let path_iter = url.path_segments().unwrap_or("".split('/'));
|
|
||||||
let mut segments: Vec<String> = path_iter
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(|s| s.trim_end_matches(".git").to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
if segments.len() < 2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let repo = segments.pop()?;
|
|
||||||
let owner = segments.pop()?;
|
|
||||||
Some((api_base, owner, repo))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 Gitea 原始 state 归一为前端三态:"pending" | "success" | "failure"
|
|
||||||
fn normalize_state(raw: &str) -> &'static str {
|
|
||||||
match raw.to_lowercase().as_str() {
|
|
||||||
"success" => "success",
|
|
||||||
"pending" | "" => "pending",
|
|
||||||
// failure / error / warning / blocked / 其他均视为失败展示
|
|
||||||
_ => "failure",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 查询指定 commit 的 CI 检查状态列表。
|
|
||||||
///
|
|
||||||
/// 任一环节失败(网络/解析/Gitea 异常)均返回空 vec,日志告警但不返回错误,
|
|
||||||
/// 避免阻断前端 Git 浏览等主流程。
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_commit_status(
|
|
||||||
_state: State<'_, AppState>,
|
|
||||||
repo_url: String,
|
|
||||||
commit_sha: String,
|
|
||||||
) -> Result<Vec<CheckStatus>, String> {
|
|
||||||
let (api_base, owner, repo) = match parse_repo_url(&repo_url) {
|
|
||||||
Some(parsed) => parsed,
|
|
||||||
None => {
|
|
||||||
tracing::warn!(
|
|
||||||
repo_url = %repo_url,
|
|
||||||
"[ci_status] repo_url 解析失败,返回空列表"
|
|
||||||
);
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!(
|
|
||||||
"{}/api/v1/repos/{}/{}/commits/{}/statuses",
|
|
||||||
api_base, owner, repo, commit_sha
|
|
||||||
);
|
|
||||||
|
|
||||||
tracing::info!(url = %url, "[ci_status] 查询 Gitea commit statuses");
|
|
||||||
|
|
||||||
let client = match reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "[ci_status] 构建 reqwest client 失败");
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let resp = match client.get(&url).send().await {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "[ci_status] 请求 Gitea 失败(网络不可达或未鉴权)");
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
tracing::warn!(
|
|
||||||
status = %resp.status(),
|
|
||||||
"[ci_status] Gitea 响应非 2xx,返回空列表"
|
|
||||||
);
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let raw: Vec<GiteaStatus> = match resp.json().await {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "[ci_status] 解析 Gitea 响应 JSON 失败");
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let checks = raw
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| CheckStatus {
|
|
||||||
name: s.context.unwrap_or_else(|| "unknown".to_string()),
|
|
||||||
status: normalize_state(s.state.as_deref().unwrap_or("")).to_string(),
|
|
||||||
url: s.target_url.filter(|u| !u.is_empty()),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(checks)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// ── parse_repo_url ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_standard_gitea_url() {
|
|
||||||
let (base, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow").unwrap();
|
|
||||||
assert_eq!(base, "https://gitea.1216.top");
|
|
||||||
assert_eq!(owner, "lxy");
|
|
||||||
assert_eq!(repo, "DevFlow");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_strips_git_suffix() {
|
|
||||||
let (_, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow.git").unwrap();
|
|
||||||
assert_eq!(owner, "lxy");
|
|
||||||
assert_eq!(repo, "DevFlow");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_with_port() {
|
|
||||||
let (base, _, _) = parse_repo_url("http://gitea.local:3000/team/proj").unwrap();
|
|
||||||
assert_eq!(base, "http://gitea.local:3000");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_rejects_non_http() {
|
|
||||||
assert!(parse_repo_url("ftp://gitea/x/y").is_none());
|
|
||||||
assert!(parse_repo_url("file:///x/y").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_rejects_short_path() {
|
|
||||||
assert!(parse_repo_url("https://gitea.1216.top/").is_none());
|
|
||||||
assert!(parse_repo_url("https://gitea.1216.top/onlyone").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── normalize_state ──
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_success_and_pending() {
|
|
||||||
assert_eq!(normalize_state("success"), "success");
|
|
||||||
assert_eq!(normalize_state("pending"), "pending");
|
|
||||||
assert_eq!(normalize_state(""), "pending");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_failure_variants() {
|
|
||||||
assert_eq!(normalize_state("failure"), "failure");
|
|
||||||
assert_eq!(normalize_state("error"), "failure");
|
|
||||||
assert_eq!(normalize_state("warning"), "failure");
|
|
||||||
assert_eq!(normalize_state("blocked"), "failure");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ use serde::Deserialize;
|
|||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use df_types::types::new_id;
|
use df_types::types::new_id;
|
||||||
use df_storage::models::{KnowledgeEventRecord, KnowledgeRecord};
|
use df_storage::models::{KnowledgeEventRecord, KnowledgeRecord, RelatedKnowledge};
|
||||||
|
|
||||||
use crate::state::{AppState, KnowledgeConfig};
|
use crate::state::{AppState, KnowledgeConfig};
|
||||||
|
|
||||||
@@ -303,6 +303,80 @@ pub async fn knowledge_retry_embedding(
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 淘汰批:零复用 + low 置信 + 超龄条目批量归档(P0 淘汰机制,2026-10-23)
|
||||||
|
///
|
||||||
|
/// 背景(知识库治理审查 2026-10-23):71% 条目零复用长尾稀释检索排序,检索面是
|
||||||
|
/// status 白名单,archived 天然出池。逐条留痕(status_changed,source_ref=archive_stale)
|
||||||
|
/// 可审计可回滚。默认阈值 90 天/批 500——从生产画像看 low 置信存量极少(实测 1 条),
|
||||||
|
/// 阈值偏保守,宁可分批多跑不误伤。
|
||||||
|
///
|
||||||
|
/// 归档不可逆性:archived 条目退出检索面,可通过 knowledge_update_status 手动转回
|
||||||
|
/// published(状态机合法迁移),留痕链完整。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn knowledge_archive_stale(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
older_than_days: Option<i64>,
|
||||||
|
limit: Option<i64>,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let days = older_than_days.unwrap_or(90).clamp(1, 3650);
|
||||||
|
let limit = limit.unwrap_or(500).clamp(1, 5000);
|
||||||
|
let ids = state
|
||||||
|
.knowledge
|
||||||
|
.archive_stale(days, limit)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
// 逐条留痕 fire-and-forget(与晋升留痕同风格,吞错只 warn)。
|
||||||
|
// from 记 "stale" 而非逐条旧值(批量 UPDATE 已合并原状态,淘汰批标记便于审计回滚)
|
||||||
|
let timeline = KnowledgeTimeline::new(&state.db);
|
||||||
|
for id in &ids {
|
||||||
|
timeline.record_status_change(id, "stale", "archived").await;
|
||||||
|
}
|
||||||
|
if !ids.is_empty() {
|
||||||
|
tracing::info!("[knowledge] 淘汰批: {} 条零复用低置信超龄条目归档(>{days}天)", ids.len());
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关联知识列表(知识中枢阶段1:详情页「关联知识」区块数据源)
|
||||||
|
///
|
||||||
|
/// 双向读边表 + 对端条目展开,archived 对端不展示(与检索面口径一致)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn knowledge_list_related(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
limit: Option<i64>,
|
||||||
|
) -> Result<Vec<RelatedKnowledge>, String> {
|
||||||
|
let limit = limit.unwrap_or(10).clamp(1, 50) as usize;
|
||||||
|
state
|
||||||
|
.knowledge
|
||||||
|
.list_related(&id, limit)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 存量蒸馏合并(治理 P0 深化,2026-10-24):对指定 kind 的已归档分组跑一次 LLM 蒸馏,
|
||||||
|
/// 产出单条合订本(candidate)并建 merges 边连组内条目。返回合订本 id(组内不足 2 条
|
||||||
|
/// 或模型判 skip 返回 None)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn knowledge_distill_group(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
kind: String,
|
||||||
|
limit: Option<i64>,
|
||||||
|
) -> Result<Option<String>, String> {
|
||||||
|
let provider_config = crate::commands::ai::prompt::get_active_provider(&state)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
crate::commands::ai::knowledge_distill::distill_archived_group(
|
||||||
|
&state.db,
|
||||||
|
&provider_config,
|
||||||
|
&state.llm_concurrency,
|
||||||
|
&kind,
|
||||||
|
limit.map(|n| n as usize),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 配置 + 手动提炼
|
// 配置 + 手动提炼
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -367,14 +441,17 @@ pub struct UpdateKnowledgeInput {
|
|||||||
pub reasoning: Option<String>,
|
pub reasoning: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 知识详情聚合负载(基本信息 + 全部生命线事件)
|
/// 知识详情聚合负载(基本信息 + 全部生命线事件 + 关联知识)
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct KnowledgeDetailPayload {
|
pub struct KnowledgeDetailPayload {
|
||||||
pub knowledge: KnowledgeRecord,
|
pub knowledge: KnowledgeRecord,
|
||||||
pub events: Vec<KnowledgeEventRecord>,
|
pub events: Vec<KnowledgeEventRecord>,
|
||||||
|
/// 关联知识(阶段1 连边,双向读+对端展开);老前端不消费此字段无影响
|
||||||
|
#[serde(default)]
|
||||||
|
pub related: Vec<RelatedKnowledge>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 知识详情(基本信息 + 生命线事件) — 详情页一次拉全
|
/// 知识详情(基本信息 + 生命线事件 + 关联知识) — 详情页一次拉全
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn knowledge_get_detail(
|
pub async fn knowledge_get_detail(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -391,7 +468,9 @@ pub async fn knowledge_get_detail(
|
|||||||
.list_by_knowledge(&id)
|
.list_by_knowledge(&id)
|
||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
Ok(KnowledgeDetailPayload { knowledge, events })
|
// 关联边读取失败非阻断(边表为空/极老库未到 V54 时降级空列表)
|
||||||
|
let related = state.knowledge.list_related(&id, 10).await.unwrap_or_default();
|
||||||
|
Ok(KnowledgeDetailPayload { knowledge, events, related })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 编辑知识(部分更新 title/content/tags/confidence/reasoning)
|
/// 编辑知识(部分更新 title/content/tags/confidence/reasoning)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
||||||
|
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod ci_status;
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod idea;
|
pub mod idea;
|
||||||
pub mod knowledge;
|
pub mod knowledge;
|
||||||
|
|||||||
+493
-48
@@ -63,6 +63,34 @@ fn cancel_watchers(
|
|||||||
CANCEL_WATCHERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()))
|
CANCEL_WATCHERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F5-2 终态窗口:审批前端默认 15min 超时自动拒绝,30min 窗覆盖「终态后用户补发决议词」
|
||||||
|
/// 的迟到判定;窗口外的决议词视为普通聊天文本(无审批语境,现状语义)
|
||||||
|
const RESOLVED_WINDOW_SECS: i64 = 1800;
|
||||||
|
|
||||||
|
/// F5-2 审批终态闭环:已推飞书侧的审批按钮卡句柄(tc_id → (卡片 message_id, 发卡时刻))。
|
||||||
|
/// 桌面决议/超时自动拒绝后原卡原地更新为终态,不再恒挂可点。内存态,重启即失——重启后
|
||||||
|
/// 陈旧卡点击走 ai_approve 幂等「已处理」回执,无功能损失。写入时顺带清过期条目防无界增长
|
||||||
|
static APPROVAL_CARDS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, (String, i64)>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// F5-2 迟到决议判定:chat → 最近一次审批终态(时刻, 是否批准)。审批已终态但用户仍在
|
||||||
|
/// 飞书回决议词时,据此前 30min 记录提示「已处理」而非把决议文本注入运行中任务
|
||||||
|
static RESOLVED_CHATS: std::sync::OnceLock<std::sync::Mutex<HashMap<String, (i64, bool)>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
fn approval_cards() -> &'static std::sync::Mutex<HashMap<String, (String, i64)>> {
|
||||||
|
APPROVAL_CARDS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolved_chats() -> &'static std::sync::Mutex<HashMap<String, (i64, bool)>> {
|
||||||
|
RESOLVED_CHATS.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 终态新鲜度判定(纯函数,迟到决议提示的窗口判据)
|
||||||
|
fn within_resolved_window(at_secs: i64, now: i64) -> bool {
|
||||||
|
now - at_secs < RESOLVED_WINDOW_SECS
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct FeishuConfig {
|
struct FeishuConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -125,6 +153,9 @@ struct Ctx {
|
|||||||
/// D3 在途会话数:消息受理(handle_message 白名单通过)到任务投递完成期间 >0,
|
/// D3 在途会话数:消息受理(handle_message 白名单通过)到任务投递完成期间 >0,
|
||||||
/// 看门狗要求为 0 才告警,防有回复待投递时误报
|
/// 看门狗要求为 0 才告警,防有回复待投递时误报
|
||||||
inflight: Arc<AtomicUsize>,
|
inflight: Arc<AtomicUsize>,
|
||||||
|
/// D5 假活自愈唤醒句柄:看门狗连续告警超阈值后 notify,run_loop 的 channel.start()
|
||||||
|
/// select 到即主动退出,交给外层重连循环重建连接(Notify 生命周期同 Ctx,跨重连复用)
|
||||||
|
selfheal: Arc<tokio::sync::Notify>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。
|
/// 启动入口:lib.rs setup 调用。未启用/配置缺失/构造失败一律记日志返回,不阻塞应用启动。
|
||||||
@@ -182,6 +213,7 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
|
|||||||
debounce: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
debounce: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||||
last_alive: Arc::new(AtomicI64::new(now_secs())),
|
last_alive: Arc::new(AtomicI64::new(now_secs())),
|
||||||
inflight: Arc::new(AtomicUsize::new(0)),
|
inflight: Arc::new(AtomicUsize::new(0)),
|
||||||
|
selfheal: Arc::new(tokio::sync::Notify::new()),
|
||||||
});
|
});
|
||||||
// D1 僵尸资源修复:client 的 'static 化只做一次(原在 run_loop 内,每次重连都
|
// D1 僵尸资源修复:client 的 'static 化只做一次(原在 run_loop 内,每次重连都
|
||||||
// Box::leak 一份 LarkClient,频繁断线场景内存阶梯上涨);Channel::builder 仅借用
|
// Box::leak 一份 LarkClient,频繁断线场景内存阶梯上涨);Channel::builder 仅借用
|
||||||
@@ -192,6 +224,10 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
|
|||||||
// 24 审批可见性:渠道生命周期订阅任务,审批挂起事件推卡到绑定 chat(跨重连常驻,
|
// 24 审批可见性:渠道生命周期订阅任务,审批挂起事件推卡到绑定 chat(跨重连常驻,
|
||||||
// 与 run_loop 的按连接重建无关)
|
// 与 run_loop 的按连接重建无关)
|
||||||
tauri::async_runtime::spawn(run_approval_notifier(ctx.clone()));
|
tauri::async_runtime::spawn(run_approval_notifier(ctx.clone()));
|
||||||
|
// 换代通知(P1-B 方案甲,2026-10-21):停靠/压缩换代事件推绑定 chat,同款生命周期
|
||||||
|
// 订阅任务。此前两事件已广播(emit_ai_chat_event 双写底座)但本文件零消费,
|
||||||
|
// 停靠/换代对飞书侧完全无感知
|
||||||
|
tauri::async_runtime::spawn(run_generation_notifier(ctx.clone()));
|
||||||
// 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死
|
// 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死
|
||||||
// 2026-10-08 重连用户通知:此前重连只落日志,渠道静默死数分钟用户无感知。
|
// 2026-10-08 重连用户通知:此前重连只落日志,渠道静默死数分钟用户无感知。
|
||||||
// 判据:run_loop 运行时长——>30s 视为「曾成功连接过」(连接建立后才有长驻),
|
// 判据:run_loop 运行时长——>30s 视为「曾成功连接过」(连接建立后才有长驻),
|
||||||
@@ -285,10 +321,16 @@ let ctx_msg = ctx.clone();
|
|||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)");
|
tracing::info!("[feishu] 长连接启动(桌面端直连,免公网回调)");
|
||||||
let result = channel
|
// D5 假活自愈:看门狗持续断流判定后 notify,主动终结本次 channel.start(),
|
||||||
.start()
|
// 交给外层重连循环 backoff 重建连接(比 SDK 内部重连更彻底:全新 WS 握手)
|
||||||
.await
|
let heal = ctx.selfheal.clone();
|
||||||
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}"));
|
let result = tokio::select! {
|
||||||
|
r = channel.start() => r
|
||||||
|
.map_err(|e: LarkError| anyhow::anyhow!("飞书长连接退出: {e}")),
|
||||||
|
_ = heal.notified() => {
|
||||||
|
Err(anyhow::anyhow!("假活自愈: 看门狗判定持续断流,主动断开重建"))
|
||||||
|
}
|
||||||
|
};
|
||||||
watchdog.abort();
|
watchdog.abort();
|
||||||
result?;
|
result?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -310,25 +352,45 @@ const LIVENESS_TICK: std::time::Duration = std::time::Duration::from_secs(60);
|
|||||||
/// 排查起点(此前 bot 静默死无任何日志可查)。
|
/// 排查起点(此前 bot 静默死无任何日志可查)。
|
||||||
async fn run_liveness_watchdog(ctx: Arc<Ctx>) {
|
async fn run_liveness_watchdog(ctx: Arc<Ctx>) {
|
||||||
let mut warned = false;
|
let mut warned = false;
|
||||||
|
// D5 假活自愈:连续告警计数。每 60s 巡检一次,断流持续 LIVENESS_HEAL_TICKS 次
|
||||||
|
// (即断流 ≥ 600s + 3×60s 确认期)仍无任何事件恢复,判定为协议假活(SDK 读空闲
|
||||||
|
// 自愈覆盖不了),主动 notify 唤醒 run_loop 退出重建连接,复用外层 backoff 重连
|
||||||
|
let mut stale_ticks: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(LIVENESS_TICK).await;
|
tokio::time::sleep(LIVENESS_TICK).await;
|
||||||
let elapsed = now_secs() - ctx.last_alive.load(Ordering::SeqCst);
|
let elapsed = now_secs() - ctx.last_alive.load(Ordering::SeqCst);
|
||||||
if elapsed <= LIVENESS_STALE_SECS {
|
if elapsed <= LIVENESS_STALE_SECS {
|
||||||
warned = false;
|
warned = false;
|
||||||
|
stale_ticks = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 有会话在途不告警(回复投递依赖当前连接);同一段断流只告警一次防刷屏
|
// 有会话在途不告警(回复投递依赖当前连接)
|
||||||
if ctx.inflight.load(Ordering::SeqCst) > 0 || warned {
|
if ctx.inflight.load(Ordering::SeqCst) > 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !warned {
|
||||||
warned = true;
|
warned = true;
|
||||||
tracing::warn!("[feishu] 探活告警: {elapsed}s 无任何事件且无在途会话,疑似连接假活");
|
tracing::warn!("[feishu] 探活告警: {elapsed}s 无任何事件且无在途会话,疑似连接假活");
|
||||||
feishu_log(&format!(
|
feishu_log(&format!(
|
||||||
"探活告警: {elapsed}s 无任何事件且无在途会话(疑似假活,持续出现请重启应用重建连接)"
|
"探活告警: {elapsed}s 无任何事件且无在途会话(疑似假活)"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
stale_ticks += 1;
|
||||||
|
if stale_ticks >= LIVENESS_HEAL_TICKS {
|
||||||
|
tracing::error!(
|
||||||
|
"[feishu] 假活自愈触发: 断流持续 {} 个巡检周期,主动断开重建长连接",
|
||||||
|
stale_ticks
|
||||||
|
);
|
||||||
|
feishu_log("假活自愈触发: 持续断流,主动断开重建长连接");
|
||||||
|
ctx.selfheal.notify_waiters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// D5 自愈确认期:首个告警后再等 N 个巡检周期仍断流才重建(防把正常空闲+慢恢复误杀)
|
||||||
|
const LIVENESS_HEAL_TICKS: u32 = 3;
|
||||||
|
|
||||||
/// D3 在途会话计数 RAII:handle_message 从注册等待桥到投递完成的任意退出路径
|
/// D3 在途会话计数 RAII:handle_message 从注册等待桥到投递完成的任意退出路径
|
||||||
/// (spawn 失败早退/投递完成)都保证计数回落
|
/// (spawn 失败早退/投递完成)都保证计数回落
|
||||||
struct InflightGuard(Arc<AtomicUsize>);
|
struct InflightGuard(Arc<AtomicUsize>);
|
||||||
@@ -376,6 +438,27 @@ fn parse_command(text: &str) -> Option<(String, String)> {
|
|||||||
Some((cmd, tail))
|
Some((cmd, tail))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 审批决议词识别(F5-1):集合对齐按钮卡文案与常见同义词——批准 {同意|批准|通过|approve},
|
||||||
|
/// 拒绝 {拒绝|驳回|不同意|reject}。容错链:半全角归一 → 前后空白 → 剥尾部标点(「批准。」
|
||||||
|
/// 「同意!」)→ 小写(英文词)。整词精确匹配非包含(「不同意」含「同意」,靠整词比对防反判),
|
||||||
|
/// 普通文本(「我同意这个方案」)不命中返回 None,由调用方落常规消息路径
|
||||||
|
fn parse_approval_verdict(text: &str) -> Option<bool> {
|
||||||
|
const APPROVE_WORDS: [&str; 4] = ["同意", "批准", "通过", "approve"];
|
||||||
|
const REJECT_WORDS: [&str; 4] = ["拒绝", "驳回", "不同意", "reject"];
|
||||||
|
let t = normalize_halfwidth(text);
|
||||||
|
let t = t
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(['。', '.', ',', ',', '、', '!', '!', '?', '?', '~', '~', ';', ';', ' ', '\t'])
|
||||||
|
.to_lowercase();
|
||||||
|
if APPROVE_WORDS.contains(&t.as_str()) {
|
||||||
|
Some(true)
|
||||||
|
} else if REJECT_WORDS.contains(&t.as_str()) {
|
||||||
|
Some(false)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 图片消息喂模型的任务语境(#958):飞书 image 为独立消息类型无法带文字,
|
/// 图片消息喂模型的任务语境(#958):飞书 image 为独立消息类型无法带文字,
|
||||||
/// 用引导词让 vision 模型知道「这是用户发来的图,按图作答」
|
/// 用引导词让 vision 模型知道「这是用户发来的图,按图作答」
|
||||||
const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查看图片并按图片内容处理)";
|
const IMAGE_INBOUND_PROMPT: &str = "(用户通过飞书发来一张图片,请查看图片并按图片内容处理)";
|
||||||
@@ -891,10 +974,13 @@ async fn handle_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 24 审批决议通路:该会话有挂起审批时,「批准/拒绝」直调审批原语不入对话链路
|
// 24 审批决议通路(F5-1 决议词容错):该会话有挂起审批时,决议词(同意/批准/通过 =
|
||||||
// (决议文本若落常规路径会被注入运行中任务,审批永远无人拍板)。决议取该会话最新
|
// 批准;拒绝/驳回/不同意 = 拒绝,含空白/尾标点/大小写容错,集合对齐按钮卡文案)直调
|
||||||
// 一条挂起;无挂起不拦截,文本照常落常规路径,零行为变更
|
// 审批原语不入对话链路(决议文本若落常规路径会被注入运行中任务,审批永远无人拍板)。
|
||||||
if text == "批准" || text == "拒绝" {
|
// 决议取该会话最新一条挂起;无挂起不拦截,文本照常落常规路径,零行为变更——唯一
|
||||||
|
// 例外(F5-2 迟到决议):该 chat 近期刚有审批终态(桌面已决议/超时自动取消),决议词
|
||||||
|
// 命中却无可拍板对象,回「已处理」提示而非当补充指令注入任务流
|
||||||
|
if let Some(approved) = parse_approval_verdict(&text) {
|
||||||
let state = ctx.app.state::<crate::state::AppState>();
|
let state = ctx.app.state::<crate::state::AppState>();
|
||||||
let conv_id = ctx.chat_map.lock().await.get(&chat_id).cloned();
|
let conv_id = ctx.chat_map.lock().await.get(&chat_id).cloned();
|
||||||
let pending = match conv_id {
|
let pending = match conv_id {
|
||||||
@@ -902,7 +988,6 @@ async fn handle_message(
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
if let Some(tc_id) = pending {
|
if let Some(tc_id) = pending {
|
||||||
let approved = text == "批准";
|
|
||||||
feishu_log(&format!("审批决议 chat={chat_id} tc={tc_id} approved={approved}"));
|
feishu_log(&format!("审批决议 chat={chat_id} tc={tc_id} approved={approved}"));
|
||||||
// 2026-10-08 决议后追加执行结果:先订阅(工具执行前,防 publish_event 无订阅者跳过),
|
// 2026-10-08 决议后追加执行结果:先订阅(工具执行前,防 publish_event 无订阅者跳过),
|
||||||
// 再调 ai_approve,后 spawn watcher 消费 AiToolCallCompleted 拿结果摘要
|
// 再调 ai_approve,后 spawn watcher 消费 AiToolCallCompleted 拿结果摘要
|
||||||
@@ -928,7 +1013,7 @@ async fn handle_message(
|
|||||||
None => 0,
|
None => 0,
|
||||||
};
|
};
|
||||||
let reply = if remaining > 0 {
|
let reply = if remaining > 0 {
|
||||||
format!("{reply}\n\n⚠️ 本会话还有 {remaining} 条待审批,请继续回复「批准」或「拒绝」。")
|
format!("{reply}\n\n⚠️ 本会话还有 {remaining} 条待审批,请继续回复「同意 / 批准」或「拒绝」。")
|
||||||
} else {
|
} else {
|
||||||
reply
|
reply
|
||||||
};
|
};
|
||||||
@@ -939,6 +1024,29 @@ async fn handle_message(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// F5-2 迟到决议:无挂起但该 chat 近期有审批终态——如实提示,不落注入旁路
|
||||||
|
let recently_resolved = {
|
||||||
|
let now = now_secs();
|
||||||
|
resolved_chats()
|
||||||
|
.lock()
|
||||||
|
.map(|m| {
|
||||||
|
m.get(&chat_id)
|
||||||
|
.is_some_and(|(at, _)| within_resolved_window(*at, now))
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
if recently_resolved {
|
||||||
|
feishu_log(&format!("迟到决议提示 chat={chat_id} text={}", truncate(&text, 40)));
|
||||||
|
reply_message(
|
||||||
|
&ctx.client,
|
||||||
|
&message_id,
|
||||||
|
&chat_id,
|
||||||
|
"ℹ️ 该审批已处理(已批准/已拒绝或超时自动取消),无需再回复;可发 /status 查看任务状态。",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 近期无审批终态:决议词属普通聊天文本,落常规路径(现状语义保留)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 纠偏注入旁路(todo.md 16,2026-10-06):会话运行中 → 直接注入运行中 conv,不排队 ──
|
// ── 纠偏注入旁路(todo.md 16,2026-10-06):会话运行中 → 直接注入运行中 conv,不排队 ──
|
||||||
@@ -1064,7 +1172,7 @@ async fn handle_message(
|
|||||||
false, // 不劫持用户活跃面板(编排节点同款语义)
|
false, // 不劫持用户活跃面板(编排节点同款语义)
|
||||||
// #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源
|
// #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源
|
||||||
Some(df_ai::provider::MessageSource {
|
Some(df_ai::provider::MessageSource {
|
||||||
channel: "feishu".into(),
|
channel: SOURCE_CHANNEL_FEISHU.into(),
|
||||||
message_id: message_id.clone(),
|
message_id: message_id.clone(),
|
||||||
chat_id: Some(chat_id.clone()),
|
chat_id: Some(chat_id.clone()),
|
||||||
sender: if open_id.is_empty() { None } else { Some(open_id.clone()) },
|
sender: if open_id.is_empty() { None } else { Some(open_id.clone()) },
|
||||||
@@ -1190,6 +1298,18 @@ fn delivery_blocked(path: &str) -> Option<&'static str> {
|
|||||||
.map(|_| "敏感文件(凭据/密钥特征)")
|
.map(|_| "敏感文件(凭据/密钥特征)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 入站消息渠道标识(source.channel 值,#958 溯源同参;spawn 链渠道段判定用)
|
||||||
|
pub(crate) const SOURCE_CHANNEL_FEISHU: &str = "feishu";
|
||||||
|
|
||||||
|
/// 交付引导(#958 生产端配套,2026-10-21 修「devflow 不会用飞书发文件」):出站回传消费端
|
||||||
|
/// (extract_delivery_paths/reply_deliveries/delivery_blocked)早已在位,但全仓 prompt 层
|
||||||
|
/// 零处教 AI 写 `[交付]` 标记行——AI 永远不会自发写标记,链路空转。本段经 spawn 链渠道
|
||||||
|
/// 标识透传(source.channel==feishu)注入飞书渠道会话的 AI 语境(落点 chat.rs
|
||||||
|
/// spawn_agentic_session 稳定段之后,与 aug_seg 同构分流)。文案与解析器是同一契约,
|
||||||
|
/// 示例格式必须能被 extract_delivery_paths 原样提取(单测 delivery_guidance 锁死);
|
||||||
|
/// 敏感拦截只一句交底不重复造(拦截器本尊在 delivery_blocked)。
|
||||||
|
pub(crate) const DELIVERY_GUIDANCE_PROMPT: &str = "(渠道提示)如需给用户发送本机文件:在报告正文写 `[交付] <文件的本机绝对路径>` 标记行,每个文件单独一行;渠道会自动把这些文件上传为飞书原生附件发给用户,无需其他操作。名字含凭据/密钥特征(如 .env、token、secret、password)的文件会被拦截拒发,不要尝试交付敏感文件。";
|
||||||
|
|
||||||
/// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举)
|
/// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举)
|
||||||
fn feishu_file_type(ext: &str) -> &'static str {
|
fn feishu_file_type(ext: &str) -> &'static str {
|
||||||
match ext {
|
match ext {
|
||||||
@@ -1337,6 +1457,10 @@ async fn run_completion_watcher(
|
|||||||
if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
|
if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
|
||||||
if !t.trim().is_empty() {
|
if !t.trim().is_empty() {
|
||||||
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len()));
|
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len()));
|
||||||
|
// #958 出站回传补口(2026-10-21):快任务兜底此前早退直投终版,漏过主循环
|
||||||
|
// 统一出口的 reply_deliveries——秒级任务报告里的 [交付] 标记行全部落空。
|
||||||
|
// 迟到终版(35min 降级 continue 后到达)走 rx.recv() 臂汇入主出口,无缺口
|
||||||
|
reply_deliveries(&ctx.client, &chat_id, &t).await;
|
||||||
let _ = frame_tx.send(CardFrame { text: t, terminal: true }).await;
|
let _ = frame_tx.send(CardFrame { text: t, terminal: true }).await;
|
||||||
let _ = stream_task.await;
|
let _ = stream_task.await;
|
||||||
return;
|
return;
|
||||||
@@ -1428,21 +1552,48 @@ async fn run_approval_notifier(ctx: Arc<Ctx>) {
|
|||||||
let mut rx = state.ai_event_bus.subscribe();
|
let mut rx = state.ai_event_bus.subscribe();
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(v) => {
|
Ok(v) => match v.get("type").and_then(|t| t.as_str()) {
|
||||||
if v.get("type").and_then(|t| t.as_str()) != Some("AiApprovalRequired") {
|
// F5-2 审批终态闭环:批/拒/超时(前端超时亦调 ai_approve(false))全路径 emit
|
||||||
continue;
|
// AiApprovalResult——订阅同 bus 复用 spawn_approval_result_watcher 先例,零后端
|
||||||
}
|
// 改动。飞书侧原按钮卡原地更新为终态(不再恒挂可点),并记 chat 维度终态
|
||||||
|
// (迟到决议提示判据)。事件无决议来源字段,拒绝文案注明含超时自动取消;
|
||||||
|
// 飞书侧自身决议同经此收口(与决议回执双保险,防旧卡再点)
|
||||||
|
Some("AiApprovalResult") => {
|
||||||
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
|
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let chat_id = ctx
|
let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||||
.chat_map
|
let approved = v
|
||||||
|
.get("approved")
|
||||||
|
.and_then(|b| b.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
note_approval_resolved(&chat_id, approved);
|
||||||
|
let mid = approval_cards()
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.ok()
|
||||||
.iter()
|
.and_then(|mut cards| cards.remove(tc_id).map(|(mid, _)| mid))
|
||||||
.find(|(_, c)| c.as_str() == cid)
|
.filter(|m| !m.is_empty());
|
||||||
.map(|(k, _)| k.clone());
|
// 无卡句柄(降级文本卡/他路无原卡):只记终态,迟到提示仍生效
|
||||||
let Some(chat_id) = chat_id else {
|
if let Some(mid) = mid {
|
||||||
|
let text = if approved {
|
||||||
|
"✅ 该审批已批准,任务继续执行(按钮已失效)。"
|
||||||
|
} else {
|
||||||
|
"🚫 该审批已结束:已拒绝或超时自动取消(按钮已失效)。"
|
||||||
|
};
|
||||||
|
let updated = update_card(&ctx.client, &mid, text).await;
|
||||||
|
feishu_log(&format!(
|
||||||
|
"审批终态收口 chat={chat_id} tc={tc_id} approved={approved} 更新={updated}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("AiApprovalRequired") => {
|
||||||
|
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("工具");
|
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("工具");
|
||||||
@@ -1455,14 +1606,14 @@ async fn run_approval_notifier(ctx: Arc<Ctx>) {
|
|||||||
// 审批卡按钮化(2026-10-08):改用带「同意/拒绝」按钮的交互卡片,
|
// 审批卡按钮化(2026-10-08):改用带「同意/拒绝」按钮的交互卡片,
|
||||||
// 按钮 value 携带 {approval_id, approved},回调经 on_card_action →
|
// 按钮 value 携带 {approval_id, approved},回调经 on_card_action →
|
||||||
// handle_card_action → ai_approve 原语(与文本决议通路同原语,双通道并存)。
|
// handle_card_action → ai_approve 原语(与文本决议通路同原语,双通道并存)。
|
||||||
// 文本「批准/拒绝」通路保留作降级(旧卡片/按钮回调异常时仍可用)。
|
// 文本决议词通路保留作降级(旧卡片/按钮回调异常时仍可用)。
|
||||||
let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
let tc_id = v.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||||
if tc_id.is_empty() {
|
if tc_id.is_empty() {
|
||||||
// 无 tool_call_id 无法构造按钮载荷,降级文本卡(回复「批准/拒绝」)
|
// 无 tool_call_id 无法构造按钮载荷,降级文本卡(回复决议词)
|
||||||
let card = if brief.is_empty() {
|
let card = if brief.is_empty() {
|
||||||
format!("⏸ 需审批: `{name}`。回复「批准」或「拒绝」继续")
|
format!("⏸ 需审批: `{name}`。回复「同意 / 批准」或「拒绝」继续")
|
||||||
} else {
|
} else {
|
||||||
format!("⏸ 需审批: `{name}` {brief}。回复「批准」或「拒绝」继续")
|
format!("⏸ 需审批: `{name}` {brief}。回复「同意 / 批准」或「拒绝」继续")
|
||||||
};
|
};
|
||||||
send_markdown_card(&ctx.client, "", &chat_id, &card).await;
|
send_markdown_card(&ctx.client, "", &chat_id, &card).await;
|
||||||
} else {
|
} else {
|
||||||
@@ -1470,6 +1621,150 @@ async fn run_approval_notifier(ctx: Arc<Ctx>) {
|
|||||||
}
|
}
|
||||||
feishu_log(&format!("审批挂起推送 chat={chat_id} conv={cid} tool={name}"));
|
feishu_log(&format!("审批挂起推送 chat={chat_id} conv={cid} tool={name}"));
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换代通知聚合窗(P1-B 方案甲 §1.4 节流从严):首条换代事件开 30s 窗,窗尾同 chat 合并
|
||||||
|
/// 一卡——多会话并行时同窗多代,逐条直推刷屏
|
||||||
|
const COMPRESS_NOTIFY_WINDOW: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// 停靠原因类别中文映射(stop_kind 词表 7 值,ai/mod.rs tests_task_parked 词表锁定;
|
||||||
|
/// 未知/缺省值走兜底,新增词表值须同步此处)
|
||||||
|
fn stop_kind_zh(kind: &str) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
"approval_timeout" => "审批超时",
|
||||||
|
"cancelled" => "已取消",
|
||||||
|
"circuit_breaker" => "连续失败熔断",
|
||||||
|
"stall_breaker" => "进展停滞熔断",
|
||||||
|
"max_rounds" => "轮次预算用尽",
|
||||||
|
"fatal" => "致命错误",
|
||||||
|
"midstream" => "中途断流",
|
||||||
|
_ => "未知原因",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停靠通知卡文案(纯函数,单测锁死三要素:类别+原因+恢复指引)。恢复指引只交底真实
|
||||||
|
/// 出路:桌面停靠卡「继续」或直接发新指令接力(飞书侧无 /continue 命令,文案不承诺)
|
||||||
|
fn park_notify_text(stop_kind: &str, reason: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"⏸ 任务已停靠({}):{}\n桌面端会话可点「继续」恢复执行,也可直接在此发新指令接力。",
|
||||||
|
stop_kind_zh(stop_kind),
|
||||||
|
truncate(reason, 200),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换代通知单行文案(纯函数):代号+摘要首行(压缩换代对用户的可感知面就一句;
|
||||||
|
/// 空摘要退化为纯换代告知,不出悬空冒号)
|
||||||
|
fn compress_notify_line(seq: u32, summary: &str) -> String {
|
||||||
|
let first = summary.lines().next().unwrap_or("").trim();
|
||||||
|
if first.is_empty() {
|
||||||
|
format!("🔄 会话上下文已自动压缩换代(第 {seq} 代)。")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"🔄 会话上下文已自动压缩换代(第 {seq} 代):{}",
|
||||||
|
truncate(first, 60)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 聚合窗合并卡文案(纯函数):单会话单行直出;同 chat 多会话合并为清单
|
||||||
|
fn merge_compress_notify(lines: &[String]) -> String {
|
||||||
|
match lines {
|
||||||
|
[one] => one.clone(),
|
||||||
|
many => format!(
|
||||||
|
"🔄 {} 个会话上下文已自动换代:\n{}",
|
||||||
|
many.len(),
|
||||||
|
many.iter().map(|l| format!("- {l}")).collect::<Vec<_>>().join("\n")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换代通知(分析 P1-B §1.4,2026-10-21):渠道生命周期订阅任务(与 run_approval_notifier
|
||||||
|
/// 同款骨架,跨重连常驻)。消费 ai_event_bus 的 AiTaskParked/AiAutoCompressed,反查 conv
|
||||||
|
/// 绑定飞书 chat 推文本卡;无绑定(纯桌面会话)不推,零打扰。此前两事件已广播(双写底座
|
||||||
|
/// emit_ai_chat_event)但本文件零消费——停靠/换代对飞书侧完全无感知。
|
||||||
|
/// AiTaskParked 同因去重(重放/多重终局汇聚不重推,恢复后再停靠因内容不同自然放行);
|
||||||
|
/// AiAutoCompressed 走 30s 聚合窗(同 chat 合并一卡);换代代号=进程内递增计数(方案甲
|
||||||
|
/// 零 DDL,持久化代号建模留 P1 检索批),重启归 1。
|
||||||
|
async fn run_generation_notifier(ctx: Arc<Ctx>) {
|
||||||
|
let state = ctx.app.state::<crate::state::AppState>();
|
||||||
|
let mut rx = state.ai_event_bus.subscribe();
|
||||||
|
// 停靠去重记忆:conv → 最近一次已推 (reason, stop_kind)
|
||||||
|
let mut pushed_parks: HashMap<String, (String, String)> = HashMap::new();
|
||||||
|
// 换代号:conv → 进程内压缩换代计数
|
||||||
|
let mut gen_seq: HashMap<String, u32> = HashMap::new();
|
||||||
|
// 聚合窗:chat → 待推行;flush_at=None=无窗,Some=窗尾时刻
|
||||||
|
let mut pending_compress: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
let mut flush_at: Option<tokio::time::Instant> = None;
|
||||||
|
loop {
|
||||||
|
let item = if let Some(deadline) = flush_at {
|
||||||
|
match tokio::time::timeout_at(deadline, rx.recv()).await {
|
||||||
|
// 窗到:同 chat 合并推送后重开等事件(无窗期零 select 开销)
|
||||||
|
Err(_) => {
|
||||||
|
flush_at = None;
|
||||||
|
let chats = pending_compress.len();
|
||||||
|
for (chat_id, lines) in pending_compress.drain() {
|
||||||
|
let body = merge_compress_notify(&lines);
|
||||||
|
let _ = send_markdown_card(&ctx.client, "", &chat_id, &body).await;
|
||||||
|
}
|
||||||
|
feishu_log(&format!("换代通知聚合窗推送 chats={chats}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(item) => item,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rx.recv().await
|
||||||
|
};
|
||||||
|
match item {
|
||||||
|
Ok(v) => match v.get("type").and_then(|t| t.as_str()) {
|
||||||
|
Some("AiTaskParked") => {
|
||||||
|
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("").to_string();
|
||||||
|
let kind = v.get("stop_kind").and_then(|k| k.as_str()).unwrap_or("").to_string();
|
||||||
|
if pushed_parks.get(cid) == Some(&(reason.clone(), kind.clone())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// 去重记忆防无界(低频事件,超限全清只失去去重,无正确性影响)
|
||||||
|
if pushed_parks.len() > 256 {
|
||||||
|
pushed_parks.clear();
|
||||||
|
}
|
||||||
|
pushed_parks.insert(cid.to_string(), (reason.clone(), kind.clone()));
|
||||||
|
let text = park_notify_text(&kind, &reason);
|
||||||
|
let _ = send_markdown_card(&ctx.client, "", &chat_id, &text).await;
|
||||||
|
feishu_log(&format!("停靠通知推送 chat={chat_id} conv={cid} kind={kind}"));
|
||||||
|
}
|
||||||
|
Some("AiAutoCompressed") => {
|
||||||
|
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let seq = gen_seq
|
||||||
|
.entry(cid.to_string())
|
||||||
|
.and_modify(|n| *n += 1)
|
||||||
|
.or_insert(1);
|
||||||
|
let line = compress_notify_line(
|
||||||
|
*seq,
|
||||||
|
v.get("summary").and_then(|s| s.as_str()).unwrap_or(""),
|
||||||
|
);
|
||||||
|
pending_compress.entry(chat_id).or_default().push(line);
|
||||||
|
if flush_at.is_none() {
|
||||||
|
flush_at = Some(tokio::time::Instant::now() + COMPRESS_NOTIFY_WINDOW);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||||
}
|
}
|
||||||
@@ -1505,15 +1800,8 @@ async fn handle_card_action(
|
|||||||
if chat_id.is_empty() {
|
if chat_id.is_empty() {
|
||||||
let state = ctx.app.state::<crate::state::AppState>();
|
let state = ctx.app.state::<crate::state::AppState>();
|
||||||
if let Some(cid) = approval_conversation_id(&state, &approval_id).await {
|
if let Some(cid) = approval_conversation_id(&state, &approval_id).await {
|
||||||
chat_id = ctx
|
if let Some(found) = chat_of_conv(&ctx, &cid).await {
|
||||||
.chat_map
|
chat_id = found;
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.iter()
|
|
||||||
.find(|(_, c)| c.as_str() == cid)
|
|
||||||
.map(|(k, _)| k.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
if !chat_id.is_empty() {
|
|
||||||
feishu_log(&format!("卡片回调 chat_id 缺失,反查补齐 chat={chat_id}"));
|
feishu_log(&format!("卡片回调 chat_id 缺失,反查补齐 chat={chat_id}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1575,9 +1863,14 @@ async fn handle_card_action(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F5-3 结果 watcher 超时话术:60s 未见完成事件时回告去向,不静默弃守让用户空等
|
||||||
|
const APPROVAL_RESULT_TIMEOUT_MSG: &str =
|
||||||
|
"⏳ 工具执行耗时较长(超 60s 仍无结果),已停止自动回推;完成后可在桌面端查看该工具的执行结果。";
|
||||||
|
|
||||||
/// 审批决议后执行结果 watcher(2026-10-08):订阅 ai_event_bus 的 AiToolCallCompleted
|
/// 审批决议后执行结果 watcher(2026-10-08):订阅 ai_event_bus 的 AiToolCallCompleted
|
||||||
/// (匹配 tool_call_id),拿 result 摘要回推 chat。原决议后只回「已批准」,用户不知工具
|
/// (匹配 tool_call_id),拿 result 摘要回推 chat。原决议后只回「已批准」,用户不知工具
|
||||||
/// 执行结果(成功/失败/输出)。设 60s 超时兜底(工具执行通常秒级,长工具超时静默放弃)。
|
/// 执行结果(成功/失败/输出)。设 60s 超时兜底(工具执行通常秒级,长工具超时回告去向
|
||||||
|
/// 后放弃,F5-3 修静默弃守)。
|
||||||
///
|
///
|
||||||
/// 订阅须在 ai_approve 调用前完成——publish_event 在无订阅者时跳过序列化,晚订阅必丢事件。
|
/// 订阅须在 ai_approve 调用前完成——publish_event 在无订阅者时跳过序列化,晚订阅必丢事件。
|
||||||
fn spawn_approval_result_watcher(
|
fn spawn_approval_result_watcher(
|
||||||
@@ -1592,6 +1885,7 @@ fn spawn_approval_result_watcher(
|
|||||||
loop {
|
loop {
|
||||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
if remaining.is_zero() {
|
if remaining.is_zero() {
|
||||||
|
let _ = send_markdown_card(&ctx.client, "", &chat_id, APPROVAL_RESULT_TIMEOUT_MSG).await;
|
||||||
feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}"));
|
feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1617,6 +1911,7 @@ fn spawn_approval_result_watcher(
|
|||||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
|
Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
|
||||||
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return,
|
Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
let _ = send_markdown_card(&ctx.client, "", &chat_id, APPROVAL_RESULT_TIMEOUT_MSG).await;
|
||||||
feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}"));
|
feishu_log(&format!("[approval-result] 等待工具结果超时 tc={tool_call_id}"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1635,10 +1930,12 @@ async fn send_approval_button_card(
|
|||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
brief: &str,
|
brief: &str,
|
||||||
) {
|
) {
|
||||||
|
// F5-1 文案统一:副注提示文字决议词(按钮不可用时降级通道),词表与 parse_approval_verdict 同源
|
||||||
|
let hint = "点击按钮,或回复「同意 / 批准 / 通过 / 拒绝」";
|
||||||
let detail = if brief.is_empty() {
|
let detail = if brief.is_empty() {
|
||||||
format!("⏸ 需审批: `{tool_name}`")
|
format!("⏸ 需审批: `{tool_name}`\n\n{hint}")
|
||||||
} else {
|
} else {
|
||||||
format!("⏸ 需审批: `{tool_name}`\n\n{brief}")
|
format!("⏸ 需审批: `{tool_name}`\n\n{brief}\n\n{hint}")
|
||||||
};
|
};
|
||||||
// 按钮 value 需为 SDK 的 JsonValue(其实现了 From<serde_json::Value>,直接 into 转换)
|
// 按钮 value 需为 SDK 的 JsonValue(其实现了 From<serde_json::Value>,直接 into 转换)
|
||||||
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue { v.into() };
|
let to_json_value = |v: serde_json::Value| -> larksuite_oapi_sdk_rs::JsonValue { v.into() };
|
||||||
@@ -1669,7 +1966,7 @@ async fn send_approval_button_card(
|
|||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("[feishu] 审批按钮卡校验失败,降级文本卡: {e}");
|
tracing::warn!("[feishu] 审批按钮卡校验失败,降级文本卡: {e}");
|
||||||
let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续");
|
let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「同意 / 批准 / 通过 / 拒绝」继续");
|
||||||
send_markdown_card(client, "", chat_id, &fallback).await;
|
send_markdown_card(client, "", chat_id, &fallback).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1686,6 +1983,8 @@ async fn send_approval_button_card(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(sent) if !sent.message_id.is_empty() => {
|
Ok(sent) if !sent.message_id.is_empty() => {
|
||||||
|
// F5-2 登记卡句柄:桌面决议/超时终态事件到达时按 tc_id 原地更新该卡为终态
|
||||||
|
note_approval_card(approval_id, &sent.message_id);
|
||||||
feishu_log(&format!("审批按钮卡已发 chat={chat_id} card={}", sent.message_id));
|
feishu_log(&format!("审批按钮卡已发 chat={chat_id} card={}", sent.message_id));
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -1693,12 +1992,38 @@ async fn send_approval_button_card(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[feishu] 审批按钮卡发送失败,降级文本卡: {e}");
|
tracing::error!("[feishu] 审批按钮卡发送失败,降级文本卡: {e}");
|
||||||
let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「批准」或「拒绝」继续");
|
let fallback = format!("⏸ 需审批: `{tool_name}` {brief}。回复「同意 / 批准 / 通过 / 拒绝」继续");
|
||||||
send_markdown_card(client, "", chat_id, &fallback).await;
|
send_markdown_card(client, "", chat_id, &fallback).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 登记审批按钮卡句柄(终态原地更新用),顺带清窗口外陈旧条目
|
||||||
|
fn note_approval_card(tc_id: &str, message_id: &str) {
|
||||||
|
let now = now_secs();
|
||||||
|
let Ok(mut cards) = approval_cards().lock() else { return };
|
||||||
|
cards.retain(|_, (_, at)| within_resolved_window(*at, now));
|
||||||
|
cards.insert(tc_id.to_string(), (message_id.to_string(), now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// conv → 绑定飞书 chat 反查(notifier 推卡/终态收口共用;无绑定(纯桌面会话)None)
|
||||||
|
async fn chat_of_conv(ctx: &Ctx, conv_id: &str) -> Option<String> {
|
||||||
|
ctx.chat_map
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.find(|(_, c)| c.as_str() == conv_id)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录 chat 维度审批终态(迟到决议提示判据),顺带清窗口外陈旧条目
|
||||||
|
fn note_approval_resolved(chat_id: &str, approved: bool) {
|
||||||
|
let now = now_secs();
|
||||||
|
let Ok(mut chats) = resolved_chats().lock() else { return };
|
||||||
|
chats.retain(|_, (at, _)| within_resolved_window(*at, now));
|
||||||
|
chats.insert(chat_id.to_string(), (now, approved));
|
||||||
|
}
|
||||||
|
|
||||||
/// 该 conv 最新一条挂起审批的 tool_call_id(决议原语的消费目标);无挂起返回 None。
|
/// 该 conv 最新一条挂起审批的 tool_call_id(决议原语的消费目标);无挂起返回 None。
|
||||||
/// created_at 缺失视为最旧(宁取有时序的),同刻并列取遍历后者(HashMap 无序,可接受)
|
/// created_at 缺失视为最旧(宁取有时序的),同刻并列取遍历后者(HashMap 无序,可接受)
|
||||||
async fn latest_pending_approval(
|
async fn latest_pending_approval(
|
||||||
@@ -2516,11 +2841,13 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type,
|
compress_notify_line, ContentPart, DebounceEntry, DELIVERY_GUIDANCE_PROMPT,
|
||||||
file_display_name, file_inbound_prompt, file_size_over_limit_error, heartbeat_due,
|
delivery_blocked, extract_delivery_paths, feishu_file_type, file_display_name,
|
||||||
is_terminal_push, md_to_lark_friendly, merge_debounce_parts, merge_debounce_text,
|
file_inbound_prompt, file_size_over_limit_error, heartbeat_due, is_terminal_push,
|
||||||
msg_after_acceptance, parse_command, sniff_image_mime, strip_mention_placeholders,
|
md_to_lark_friendly, merge_compress_notify, merge_debounce_parts, merge_debounce_text,
|
||||||
stream_preview,
|
msg_after_acceptance, park_notify_text, parse_approval_verdict, parse_command,
|
||||||
|
sniff_image_mime, stop_kind_zh, stream_preview, strip_mention_placeholders,
|
||||||
|
within_resolved_window,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2543,6 +2870,72 @@ mod tests {
|
|||||||
assert_eq!(delivery_blocked("E:\\out\\report.pdf"), None);
|
assert_eq!(delivery_blocked("E:\\out\\report.pdf"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delivery_guidance_teaches_extractable_format() {
|
||||||
|
// 件三(2026-10-21):引导文案与解析器是同一契约——文案教的标记行格式必须能被
|
||||||
|
// extract_delivery_paths 原样提取,教错格式=引导写出的标记全部落空(链路二次空转)
|
||||||
|
assert!(DELIVERY_GUIDANCE_PROMPT.contains("[交付]"), "缺标记格式: {DELIVERY_GUIDANCE_PROMPT}");
|
||||||
|
let marked = "[交付] E:\\out\\report.md\n其余为叙述文本";
|
||||||
|
assert_eq!(
|
||||||
|
extract_delivery_paths(marked),
|
||||||
|
vec!["E:\\out\\report.md".to_string()],
|
||||||
|
"引导教的标记行格式必须可被消费端解析"
|
||||||
|
);
|
||||||
|
// 三要素交底:自动上传机制 / 敏感拦截 / 绝对路径口径
|
||||||
|
assert!(DELIVERY_GUIDANCE_PROMPT.contains("附件"), "缺自动上传机制交底");
|
||||||
|
assert!(DELIVERY_GUIDANCE_PROMPT.contains("拦截"), "缺敏感拦截交底");
|
||||||
|
assert!(DELIVERY_GUIDANCE_PROMPT.contains("绝对路径"), "缺路径口径交底");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_kind_zh_maps_full_vocabulary() {
|
||||||
|
// 停靠词表 7 值全映射(与 ai/mod.rs tests_task_parked 词表对齐),词表值不得落兜底
|
||||||
|
for k in [
|
||||||
|
"approval_timeout",
|
||||||
|
"cancelled",
|
||||||
|
"circuit_breaker",
|
||||||
|
"stall_breaker",
|
||||||
|
"max_rounds",
|
||||||
|
"fatal",
|
||||||
|
"midstream",
|
||||||
|
] {
|
||||||
|
assert_ne!(stop_kind_zh(k), "未知原因", "词表值 {k} 不应落兜底");
|
||||||
|
}
|
||||||
|
assert_eq!(stop_kind_zh("__unknown__"), "未知原因");
|
||||||
|
assert_eq!(stop_kind_zh(""), "未知原因", "stop_kind 缺省走兜底");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn park_and_compress_notify_texts_carry_essentials() {
|
||||||
|
// 停靠卡三要素:类别+原因+恢复指引(指引不承诺飞书侧不存在的 /continue 命令)
|
||||||
|
let park = park_notify_text("approval_timeout", "审批超时(15分钟)自动取消,任务已安全停靠");
|
||||||
|
assert!(park.contains("审批超时"), "缺类别: {park}");
|
||||||
|
assert!(park.contains("审批超时(15分钟)自动取消"), "缺原因: {park}");
|
||||||
|
assert!(park.contains("「继续」"), "缺恢复指引: {park}");
|
||||||
|
assert!(!park.contains("/continue"), "不得承诺不存在的命令: {park}");
|
||||||
|
// 换代行:代号+摘要首行,不带出后续行;空摘要退化纯告知(无悬空冒号)
|
||||||
|
let line = compress_notify_line(3, "第一行摘要\n第二行细节");
|
||||||
|
assert!(line.contains("第 3 代"), "缺代号: {line}");
|
||||||
|
assert!(line.contains("第一行摘要"), "缺摘要首行: {line}");
|
||||||
|
assert!(!line.contains("第二行"), "不得带出后续行: {line}");
|
||||||
|
let bare = compress_notify_line(1, "");
|
||||||
|
assert!(
|
||||||
|
bare.contains("第 1 代") && !bare.contains(':') && !bare.contains(':'),
|
||||||
|
"空摘要应退化纯告知: {bare}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compress_aggregate_window_merges_by_chat() {
|
||||||
|
// 聚合窗合并:同 chat 单会话单行直出;多会话合并为计数+清单
|
||||||
|
let one = vec!["🔄 会话上下文已自动压缩换代(第 1 代)。".to_string()];
|
||||||
|
assert_eq!(merge_compress_notify(&one), one[0], "单行直出不加壳");
|
||||||
|
let many = vec!["换代A".to_string(), "换代B".to_string()];
|
||||||
|
let merged = merge_compress_notify(&many);
|
||||||
|
assert!(merged.contains("2 个会话"), "缺计数: {merged}");
|
||||||
|
assert!(merged.contains("- 换代A") && merged.contains("- 换代B"), "缺清单项: {merged}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feishu_file_type_maps_common_exts() {
|
fn feishu_file_type_maps_common_exts() {
|
||||||
assert_eq!(feishu_file_type("pdf"), "pdf");
|
assert_eq!(feishu_file_type("pdf"), "pdf");
|
||||||
@@ -2858,4 +3251,56 @@ mod tests {
|
|||||||
Some(vec![ContentPart::image_url("https://x/2.png")])
|
Some(vec![ContentPart::image_url("https://x/2.png")])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F5-1 决议词识别(表驱动) ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verdict_table_approve_and_reject_with_tolerance() {
|
||||||
|
// 批准集含按钮卡文案「同意」;拒绝集含「不同意」;空白/尾标点/大小写/半全角容错
|
||||||
|
let table = [
|
||||||
|
// (输入, 期望)
|
||||||
|
("同意", Some(true)),
|
||||||
|
("批准", Some(true)),
|
||||||
|
("通过", Some(true)),
|
||||||
|
("approve", Some(true)),
|
||||||
|
(" 同意 ", Some(true)),
|
||||||
|
("批准。", Some(true)),
|
||||||
|
("同意!", Some(true)),
|
||||||
|
("通过~", Some(true)),
|
||||||
|
(" 拒绝 ", Some(false)), // 全角空格
|
||||||
|
("REJECT", Some(false)),
|
||||||
|
("Approve", Some(true)), // 全角 ASCII 变体
|
||||||
|
("拒绝", Some(false)),
|
||||||
|
("驳回", Some(false)),
|
||||||
|
("不同意", Some(false)),
|
||||||
|
("驳回。", Some(false)),
|
||||||
|
("不同意?", Some(false)),
|
||||||
|
];
|
||||||
|
for (input, expected) in table {
|
||||||
|
assert_eq!(parse_approval_verdict(input), expected, "case {input:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verdict_plain_text_not_misjudged() {
|
||||||
|
// 普通文本/带语气词/长句/子串形态不误判(整词精确匹配,「不同意」含「同意」靠整词防反判)
|
||||||
|
let plain = [
|
||||||
|
"你好", "我同意这个方案", "不同意这个做法", "批准吧", "通不过", "同意一下再看看",
|
||||||
|
"/status", "拒绝的理由如下", "通过率不高", "", "ok 同意吗",
|
||||||
|
];
|
||||||
|
for input in plain {
|
||||||
|
assert_eq!(parse_approval_verdict(input), None, "case {input:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── F5-2 迟到决议窗口判定 ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolved_window_boundary() {
|
||||||
|
// 30min 窗口内算「刚终态」(迟到决议提示),窗外视为无审批语境(普通消息语义)
|
||||||
|
assert!(within_resolved_window(1000, 1000 + 1799));
|
||||||
|
assert!(within_resolved_window(1000, 1000 + 1800 - 1));
|
||||||
|
assert!(!within_resolved_window(1000, 1000 + 1800));
|
||||||
|
assert!(!within_resolved_window(1000, 1000 + 7200));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,6 +496,8 @@ pub fn run() {
|
|||||||
commands::ai::ai_pending_tool_calls,
|
commands::ai::ai_pending_tool_calls,
|
||||||
commands::ai::ai_chat_clear,
|
commands::ai::ai_chat_clear,
|
||||||
commands::ai::ai_is_generating,
|
commands::ai::ai_is_generating,
|
||||||
|
// 桌面拖拽/粘贴文件落盘(治「文件内容被当文本塞进输入框」)
|
||||||
|
commands::ai::ai_save_dropped_file,
|
||||||
// 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队)
|
// 桌面端补充注入(运行中会话 u-claw 语义:补充并进当前回答,不开新轮不入队)
|
||||||
commands::ai::ai_inject_runtime,
|
commands::ai::ai_inject_runtime,
|
||||||
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
|
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
|
||||||
@@ -532,6 +534,8 @@ pub fn run() {
|
|||||||
commands::ai::ai_conversation_archive,
|
commands::ai::ai_conversation_archive,
|
||||||
commands::ai::ai_conversation_set_pinned,
|
commands::ai::ai_conversation_set_pinned,
|
||||||
commands::ai::ai_update_conversation_goals,
|
commands::ai::ai_update_conversation_goals,
|
||||||
|
// P1-A 跨会话消息检索(F4-1 后端搜索面;AI 工具 history_search 共用同源内核)
|
||||||
|
commands::ai::search_messages,
|
||||||
// 无人值守任务边界(设计 §2.3):发起时一次拍板 / 回显;清除=回退逐次审批
|
// 无人值守任务边界(设计 §2.3):发起时一次拍板 / 回显;清除=回退逐次审批
|
||||||
commands::ai::ai_task_boundary_get,
|
commands::ai::ai_task_boundary_get,
|
||||||
commands::ai::ai_task_boundary_set,
|
commands::ai::ai_task_boundary_set,
|
||||||
@@ -547,8 +551,6 @@ pub fn run() {
|
|||||||
commands::ai::ai_set_allowed_dirs,
|
commands::ai::ai_set_allowed_dirs,
|
||||||
// 工具风险元数据投影(前端启动构建 High 风险名单,审批二次确认门控)
|
// 工具风险元数据投影(前端启动构建 High 风险名单,审批二次确认门控)
|
||||||
commands::ai::ai_list_tools,
|
commands::ai::ai_list_tools,
|
||||||
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
|
||||||
commands::ai::ai_authorize_dir,
|
|
||||||
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
|
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
|
||||||
commands::ai::audit::record::list_tool_executions,
|
commands::ai::audit::record::list_tool_executions,
|
||||||
// S4 TaskDrawer 关联会话:按 task_id 反查发起过任务工具的主会话(审计表推导,只读)
|
// S4 TaskDrawer 关联会话:按 task_id 反查发起过任务工具的主会话(审计表推导,只读)
|
||||||
@@ -566,6 +568,9 @@ pub fn run() {
|
|||||||
commands::knowledge::knowledge_record_reuse,
|
commands::knowledge::knowledge_record_reuse,
|
||||||
commands::knowledge::knowledge_list_candidates,
|
commands::knowledge::knowledge_list_candidates,
|
||||||
commands::knowledge::knowledge_archive,
|
commands::knowledge::knowledge_archive,
|
||||||
|
commands::knowledge::knowledge_archive_stale,
|
||||||
|
commands::knowledge::knowledge_list_related,
|
||||||
|
commands::knowledge::knowledge_distill_group,
|
||||||
commands::knowledge::knowledge_retry_embedding,
|
commands::knowledge::knowledge_retry_embedding,
|
||||||
commands::knowledge::knowledge_get_config,
|
commands::knowledge::knowledge_get_config,
|
||||||
commands::knowledge::knowledge_save_config,
|
commands::knowledge::knowledge_save_config,
|
||||||
@@ -604,7 +609,6 @@ pub fn run() {
|
|||||||
commands::settings::save_template,
|
commands::settings::save_template,
|
||||||
commands::settings::delete_template,
|
commands::settings::delete_template,
|
||||||
// CI 状态
|
// CI 状态
|
||||||
commands::ci_status::get_commit_status,
|
|
||||||
// Phase3: 内嵌 HTTP MCP server 状态查询
|
// Phase3: 内嵌 HTTP MCP server 状态查询
|
||||||
crate::mcp::mcp_get_status,
|
crate::mcp::mcp_get_status,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -103,6 +103,29 @@ impl MediaStore {
|
|||||||
.map(|(url, _)| url)
|
.map(|(url, _)| url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 桌面拖拽/粘贴的非图片文件落盘(attachments/ 目录,本机 read_file 语境)。
|
||||||
|
/// 与 media/ 图片分目录:文件不进 OSS(跨设备 https 引用对本机 read_file 无意义),
|
||||||
|
/// hash 去重命名复用 content_key(原名+扩展保真)。返回本机绝对路径供前端插引用。
|
||||||
|
pub async fn store_attachment(
|
||||||
|
&self,
|
||||||
|
bytes: &[u8],
|
||||||
|
original_name: &str,
|
||||||
|
) -> Result<std::path::PathBuf, String> {
|
||||||
|
let key = content_key(bytes, "application/octet-stream", Some(original_name));
|
||||||
|
let dir = self.data_dir.join("attachments");
|
||||||
|
tokio::fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("创建附件目录失败: {e}"))?;
|
||||||
|
let path = dir.join(&key);
|
||||||
|
if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
|
||||||
|
if let Err(e) = tokio::fs::write(&path, bytes).await {
|
||||||
|
tokio::fs::remove_file(&path).await.ok();
|
||||||
|
return Err(format!("附件写入失败: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机
|
/// 落盘并返回 (引用 url, 本机绝对路径)。文件消息入站用:模型 read_file 只认本机
|
||||||
/// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用,
|
/// 路径,OSS `https://` 引用对本机读取无意义,须另取落盘路径;图片入站只要引用,
|
||||||
/// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致
|
/// 走 save 薄包装。落盘/双写/降级语义与 save 完全一致
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ impl AllowedDirs {
|
|||||||
///
|
///
|
||||||
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
||||||
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
||||||
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
|
/// - `NeedsAuthorization`:路径未授权但非黑名单 → 挂起弹窗(已随 path_auth 退役)
|
||||||
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum PathAuthDecision {
|
pub enum PathAuthDecision {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "devflow",
|
"productName": "devflow",
|
||||||
"version": "0.1.2",
|
"version": "0.1.5",
|
||||||
"identifier": "top.1216.devflow",
|
"identifier": "top.1216.devflow",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun dev",
|
"beforeDevCommand": "bun dev",
|
||||||
|
|||||||
+22
-72
@@ -31,22 +31,21 @@
|
|||||||
|
|
||||||
<!-- 走查 P3-16:全局 toast 统一收进同一纵向堆叠容器。原错误 toast 与托盘提示气泡
|
<!-- 走查 P3-16:全局 toast 统一收进同一纵向堆叠容器。原错误 toast 与托盘提示气泡
|
||||||
各自 fixed 同位(bottom:24px 居中),两者同现时互相压盖、后弹者盖住先弹者。 -->
|
各自 fixed 同位(bottom:24px 居中),两者同现时互相压盖、后弹者盖住先弹者。 -->
|
||||||
<div class="df-toast-stack">
|
<div class="app-toast-stack">
|
||||||
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
|
<!-- 全局错误 toast(根级,主窗口与分离窗口共用)-->
|
||||||
<transition name="toast">
|
<transition name="toast">
|
||||||
<div v-if="errorMsg" class="df-toast">⚠ {{ errorMsg }}</div>
|
<div v-if="errorMsg" class="app-toast">⚠ {{ errorMsg }}</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
|
<!-- 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice):
|
||||||
告知会话转入后台继续生成、托盘图标可随时唤起,复用 df-toast 形态 -->
|
告知会话转入后台继续生成、托盘图标可随时唤起,复用 app-toast 形态 -->
|
||||||
<transition name="toast">
|
<transition name="toast">
|
||||||
<div v-if="trayNoticeMsg" class="df-toast df-toast--info">{{ trayNoticeMsg }}</div>
|
<div v-if="trayNoticeMsg" class="app-toast app-toast--info">{{ trayNoticeMsg }}</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
|
<!-- 审批待办浮层(应用内右上角,窗口聚焦/可见时作快速提示)。
|
||||||
仅在主窗口与 ai-detached 窗口挂载(file-explorer / approval-popup 自身不挂)。
|
仅在主窗口与 ai-detached 窗口挂载(file-explorer 分离窗不挂)。 -->
|
||||||
应用失焦/最小化时的系统级提示由 useApprovalPopup 启动的独立浮窗(approval-popup)负责。 -->
|
|
||||||
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
<ApprovalOverlay v-if="showApprovalOverlay" @activate="activateAiPanel" />
|
||||||
|
|
||||||
<!-- 人工审批对话框(全局,任意路由响应 store.pendingApproval)。
|
<!-- 人工审批对话框(全局,任意路由响应 store.pendingApproval)。
|
||||||
@@ -73,20 +72,16 @@ import ApprovalOverlay from './components/ai/ApprovalOverlay.vue'
|
|||||||
import ApprovalDialog from './components/project/ApprovalDialog.vue'
|
import ApprovalDialog from './components/project/ApprovalDialog.vue'
|
||||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||||
import AppLayout from './components/layout/AppLayout.vue'
|
import AppLayout from './components/layout/AppLayout.vue'
|
||||||
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
|
|
||||||
import { startUpdateAutoCheck } from '@/composables/useUpdateCheck'
|
import { startUpdateAutoCheck } from '@/composables/useUpdateCheck'
|
||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached' || route.path === '/approval-popup')
|
const isDetached = computed(() => route.path === '/ai-detached' || route.path === '/file-explorer-detached')
|
||||||
const aiStore = useAiStore()
|
const aiStore = useAiStore()
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer / approval-popup 等其他分离窗口弹)
|
// 审批浮层仅在主窗口与 ai-detached 窗口挂载(不在 file-explorer 等其他分离窗口弹)
|
||||||
// approval-popup 是系统级浮窗本身,内部已显式渲染 pending 列表,再挂 ApprovalOverlay 会重复
|
const showApprovalOverlay = computed(() => route.path !== '/file-explorer-detached')
|
||||||
const showApprovalOverlay = computed(() =>
|
|
||||||
route.path !== '/file-explorer-detached' && route.path !== '/approval-popup'
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
/** 用户点击审批浮层 → 激活 AI 面板/分离窗口。
|
||||||
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
* 主窗口:打开 AI 面板;分离窗口:提升焦点(窗口本身 alwaysOnTop 已置顶)。 */
|
||||||
@@ -114,7 +109,7 @@ watch(() => projectStore.error, (err) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)──
|
// ── 关窗后台运行气泡(主窗关闭被拦截且仍有会话运行时,后端 emit tray-hide-notice)──
|
||||||
// 与错误 toast 同款 df-toast 形态,信息语义走 --info 变体底色,不与错误红混用
|
// 与错误 toast 同款 app-toast 形态,信息语义走 --info 变体底色,不与错误红混用
|
||||||
const trayNoticeMsg = ref('')
|
const trayNoticeMsg = ref('')
|
||||||
let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null
|
let trayNoticeTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let _unlistenTrayHideNotice: (() => void) | null = null
|
let _unlistenTrayHideNotice: (() => void) | null = null
|
||||||
@@ -122,7 +117,6 @@ let _unlistenTrayHideNotice: (() => void) | null = null
|
|||||||
// ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)──
|
// ── 人工审批对话框(全局,工作流推进到 HumanNode 审批时,任意页面都应能响应)──
|
||||||
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
|
// 原 ProjectDetail 局部渲染 → TaskDetail 无 ApprovalDialog 致 store.pendingApproval 被设但
|
||||||
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
|
// 用户在 TaskDetail 页无法操作 → HumanNode 超时失败。提到 App.vue 全局化:任何路由响应审批。
|
||||||
// (系统级浮窗 useApprovalPopup 是窗口失焦时的提示;此处是聚焦时弹的对话框,二者职责互补)
|
|
||||||
const showApprovalDialog = ref(false)
|
const showApprovalDialog = ref(false)
|
||||||
watch(() => projectStore.pendingApproval, (newApproval) => {
|
watch(() => projectStore.pendingApproval, (newApproval) => {
|
||||||
if (newApproval) {
|
if (newApproval) {
|
||||||
@@ -146,7 +140,7 @@ const restoreMaximizedOnReturn = ref(false)
|
|||||||
|
|
||||||
watch(() => route.path, (path) => {
|
watch(() => route.path, (path) => {
|
||||||
// 分离窗口不参与主面板最大化联动:分离窗是独立 JS context,state 与主窗无关;
|
// 分离窗口不参与主面板最大化联动:分离窗是独立 JS context,state 与主窗无关;
|
||||||
// 且其 path(/ai-detached、/file-explorer-detached、/approval-popup)不在 AI 域内,
|
// 且其 path(/ai-detached、/file-explorer-detached)不在 AI 域内,
|
||||||
// 若不守卫会因分离窗默认 maximized=true 误触发 setMaximized(false) 并落盘,
|
// 若不守卫会因分离窗默认 maximized=true 误触发 setMaximized(false) 并落盘,
|
||||||
// 覆盖主窗口的 df-ai-ui 布局
|
// 覆盖主窗口的 df-ai-ui 布局
|
||||||
if (isDetached.value) return
|
if (isDetached.value) return
|
||||||
@@ -189,31 +183,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统级审批浮窗(独立窗口):仅主窗口启动(ai-detached/approval-popup 自身不启动,避免重复 watch)
|
|
||||||
let _stopApprovalPopup: (() => void) | null = null
|
|
||||||
let _unlistenPopupReady: (() => void) | null = null
|
|
||||||
async function activateForPopup() {
|
|
||||||
// 浮窗「打开审批面板」按钮 → 聚焦主窗口 + 打开 AI 面板/激活分离窗口
|
|
||||||
// 修复 BUG1:setFocus 先行(已授权,失焦场景有效),show 兜底(需 core:window:allow-show,
|
|
||||||
// capabilities/default.json 已补)。原顺序 show→setFocus,show reject 直接吞掉 setFocus,
|
|
||||||
// 主窗口最小化/隐藏时 show 失败 → 用户看到「点了没反应」。
|
|
||||||
try {
|
|
||||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
|
||||||
const mainWin = await WebviewWindow.getByLabel('main')
|
|
||||||
if (mainWin) {
|
|
||||||
// setFocus 先行(对未聚焦但可见的窗口即可恢复焦点);show 兜底取消最小化/隐藏
|
|
||||||
try { await mainWin.setFocus() } catch { /* setFocus 失败不阻塞 show */ }
|
|
||||||
try { await mainWin.show() } catch { /* show 未授权或不可用,忽略(setFocus 已尽力) */ }
|
|
||||||
}
|
|
||||||
} catch { /* 忽略 */ }
|
|
||||||
if (!aiStore.state.detached && !aiStore.state.panelOpen) {
|
|
||||||
aiStore.togglePanel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
|
// 1. 先把 SQLite 全表 KV 填进 appSettings 缓存(后续 get/useSetting 才能读到真实值)
|
||||||
|
// 包 try/catch 防 loadAll reject 中断 onMounted 后续初始化(对齐下方各步 try/catch 模式)
|
||||||
|
try {
|
||||||
await appSettings.loadAll()
|
await appSettings.loadAll()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('启动加载应用设置失败:', e)
|
||||||
|
}
|
||||||
// 2. 一次性迁移:把旧 localStorage 的 11 个 key 导入 SQLite(仅首次跑,之后 localStorage 清净)
|
// 2. 一次性迁移:把旧 localStorage 的 11 个 key 导入 SQLite(仅首次跑,之后 localStorage 清净)
|
||||||
await migrateLegacyLocalStorage()
|
await migrateLegacyLocalStorage()
|
||||||
// 3. 缓存已就绪,初始化主题/locale
|
// 3. 缓存已就绪,初始化主题/locale
|
||||||
@@ -267,33 +244,9 @@ onMounted(async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('关窗后台运行提示监听失败:', e)
|
console.error('关窗后台运行提示监听失败:', e)
|
||||||
}
|
}
|
||||||
// 系统级审批浮窗:仅主窗口启动(approval-popup 自身路由不启动,避免重复 watch)。
|
|
||||||
// 设计见 useApprovalPopup.ts:主窗口 watch pendingApprovals → 弹/更新/关浮窗(单一真相源)。
|
|
||||||
if (route.path !== '/approval-popup') {
|
|
||||||
_stopApprovalPopup = startApprovalPopup(
|
|
||||||
() => aiStore.state.pendingApprovals,
|
|
||||||
activateForPopup,
|
|
||||||
)
|
|
||||||
// 浮窗 webview 挂载后会 emit 'approval-popup-ready' 请求初始快照(防主窗口在浮窗 listen 前
|
|
||||||
// emit 丢失)。此处 listen 后立即重推当前 pendingApprovals。
|
|
||||||
try {
|
|
||||||
const { listen } = await import('@tauri-apps/api/event')
|
|
||||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
|
||||||
_unlistenPopupReady = await listen('approval-popup-ready', async () => {
|
|
||||||
const popup = await WebviewWindow.getByLabel('approval-popup')
|
|
||||||
if (popup) {
|
|
||||||
await popup.emit('approval-popup-update', { approvals: aiStore.state.pendingApprovals })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[ApprovalPopup] ready 监听失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// B2 应用内升级:自动检查调度(启动后 3s 首查 + 周期检查,同版本只提示一次)。
|
// B2 应用内升级:自动检查调度(启动后 3s 首查 + 周期检查,同版本只提示一次)。
|
||||||
// 幂等(模块级单例),仅主窗口启动;auto_check=false 时内部自动跳过。
|
// 幂等(模块级单例),仅主窗口启动;auto_check=false 时内部自动跳过。
|
||||||
if (route.path !== '/approval-popup') {
|
|
||||||
startUpdateAutoCheck()
|
startUpdateAutoCheck()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite),
|
/// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite),
|
||||||
@@ -334,10 +287,6 @@ const knowledgeStore = useKnowledgeStore()
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', handleKeydown)
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
projectStore.stopDataChangedListener?.()
|
projectStore.stopDataChangedListener?.()
|
||||||
_stopApprovalPopup?.()
|
|
||||||
_unlistenPopupReady?.()
|
|
||||||
_stopApprovalPopup = null
|
|
||||||
_unlistenPopupReady = null
|
|
||||||
_unlistenTrayHideNotice?.()
|
_unlistenTrayHideNotice?.()
|
||||||
_unlistenTrayHideNotice = null
|
_unlistenTrayHideNotice = null
|
||||||
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
|
if (trayNoticeTimer) clearTimeout(trayNoticeTimer)
|
||||||
@@ -353,20 +302,21 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══
|
/* ═══ 全局 toast 纵向堆叠容器(走查 P3-16)═══
|
||||||
容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。
|
容器独占 fixed 定位,内部 flex 纵排 + 底对齐,多条 toast 依次向上堆叠不再同位互盖。
|
||||||
子项保持 .df-toast 原形态(定宽自适应),定位字段从子项上移到容器。 */
|
子项 .app-toast 独立命名(2026-10-18 与 global.css .df-toast 顶部居中族撞名根治,
|
||||||
.df-toast-stack {
|
不再依赖定位覆盖对冲),主题保持根级强信号形态(实底白字)。 */
|
||||||
|
.app-toast-stack {
|
||||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
z-index: 9999; max-width: 90vw; pointer-events: none;
|
z-index: 9999; max-width: 90vw; pointer-events: none;
|
||||||
}
|
}
|
||||||
.df-toast {
|
.app-toast {
|
||||||
background: var(--df-danger); color: #fff;
|
background: var(--df-danger); color: #fff;
|
||||||
padding: 10px 18px; border-radius: var(--df-radius-sm);
|
padding: 10px 18px; border-radius: var(--df-radius-sm);
|
||||||
font-size: 13px; max-width: 90vw;
|
font-size: 13px; max-width: 90vw;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
box-shadow: var(--df-shadow-md);
|
||||||
}
|
}
|
||||||
.toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); }
|
.toast-enter-active, .toast-leave-active { transition: opacity 0.25s var(--df-ease), transform 0.25s var(--df-ease); }
|
||||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(10px); }
|
||||||
/* 后台运行提示:同 df-toast 形态,信息语义用 accent 底色与错误红区分 */
|
/* 后台运行提示:同 app-toast 形态,信息语义用 accent 底色与错误红区分(实底需白字) */
|
||||||
.df-toast--info { background: var(--df-accent); }
|
.app-toast--info { background: var(--df-accent); color: #fff; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+6
-23
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types'
|
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, GoalEntry, MentionSpan, ModelConfig, SkillInfo, TaskBoundary } from './types'
|
||||||
|
|
||||||
export const aiApi = {
|
export const aiApi = {
|
||||||
/**
|
/**
|
||||||
@@ -75,15 +75,6 @@ export const aiApi = {
|
|||||||
return invoke('ai_approve', { toolCallId, approved })
|
return invoke('ai_approve', { toolCallId, approved })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
|
||||||
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
|
|
||||||
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
|
||||||
*/
|
|
||||||
authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
|
|
||||||
return invoke('ai_authorize_dir', { toolCallId, decision })
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 续跑 agentic 循环(达 max_iterations 暂停态点继续)。
|
* 续跑 agentic 循环(达 max_iterations 暂停态点继续)。
|
||||||
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
|
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
|
||||||
@@ -107,8 +98,8 @@ export const aiApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
|
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
|
||||||
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
|
* 返 kind 字段(恒 'risk')与后端超时到期时刻(前端倒计时单一真值)。 */
|
||||||
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
|
pendingToolCalls(conversationId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path'; expires_at_millis?: number | null }[]> {
|
||||||
return invoke('ai_pending_tool_calls', { conversationId })
|
return invoke('ai_pending_tool_calls', { conversationId })
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -290,21 +281,11 @@ export const aiApi = {
|
|||||||
return invoke('ai_reload_skills')
|
return invoke('ai_reload_skills')
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
|
|
||||||
getAllowedDirs(): Promise<string[]> {
|
|
||||||
return invoke<string[]>('ai_get_allowed_dirs')
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 列出全部已注册 AI 工具的名称与风险级(审批高风险名单的权威数据源) */
|
/** 列出全部已注册 AI 工具的名称与风险级(审批高风险名单的权威数据源) */
|
||||||
listTools(): Promise<AiToolMeta[]> {
|
listTools(): Promise<AiToolMeta[]> {
|
||||||
return invoke('ai_list_tools')
|
return invoke('ai_list_tools')
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
|
|
||||||
setAllowedDirs(dirs: string[]): Promise<string[]> {
|
|
||||||
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
|
|
||||||
},
|
|
||||||
|
|
||||||
/** 监听 AI 聊天事件 */
|
/** 监听 AI 聊天事件 */
|
||||||
onEvent(callback: (event: AiChatEvent) => void): Promise<UnlistenFn> {
|
onEvent(callback: (event: AiChatEvent) => void): Promise<UnlistenFn> {
|
||||||
return listen<AiChatEvent>('ai-chat-event', (e) => {
|
return listen<AiChatEvent>('ai-chat-event', (e) => {
|
||||||
@@ -387,8 +368,10 @@ export const aiApi = {
|
|||||||
/**
|
/**
|
||||||
* 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。
|
* 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。
|
||||||
* 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。
|
* 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。
|
||||||
|
* 传完整 GoalEntry[](含 status):后端按原样存储,降级 string[] 会被逐条
|
||||||
|
* GoalEntry::new 重置 active,已完成目标完成态被抹(2026-10-24 跨端修复)。
|
||||||
*/
|
*/
|
||||||
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
|
updateConversationGoals(conversationId: string, goals: GoalEntry[]): Promise<void> {
|
||||||
return invoke('ai_update_conversation_goals', { conversationId, goals })
|
return invoke('ai_update_conversation_goals', { conversationId, goals })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+33
-45
@@ -439,14 +439,10 @@ export type AiChatEvent = ({
|
|||||||
// stream: "stdout"|"stderr"(来源,前端差异化着色);line:累积文本块(多行 '\n' 连接)。
|
// stream: "stdout"|"stderr"(来源,前端差异化着色);line:累积文本块(多行 '\n' 连接)。
|
||||||
type: 'AiCommandOutput'; id: string; stream: string; line: string
|
type: 'AiCommandOutput'; id: string; stream: string; line: string
|
||||||
} | {
|
} | {
|
||||||
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
// 普通 RiskLevel 审批(approve/reject)。原 path_auth 双 kind 设计已随路径授权机制退役(2026-10-25)。
|
||||||
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
// expires_at_millis:后端超时收敛到期时刻(ms Unix;超时收敛单一真值=后端,前端无本地自动拒绝)。
|
||||||
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
|
|
||||||
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
|
|
||||||
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
|
|
||||||
// 开关 df-ai-unified-approval:true=归一进 pendingApprovals 带 kind;false=回退 DirAuthDialog 老链路。
|
|
||||||
type: 'AiApprovalRequired'; id: string; name: string; args: unknown; reason: string; diff?: string
|
type: 'AiApprovalRequired'; id: string; name: string; args: unknown; reason: string; diff?: string
|
||||||
kind?: 'path' | 'risk'
|
expires_at_millis?: number | null
|
||||||
} | {
|
} | {
|
||||||
type: 'AiApprovalResult'; id: string; approved: boolean
|
type: 'AiApprovalResult'; id: string; approved: boolean
|
||||||
} | {
|
} | {
|
||||||
@@ -491,10 +487,6 @@ export type AiChatEvent = ({
|
|||||||
} | {
|
} | {
|
||||||
// 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
|
// 达 max_iterations 暂停态(后端仍 generating=true),前端展示操作卡询问继续/停止
|
||||||
type: 'AiMaxRoundsReached'
|
type: 'AiMaxRoundsReached'
|
||||||
} | {
|
|
||||||
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
|
||||||
// 前端弹窗四选项:"本次"(once 执行后清)/"当前会话"(session 切会话清)/"始终放行"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
|
||||||
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
|
||||||
} | {
|
} | {
|
||||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||||
// agent 断路器熔断 / 自省时发,结构化求助(区别于 prompt 教 AI 主动问用户——LLM 不可靠)。
|
// agent 断路器熔断 / 自省时发,结构化求助(区别于 prompt 教 AI 主动问用户——LLM 不可靠)。
|
||||||
@@ -539,6 +531,9 @@ export type AiChatEvent = ({
|
|||||||
} | {
|
} | {
|
||||||
// 上下文已清空:前端刷新消息列表 + toast 提示
|
// 上下文已清空:前端刷新消息列表 + toast 提示
|
||||||
type: 'AiContextCleared'
|
type: 'AiContextCleared'
|
||||||
|
} | {
|
||||||
|
// E2 压缩失败降级(关键词摘要兜底,loop 仍在跑):前端 toast 提示不中断流
|
||||||
|
type: 'AiContextDegraded'; reason: string
|
||||||
}) & {
|
}) & {
|
||||||
conversation_id?: ConvId
|
conversation_id?: ConvId
|
||||||
}
|
}
|
||||||
@@ -571,7 +566,7 @@ export interface AiMessage {
|
|||||||
* 消息来源(958 溯源/961 轮次归属):undefined=null=桌面本端发起;'inject'=运行中
|
* 消息来源(958 溯源/961 轮次归属):undefined=null=桌面本端发起;'inject'=运行中
|
||||||
* 补充注入(961② 轮次分桶判据,归属当前轮不开新轮);'feishu'=飞书入站发起。
|
* 补充注入(961② 轮次分桶判据,归属当前轮不开新轮);'feishu'=飞书入站发起。
|
||||||
*/
|
*/
|
||||||
source?: { channel: string; message_id: string; chat_id?: string; sender?: string } | null
|
source?: { channel: string; message_id?: string; chat_id?: string; sender?: string } | null
|
||||||
/**
|
/**
|
||||||
* 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库
|
* 推理过程文本(DeepSeek reasoning_content / Anthropic thinking 同通道,后端落库
|
||||||
* ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时
|
* ai_messages.reasoning_content)。undefined=该轮无思考内容。历史消息 reload 时
|
||||||
@@ -746,25 +741,6 @@ export interface AiToolCallInfo {
|
|||||||
/** write_file 行级 unified diff(旧文件 vs 新内容),审批卡即时预览。
|
/** write_file 行级 unified diff(旧文件 vs 新内容),审批卡即时预览。
|
||||||
* 旧文件不存在(新建)或非 write_file 为 undefined,前端回退显新 content。 */
|
* 旧文件不存在(新建)或非 write_file 为 undefined,前端回退显新 content。 */
|
||||||
diff?: string
|
diff?: string
|
||||||
/**
|
|
||||||
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
|
|
||||||
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 四选项,
|
|
||||||
* 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。
|
|
||||||
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
|
|
||||||
* 调 aiApi.approve(一次性)。
|
|
||||||
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。
|
|
||||||
* 开关 df-ai-unified-approval 控制 path 类是否归一进 pendingApprovals 带此字段。
|
|
||||||
*/
|
|
||||||
kind?: 'path' | 'risk'
|
|
||||||
/**
|
|
||||||
* path_auth 审批链阶段3b:'path' 类挂起携带的目录信息(规范化后的待授权目录)。
|
|
||||||
* AiDirAuthRequired 归一为 pendingApprovals 项时透传,ToolCard 显提示"LLM 想访问 X 目录"。
|
|
||||||
* 'risk' 类为 undefined。
|
|
||||||
*/
|
|
||||||
dir?: string
|
|
||||||
/** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。
|
|
||||||
* ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */
|
|
||||||
path?: string
|
|
||||||
/** 工具开始执行时间(前端在 AiToolCallStarted 时记录,毫秒)。历史消息从 DB 回载无此字段,
|
/** 工具开始执行时间(前端在 AiToolCallStarted 时记录,毫秒)。历史消息从 DB 回载无此字段,
|
||||||
* 后端事件协议不含耗时,卡片耗时/轨迹总耗时由前端时间差计算,缺字段时隐藏不显 0。 */
|
* 后端事件协议不含耗时,卡片耗时/轨迹总耗时由前端时间差计算,缺字段时隐藏不显 0。 */
|
||||||
startedAt?: number
|
startedAt?: number
|
||||||
@@ -779,11 +755,11 @@ export interface AiToolCallInfo {
|
|||||||
streamLines?: string[]
|
streamLines?: string[]
|
||||||
/**
|
/**
|
||||||
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
|
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
|
||||||
* 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id
|
* 来源:AiApprovalRequired 事件透传 conversation_id
|
||||||
* (缺失时兜底 activeConversationId);switchConversation 恢复历史挂起时填目标会话 id。
|
* (缺失时兜底 activeConversationId);switchConversation 恢复历史挂起时填目标会话 id。
|
||||||
* cleanupTerminatedConversation 据此仅清目标会话的待审批项 + 对称清其审批超时计时器,
|
* cleanupTerminatedConversation 据此仅清目标会话的待审批项 + 对称清其审批超时计时器,
|
||||||
* 不连累并发会话正在审批的卡(conv-scoped,误清其他会话审批 = 回归)。
|
* 不连累并发会话正在审批的卡(conv-scoped,误清其他会话审批 = 回归)。
|
||||||
* undefined = 非挂起项或无法归属(保守保留,不清)。对齐 PendingDirAuth.conversationId。
|
* undefined = 非挂起项或无法归属(保守保留,不清)。
|
||||||
*/
|
*/
|
||||||
conversationId?: string
|
conversationId?: string
|
||||||
}
|
}
|
||||||
@@ -939,10 +915,33 @@ export interface KnowledgeEventRecord {
|
|||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 知识详情聚合负载(基本信息 + 全部生命线事件) */
|
/** 知识关联边(阶段1 连边,双向语义) */
|
||||||
|
export interface KnowledgeEdgeRecord {
|
||||||
|
id: string
|
||||||
|
src_id: string
|
||||||
|
dst_id: string
|
||||||
|
/** similar 强相关 | evolves 演进 | causal 因果 | merges 合并 | anchors 锚点 */
|
||||||
|
kind: string
|
||||||
|
weight: number
|
||||||
|
/** llm-tag | embed-sim | co-cite | manual | tag-co-occur */
|
||||||
|
origin: string
|
||||||
|
created_at: string
|
||||||
|
last_verified_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关联知识条目(边 + 对端知识展开,direction: out=指出/in=指入) */
|
||||||
|
export interface RelatedKnowledge {
|
||||||
|
edge: KnowledgeEdgeRecord
|
||||||
|
knowledge: KnowledgeRecord
|
||||||
|
direction: 'out' | 'in'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 知识详情聚合负载(基本信息 + 全部生命线事件 + 关联知识) */
|
||||||
export interface KnowledgeDetailPayload {
|
export interface KnowledgeDetailPayload {
|
||||||
knowledge: KnowledgeRecord
|
knowledge: KnowledgeRecord
|
||||||
events: KnowledgeEventRecord[]
|
events: KnowledgeEventRecord[]
|
||||||
|
/** 关联知识(老库/无边时为空数组) */
|
||||||
|
related: RelatedKnowledge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑知识入参(部分更新,仅传需改字段) */
|
/** 编辑知识入参(部分更新,仅传需改字段) */
|
||||||
@@ -1026,14 +1025,3 @@ export interface ConflictRecord {
|
|||||||
created_at: string
|
created_at: string
|
||||||
resolved_at: string | null
|
resolved_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 冲突信息(AiMergeCompleted 事件载荷) */
|
|
||||||
export interface ConflictInfo {
|
|
||||||
id: string
|
|
||||||
file_path: string
|
|
||||||
conflict_type: ConflictType
|
|
||||||
subtask_a: string | null
|
|
||||||
subtask_b: string | null
|
|
||||||
diff_a?: string | null
|
|
||||||
diff_b?: string | null
|
|
||||||
}
|
|
||||||
|
|||||||
+50
-280
@@ -31,10 +31,6 @@
|
|||||||
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
|
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 「信任所有目录」放行横幅:开关开启期间常驻提示(设置-安全-AI 工具授权目录),
|
|
||||||
提醒当前 AI 可直接访问任意非受保护目录;黑名单目录仍被拒绝 -->
|
|
||||||
<div v-if="pathAuthBypass" class="ai-auth-bypass-banner">{{ $t('aiChat.pathAuthBypassBanner') }}</div>
|
|
||||||
|
|
||||||
<!-- 第四批抽离至 ai/MessageList.vue(消息列表 + 流式 Markdown 分块渲染 + 工具卡转发 +
|
<!-- 第四批抽离至 ai/MessageList.vue(消息列表 + 流式 Markdown 分块渲染 + 工具卡转发 +
|
||||||
折叠归档段 + 滚动跟随/回底部,零行为变更,store/useMarkdown 单例共享) -->
|
折叠归档段 + 滚动跟随/回底部,零行为变更,store/useMarkdown 单例共享) -->
|
||||||
<MessageList
|
<MessageList
|
||||||
@@ -53,68 +49,19 @@
|
|||||||
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
|
<div v-if="autoCompressedFlash" class="ai-autocompressed-hint">{{ t('aiChat.autoCompressed') }}</div>
|
||||||
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
|
<!-- 无人值守边界配置卡(默认收起=现状零变化):发起时一次授权,生成中锁编辑 -->
|
||||||
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
|
<TaskBoundaryCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||||
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
|
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)。
|
||||||
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
|
C4b 拆分至 ai-chat/agentic-progress-bar.vue(纯展示,store 单例直读,零行为变更) -->
|
||||||
单轮问答 agentRound 恒 0,不显示(避免误显)。
|
<AgenticProgressBar />
|
||||||
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
|
|
||||||
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
|
|
||||||
完成聚合语义由 MessageList 的执行轨迹卡承接。
|
|
||||||
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。 -->
|
|
||||||
<div v-if="showAgenticProgress" class="ai-agentic-progress">
|
|
||||||
<span class="ai-agentic-progress-dot"></span>
|
|
||||||
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||||
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
|
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
|
||||||
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
|
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
|
||||||
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
|
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||||
<!-- 无人值守安全停靠卡(AiTaskParked):继续=重新授权续跑,停止归档=不再续跑 -->
|
<!-- 无人值守安全停靠卡(AiTaskParked):继续=重新授权续跑,停止归档=不再续跑 -->
|
||||||
<TaskParkedCard @toast="(p) => showToast(p.msg, p.type)" />
|
<TaskParkedCard @toast="(p) => showToast(p.msg, p.type)" />
|
||||||
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
|
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)。
|
||||||
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
|
C4b 拆分至 ai-chat/queue-panel.vue(L2 发送韧性 + V7 排队时钟 + 队列编辑/回填随迁,
|
||||||
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
|
confirm 自持同 ChatInput 模式;hasUnsentInput/fillDraft 经 props/emits 接 ChatInput ref) -->
|
||||||
DirAuthDialog 独立弹窗老链路。两路共存,appSettings 随时回退无需改代码。 -->
|
<QueuePanel :has-unsent-input="hasUnsentInput" @fill-draft="onQueueFillDraft" />
|
||||||
<DirAuthDialog v-if="!unifiedApproval" @toast="(p) => showToast(p.msg, p.type)" />
|
|
||||||
<!-- 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目) -->
|
|
||||||
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
|
||||||
<div class="ai-queue-head">
|
|
||||||
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
|
|
||||||
<div class="ai-queue-actions">
|
|
||||||
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
|
|
||||||
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ai-queue-list">
|
|
||||||
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
|
|
||||||
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
|
|
||||||
<template v-if="editingQueueIdx === idx">
|
|
||||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
|
||||||
<input
|
|
||||||
v-model="editingQueueText"
|
|
||||||
class="ai-queue-edit-input"
|
|
||||||
type="text"
|
|
||||||
@keydown="onQueueEditKeydown"
|
|
||||||
@blur="saveEditQueued"
|
|
||||||
/>
|
|
||||||
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
|
|
||||||
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
|
||||||
<span class="ai-queue-text" :title="item.text">{{ item.text }}</span>
|
|
||||||
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
|
|
||||||
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
|
|
||||||
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
|
|
||||||
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
|
|
||||||
<!-- 编辑按钮 -->
|
|
||||||
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
|
|
||||||
<!-- 立即发送(打断当前+发本条) -->
|
|
||||||
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
|
|
||||||
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 第二批抽离: ChatInput 子组件(输入框+附件+技能/实体联想+发送/停止) -->
|
<!-- 第二批抽离: ChatInput 子组件(输入框+附件+技能/实体联想+发送/停止) -->
|
||||||
<ChatInput
|
<ChatInput
|
||||||
ref="chatInputRef"
|
ref="chatInputRef"
|
||||||
@@ -132,28 +79,14 @@
|
|||||||
:danger-label="confirmState.dangerLabel"
|
:danger-label="confirmState.dangerLabel"
|
||||||
@result="answerConfirm"
|
@result="answerConfirm"
|
||||||
/>
|
/>
|
||||||
<!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) -->
|
<!-- 错误 toast(零依赖 useToast;分离窗口无 App.vue 根 toast,故本组件自管;样式消费全局 .df-toast 族,error 映射 danger) -->
|
||||||
<Transition name="ai-toast">
|
<Transition name="df-toast">
|
||||||
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
|
<div v-if="toast.visible" class="df-toast" :class="toast.type === 'error' ? 'df-toast--danger' : 'df-toast--' + toast.type">{{ toast.msg }}</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
|
<!-- I12(2026-10-05):键盘快捷键速查。C4b 拆分至 ai-chat/shortcuts-overlay.vue
|
||||||
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts) -->
|
(Teleport 到 body 防 .ai-panel overflow 裁剪;主窗/分离窗各自实例独立渲染,
|
||||||
<Teleport to="body">
|
入口在 TopBar ... 菜单 emit show-shortcuts;visible 状态留父供 Esc 仲裁) -->
|
||||||
<div v-if="shortcutsVisible" class="ai-shortcuts-mask" @click.self="shortcutsVisible = false">
|
<ShortcutsOverlay :visible="shortcutsVisible" @close="shortcutsVisible = false" />
|
||||||
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
|
|
||||||
<div class="ai-shortcuts-head">
|
|
||||||
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
|
|
||||||
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="shortcutsVisible = false">✕</button>
|
|
||||||
</div>
|
|
||||||
<ul class="ai-shortcuts-list">
|
|
||||||
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
|
|
||||||
<span class="ai-shortcuts-key">{{ s.key }}</span>
|
|
||||||
<span class="ai-shortcuts-act">{{ s.label }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -172,14 +105,16 @@ import MessageList from './ai/MessageList.vue'
|
|||||||
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
|
||||||
import TaskParkedCard from './ai/TaskParkedCard.vue'
|
import TaskParkedCard from './ai/TaskParkedCard.vue'
|
||||||
import TaskBoundaryCard from './ai/TaskBoundaryCard.vue'
|
import TaskBoundaryCard from './ai/TaskBoundaryCard.vue'
|
||||||
import DirAuthDialog from './ai/DirAuthDialog.vue'
|
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '../stores/project'
|
||||||
import { collectTurnStats, turnMessages } from '../composables/ai/useTurnTrace'
|
|
||||||
import { useAiContext } from '../composables/ai/useAiContext'
|
import { useAiContext } from '../composables/ai/useAiContext'
|
||||||
import { emitReattachToMain, registerMainReattachListener, emitOpenSettings, registerMainOpenSettingsListener } from '@/composables/ai/useAiWindow'
|
import { emitReattachToMain, registerMainReattachListener, emitOpenSettings, registerMainOpenSettingsListener } from '@/composables/ai/useAiWindow'
|
||||||
import type { AiMessage } from '../api/types'
|
import type { AiMessage, SkillInfo } from '../api/types'
|
||||||
|
// C4b 拆分(第五批): Agentic 进度条/待发送队列/快捷键速查 三自有区块抽至 ai-chat/ 子目录
|
||||||
|
import AgenticProgressBar from './ai-chat/agentic-progress-bar.vue'
|
||||||
|
import QueuePanel from './ai-chat/queue-panel.vue'
|
||||||
|
import ShortcutsOverlay from './ai-chat/shortcuts-overlay.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
detached?: boolean
|
detached?: boolean
|
||||||
@@ -192,13 +127,7 @@ const router = useRouter()
|
|||||||
// B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭)
|
// B4: 自动压缩完成一次性可见信号(输入区上方 hint 行短暂显示,4s 自灭)
|
||||||
const { autoCompressedFlash } = useAiContext()
|
const { autoCompressedFlash } = useAiContext()
|
||||||
|
|
||||||
// path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。
|
// 「信任所有目录」总开关横幅已随授权目录机制退役移除(2026-10-25 默认全放行,横幅失去信息量)
|
||||||
// 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。
|
|
||||||
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
|
|
||||||
const appSettings = useAppSettingsStore()
|
|
||||||
const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true)
|
|
||||||
// 「信任所有目录」总开关(与设置页 AllowedDirsPanel 同一 KV):开启时对话区顶部常驻放行横幅
|
|
||||||
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
|
|
||||||
|
|
||||||
// ── 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内);
|
// ── 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内);
|
||||||
// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。──
|
// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。──
|
||||||
@@ -262,22 +191,7 @@ const messageListRef = ref<InstanceType<typeof MessageList> | null>(null)
|
|||||||
// 头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示)
|
// 头部审批徽标 — 当前对话积压的待审批工具数(>0 时显示)
|
||||||
const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
|
const pendingApprovalCount = computed(() => store.state.pendingApprovals.length)
|
||||||
|
|
||||||
// Agentic 循环进度条
|
// C4b: Agentic 循环进度条(显示条件+文案 computed)已随 ai-chat/agentic-progress-bar.vue 迁出
|
||||||
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
|
|
||||||
const showAgenticProgress = computed(() =>
|
|
||||||
store.state.streaming && store.state.agentRound > 0,
|
|
||||||
)
|
|
||||||
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
|
|
||||||
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
|
|
||||||
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
|
|
||||||
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
|
|
||||||
const agenticProgressText = computed(() => {
|
|
||||||
const pending = store.state.pendingApprovals.length
|
|
||||||
const total = collectTurnStats(turnMessages(store.state.messages)).total
|
|
||||||
return pending > 0
|
|
||||||
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
|
|
||||||
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
|
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
|
||||||
function scrollToFirstPending(): void {
|
function scrollToFirstPending(): void {
|
||||||
@@ -337,10 +251,7 @@ const projectsStore = useProjectStore()
|
|||||||
// 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。
|
// 滚动边沿/跟随意图重置随 MessageList 迁移,由子组件自管。
|
||||||
watch(() => store.state.activeConversationId, () => {
|
watch(() => store.state.activeConversationId, () => {
|
||||||
if (editingMsgId.value) cancelEdit()
|
if (editingMsgId.value) cancelEdit()
|
||||||
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
|
// UX-P3: 队列编辑态重置已随 QueuePanel 迁出(子组件内自 watch activeConversationId)。
|
||||||
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
|
|
||||||
// 新会话同下标队列项上,随消息编辑态一并重置。
|
|
||||||
if (editingQueueIdx.value !== null) cancelEditQueued()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -405,6 +316,9 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
showToast(t('aiChat.regenerateFailed', { msg }), 'error')
|
showToast(t('aiChat.regenerateFailed', { msg }), 'error')
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// 走查:生成中按键被 streaming 守卫静默吞掉,用户以为快捷键坏了;补一句轻提示
|
||||||
|
showToast(t('aiChat.generatingBusyHint'), 'warning')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -413,6 +327,9 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!store.state.streaming) {
|
if (!store.state.streaming) {
|
||||||
confirmClearChat()
|
confirmClearChat()
|
||||||
|
} else {
|
||||||
|
// 走查:同 Ctrl+R——守卫吞键须有反馈
|
||||||
|
showToast(t('aiChat.generatingBusyHint'), 'warning')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -485,135 +402,23 @@ function onBeforeUnloadGuard(e: BeforeUnloadEvent) {
|
|||||||
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
|
// (ChatInput 经 emit('sent')/emit('cancel-edit')/emit('error') 与父交互)
|
||||||
|
|
||||||
// ── L2 发送韧性:排队超时检测 + 强制发送 ──
|
// ── L2 发送韧性:排队超时检测 + 强制发送 ──
|
||||||
|
// (activeQueue 过滤视图已随 queue-panel.vue 子组件迁移,父侧死副本删除)
|
||||||
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
|
|
||||||
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
|
|
||||||
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
|
|
||||||
const activeQueue = computed(() =>
|
|
||||||
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
|
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
|
||||||
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
|
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
|
||||||
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
|
// 队列超时横幅/强制发送/清空/等待时钟(V7 nowTick)已随 queue-panel.vue 子组件迁移
|
||||||
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
|
// (2026-10-18 UI 重构批,子组件自带 interval 与清理),父侧死副本删除(vue-tsc TS6133;
|
||||||
const nowTick = ref(Date.now())
|
// 旧父组件时钟在无消费方后仍每秒空转,一并消除)。
|
||||||
let queueTickTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
|
/** QueuePanel emit fill-draft 的父侧转发:写入 ChatInput 草稿(拆分纪律:ChatInput ref 留父) */
|
||||||
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
|
function onQueueFillDraft(text: string, skill: SkillInfo | null) {
|
||||||
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
|
chatInputRef.value?.fillDraft(text, skill)
|
||||||
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
|
|
||||||
const queueTimedOut = computed(() => {
|
|
||||||
void nowTick.value
|
|
||||||
return store.isQueueTimedOut(store.state.activeConversationId)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => activeQueue.value.length > 0,
|
|
||||||
(waiting) => {
|
|
||||||
if (waiting && queueTickTimer === null) {
|
|
||||||
nowTick.value = Date.now()
|
|
||||||
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
|
|
||||||
} else if (!waiting && queueTickTimer !== null) {
|
|
||||||
clearInterval(queueTickTimer)
|
|
||||||
queueTickTimer = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
|
|
||||||
function formatWaitTime(totalSeconds: number): string {
|
|
||||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
|
|
||||||
function queueItemWait(item: { enqueuedAt: number }): string {
|
|
||||||
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
|
|
||||||
async function handleForceSend() {
|
|
||||||
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
|
|
||||||
* 一键误触会静默丢掉全部排队消息且不可恢复,复用本组件 ConfirmDialog 先例
|
|
||||||
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
|
|
||||||
async function handleClearQueue() {
|
|
||||||
const convId = store.state.activeConversationId
|
|
||||||
if (!convId) return
|
|
||||||
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
|
|
||||||
if (confirmed) store.clearQueue(convId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
|
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
|
||||||
|
|
||||||
/** 当前正在编辑的队列项 index;null=非编辑态 */
|
// 队列编辑态(编辑中 index/文本/进取消存取)已随 queue-panel.vue 子组件迁移(2026-10-18
|
||||||
const editingQueueIdx = ref<number | null>(null)
|
// UI 重构批),父组件死副本删除(vue-tsc TS6133);IME 守卫等行为在子组件内同名实现。
|
||||||
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
|
|
||||||
const editingQueueText = ref('')
|
|
||||||
|
|
||||||
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
|
|
||||||
function startEditQueued(idx: number) {
|
|
||||||
const item = activeQueue.value[idx]
|
|
||||||
if (!item) return
|
|
||||||
editingQueueIdx.value = idx
|
|
||||||
editingQueueText.value = item.text
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消编辑:还原非编辑态,不写回 */
|
|
||||||
function cancelEditQueued() {
|
|
||||||
editingQueueIdx.value = null
|
|
||||||
editingQueueText.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
|
||||||
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
|
|
||||||
* 收敛到本函数统一判。 */
|
|
||||||
function onQueueEditKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.isComposing || e.keyCode === 229) return
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
saveEditQueued()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
cancelEditQueued()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
|
|
||||||
function saveEditQueued() {
|
|
||||||
if (editingQueueIdx.value === null) return
|
|
||||||
store.editQueued(editingQueueIdx.value, editingQueueText.value)
|
|
||||||
editingQueueIdx.value = null
|
|
||||||
editingQueueText.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
|
|
||||||
async function handleSendQueuedNow(idx: number) {
|
|
||||||
await store.sendQueuedNow(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
|
|
||||||
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
|
|
||||||
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
|
|
||||||
* 与队列原地编辑只保 text 同一取舍。 */
|
|
||||||
async function backfillQueued(idx: number) {
|
|
||||||
const item = activeQueue.value[idx]
|
|
||||||
if (!item) return
|
|
||||||
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
|
|
||||||
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
|
|
||||||
if (chatInputRef.value?.hasUnsentInput()) {
|
|
||||||
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
|
|
||||||
}
|
|
||||||
const skill = item.skill
|
|
||||||
? store.state.skills.find(s => s.name === item.skill) ?? null
|
|
||||||
: null
|
|
||||||
store.cancelQueued(idx)
|
|
||||||
chatInputRef.value?.fillDraft(item.text, skill)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
|
// 第四批抽离: 滚动跟随/回底部/流式 Markdown 分块渲染/工具卡自动收起/选区保持 全部随
|
||||||
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
|
// MessageList 子组件迁移(零行为变更,store/useMarkdown 单例共享)。
|
||||||
@@ -627,8 +432,7 @@ onBeforeUnmount(() => {
|
|||||||
// 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露)
|
// 归还 listener 租约(最后一份归还才真拆单例 listener;双重归还/下溢由 useAiEvents dev 断言暴露)
|
||||||
_listenerRelease?.()
|
_listenerRelease?.()
|
||||||
_listenerRelease = null
|
_listenerRelease = null
|
||||||
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
|
// V7 排队时钟已随 queue-panel.vue 迁移,其 interval 由子组件 onBeforeUnmount 自清。
|
||||||
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
|
|
||||||
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
|
// 全局快捷键卸载时移除 window listener,防分离窗口/切组件后泄漏
|
||||||
window.removeEventListener('keydown', onGlobalKeydown)
|
window.removeEventListener('keydown', onGlobalKeydown)
|
||||||
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
|
// 摘除未发送输入保护(分离窗口关闭/页面刷新触发 beforeunload)
|
||||||
@@ -786,16 +590,7 @@ async function toggleAlwaysOnTop() {
|
|||||||
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
|
// I12(2026-10-05):顶部 ... 菜单入口(TopBar emit show-shortcuts)→ 此处 Teleport 展示速查浮层。
|
||||||
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
|
// 键位表 shortcuts 与 onGlobalKeydown 同文件同居——键区调整时表随手同步,防速查与真实键位漂移。
|
||||||
const shortcutsVisible = ref(false)
|
const shortcutsVisible = ref(false)
|
||||||
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略);t 已在本组件上方 useI18n 解构
|
// 键位表已随 shortcuts-overlay.vue 子组件迁移(C4b),父侧死副本删除(vue-tsc TS6133)。
|
||||||
const shortcuts = computed(() => [
|
|
||||||
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
|
|
||||||
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
|
|
||||||
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
|
|
||||||
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
|
|
||||||
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
|
|
||||||
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
|
|
||||||
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
|
|
||||||
])
|
|
||||||
function showShortcuts() {
|
function showShortcuts() {
|
||||||
shortcutsVisible.value = true
|
shortcutsVisible.value = true
|
||||||
}
|
}
|
||||||
@@ -893,6 +688,10 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
onError: (_convId, message) => {
|
onError: (_convId, message) => {
|
||||||
showToast(t('aiChat.compressError', { msg: message }), 'error')
|
showToast(t('aiChat.compressError', { msg: message }), 'error')
|
||||||
},
|
},
|
||||||
|
// E2 压缩失败降级:仅提示不刷新不打断流(loop 仍在跑,实证静默降级致用户原句重发)
|
||||||
|
onContextDegraded: (_convId, reason) => {
|
||||||
|
showToast(t('aiChat.contextDegraded', { reason }), 'warning')
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -902,7 +701,7 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
复用按钮抽 components.css 全局。本块仅保留 AiChat 自身元素:面板布局/聊天区/input-area
|
复用按钮抽 components.css 全局。本块仅保留 AiChat 自身元素:面板布局/聊天区/input-area
|
||||||
容器/agentic 进度/待发队列/toast。子组件元素由各子组件 scoped 负责,父级 scoped 不渗透。) ═══ */
|
容器/agentic 进度/待发队列/toast。子组件元素由各子组件 scoped 负责,父级 scoped 不渗透。) ═══ */
|
||||||
.ai-panel {
|
.ai-panel {
|
||||||
position: relative; /* 容纳 .ai-toast(absolute,相对面板定位,嵌入/分离模式均生效) */
|
position: relative; /* toast 已改全局 .df-toast(fixed 定位);relative 保留兜底其他绝对定位子元素 */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -924,16 +723,6 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
|
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
|
||||||
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
|
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
|
||||||
|
|
||||||
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .ai-toast-warning) ── */
|
|
||||||
.ai-auth-bypass-banner {
|
|
||||||
padding: 5px 14px;
|
|
||||||
background: var(--df-warning-bg);
|
|
||||||
color: var(--df-warning);
|
|
||||||
border-bottom: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
|
||||||
font-size: 12px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ═══ Input ═══ */
|
/* ═══ Input ═══ */
|
||||||
.ai-input-area {
|
.ai-input-area {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
@@ -1054,27 +843,8 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
|
|
||||||
/* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */
|
/* (.ai-btn-icon:disabled / .ai-btn-spinner / @keyframes ai-btn-spin 已抽 components.css 全局共享) */
|
||||||
|
|
||||||
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */
|
/* ═══ 错误 toast:消费 global.css .df-toast 四色族 + .df-toast-enter/leave 过渡
|
||||||
.ai-toast {
|
(2026-10-18 整洁波双轨合一,原 .ai-toast-* 本地族删除;error 态映射 --danger) ═══ */
|
||||||
position: absolute;
|
|
||||||
top: 16px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: var(--df-radius);
|
|
||||||
font-size: 12px;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
|
||||||
max-width: 80%;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
|
||||||
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
|
||||||
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
|
||||||
/* ToastType 新增 'success' 兜底样式 */
|
|
||||||
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
|
||||||
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
|
||||||
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
|
||||||
|
|
||||||
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
|
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
|
||||||
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
|
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关) ═══ */
|
||||||
@@ -1091,7 +861,7 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
background: var(--df-bg-card);
|
background: var(--df-bg-card);
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
border-radius: var(--df-radius-lg, 8px);
|
border-radius: var(--df-radius-lg, 8px);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
box-shadow: var(--df-shadow-sm);
|
||||||
width: 320px;
|
width: 320px;
|
||||||
max-width: calc(100vw - 48px);
|
max-width: calc(100vw - 48px);
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
@@ -1116,7 +886,7 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-secondary); }
|
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
|
||||||
.ai-shortcuts-list {
|
.ai-shortcuts-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1132,9 +902,9 @@ async function initContextEventListeners(): Promise<void> {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.ai-shortcuts-key {
|
.ai-shortcuts-key {
|
||||||
font-family: var(--df-mono, monospace);
|
font-family: var(--df-font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--df-bg-secondary);
|
background: var(--df-bg-card);
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const sizeText = computed(() =>
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: var(--df-warning-bg, rgba(240,199,94,0.08));
|
background: var(--df-warning-bg);
|
||||||
}
|
}
|
||||||
.offload-badge {
|
.offload-badge {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -59,8 +59,8 @@ const sizeText = computed(() =>
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
border-radius: var(--df-radius-sm);
|
border-radius: var(--df-radius-sm);
|
||||||
background: var(--df-warning-bg, rgba(240,199,94,0.14));
|
background: var(--df-warning-bg);
|
||||||
color: var(--df-warning, #f0c75e);
|
color: var(--df-warning);
|
||||||
}
|
}
|
||||||
.offload-meta {
|
.offload-meta {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ function onPageSizeChange(val: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: var(--df-gap-grid, 16px);
|
margin-top: var(--df-gap-grid);
|
||||||
padding: 8px 4px;
|
padding: 8px 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
|
|||||||
+84
-116
@@ -21,9 +21,9 @@
|
|||||||
<span v-html="toolIcon(tc.name)"></span>
|
<span v-html="toolIcon(tc.name)"></span>
|
||||||
</span>
|
</span>
|
||||||
<div class="ai-tool-header-text">
|
<div class="ai-tool-header-text">
|
||||||
<span class="ai-tool-name">{{ toolDisplayName(tc) }}</span>
|
<span class="ai-tool-name df-mono">{{ toolDisplayName(tc) }}</span>
|
||||||
<span v-if="tc.status === 'running'" class="ai-tool-sub">{{ $t('aiTool.running') }}</span>
|
<span v-if="tc.status === 'running'" class="ai-tool-sub df-badge df-badge--info ai-tool-chip-run">{{ $t('aiTool.running') }}</span>
|
||||||
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
|
<span v-else-if="isFailed" class="ai-tool-sub ai-tool-sub--failed df-badge df-badge--danger" :title="summaryTitle">{{ $t('aiTool.executionFailed') }}</span>
|
||||||
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
|
<span v-else-if="resultSummary" class="ai-tool-sub" :title="summaryTitle">{{ resultSummary }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
|
<span class="ai-tool-status-dot" :class="'ai-tool-status-dot--' + (isFailed ? 'failed' : tc.status)" :title="statusTitle"></span>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
|
hover 显绝对起止时刻,相对时间不再占卡片行(B 聚合卡统一)。 -->
|
||||||
<span
|
<span
|
||||||
v-if="durationText"
|
v-if="durationText"
|
||||||
class="ai-tool-duration"
|
class="ai-tool-duration df-mono"
|
||||||
:title="durationTitle"
|
:title="durationTitle"
|
||||||
>{{ durationText }}</span>
|
>{{ durationText }}</span>
|
||||||
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
|
<span v-if="!shouldKeepOpen(tc)" class="ai-tool-collapse-hint" :class="{ 'is-open': isExpanded }">▸</span>
|
||||||
@@ -48,16 +48,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 执行中实时流式输出(run_command shell 行 / ask_uclaw 进度块):
|
<!-- 执行中实时流式输出(run_command shell 行 / ask_uclaw 进度块):
|
||||||
尾部最多 8 行(溢出折叠提示),Completed 后清空走 result 渲染 -->
|
尾部最多 8 行(溢出折叠提示),Completed 后清空走 result 渲染;新行追加自动滚尾 -->
|
||||||
<div v-if="tc.status === 'running' && tc.streamLines?.length" class="ai-tool-stream">
|
<div v-if="tc.status === 'running' && tc.streamLines?.length" ref="streamEl" class="ai-tool-stream">
|
||||||
<div v-for="(ln, i) in tc.streamLines.slice(-8)" :key="tc.streamLines.length - 8 + i" class="ai-tool-stream-line">{{ ln }}</div>
|
<div v-for="(ln, i) in tc.streamLines.slice(-8)" :key="tc.streamLines.length - 8 + i" class="ai-tool-stream-line">{{ ln }}</div>
|
||||||
<div v-if="tc.streamLines.length > 8" class="ai-tool-stream-more">{{ $t('aiTool.streamMore', { n: tc.streamLines.length - 8 }) }}</div>
|
<div v-if="tc.streamLines.length > 8" class="ai-tool-stream-more">{{ $t('aiTool.streamMore', { n: tc.streamLines.length - 8 }) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 审批:参数内容 + 风险提示 + 按钮 -->
|
<!-- 审批:参数内容 + 风险提示 + 按钮 -->
|
||||||
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
|
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
|
||||||
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系) -->
|
<!-- 风险徽章:High 橙红/Medium 黄,一眼识别操作等级(与左侧着色条同色系)
|
||||||
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge" :class="'is-' + riskLevel">
|
wave2:迁 .df-badge 透明彩底三件套(danger/warning 变体) -->
|
||||||
|
<div v-if="riskLevel !== 'low'" class="ai-tool-risk-badge df-badge" :class="riskLevel === 'high' ? 'df-badge--danger' : 'df-badge--warning'">
|
||||||
{{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }}
|
{{ riskLevel === 'high' ? $t('aiTool.riskHigh') : $t('aiTool.riskMedium') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="visibleArgs.length" class="ai-tool-approval-args">
|
<div v-if="visibleArgs.length" class="ai-tool-approval-args">
|
||||||
@@ -81,43 +82,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
|
<!-- AE-2025-03: write_file 审批 diff 预览(红删绿增,行级)。旧文件不存在→tc.diff 为空,回退显上方 args content -->
|
||||||
<div v-if="tc.diff" class="ai-tool-approval-diff">
|
<div v-if="tc.diff" class="ai-tool-approval-diff">
|
||||||
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
<pre class="df-diff-pre"><code><span v-for="(ln, idx) in diffLines" :key="idx" class="df-diff-line" :class="'df-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
||||||
</div>
|
</div>
|
||||||
<!-- path_auth 审批链阶段3b:按 tc.kind 分两组按钮。
|
<!-- 普通 RiskLevel 审批:approve/reject 两选项(path_auth 四选项链已退役 2026-10-25)。 -->
|
||||||
- kind='path'(路径授权挂起):once/session/always/deny 四选项,调 authorizeDir
|
<div class="ai-tool-actions">
|
||||||
(与 DirAuthDialog.vue 同款四按钮;IPC authorizeDir 后端 once/session/always/deny 全支持)。
|
|
||||||
- 其余(risk/缺省,普通 RiskLevel 审批):approve/reject 两选项,调 approve。 -->
|
|
||||||
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
|
||||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
|
||||||
:disabled="approving"
|
|
||||||
@click="onAuthorizeOnce">
|
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
||||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
|
||||||
{{ $t('aiChat.dirAuthOnce') }}
|
|
||||||
</button>
|
|
||||||
<button class="ai-tool-btn ai-tool-btn--session"
|
|
||||||
:disabled="approving"
|
|
||||||
@click="onAuthorizeSession">
|
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
||||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
|
||||||
{{ $t('aiChat.dirAuthSession') }}
|
|
||||||
</button>
|
|
||||||
<button class="ai-tool-btn ai-tool-btn--always"
|
|
||||||
:disabled="approving"
|
|
||||||
@click="onAuthorizeAlways">
|
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
||||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
|
||||||
{{ $t('aiChat.dirAuthAlways') }}
|
|
||||||
</button>
|
|
||||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
|
||||||
:disabled="approving"
|
|
||||||
@click="onDeny">
|
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
|
||||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
|
|
||||||
{{ $t('aiChat.dirAuthDeny') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="ai-tool-actions">
|
|
||||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||||
:disabled="approving"
|
:disabled="approving"
|
||||||
@click="onApprove">
|
@click="onApprove">
|
||||||
@@ -170,14 +138,6 @@
|
|||||||
/>
|
/>
|
||||||
</div><!-- /.ai-tool-body -->
|
</div><!-- /.ai-tool-body -->
|
||||||
|
|
||||||
<!-- AE-2025-05:High 风险审批二次确认(本卡自治 useConfirm,不与 AiChat 父级 confirmState 串台) -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:visible="confirmState.visible"
|
|
||||||
:msg="confirmState.msg"
|
|
||||||
:danger-label="t('common.confirm')"
|
|
||||||
@result="answerConfirm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
|
<!-- 审批 loading 超时兜底 toast(零依赖 reactive,复用 AiChat 同款样式) -->
|
||||||
<Transition name="ai-tool-toast">
|
<Transition name="ai-tool-toast">
|
||||||
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
|
<div v-if="approveToast.visible" class="ai-tool-toast ai-tool-toast--warning">{{ approveToast.msg }}</div>
|
||||||
@@ -186,7 +146,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, toRef, watch } from 'vue'
|
import { computed, nextTick, ref, toRef, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
shouldKeepOpen,
|
shouldKeepOpen,
|
||||||
@@ -204,7 +164,6 @@ import { useTimerOwnership } from '@/composables/useTimerOwnership'
|
|||||||
import { callDurationText } from '@/composables/ai/useTurnTrace'
|
import { callDurationText } from '@/composables/ai/useTurnTrace'
|
||||||
import { formatDate, formatDuration } from '@/utils/time'
|
import { formatDate, formatDuration } from '@/utils/time'
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
|
||||||
import ToolResultBody from './ToolResultBody.vue'
|
import ToolResultBody from './ToolResultBody.vue'
|
||||||
import OffloadedResultBar from './OffloadedResultBar.vue'
|
import OffloadedResultBar from './OffloadedResultBar.vue'
|
||||||
|
|
||||||
@@ -233,18 +192,14 @@ const emit = defineEmits<{
|
|||||||
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
|
* 调 store.sendMessage 触发 AI 装流式。仅 fetch_url 工具会发此事件。
|
||||||
*/
|
*/
|
||||||
'install-obscura': []
|
'install-obscura': []
|
||||||
/**
|
/** 审批按钮 → 父级转发 store.approveToolCall(一次性 approve/reject)。 */
|
||||||
* 审批按钮 → 父级转发 store.approveToolCall。
|
|
||||||
* path_auth 审批链阶段3b:risk 类带 approved boolean;path 类带 decision('once'|'session'|'always'|'deny')。
|
|
||||||
* 调用方(store.approveToolCall)按是否有 decision 分派 approve / authorizeDir。
|
|
||||||
*/
|
|
||||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
|
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
|
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 composable,传入 tc getter 保持响应式。
|
||||||
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
|
const { toolDisplayName, displayArgValue, argsEntries } = useToolCardHeader(() => props.tc)
|
||||||
|
|
||||||
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject+path_auth 三选项)抽离至 composable。
|
// 审批状态机(按钮 loading/挂起时长/toast/approve+reject)抽离至 composable。
|
||||||
const {
|
const {
|
||||||
approving,
|
approving,
|
||||||
approveToast,
|
approveToast,
|
||||||
@@ -255,12 +210,6 @@ const {
|
|||||||
expireLevel,
|
expireLevel,
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
onAuthorizeOnce,
|
|
||||||
onAuthorizeSession,
|
|
||||||
onAuthorizeAlways,
|
|
||||||
onDeny,
|
|
||||||
confirmState,
|
|
||||||
answerConfirm,
|
|
||||||
} = useToolApproval(toRef(props, 'tc'), emit)
|
} = useToolApproval(toRef(props, 'tc'), emit)
|
||||||
|
|
||||||
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
||||||
@@ -331,6 +280,14 @@ watch(() => props.tc.status, (s) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
/** 实时输出滚尾:流式行追加时输出区滚到底,最新一行恒可见(容器 160px 上限,超限即内滚) */
|
||||||
|
const streamEl = ref<HTMLElement | null>(null)
|
||||||
|
watch(() => props.tc.streamLines?.length, async () => {
|
||||||
|
await nextTick()
|
||||||
|
const el = streamEl.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
})
|
||||||
|
|
||||||
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏);
|
/** 单卡耗时(前端时间差;历史消息无 startedAt/completedAt 返空串隐藏);
|
||||||
* running 态以 nowTick 实时走表(需 startedAt,与完成态共用同一时间差口径) */
|
* running 态以 nowTick 实时走表(需 startedAt,与完成态共用同一时间差口径) */
|
||||||
const durationText = computed(() => {
|
const durationText = computed(() => {
|
||||||
@@ -415,7 +372,7 @@ const visibleArgs = computed(() => {
|
|||||||
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,见 ToolCardList 注释)。 */
|
不做 body 高度动画(v-show display 切换 + flex gap 会留双倍间距致布局跳变,见 ToolCardList 注释)。 */
|
||||||
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
|
transition: border-color 0.2s var(--df-ease), box-shadow 0.2s var(--df-ease), opacity 0.2s var(--df-ease);
|
||||||
}
|
}
|
||||||
.ai-tool-card--pending_approval { border-color: rgba(240,199,94,0.3); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
|
.ai-tool-card--pending_approval { border-color: var(--df-warning-bd); box-shadow: 0 0 0 1px rgba(240,199,94,0.06); }
|
||||||
.ai-tool-card--completed { border-color: rgba(61,219,160,0.2); }
|
.ai-tool-card--completed { border-color: rgba(61,219,160,0.2); }
|
||||||
.ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; }
|
.ai-tool-card--rejected { border-color: rgba(240,101,101,0.2); opacity: 0.55; }
|
||||||
/* 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */
|
/* 工具失败态——AR-6 下 status 仍为 completed,故单独覆盖边框与 completed 区分 */
|
||||||
@@ -424,13 +381,17 @@ const visibleArgs = computed(() => {
|
|||||||
.ai-tool-sub--failed { color: var(--df-danger); }
|
.ai-tool-sub--failed { color: var(--df-danger); }
|
||||||
.ai-tool-rejected {
|
.ai-tool-rejected {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
padding: 4px 10px 8px 42px; font-size: 11px; color: var(--df-danger);
|
padding: 4px 10px 8px var(--df-icon-gutter); font-size: 11px; color: var(--df-danger);
|
||||||
}
|
}
|
||||||
.ai-tool-rejected-icon { font-weight: 500; }
|
.ai-tool-rejected-icon { font-weight: 500; }
|
||||||
|
|
||||||
|
/* wave2:折叠轨迹卡的展开态左边 2px 强调边(对齐 workbench cv.on 范式);
|
||||||
|
只作用于可折叠卡(completed),pending 的 data-risk 左缘与 running 强制展开态不参与 */
|
||||||
|
.ai-tool-card--completed:not(.ai-tool-card--collapsed) { border-left: 2px solid var(--df-info); }
|
||||||
|
|
||||||
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
|
/* -- 卡片折叠态(running/pending 始终展开,completed/rejected 默认折叠为单行 header) -- */
|
||||||
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
|
.ai-tool-card--collapsed .ai-tool-header { cursor: pointer; }
|
||||||
.ai-tool-card--collapsed .ai-tool-header:hover { background: rgba(255,255,255,0.03); }
|
.ai-tool-card--collapsed .ai-tool-header:hover { background: var(--df-bg-card-hover); }
|
||||||
.ai-tool-body { overflow: hidden; }
|
.ai-tool-body { overflow: hidden; }
|
||||||
.ai-tool-collapse-hint {
|
.ai-tool-collapse-hint {
|
||||||
font-size: 10px; color: var(--df-text-dim); margin-left: auto; flex-shrink: 0;
|
font-size: 10px; color: var(--df-text-dim); margin-left: auto; flex-shrink: 0;
|
||||||
@@ -444,9 +405,9 @@ const visibleArgs = computed(() => {
|
|||||||
width: 24px; height: 24px; border-radius: var(--df-radius-sm);
|
width: 24px; height: 24px; border-radius: var(--df-radius-sm);
|
||||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ai-tool-icon-wrap--file-read { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
.ai-tool-icon-wrap--file-read { background: var(--df-info-bg); color: var(--df-info); }
|
||||||
.ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); }
|
.ai-tool-icon-wrap--file-write { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||||
.ai-tool-icon-wrap--dir { background: rgba(240,199,94,0.12); color: var(--df-warning); }
|
.ai-tool-icon-wrap--dir { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||||
.ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); }
|
.ai-tool-icon-wrap--create { background: var(--df-success-bg); color: var(--df-success); }
|
||||||
.ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); }
|
.ai-tool-icon-wrap--delete { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||||
.ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); }
|
.ai-tool-icon-wrap--list { background: var(--df-accent-bg); color: var(--df-accent); }
|
||||||
@@ -455,14 +416,40 @@ const visibleArgs = computed(() => {
|
|||||||
display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline;
|
display: flex; flex-direction: row; flex-wrap: wrap; align-items: baseline;
|
||||||
column-gap: 8px; row-gap: 1px; flex: 1; min-width: 0;
|
column-gap: 8px; row-gap: 1px; flex: 1; min-width: 0;
|
||||||
}
|
}
|
||||||
|
/* wave2:工具名迁 mono 元数据(df-mono 接管字体,此处 10px dim 收敛视觉) */
|
||||||
.ai-tool-name {
|
.ai-tool-name {
|
||||||
font-size: 12px; font-weight: 500; color: var(--df-text);
|
font-size: 10px; font-weight: 500; color: var(--df-text-dim);
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ai-tool-sub {
|
.ai-tool-sub {
|
||||||
font-size: 10px; color: var(--df-text-dim);
|
font-size: 10px; color: var(--df-text-dim);
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* wave2:状态 chip 迁 .df-badge(running=info+脉冲点 / failed=danger);
|
||||||
|
scoped 双类压过 .ai-tool-sub 灰字,色板与 df-badge 变体对齐(token 化) */
|
||||||
|
.ai-tool-sub.df-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
.ai-tool-sub.df-badge--info {
|
||||||
|
background: var(--df-info-bg);
|
||||||
|
color: var(--df-info);
|
||||||
|
border-color: color-mix(in srgb, var(--df-info) 30%, transparent);
|
||||||
|
}
|
||||||
|
.ai-tool-sub.df-badge--danger {
|
||||||
|
background: var(--df-danger-bg);
|
||||||
|
color: var(--df-danger);
|
||||||
|
border-color: color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||||
|
}
|
||||||
|
/* running chip 内嵌脉冲点(全局 df-pulse 节奏,对齐 df-dot--run 活性语义) */
|
||||||
|
.ai-tool-chip-run::before {
|
||||||
|
content: '';
|
||||||
|
width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
background: var(--df-info);
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: df-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Status dot (脉冲动画) -- */
|
/* -- Status dot (脉冲动画) -- */
|
||||||
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
.ai-tool-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||||
@@ -487,73 +474,67 @@ const visibleArgs = computed(() => {
|
|||||||
.ai-tool-status-dot--rejected { background: var(--df-danger); }
|
.ai-tool-status-dot--rejected { background: var(--df-danger); }
|
||||||
|
|
||||||
/* -- Skeleton (running 状态) -- */
|
/* -- Skeleton (running 状态) -- */
|
||||||
.ai-tool-skeleton { padding: 4px 10px 10px 42px; display: flex; flex-direction: column; gap: 5px; }
|
.ai-tool-skeleton { padding: 4px 10px 10px var(--df-icon-gutter); display: flex; flex-direction: column; gap: 5px; }
|
||||||
.ai-tool-skeleton-line {
|
.ai-tool-skeleton-line {
|
||||||
height: 8px; border-radius: var(--df-radius-xs);
|
height: 8px; border-radius: var(--df-radius-xs);
|
||||||
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
|
background: linear-gradient(90deg, var(--df-bg) 25%, var(--df-bg-card-hover) 50%, var(--df-bg) 75%);
|
||||||
background-size: 200% 100%; animation: shimmer 1.5s infinite;
|
background-size: 200% 100%; animation: df-shimmer 1.5s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- 实时流式输出(running 态:run_command 行 / ask_uclaw 进度块) -- */
|
/* -- 实时流式输出(running 态:run_command 行 / ask_uclaw 进度块) -- */
|
||||||
.ai-tool-stream {
|
.ai-tool-stream {
|
||||||
padding: 4px 10px 10px 42px;
|
padding: 4px 10px 10px var(--df-icon-gutter);
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
max-height: 160px; overflow-y: auto;
|
max-height: 160px; overflow-y: auto;
|
||||||
}
|
}
|
||||||
.ai-tool-stream-line {
|
.ai-tool-stream-line {
|
||||||
font-family: var(--df-font-mono, monospace);
|
font-family: var(--df-font-mono);
|
||||||
font-size: 11px; line-height: 1.5;
|
font-size: 11px; line-height: 1.5;
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
white-space: pre-wrap; word-break: break-all;
|
white-space: pre-wrap; word-break: break-all;
|
||||||
}
|
}
|
||||||
.ai-tool-stream-more {
|
.ai-tool-stream-more {
|
||||||
font-size: 10px; color: var(--df-text-tertiary);
|
font-size: 10px; color: var(--df-text-dim);
|
||||||
padding-top: 2px; font-style: italic;
|
padding-top: 2px; font-style: italic;
|
||||||
}
|
}
|
||||||
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
|
||||||
|
|
||||||
/* -- 审批参数展示 -- */
|
/* -- 审批参数展示 -- */
|
||||||
.ai-tool-approval-args {
|
.ai-tool-approval-args {
|
||||||
padding: 4px 10px 6px; margin-bottom: 6px;
|
padding: 4px 10px 6px; margin-bottom: 6px;
|
||||||
border-top: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
|
border-top: 0.5px solid var(--df-border); border-bottom: 0.5px solid var(--df-border);
|
||||||
background: var(--df-bg-elevated, rgba(0, 0, 0, 0.03));
|
background: var(--df-bg);
|
||||||
}
|
}
|
||||||
.ai-tool-arg { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; line-height: 1.5; }
|
.ai-tool-arg { display: flex; gap: 8px; padding: 2px 0; font-size: 11px; line-height: 1.5; }
|
||||||
.ai-tool-arg-key { flex-shrink: 0; min-width: 64px; color: var(--df-text-secondary); font-family: var(--df-font-mono); }
|
.ai-tool-arg-key { flex-shrink: 0; min-width: 64px; color: var(--df-text-secondary); font-family: var(--df-font-mono); }
|
||||||
.ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text-primary); }
|
.ai-tool-arg-val { flex: 1; word-break: break-all; white-space: pre-wrap; font-family: var(--df-font-mono); color: var(--df-text); }
|
||||||
.ai-tool-approval-reason {
|
.ai-tool-approval-reason {
|
||||||
padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px;
|
padding: 4px 10px 6px; margin-bottom: 6px; font-size: 11px;
|
||||||
color: var(--df-warning, #d97706); background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
|
color: var(--df-warning); background: var(--df-warning-bg);
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
border-radius: var(--df-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
|
/* 审批挂起时长提示(>2分钟橙色,>5分钟红色) */
|
||||||
.ai-tool-wait-time {
|
.ai-tool-wait-time {
|
||||||
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
|
padding: 3px 10px; margin-bottom: 6px; font-size: 11px;
|
||||||
font-family: var(--df-font-mono);
|
font-family: var(--df-font-mono);
|
||||||
color: var(--df-text-dim, #888);
|
color: var(--df-text-dim);
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
border-radius: var(--df-radius-sm);
|
||||||
}
|
}
|
||||||
.ai-tool-wait-time.is-long {
|
.ai-tool-wait-time.is-long {
|
||||||
color: var(--df-warning, #d97706);
|
color: var(--df-warning);
|
||||||
animation: pulseGlow 1.5s ease-in-out infinite;
|
animation: pulseGlow 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.ai-tool-wait-time.is-urgent {
|
.ai-tool-wait-time.is-urgent {
|
||||||
color: var(--df-danger, #e5484d);
|
color: var(--df-danger);
|
||||||
animation: pulseGlow 1s ease-in-out infinite;
|
animation: pulseGlow 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* write_file 审批 diff 预览区(红删绿增行级) */
|
/* write_file 审批 diff 预览区(红删绿增行级);
|
||||||
|
基类 .df-diff-pre(+code)/.df-diff-line 消费 global.css 共享定义,此处仅留行配色变体 */
|
||||||
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
|
.ai-tool-approval-diff { margin: 0 10px 8px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm, 4px); overflow: hidden; }
|
||||||
.ai-tool-diff-pre {
|
.df-diff-line--add { color: var(--df-success); background: var(--df-success-bg); }
|
||||||
margin: 0; padding: 6px 0; max-height: 240px; overflow: auto;
|
.df-diff-line--del { color: var(--df-danger); background: var(--df-danger-bg); }
|
||||||
font-family: var(--df-font-mono); font-size: 11px; line-height: 1.5; background: var(--df-bg-card);
|
.df-diff-line--ctx { color: var(--df-text-dim); }
|
||||||
}
|
|
||||||
.ai-tool-diff-pre code { display: block; white-space: pre; }
|
|
||||||
.ai-tool-diff-line { display: block; padding: 0 10px; white-space: pre-wrap; word-break: break-all; }
|
|
||||||
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
|
|
||||||
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
|
|
||||||
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
|
|
||||||
|
|
||||||
/* -- 审批按钮 -- */
|
/* -- 审批按钮 -- */
|
||||||
.ai-tool-actions { display: flex; gap: 6px; padding: 0 10px 8px; }
|
.ai-tool-actions { display: flex; gap: 6px; padding: 0 10px 8px; }
|
||||||
@@ -564,16 +545,6 @@ const visibleArgs = computed(() => {
|
|||||||
}
|
}
|
||||||
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
|
.ai-tool-btn--approve { background: var(--df-success-bg); color: var(--df-success); }
|
||||||
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
|
.ai-tool-btn--approve:hover { background: var(--df-success); color: #fff; border-color: var(--df-success); }
|
||||||
/* M4:session 按钮(accent 描边态,介于 approve 弱背景与 always 强 warning 之间;对齐 DirAuthDialog session 语义) */
|
|
||||||
.ai-tool-btn--session {
|
|
||||||
background: var(--df-accent-bg, rgba(123, 111, 240, 0.12));
|
|
||||||
color: var(--df-accent, #7b6ff0);
|
|
||||||
border-color: color-mix(in srgb, var(--df-accent, #7b6ff0) 40%, transparent);
|
|
||||||
}
|
|
||||||
.ai-tool-btn--session:hover { background: var(--df-accent, #7b6ff0); color: #fff; border-color: var(--df-accent, #7b6ff0); }
|
|
||||||
/* path_auth 审批链阶段3b:always 按钮(warning 色调,与 DirAuthDialog.vue 同款语义) */
|
|
||||||
.ai-tool-btn--always { background: var(--df-warning-bg); color: var(--df-warning); }
|
|
||||||
.ai-tool-btn--always:hover { background: var(--df-warning); color: #000; border-color: var(--df-warning); }
|
|
||||||
.ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); }
|
.ai-tool-btn--reject { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||||
.ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); }
|
.ai-tool-btn--reject:hover { background: var(--df-danger); color: #fff; border-color: var(--df-danger); }
|
||||||
/* 审批 loading禁用两按钮防重复点击,spinner 转在原位 */
|
/* 审批 loading禁用两按钮防重复点击,spinner 转在原位 */
|
||||||
@@ -587,15 +558,14 @@ const visibleArgs = computed(() => {
|
|||||||
.ai-tool-btn-icon :deep(svg) { display: block; }
|
.ai-tool-btn-icon :deep(svg) { display: block; }
|
||||||
.ai-tool-btn-spinner {
|
.ai-tool-btn-spinner {
|
||||||
width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent;
|
width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent;
|
||||||
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: aiToolBtnSpin 0.7s linear infinite;
|
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: df-spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes aiToolBtnSpin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
/* -- 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
|
/* -- 审批 loading 超时兜底 toast(复用 AiChat 同款视觉 token) -- */
|
||||||
.ai-tool-toast {
|
.ai-tool-toast {
|
||||||
position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
|
position: absolute; top: 6px; left: 50%; transform: translateX(-50%);
|
||||||
padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100;
|
padding: 6px 12px; border-radius: var(--df-radius-sm); font-size: 11px; z-index: 100;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3); max-width: 90%; overflow-wrap: anywhere; pointer-events: none;
|
box-shadow: var(--df-shadow-sm); max-width: 90%; overflow-wrap: anywhere; pointer-events: none;
|
||||||
}
|
}
|
||||||
.ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
.ai-tool-toast--warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||||
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
.ai-tool-toast-enter-active, .ai-tool-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||||
@@ -605,15 +575,13 @@ const visibleArgs = computed(() => {
|
|||||||
.ai-tool-card[data-risk='high'] { border-left: 2px solid var(--df-danger); }
|
.ai-tool-card[data-risk='high'] { border-left: 2px solid var(--df-danger); }
|
||||||
.ai-tool-card[data-risk='medium'] { border-left: 2px solid var(--df-warning); }
|
.ai-tool-card[data-risk='medium'] { border-left: 2px solid var(--df-warning); }
|
||||||
|
|
||||||
/* -- 风险徽章(审批区首行,High 橙红底/Medium 黄底,10px 大写字距) -- */
|
/* -- 风险徽章(审批区首行):wave2 迁 .df-badge 透明彩底(danger/warning 变体接管配色),
|
||||||
|
此处只留布局(独立成行+外边距) -- */
|
||||||
.ai-tool-risk-badge {
|
.ai-tool-risk-badge {
|
||||||
display: inline-flex; align-items: center; align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 2px 8px; margin: 6px 10px 0;
|
margin: 6px 10px 0;
|
||||||
font-size: 10px; font-weight: 600; letter-spacing: 0.5px;
|
font-weight: 600; letter-spacing: 0.5px;
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
}
|
}
|
||||||
.ai-tool-risk-badge.is-high { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent); }
|
|
||||||
.ai-tool-risk-badge.is-medium { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent); }
|
|
||||||
|
|
||||||
/* 审批区改纵向 flex(徽章需独立成行,原块级布局不变) */
|
/* 审批区改纵向 flex(徽章需独立成行,原块级布局不变) */
|
||||||
.ai-tool-approval { display: flex; flex-direction: column; }
|
.ai-tool-approval { display: flex; flex-direction: column; }
|
||||||
|
|||||||
@@ -13,10 +13,6 @@
|
|||||||
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
|
<button class="ai-btn ai-btn--sm ai-btn--danger" :disabled="batchProcessing" @click="$emit('batch-approve', 'reject')">
|
||||||
{{ $t('aiTool.rejectAll') }}
|
{{ $t('aiTool.rejectAll') }}
|
||||||
</button>
|
</button>
|
||||||
<!-- High 风险计数徽标:批量批准将二次确认的项数,提示操作后果 -->
|
|
||||||
<span v-if="highRiskPendingCount > 0" class="ai-batch-high-risk-hint">
|
|
||||||
{{ $t('aiTool.batchHighRiskHint', { n: highRiskPendingCount }) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,8 +22,8 @@
|
|||||||
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
|
<div v-if="group.calls.length >= 2" class="ai-tool-group-header" @click="toggleGroupCollapse(gi)">
|
||||||
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
|
<span class="ai-tool-group-icon" v-html="groupIcon(group.name)"></span>
|
||||||
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
|
<span class="ai-tool-group-name">{{ groupDisplayName(group) }}</span>
|
||||||
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(红字计数) -->
|
<!-- R7 组头失败徽标:折叠组内失败卡不展开也能从组头发现(wave2 迁 .df-badge danger) -->
|
||||||
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
|
<span v-if="groupFailCounts[gi] > 0" class="ai-tool-group-fail df-badge df-badge--danger">{{ $t('aiTool.groupFailCount', { n: groupFailCounts[gi] }) }}</span>
|
||||||
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
|
<span class="ai-tool-group-chevron" :class="{ 'is-open': !isGroupCollapsed(gi) }">▸</span>
|
||||||
</div>
|
</div>
|
||||||
<ToolCard
|
<ToolCard
|
||||||
@@ -43,14 +39,6 @@
|
|||||||
:class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }"
|
:class="{ 'ai-tool-card--group-hidden': isGroupHidden(tc, group, gi) }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 批量批准 High 风险二次确认(本组件自治 useConfirm,不与父级串台) -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:visible="confirmState.visible"
|
|
||||||
:msg="confirmState.msg"
|
|
||||||
:danger-label="t('common.confirm')"
|
|
||||||
@result="answerConfirm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -58,11 +46,8 @@
|
|||||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import ToolCard from './ToolCard.vue'
|
import ToolCard from './ToolCard.vue'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
|
||||||
import { toolRiskLevel } from '@/composables/ai/useToolApproval'
|
|
||||||
import { isToolFailure } from '@/composables/ai/useToolCard'
|
import { isToolFailure } from '@/composables/ai/useToolCard'
|
||||||
|
|
||||||
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
|
// useI18n needed for $t() in template (collapseAll / expandAll) + groupDisplayName(i18n key)
|
||||||
@@ -74,10 +59,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/**
|
/** 审批按钮 → 父组件转发 store.approveToolCall(一次性 approve/reject)。 */
|
||||||
* 审批按钮 → 父组件转发 store.approveToolCall。
|
|
||||||
* path_auth 审批链阶段3b:path 类带 decision('once'|'session'|'always'|'deny'),risk 类仅 approved boolean。
|
|
||||||
*/
|
|
||||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
|
approve: [{ id: string; approved: boolean; decision?: 'once' | 'session' | 'always' | 'deny' }]
|
||||||
/** 批量审批 → 父组件调用 approveAll/rejectAll */
|
/** 批量审批 → 父组件调用 approveAll/rejectAll */
|
||||||
'batch-approve': [decision: 'approve' | 'reject']
|
'batch-approve': [decision: 'approve' | 'reject']
|
||||||
@@ -154,27 +136,15 @@ const pendingCount = computed(() =>
|
|||||||
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
|
props.toolCalls.filter(tc => tc.status === 'pending_approval').length
|
||||||
)
|
)
|
||||||
|
|
||||||
// -- 批量批准 High 风险拦截(治安全缺口:原批量批准绕过单卡二次确认直接放行) --
|
// 批量批准 High 风险二次确认已退役(2026-10-25 批C):审批按钮本身即确认(与单卡/浮窗同口径)。
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
|
||||||
/** 待审批中的 High 风险项数(徽标提示 + 确认门槛判定) */
|
|
||||||
const highRiskPendingCount = computed(() =>
|
|
||||||
props.toolCalls.filter(tc => tc.status === 'pending_approval' && toolRiskLevel(tc.name) === 'high').length
|
|
||||||
)
|
|
||||||
/** 批量处理中(透传 isBatchProcessing 不便——本组件直接用 pendingCount 收敛到 0 判断,简单可靠) */
|
/** 批量处理中(透传 isBatchProcessing 不便——本组件直接用 pendingCount 收敛到 0 判断,简单可靠) */
|
||||||
const batchProcessing = ref(false)
|
const batchProcessing = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量批准入口:含 High 风险项 → 先二次确认(文案对齐单卡 highRiskConfirmText 语义,
|
* 批量批准入口:直接向父级派发 batch-approve('approve')。
|
||||||
* 列出将放行的高危工具数);确认后才向父级派发 batch-approve('approve')。
|
|
||||||
* 拒绝批量无破坏性,不拦。
|
* 拒绝批量无破坏性,不拦。
|
||||||
*/
|
*/
|
||||||
async function onBatchApprove() {
|
async function onBatchApprove() {
|
||||||
if (highRiskPendingCount.value > 0) {
|
|
||||||
const ok = await confirmDialog(
|
|
||||||
t('aiTool.batchHighRiskConfirm', { n: highRiskPendingCount.value }),
|
|
||||||
)
|
|
||||||
if (!ok) return
|
|
||||||
}
|
|
||||||
batchProcessing.value = true
|
batchProcessing.value = true
|
||||||
emit('batch-approve', 'approve')
|
emit('batch-approve', 'approve')
|
||||||
// emit 后父级异步批量处理;本组件无完成回执,以 pendingCount 归零兜底复位
|
// emit 后父级异步批量处理;本组件无完成回执,以 pendingCount 归零兜底复位
|
||||||
@@ -366,7 +336,7 @@ function scrollToFirstPending(): void {
|
|||||||
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
|
// 自动展开折叠分组 + scroll 到 pending 卡。根因:统一审批开关 df-ai-unified-approval 默认开 →
|
||||||
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
|
// path 审批走 ToolCard 内联,但同名工具≥2 分组默认折叠(initDefaultCollapse)+ group-hidden
|
||||||
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
|
// (display:none)→ 审批按钮对用户不可见 → 5min 超时(aiShared APPROVAL_TIMEOUT_MS)静默
|
||||||
// authorizeDir/approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
|
// 超时自动 approve(deny)→ 误显"用户拒绝" → 用户全程未见审批入口即"卡死/被拒"。
|
||||||
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
|
// scrollToFirstPending(:285)已能自动展开折叠组(offsetParent===null → collapsedGroups 清空),
|
||||||
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
|
// 原仅 AiChat 头部徽标手动触发(:230);此处 pending 到达即自动调用,让审批卡必可见可操作。
|
||||||
let _unlistenPending: UnlistenFn | null = null
|
let _unlistenPending: UnlistenFn | null = null
|
||||||
@@ -451,23 +421,17 @@ function groupIcon(name: string): string {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- 批量操作栏 -- */
|
/* -- 批量操作栏(wave2:白透明底残留改 token,浅色主题成立) -- */
|
||||||
.ai-batch-approve {
|
.ai-batch-approve {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: var(--df-radius-sm);
|
border-radius: var(--df-radius-sm);
|
||||||
background: rgba(255,255,255,0.02);
|
background: var(--df-bg-card);
|
||||||
}
|
}
|
||||||
/* 批量批准将二次确认的 High 风险项数徽标(橙红警示,不抢按钮焦点) */
|
/* 批量批准将二次确认的 High 风险项数徽标:配色由 df-badge--danger 接管,此处只留布局 */
|
||||||
.ai-batch-high-risk-hint {
|
.ai-batch-high-risk-hint {
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-danger);
|
|
||||||
background: var(--df-danger-bg);
|
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
padding: 2px 8px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
/* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
|
/* .ai-btn 基类:批量审批按钮(历史重构遗漏定义,补全治本) */
|
||||||
@@ -512,16 +476,13 @@ function groupIcon(name: string): string {
|
|||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
}
|
}
|
||||||
.ai-tool-group-header:hover {
|
.ai-tool-group-header:hover {
|
||||||
background: rgba(255,255,255,0.03);
|
background: var(--df-bg-card-hover);
|
||||||
}
|
}
|
||||||
/* R7 组头失败徽标:失败计数红字,margin-left auto 推到 chevron 之前右对齐 */
|
/* R7 组头失败徽标:配色由 df-badge--danger 接管,此处只留布局(右对齐推到 chevron 前) */
|
||||||
.ai-tool-group-fail {
|
.ai-tool-group-fail {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--df-danger);
|
|
||||||
opacity: 0.9;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ai-tool-group-icon {
|
.ai-tool-group-icon {
|
||||||
|
|||||||
@@ -1,221 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
<!-- 结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||||
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。 -->
|
分支顺序/条件/文案与原 ToolCard.vue 一字不变,仅搬运行内联模板。
|
||||||
|
C4a 拆分:九个工具专属分支下沉 tool-result-body/ 两族子件,本组件持分族派发 + 通用兜底。 -->
|
||||||
<!-- read_file 结果:全文预览(默认)/ 命中行(search 模式) -->
|
<!-- 读取浏览族:read_file / list_directory / search_files / grep(原链第 1-4 支) -->
|
||||||
<div v-if="tc.name === 'read_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-file-content">
|
<FileBrowseResult
|
||||||
<div class="ai-tool-file-bar">
|
v-if="browseBranch"
|
||||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
:tc="tc"
|
||||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
:is-content-expanded="isContentExpanded"
|
||||||
<!-- BUG-260625-02: search 模式返回 matches(命中行)非 content,
|
@expand-content="emit('expand-content', $event)"
|
||||||
旧模板取 lines/returned_lines 显「0 行」+ 空 content 区,用户误以为读取失败。
|
/>
|
||||||
search 模式显搜索词 + 命中数(复用 grepHitsN 文案);默认模式显行数/大小 + 展开。 -->
|
<!-- 写入执行族:write_file / patch_file / run_command / http_request / fetch_url(原链第 5-9 支) -->
|
||||||
<span v-if="parsed?.search" class="ai-tool-file-meta">「{{ parsed?.search }}」 {{ $t('aiTool.grepHitsN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.matches?.length || 0) }) }}</span>
|
<WriteRunResult
|
||||||
<span v-else class="ai-tool-file-meta">{{ parsed?.has_more
|
v-else-if="writeRunBranch"
|
||||||
? $t('aiTool.linesTruncated', { shown: parsed?.returned_lines ?? 0, total: parsed?.lines ?? 0 })
|
:tc="tc"
|
||||||
: `${parsed?.lines || 0} ${$t('aiTool.lines')}` }} · {{ formatBytes(parsed?.size) }}</span>
|
@install-obscura="emit('install-obscura')"
|
||||||
<button v-if="!parsed?.search" class="ai-tool-expand-btn" @click="emit('expand-content', tc.id)">
|
/>
|
||||||
{{ isContentExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- search 模式:命中行渲染(复用 grep content 样式;read_file matches 仅 {line,content} 无 file) -->
|
|
||||||
<!-- 行号 gutter 与内容同一 flex row(IDE 风格),不再 head/content 分两行块级堆叠 -->
|
|
||||||
<div v-if="parsed?.search && parsed?.matches?.length" class="ai-tool-grep-matches">
|
|
||||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
|
||||||
<div class="ai-tool-grep-match-main">
|
|
||||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
|
|
||||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 默认模式:全文预览 -->
|
|
||||||
<pre v-else class="ai-tool-file-pre" :class="{ 'ai-tool-file-pre--collapsed': !isContentExpanded }"><code>{{ parsed?.content }}</code></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- list_directory 结果:文件树 -->
|
|
||||||
<div v-else-if="tc.name === 'list_directory' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
|
||||||
<div class="ai-tool-file-bar">
|
|
||||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
|
||||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
|
||||||
<span class="ai-tool-file-meta">{{ parsed?.entries?.length || 0 }} {{ $t('aiTool.items') }}</span>
|
|
||||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.dirTruncated', { n: DIR_TRUNCATE_LIMIT }) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ai-tool-dir-entries">
|
|
||||||
<div
|
|
||||||
v-for="(entry, i) in parsed?.entries"
|
|
||||||
:key="i"
|
|
||||||
class="ai-tool-dir-entry"
|
|
||||||
:class="'ai-tool-dir-entry--' + entry.type"
|
|
||||||
:style="{ paddingLeft: `${12 + (entry.depth || 0) * 16}px` }"
|
|
||||||
>
|
|
||||||
<span class="ai-tool-dir-icon" :class="entry.type === 'directory' ? 'ai-tool-dir-icon--folder' : 'ai-tool-dir-icon--file'" v-html="entry.type === 'directory' ? dirIcon : fileIcon"></span>
|
|
||||||
<span class="ai-tool-dir-name">{{ entry.name }}</span>
|
|
||||||
<span v-if="entry.type === 'file'" class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- search_files 结果:匹配文件列表(对齐 list_directory 渲染,修 results 被丢弃致 body 仅显「找到 N 项」) -->
|
|
||||||
<div v-else-if="tc.name === 'search_files' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-dir-list">
|
|
||||||
<div class="ai-tool-file-bar">
|
|
||||||
<span class="ai-tool-file-icon ai-tool-file-icon--dir" v-html="dirIcon"></span>
|
|
||||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
|
||||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
|
||||||
<span class="ai-tool-file-meta">{{ $t('aiTool.foundN', { n: typeof parsed?.total === 'number' ? parsed.total : (parsed?.results?.length || 0) }) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ai-tool-dir-entries">
|
|
||||||
<div v-for="(entry, i) in parsed?.results" :key="i" class="ai-tool-dir-entry">
|
|
||||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
|
||||||
<span class="ai-tool-dir-name">{{ entry.path }}</span>
|
|
||||||
<span class="ai-tool-dir-size">{{ formatBytes(entry.size) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- grep 结果:跨文件内容搜索(content/files_with_matches/count 三模式,F-260621)
|
|
||||||
对齐 search_files 文件栏样式;content 模式按命中行渲染(文件:行:内容),支持 -C 上下文折叠 -->
|
|
||||||
<div v-else-if="tc.name === 'grep' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-grep-result">
|
|
||||||
<div class="ai-tool-file-bar">
|
|
||||||
<span class="ai-tool-file-icon" v-html="searchIcon"></span>
|
|
||||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
|
||||||
<span v-if="parsed?.pattern" class="ai-tool-file-meta">「{{ parsed?.pattern }}」</span>
|
|
||||||
<span class="ai-tool-file-meta">{{ grepHitLabel }}</span>
|
|
||||||
<span v-if="parsed?.truncated" class="ai-tool-file-meta ai-tool-file-meta--warn">{{ $t('aiTool.grepTruncated') }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- files_with_matches 模式:仅文件列表 -->
|
|
||||||
<div v-if="parsed?.output_mode === 'files_with_matches'" class="ai-tool-dir-entries">
|
|
||||||
<div v-for="(f, i) in parsed?.files" :key="i" class="ai-tool-dir-entry">
|
|
||||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
|
||||||
<span class="ai-tool-dir-name">{{ f }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- count 模式:文件 + 命中行数 -->
|
|
||||||
<div v-else-if="parsed?.output_mode === 'count'" class="ai-tool-dir-entries">
|
|
||||||
<div v-for="(c, i) in parsed?.counts" :key="i" class="ai-tool-dir-entry">
|
|
||||||
<span class="ai-tool-dir-icon ai-tool-dir-icon--file" v-html="fileIcon"></span>
|
|
||||||
<span class="ai-tool-dir-name">{{ c.file }}</span>
|
|
||||||
<span class="ai-tool-dir-size">{{ $t('aiTool.grepCountLines', { n: c.count }) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- content 模式:命中行(文件 | 行号 gutter | 内容 同行),支持上下文折叠 -->
|
|
||||||
<!-- 文件名/行号/内容同一 flex row,不再 head 块 + content 块分两行 -->
|
|
||||||
<div v-else class="ai-tool-grep-matches">
|
|
||||||
<div v-for="(m, i) in parsed?.matches" :key="i" class="ai-tool-grep-match">
|
|
||||||
<div class="ai-tool-grep-match-main">
|
|
||||||
<span class="ai-tool-grep-file">{{ m.file }}</span>
|
|
||||||
<span v-if="m.line !== null && m.line !== undefined" class="ai-tool-grep-line">{{ m.line }}</span>
|
|
||||||
<code class="ai-tool-grep-content">{{ m.content }}</code>
|
|
||||||
</div>
|
|
||||||
<pre v-if="m.context" class="ai-tool-grep-context"><code>{{ m.context }}</code></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- write_file 结果 -->
|
|
||||||
<div v-else-if="tc.name === 'write_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-write-result">
|
|
||||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--df-success)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
||||||
<span class="ai-tool-write-path" :title="parsed?.path">{{ shortPath(parsed?.path || '') }}</span>
|
|
||||||
<span class="ai-tool-write-meta">{{ formatBytes(parsed?.bytes_written) }} {{ $t('aiTool.written') }}</span>
|
|
||||||
<span v-if="typeof parsed?.old_size === 'number' && parsed.old_size > 0" class="ai-tool-write-meta">{{ $t('aiTool.writeOverwrite', { old: formatBytes(parsed.old_size), neww: formatBytes(parsed?.bytes_written) }) }}</span>
|
|
||||||
<span v-if="typeof parsed?.encoding === 'string' && parsed.encoding !== 'utf-8'" class="ai-tool-write-meta">{{ $t('aiTool.encodingLabel', { enc: parsed.encoding }) }}</span>
|
|
||||||
<!-- R12(2026-10-05):覆盖写入的行级 diff 回看(后端 diff 字段,新建/base64 为 null 不渲染) -->
|
|
||||||
<div v-if="resultDiffLines.length" class="ai-tool-write-diff">
|
|
||||||
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
|
||||||
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- patch_file 结果:文件路径/字节增减 + unified diff 红删绿增行级预览(复用 ai-tool-diff-pre/--add/--del/--ctx 既有样式)
|
|
||||||
后端 generate_diff 产完整 diff,result.diff 字段此前被丢弃,用户无法验证改了什么 → 此分支渲染 diff -->
|
|
||||||
<div v-else-if="tc.name === 'patch_file' && tc.result && tc.status === 'completed' && parsed" class="ai-tool-patch-result">
|
|
||||||
<div class="ai-tool-file-bar">
|
|
||||||
<span class="ai-tool-file-icon" v-html="fileIcon"></span>
|
|
||||||
<button type="button" class="ai-tool-file-path ai-tool-file-path--copy" :title="parsed?.path + ' — ' + $t('aiTool.clickToCopy')" @click.stop="copyResultPath($event, parsed?.path || '')">{{ shortPath(parsed?.path || '') }}</button>
|
|
||||||
<span class="ai-tool-file-meta">{{ formatSizeDiff(parsed?.size_diff) }} {{ $t('aiTool.bytesUnit') }}</span>
|
|
||||||
<span v-if="parsed?.matches_found && parsed.matches_found > 1" class="ai-tool-file-meta">{{ $t('aiTool.patchedMatches', { n: parsed.matches_found }) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="parsed?.warning" class="ai-tool-approval-reason"><span aria-hidden="true">⚠</span> {{ parsed.warning }}</div>
|
|
||||||
<div v-if="resultDiffLines.length" class="ai-tool-approval-diff">
|
|
||||||
<pre class="ai-tool-diff-pre"><code><span v-for="(ln, idx) in resultDiffLines" :key="idx" class="ai-tool-diff-line" :class="'ai-tool-diff-line--' + ln.kind">{{ ln.text }}{{ '\n' }}</span></code></pre>
|
|
||||||
<!-- R12a:长 diff 展开入口(默认预览 120 行,点开全量) -->
|
|
||||||
<button v-if="diffTotal > DIFF_PREVIEW_LINES" type="button" class="ai-tool-diff-toggle" @click="diffExpanded = !diffExpanded">{{ diffExpanded ? $t('aiTool.diffCollapse') : $t('aiTool.diffExpand', { n: diffTotal }) }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- run_command 结果:命令行常显 + 可折叠输出(执行后仍能看到跑了什么命令) -->
|
|
||||||
<div
|
|
||||||
v-else-if="tc.name === 'run_command' && tc.result && tc.status === 'completed'"
|
|
||||||
class="ai-tool-cmd-result"
|
|
||||||
>
|
|
||||||
<div class="ai-tool-cmd-line" :class="{ 'ai-tool-cmd-line--failed': isFailed }">
|
|
||||||
<span class="ai-tool-cmd-prompt">$</span>
|
|
||||||
<code class="ai-tool-cmd-text">{{ argString(tc.args, 'command') }}</code>
|
|
||||||
<button
|
|
||||||
v-if="cmdOutput"
|
|
||||||
class="ai-tool-cmd-toggle"
|
|
||||||
:title="cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
|
||||||
@click="cmdOutputExpanded = !cmdOutputExpanded"
|
|
||||||
>{{ cmdOutputExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
|
||||||
</div>
|
|
||||||
<pre v-if="cmdOutputExpanded && cmdOutput" class="ai-tool-cmd-output"><code>{{ cmdOutput }}</code></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- http_request 结果:method + url + 状态码徽标 + 可折叠响应 body(对齐 run_command 模式 ) -->
|
|
||||||
<div
|
|
||||||
v-else-if="tc.name === 'http_request' && tc.result && tc.status === 'completed' && parsed"
|
|
||||||
class="ai-tool-http-result"
|
|
||||||
>
|
|
||||||
<div class="ai-tool-http-line" :class="{ 'ai-tool-http-line--failed': isFailed }">
|
|
||||||
<span class="ai-tool-http-method">{{ parsed?.method || 'GET' }}</span>
|
|
||||||
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
|
||||||
<span class="ai-tool-http-status" :class="{ 'ai-tool-http-status--ok': !isFailed }">{{ parsed?.status }}</span>
|
|
||||||
<button
|
|
||||||
v-if="parsed?.body"
|
|
||||||
class="ai-tool-cmd-toggle"
|
|
||||||
:title="httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
|
||||||
@click="httpBodyExpanded = !httpBodyExpanded"
|
|
||||||
>{{ httpBodyExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="parsed?.truncated && !httpBodyExpanded" class="ai-tool-http-trunc-warn">
|
|
||||||
<span aria-hidden="true">⚠</span> {{ $t('aiTool.httpTruncated', { bytes: formatBytes(parsed?.body_bytes) }) }}
|
|
||||||
</div>
|
|
||||||
<pre v-if="httpBodyExpanded && parsed?.body" class="ai-tool-cmd-output"><code>{{ parsed.body }}</code></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- fetch_url 结果:URL + title + render_mode 徽标 + 可折叠 markdown
|
|
||||||
render_mode='static_fallback'(obscura 未装/失败)→ 显 obscura 未装提示 + "AI 帮装"按钮
|
|
||||||
(按钮 emit install-obscura → MessageList 调 store.sendMessage 触发 AI 装流式) -->
|
|
||||||
<div
|
|
||||||
v-else-if="tc.name === 'fetch_url' && tc.result && tc.status === 'completed' && parsed"
|
|
||||||
class="ai-tool-fetch-result"
|
|
||||||
>
|
|
||||||
<div class="ai-tool-fetch-line">
|
|
||||||
<span class="ai-tool-http-method ai-tool-fetch-mode" :class="'ai-tool-fetch-mode--' + (parsed?.render_mode || 'static')">
|
|
||||||
{{ parsed?.render_mode === 'obscura' ? $t('aiTool.fetchUrlRendered')
|
|
||||||
: parsed?.render_mode === 'obscura_failed' ? $t('aiTool.fetchUrlFallback')
|
|
||||||
: $t('aiTool.fetchUrlStatic') }}
|
|
||||||
</span>
|
|
||||||
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
|
||||||
<span v-if="typeof parsed?.length === 'number' && parsed?.length > 0" class="ai-tool-file-meta">{{ parsed?.length }} {{ $t('aiTool.charsUnit') }}</span>
|
|
||||||
<button
|
|
||||||
v-if="parsed?.markdown"
|
|
||||||
class="ai-tool-cmd-toggle"
|
|
||||||
:title="fetchMdExpanded ? $t('aiTool.collapse') : $t('aiTool.expand')"
|
|
||||||
@click="fetchMdExpanded = !fetchMdExpanded"
|
|
||||||
>{{ fetchMdExpanded ? $t('aiTool.collapse') : $t('aiTool.expand') }}</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="parsed?.title" class="ai-tool-fetch-title">{{ parsed?.title }}</div>
|
|
||||||
<!-- obscura 未装引导(render=true 但回退静态):提示 + "AI 帮装"按钮 -->
|
|
||||||
<div v-if="obscuraMissing" class="ai-tool-obscura-hint">
|
|
||||||
<div class="ai-tool-obscura-hint-text"><span aria-hidden="true">⚠</span> {{ parsed?.hint || $t('aiTool.fetchUrlFallbackHint') }}</div>
|
|
||||||
<button class="ai-tool-btn ai-tool-btn--install" @click="onInstallObscura">
|
|
||||||
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
{{ $t('aiTool.installObscura') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre v-if="fetchMdExpanded && parsed?.markdown" class="ai-tool-cmd-output"><code>{{ parsed.markdown }}</code></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 通用 CRUD 结果 -->
|
<!-- 通用 CRUD 结果 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="tc.result && tc.status === 'completed'"
|
v-else-if="tc.result && tc.status === 'completed'"
|
||||||
@@ -228,24 +27,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
/**
|
||||||
import {
|
* 工具结果展示区(从 ToolCard.vue 抽离,纯渲染,逻辑等价)。
|
||||||
isToolFailure,
|
*
|
||||||
parseResult,
|
* C4a 大文件拆分:九个工具专属分支分两族下沉 tool-result-body/
|
||||||
formatBytes,
|
* - FileBrowseResult = 读取浏览族(read_file / list_directory / search_files / grep,原链第 1-4 支)
|
||||||
argString,
|
* - WriteRunResult = 写入执行族(write_file / patch_file / run_command / http_request / fetch_url,原链第 5-9 支)
|
||||||
formatSizeDiff,
|
* 本组件持分族派发 + 通用 CRUD 兜底分支。
|
||||||
combineOutputs,
|
*
|
||||||
formatToolResult,
|
* 派发口径与原 v-else-if 链等价:各支条件原样,组判定=组内各支条件析取,组序=原链序——
|
||||||
formatGrep,
|
* 原链渲染首个命中支;同一支必落同组,组内首个命中支即该组渲染内容,故渲染结果一一对应。
|
||||||
shortPath,
|
*/
|
||||||
dirIcon,
|
import { computed } from 'vue'
|
||||||
fileIcon,
|
import { parseResult, formatToolResult } from '@/composables/ai/useToolCard'
|
||||||
searchIcon,
|
|
||||||
DIR_TRUNCATE_LIMIT,
|
|
||||||
} from '@/composables/ai/useToolCard'
|
|
||||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
import { resultIsFailed } from './tool-result-body/shared'
|
||||||
|
import FileBrowseResult from './tool-result-body/FileBrowseResult.vue'
|
||||||
|
import WriteRunResult from './tool-result-body/WriteRunResult.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 单条工具调用数据(结果展示只读消费) */
|
/** 单条工具调用数据(结果展示只读消费) */
|
||||||
@@ -264,135 +62,35 @@ const emit = defineEmits<{
|
|||||||
'install-obscura': []
|
'install-obscura': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── R6 结果路径可复制 ──
|
|
||||||
// AI 读过/写过的文件,用户想拿去本地操作(编辑器打开/再 grep)只能手动重敲路径。
|
|
||||||
// file-bar 的 path 改为可点击:复制完整路径(parsed.path 原文,非 shortPath 截断展示),
|
|
||||||
// 按钮文字短暂变「✓」作反馈(1.2s 还原,同 R4 代码块复制反馈节奏)。
|
|
||||||
let _pathCopiedTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
async function copyResultPath(e: MouseEvent, path: string): Promise<void> {
|
|
||||||
if (!path) return
|
|
||||||
const btn = e.currentTarget as HTMLButtonElement | null
|
|
||||||
let ok = false
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(path)
|
|
||||||
ok = true
|
|
||||||
}
|
|
||||||
} catch { /* 落 execCommand 兜底 */ }
|
|
||||||
if (!ok) {
|
|
||||||
const ta = document.createElement('textarea')
|
|
||||||
ta.value = path
|
|
||||||
ta.style.position = 'fixed'
|
|
||||||
ta.style.opacity = '0'
|
|
||||||
document.body.appendChild(ta)
|
|
||||||
ta.select()
|
|
||||||
try { ok = document.execCommand('copy') } catch { ok = false }
|
|
||||||
document.body.removeChild(ta)
|
|
||||||
}
|
|
||||||
if (btn) {
|
|
||||||
const prev = btn.textContent
|
|
||||||
btn.textContent = ok ? '✓' : '✕'
|
|
||||||
if (_pathCopiedTimer) clearTimeout(_pathCopiedTimer)
|
|
||||||
_pathCopiedTimer = setTimeout(() => { btn.textContent = prev }, 1200)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// run_command 输出折叠态:失败命令默认展开(便于看 stderr),成功默认折叠
|
|
||||||
const cmdOutputExpanded = ref(false)
|
|
||||||
// http_request 响应 body 折叠态:失败(非 2xx)默认展开便于看错误,成功默认折叠
|
|
||||||
const httpBodyExpanded = ref(false)
|
|
||||||
// fetch_url markdown 折叠态:默认折叠(可能很长,头部已有 title + 截断字符数摘要)
|
|
||||||
const fetchMdExpanded = ref(false)
|
|
||||||
|
|
||||||
/** 一次解析结果供模板多次读(模板引用多次,computed 避免每次 patch 重 parse) */
|
/** 一次解析结果供模板多次读(模板引用多次,computed 避免每次 patch 重 parse) */
|
||||||
const parsed = computed(() => parseResult(props.tc.result))
|
const parsed = computed(() => parseResult(props.tc.result))
|
||||||
|
|
||||||
/**
|
/** 是否工具失败(completed 语义,通用兜底分支失败态);判定逻辑本体在 shared.resultIsFailed */
|
||||||
* 是否工具失败(completed 语义)。复用 parsed 不重 parse。逻辑与模块级 isToolFailure 同源,
|
const isFailed = computed(() => resultIsFailed(props.tc, parsed.value))
|
||||||
* 但因 run_command 失败判定依赖 parsed.exit_code(本组件已 computed),此处就近复用避免重复 parse。
|
|
||||||
*/
|
/** 读取浏览族命中(原链第 1-4 支条件原样析取) */
|
||||||
const isFailed = computed(() => {
|
const browseBranch = computed(() => {
|
||||||
const tc = props.tc
|
const tc = props.tc
|
||||||
if (tc.status !== 'completed') return false
|
return (tc.name === 'read_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||||
const r = parsed.value
|
|| (tc.name === 'list_directory' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||||
if (tc.name === 'run_command') {
|
|| (tc.name === 'search_files' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||||
if (r && typeof r.exit_code === 'number') return r.exit_code !== 0
|
|| (tc.name === 'grep' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||||
if (r) return true
|
|
||||||
}
|
|
||||||
// 可解析 JSON 且显式 success===false(git status/diff/log 只读工具失败返
|
|
||||||
// {success:false,error} 合法 JSON 对象非 Err)→ 判失败,防绿框假成功
|
|
||||||
if (r && r.success === false) return true
|
|
||||||
if (!r) {
|
|
||||||
const raw = typeof tc.result === 'string' ? tc.result : ''
|
|
||||||
if (!raw) return false
|
|
||||||
return /^(执行失败|Error:|Failed:)/m.test(raw)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** run_command 展开态全量输出(stdout+stderr 合并);空串隐藏 toggle。复用 parsed 不重 parse */
|
/** 写入执行族命中(原链第 5-9 支条件原样析取;run_command 原条件本就无 parsed 判定,照抄) */
|
||||||
const cmdOutput = computed(() => {
|
const writeRunBranch = computed(() => {
|
||||||
const r = parsed.value
|
const tc = props.tc
|
||||||
if (!r) return ''
|
return (tc.name === 'write_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||||
return combineOutputs(r.stdout, r.stderr)
|
|| (tc.name === 'patch_file' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||||
|
|| (tc.name === 'run_command' && !!tc.result && tc.status === 'completed')
|
||||||
|
|| (tc.name === 'http_request' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||||
|
|| (tc.name === 'fetch_url' && !!tc.result && tc.status === 'completed' && !!parsed.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* patch_file / write_file 结果 diff 行拆解(复用 parseDiffLines +add/-del/ ctx 着色)。
|
|
||||||
* 数据源:parsed.diff(工具执行结果字段,write_file 覆盖写入时后端同款生成)。
|
|
||||||
* R12a:默认截断前 120 行防撑爆卡片,expanded 后全量渲染(toggle 入口在 diff 块尾部)。
|
|
||||||
*/
|
|
||||||
const DIFF_PREVIEW_LINES = 120
|
|
||||||
const diffExpanded = ref(false)
|
|
||||||
const resultDiffLines = computed(() => parseDiffLines(parsed.value?.diff, diffExpanded.value ? undefined : DIFF_PREVIEW_LINES))
|
|
||||||
/** diff 全量行数(不受预览截断影响),驱动展开按钮显隐与行数文案 */
|
|
||||||
const diffTotal = computed(() => parseDiffLines(parsed.value?.diff).length)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* grep 命中计数标签G-1 复用 useToolCard.formatGrep 单一函数,
|
|
||||||
* 消除原与 ToolResultBody 重复的三分支(files_with_matches/count/content 文案)。
|
|
||||||
* parsed 为 null 返空串,等价原逻辑;非 null 委托 formatGrep。
|
|
||||||
*/
|
|
||||||
const grepHitLabel = computed(() => {
|
|
||||||
const r = parsed.value
|
|
||||||
return r ? formatGrep(r) : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fetch_url obscura 未装检测(render=true 但 obscura 未装):
|
|
||||||
* 后端 render=true 且 obscura 未装 → result.render_mode='obscura_not_installed' + hint 引导。
|
|
||||||
* 装了但失败/超时 → 'obscura_failed'(不弹引导,只 hint)。此 computed 仅未装弹"AI 帮装"。
|
|
||||||
*/
|
|
||||||
const obscuraMissing = computed(() => {
|
|
||||||
if (props.tc.name !== 'fetch_url') return false
|
|
||||||
const r = parsed.value
|
|
||||||
if (!r) return false
|
|
||||||
const mode = typeof r.render_mode === 'string' ? r.render_mode : ''
|
|
||||||
return mode === 'obscura_not_installed'
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fetch_url "AI 帮装 obscura" → emit 透传到 MessageList,调 store.sendMessage
|
|
||||||
* 触发 AI run_command 装流式。安装完成后用户重试即可用 obscura 渲染。
|
|
||||||
* 提示语足够具体(给出两种装法),AI 拿到能直接 run_command 不需再问。
|
|
||||||
*/
|
|
||||||
function onInstallObscura() {
|
|
||||||
emit('install-obscura')
|
|
||||||
}
|
|
||||||
|
|
||||||
// status 切到 completed 时按成功/失败初始化折叠态(失败默认展开便于看 stderr/错误 body)。
|
|
||||||
// immediate 时若初始非 completed,两 ref 保持默认 false,无副作用。
|
|
||||||
watch(() => props.tc.status, (s) => {
|
|
||||||
if (s === 'completed') {
|
|
||||||
cmdOutputExpanded.value = isToolFailure(props.tc)
|
|
||||||
httpBodyExpanded.value = isToolFailure(props.tc)
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ═══ ToolResultBody scoped 样式(C4a 后本组件只持通用兜底分支样式;
|
||||||
|
读取浏览/写入执行两族样式随子件迁 tool-result-body/,路径条族在 path-bar.css 两子件共享) ═══ -->
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 结果展示区样式(从 ToolCard.vue 迁移,仅含结果分支用到的 class;卡片容器/header/审批/骨架
|
|
||||||
等样式留 ToolCard.vue)。失败横幅/通用结果失败态样式一并迁移,因结果区独立判定 isFailed。 */
|
|
||||||
.ai-tool-failed-banner {
|
.ai-tool-failed-banner {
|
||||||
padding: 4px 10px 2px;
|
padding: 4px 10px 2px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -409,281 +107,6 @@ watch(() => props.tc.status, (s) => {
|
|||||||
color: var(--df-danger);
|
color: var(--df-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- File Content (read_file) -- */
|
|
||||||
.ai-tool-file-content {
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.ai-tool-file-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
background: color-mix(in srgb, var(--df-bg) 80%, transparent);
|
|
||||||
border-bottom: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.ai-tool-file-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--df-info);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ai-tool-file-icon--dir {
|
|
||||||
color: var(--df-warning);
|
|
||||||
}
|
|
||||||
.ai-tool-file-path {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-accent);
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ai-tool-file-meta {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ai-tool-file-meta--warn {
|
|
||||||
color: var(--df-warning);
|
|
||||||
}
|
|
||||||
/* R6:路径可点击复制(按钮语义,视觉保持原 span 形态;hover 变 accent 提示可点) */
|
|
||||||
.ai-tool-file-path--copy {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.ai-tool-file-path--copy:hover {
|
|
||||||
color: var(--df-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* grep 结果文件栏复用 ai-tool-file-bar,命中行用等宽渲染 */
|
|
||||||
.ai-tool-grep-result {
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.ai-tool-grep-matches {
|
|
||||||
padding: 4px 0;
|
|
||||||
max-height: 260px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.ai-tool-grep-match {
|
|
||||||
padding: 3px 12px;
|
|
||||||
border-bottom: 0.5px solid rgba(255,255,255,0.03);
|
|
||||||
}
|
|
||||||
.ai-tool-grep-match:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
/* 命中行主体 = 单行 flex row(文件名 | 行号 gutter | 内容 并排),
|
|
||||||
取代原 head 块 + content 块的垂直两行堆叠(行号内容分行根因)。 */
|
|
||||||
.ai-tool-grep-match-main {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.ai-tool-grep-file {
|
|
||||||
color: var(--df-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-width: 40%;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 10px;
|
|
||||||
padding-top: 1px;
|
|
||||||
}
|
|
||||||
.ai-tool-grep-line {
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 2em;
|
|
||||||
text-align: right;
|
|
||||||
font-size: 10px;
|
|
||||||
user-select: none;
|
|
||||||
padding-top: 1px;
|
|
||||||
}
|
|
||||||
.ai-tool-grep-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--df-text);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
background: rgba(61,219,160,0.05);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: var(--df-radius-xs, 3px);
|
|
||||||
}
|
|
||||||
.ai-tool-grep-context {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
max-height: 120px;
|
|
||||||
overflow: auto;
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
background: rgba(255,255,255,0.02);
|
|
||||||
border-radius: var(--df-radius-xs, 3px);
|
|
||||||
}
|
|
||||||
.ai-tool-grep-context code {
|
|
||||||
display: block;
|
|
||||||
padding: 4px 6px;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
.ai-tool-expand-btn {
|
|
||||||
font-family: var(--df-font-sans);
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
padding: 2px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.ai-tool-expand-btn:hover {
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
border-color: var(--df-border-strong);
|
|
||||||
}
|
|
||||||
.ai-tool-file-pre {
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: color-mix(in srgb, var(--df-bg) 60%, transparent);
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
overflow-x: auto;
|
|
||||||
tab-size: 4;
|
|
||||||
}
|
|
||||||
.ai-tool-file-pre--collapsed {
|
|
||||||
max-height: 180px;
|
|
||||||
/* hidden→auto: 默认高度内可直接滚轮看完整内容,不必先点"展开全部" */
|
|
||||||
overflow-y: auto;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.ai-tool-file-pre--collapsed::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 48px;
|
|
||||||
background: linear-gradient(transparent, color-mix(in srgb, var(--df-bg) 90%, transparent));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- Directory List (list_directory / search_files) -- */
|
|
||||||
.ai-tool-dir-list {
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.ai-tool-dir-entries {
|
|
||||||
padding: 4px 0;
|
|
||||||
max-height: 220px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.ai-tool-dir-entry {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
padding: 3px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.ai-tool-dir-entry:hover {
|
|
||||||
background: rgba(255,255,255,0.03);
|
|
||||||
}
|
|
||||||
.ai-tool-dir-entry--directory .ai-tool-dir-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
.ai-tool-dir-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ai-tool-dir-icon--folder { color: var(--df-warning); }
|
|
||||||
.ai-tool-dir-icon--file { color: var(--df-text-dim); }
|
|
||||||
.ai-tool-dir-name {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.ai-tool-dir-size {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- Write File Result -- */
|
|
||||||
.ai-tool-write-result {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
background: rgba(61,219,160,0.04);
|
|
||||||
}
|
|
||||||
.ai-tool-write-path {
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--df-accent);
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ai-tool-write-meta {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-success);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- patch_file diff 预览(复用审批 diff 同款 token) -- */
|
|
||||||
.ai-tool-approval-reason {
|
|
||||||
padding: 4px 10px 6px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--df-warning, #d97706);
|
|
||||||
background: var(--df-warning-bg, rgba(217, 119, 6, 0.08));
|
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
|
||||||
}
|
|
||||||
.ai-tool-approval-diff {
|
|
||||||
margin: 0 10px 8px;
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.ai-tool-diff-pre {
|
|
||||||
margin: 0;
|
|
||||||
padding: 6px 0;
|
|
||||||
max-height: 240px;
|
|
||||||
overflow: auto;
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.5;
|
|
||||||
background: var(--df-bg-card);
|
|
||||||
}
|
|
||||||
.ai-tool-diff-pre code {
|
|
||||||
display: block;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
.ai-tool-diff-line {
|
|
||||||
display: block;
|
|
||||||
padding: 0 10px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.ai-tool-diff-line--add { color: var(--df-success, #3ddb9c); background: var(--df-success-bg, rgba(61, 219, 160, 0.08)); }
|
|
||||||
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
|
|
||||||
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
|
|
||||||
|
|
||||||
/* -- Generic Result --
|
/* -- Generic Result --
|
||||||
max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性;
|
max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性;
|
||||||
对齐 ai-tool-file-pre--collapsed(180px) 与 ai-tool-dir-entries(220px) 量级。 */
|
对齐 ai-tool-file-pre--collapsed(180px) 与 ai-tool-dir-entries(220px) 量级。 */
|
||||||
@@ -701,195 +124,4 @@ watch(() => props.tc.status, (s) => {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
/* -- run_command 结果:命令行(常显) + 可折叠输出 -- */
|
|
||||||
.ai-tool-cmd-result {
|
|
||||||
margin: 0;
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.ai-tool-cmd-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: rgba(61,219,160,0.04);
|
|
||||||
}
|
|
||||||
.ai-tool-cmd-line--failed {
|
|
||||||
background: var(--df-danger-bg);
|
|
||||||
}
|
|
||||||
.ai-tool-cmd-prompt {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--df-success);
|
|
||||||
}
|
|
||||||
.ai-tool-cmd-line--failed .ai-tool-cmd-prompt {
|
|
||||||
color: var(--df-danger);
|
|
||||||
}
|
|
||||||
.ai-tool-cmd-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
.ai-tool-cmd-toggle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
font-size: 10.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.ai-tool-cmd-toggle:hover {
|
|
||||||
color: var(--df-text);
|
|
||||||
background: var(--df-bg-hover, rgba(255,255,255,0.06));
|
|
||||||
}
|
|
||||||
.ai-tool-cmd-output {
|
|
||||||
margin: 0;
|
|
||||||
padding: 8px 10px;
|
|
||||||
max-height: 160px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: rgba(0,0,0,0.18);
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- http_request 结果:method 徽标 + url + 状态码(复用 cmd-line 视觉 token) -- */
|
|
||||||
.ai-tool-http-result {
|
|
||||||
margin: 0;
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.ai-tool-http-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: rgba(61,219,160,0.04); /* 成功默认(对齐 cmd-line);失败由 --failed 覆盖 */
|
|
||||||
}
|
|
||||||
.ai-tool-http-line--failed {
|
|
||||||
background: var(--df-danger-bg);
|
|
||||||
}
|
|
||||||
.ai-tool-http-method {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
color: var(--df-text);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.ai-tool-http-url {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
}
|
|
||||||
.ai-tool-http-status {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--df-danger);
|
|
||||||
}
|
|
||||||
.ai-tool-http-status--ok {
|
|
||||||
color: var(--df-success);
|
|
||||||
}
|
|
||||||
.ai-tool-http-trunc-warn {
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: var(--df-warning, #f0c75e);
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- fetch_url 结果:复用 http-line 视觉 token;render_mode 徽标按 mode 着色 -- */
|
|
||||||
.ai-tool-fetch-result {
|
|
||||||
margin: 0;
|
|
||||||
border-top: 0.5px solid var(--df-border);
|
|
||||||
font-family: var(--df-font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.ai-tool-fetch-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: rgba(94,175,240,0.04);
|
|
||||||
}
|
|
||||||
.ai-tool-fetch-mode {
|
|
||||||
/* 复用 .ai-tool-http-method 基样(template 已加该 class),此处仅覆盖 mode 着色 */
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.ai-tool-fetch-mode--static,
|
|
||||||
.ai-tool-fetch-mode--obscura {
|
|
||||||
color: var(--df-success, #3ddb9c);
|
|
||||||
}
|
|
||||||
.ai-tool-fetch-mode--static_fallback {
|
|
||||||
color: var(--df-warning, #f0c75e);
|
|
||||||
}
|
|
||||||
.ai-tool-fetch-title {
|
|
||||||
padding: 4px 10px 2px;
|
|
||||||
font-family: var(--df-font-sans);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--df-text);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
/* obscura 未装引导(static_fallback):warning 色调提示 + AI 帮装按钮 */
|
|
||||||
.ai-tool-obscura-hint {
|
|
||||||
margin: 4px 10px 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: var(--df-warning-bg, rgba(240,199,94,0.08));
|
|
||||||
border: 0.5px solid var(--df-warning, #f0c75e);
|
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.ai-tool-obscura-hint-text {
|
|
||||||
font-family: var(--df-font-sans);
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: var(--df-warning, #f0c75e);
|
|
||||||
line-height: 1.5;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
/* AI 帮装按钮(accent 色调,对齐 batch-approve 视觉) */
|
|
||||||
.ai-tool-btn {
|
|
||||||
align-self: flex-start;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 5px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
|
||||||
border: none;
|
|
||||||
background: var(--df-accent, #5b8def);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--df-font-sans);
|
|
||||||
transition: filter 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.ai-tool-btn:hover { filter: brightness(1.1); }
|
|
||||||
|
|
||||||
/* R12: write_file diff 回看 + 长 diff 展开按钮 */
|
|
||||||
.ai-tool-write-diff { width: 100%; margin-top: 6px; }
|
|
||||||
.ai-tool-diff-toggle {
|
|
||||||
display: block;
|
|
||||||
margin: 4px auto 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 0.5px dashed var(--df-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 3px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.ai-tool-diff-toggle:hover { color: var(--df-text); background: var(--df-bg-secondary); }
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<!-- AE-2025-07: Agentic 循环进度条(C 状态语义化)
|
||||||
|
仅 agentic 多轮(streaming 且收到过 AiAgentRound round>0 事件)显示;
|
||||||
|
单轮问答 agentRound 恒 0,不显示(避免误显)。
|
||||||
|
文案:第 {round} 轮 · 累计 {total} 次调用 · 进行中/待审批;
|
||||||
|
total 由前端聚合当前轮次 toolCalls(useTurnTrace),完成后进度条隐藏,
|
||||||
|
完成聚合语义由 MessageList 的执行轨迹卡承接。
|
||||||
|
降级省略:max(总轮次)后端 AiAgentRound 未透传 max_rounds,不显。
|
||||||
|
C4b 拆分自 AiChat.vue(纯展示,store 单例直读,零行为变更)。 -->
|
||||||
|
<div v-if="showAgenticProgress" class="ai-agentic-progress">
|
||||||
|
<span class="ai-agentic-progress-dot"></span>
|
||||||
|
<span class="ai-agentic-progress-text">{{ agenticProgressText }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAiStore } from '../../stores/ai'
|
||||||
|
import { collectTurnStats, turnMessages } from '../../composables/ai/useTurnTrace'
|
||||||
|
|
||||||
|
const store = useAiStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 仅 agentic 多轮(streaming 且 agentRound>0)显示;单轮问答不收 AiAgentRound 故恒隐藏。
|
||||||
|
const showAgenticProgress = computed(() =>
|
||||||
|
store.state.streaming && store.state.agentRound > 0,
|
||||||
|
)
|
||||||
|
// 进度文案(C 状态语义化):「第 N 轮 · 累计 M 次调用 · 进行中/待审批」。
|
||||||
|
// 累计调用数由前端聚合当前轮次消息流上的 toolCalls(后端事件无累计字段,不改协议);
|
||||||
|
// 替代旧「循环 N · X个工具完成」——旧 completedTools 每轮清零,轮初叙述期恒显 0 造成歧义。
|
||||||
|
// 「已完成」态不出现在状态栏:完成后进度条隐藏(既有设计),完成聚合由执行轨迹卡承接。
|
||||||
|
const agenticProgressText = computed(() => {
|
||||||
|
const pending = store.state.pendingApprovals.length
|
||||||
|
const total = collectTurnStats(turnMessages(store.state.messages)).total
|
||||||
|
return pending > 0
|
||||||
|
? t('ai.agenticProgress', { round: store.state.agentRound, total, pending })
|
||||||
|
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, total })
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ═══ Agentic 循环进度条(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
|
||||||
|
.ai-agentic-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ai-agentic-progress-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--df-accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: ai-agentic-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.ai-agentic-progress-text {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@keyframes ai-agentic-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<template>
|
||||||
|
<!-- ═══ 待发送队列(生成中排队的消息,完成后自动续发;仅显示当前活跃会话的条目)
|
||||||
|
C4b 拆分自 AiChat.vue 队列区(模板 + L2 发送韧性 + V7 排队时钟 + 队列编辑/回填,零行为变更;
|
||||||
|
store 单例共享,confirm 自持一份 useConfirm + ConfirmDialog——同 ChatInput/ConversationSidebar
|
||||||
|
模式:useConfirm 非单例,子组件发起的 confirm 须本组件渲染) ═══ -->
|
||||||
|
<div v-if="activeQueue.length > 0" class="ai-queue" :class="{ 'ai-queue--timeout': queueTimedOut }">
|
||||||
|
<div class="ai-queue-head">
|
||||||
|
<span class="ai-queue-title">{{ $t('aiChat.queueTitle', { n: activeQueue.length }) }}</span>
|
||||||
|
<div class="ai-queue-actions">
|
||||||
|
<button v-if="queueTimedOut" class="ai-queue-force" @click="handleForceSend">{{ $t('ai.forceSendBtn') }}</button>
|
||||||
|
<button class="ai-queue-clear" @click="handleClearQueue">{{ $t('aiChat.clearQueue') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ai-queue-list">
|
||||||
|
<div v-for="(item, idx) in activeQueue" :key="idx" class="ai-queue-item">
|
||||||
|
<!-- 编辑态只编 text:inline input,回车/失焦写回,ESC 取消 -->
|
||||||
|
<template v-if="editingQueueIdx === idx">
|
||||||
|
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||||
|
<input
|
||||||
|
v-model="editingQueueText"
|
||||||
|
class="ai-queue-edit-input"
|
||||||
|
type="text"
|
||||||
|
@keydown="onQueueEditKeydown"
|
||||||
|
@blur="saveEditQueued"
|
||||||
|
/>
|
||||||
|
<button class="ai-queue-btn" @mousedown.prevent="saveEditQueued" :title="$t('aiChat.queueSave')" :aria-label="$t('aiChat.queueSave')">✓</button>
|
||||||
|
<button class="ai-queue-btn" @mousedown.prevent="cancelEditQueued" :title="$t('aiChat.queueCancel')" :aria-label="$t('aiChat.queueCancel')">✕</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="item.skill" class="ai-queue-skill">/{{ item.skill }}</span>
|
||||||
|
<span class="ai-queue-text" :title="item.text">{{ item.text }}</span>
|
||||||
|
<!-- V7 排队时钟:该项已等待时长 m:ss(nowTick 每秒推进,入发送随队列清空即停) -->
|
||||||
|
<span class="ai-queue-wait">{{ queueItemWait(item) }}</span>
|
||||||
|
<!-- I4 队列回填:取出该项填回输入框重新编辑(从队列移除) -->
|
||||||
|
<button class="ai-queue-btn" @click="backfillQueued(idx)" :title="$t('aiChat.queueBackfill')">{{ $t('aiChat.queueBackfill') }}</button>
|
||||||
|
<!-- 编辑按钮 -->
|
||||||
|
<button class="ai-queue-btn" @click="startEditQueued(idx)" :title="$t('aiChat.queueEdit')">{{ $t('aiChat.queueEdit') }}</button>
|
||||||
|
<!-- 立即发送(打断当前+发本条) -->
|
||||||
|
<button class="ai-queue-btn" @click="handleSendQueuedNow(idx)" :title="$t('aiChat.queueSendNow')">{{ $t('aiChat.queueSendNow') }}</button>
|
||||||
|
<button class="ai-queue-x" @click="store.cancelQueued(idx)" :title="$t('common.cancel')" :aria-label="$t('common.cancel')">×</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 确认弹层(强制发送/清空/回填覆盖二次确认;同 ChatInput 自持模式) -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:visible="confirmState.visible"
|
||||||
|
:msg="confirmState.msg"
|
||||||
|
:danger-label="confirmState.dangerLabel"
|
||||||
|
@result="answerConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useAiStore } from '../../stores/ai'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import { useConfirm } from '../../composables/useConfirm'
|
||||||
|
import type { SkillInfo } from '../../api/types'
|
||||||
|
|
||||||
|
// 回填链路跨组件:输入框 ref(chatInputRef)在父,本组件经 props 读草稿态(hasUnsentInput)、
|
||||||
|
// 经 emit 写草稿(fill-draft),由父转发 ChatInput——共享函数留主文件 props/emits 传(拆分纪律)。
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 父侧 ChatInput.hasUnsentInput 转发(回填覆盖未发送草稿前的 confirm 守卫) */
|
||||||
|
hasUnsentInput: () => boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 队列项回填:父转发 ChatInput.fillDraft(text, skill) 写入输入框草稿 */
|
||||||
|
(e: 'fill-draft', text: string, skill: SkillInfo | null): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useAiStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// ── 确认弹层(useConfirm 非单例,子组件自持一份 + 自渲染 ConfirmDialog,同 ChatInput 先例) ──
|
||||||
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
|
|
||||||
|
/** 当前活跃会话的待发送队列(按 activeConversationId 过滤)。
|
||||||
|
* 队列是全局数组,多会话并发可各自排队;旧实现渲染全部会话的 queue,切/新建后会显示
|
||||||
|
* 旧会话排队消息。此处仅显当前活跃会话条目,相关操作(编辑/删除/立即发送/清空/超时)均按此收敛。 */
|
||||||
|
const activeQueue = computed(() =>
|
||||||
|
store.state.queue.filter(q => q.conversationId === store.state.activeConversationId),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── V7 排队时钟:响应式 tick 驱动排队项已等待时长 ──
|
||||||
|
// 原 queueWaitSeconds 在 computed 内直接取 Date.now(),非响应式依赖:纯等待期(无 store 变更)
|
||||||
|
// 永不重算,秒数冻结;且只覆盖队首。改为有排队时起 1s interval 推进 nowTick,展示函数读
|
||||||
|
// nowTick 建立依赖实现每秒刷新;队列清空(含队首进入发送被移除)即关表,卸载时显式清理防泄漏。
|
||||||
|
const nowTick = ref(Date.now())
|
||||||
|
let queueTickTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
/** 队首排队是否已超 30s(仅看当前活跃会话队列)。
|
||||||
|
* UX-P2-A1:isQueueTimedOut 内取 Date.now() 非响应式,纯等待期(无 store 变更)本 computed
|
||||||
|
* 永不重算,超时横幅/强制发送按钮延迟到下一次无关 store 变更才出现。显式读 nowTick
|
||||||
|
* (V7 排队时钟,有排队时 1s interval 推进)建立每秒依赖,超时判定随 tick 每秒重算。 */
|
||||||
|
const queueTimedOut = computed(() => {
|
||||||
|
void nowTick.value
|
||||||
|
return store.isQueueTimedOut(store.state.activeConversationId)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => activeQueue.value.length > 0,
|
||||||
|
(waiting) => {
|
||||||
|
if (waiting && queueTickTimer === null) {
|
||||||
|
nowTick.value = Date.now()
|
||||||
|
queueTickTimer = setInterval(() => { nowTick.value = Date.now() }, 1000)
|
||||||
|
} else if (!waiting && queueTickTimer !== null) {
|
||||||
|
clearInterval(queueTickTimer)
|
||||||
|
queueTickTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 秒数转 m:ss(分钟不封顶,长等待继续累计) */
|
||||||
|
function formatWaitTime(totalSeconds: number): string {
|
||||||
|
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排队项已等待时长(m:ss);读 nowTick 建立响应式依赖,每秒随 tick 刷新 */
|
||||||
|
function queueItemWait(item: { enqueuedAt: number }): string {
|
||||||
|
return formatWaitTime(Math.max(0, Math.floor((nowTick.value - item.enqueuedAt) / 1000)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制发送:委托 tryForceSend 唯一负责弹 confirm + 移除队列 + force_send(作用于当前活跃会话) */
|
||||||
|
async function handleForceSend() {
|
||||||
|
await store.tryForceSend(confirmDialog, store.state.activeConversationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI-4(看板 943)清空队列:二次确认后全清当前活跃会话排队消息。
|
||||||
|
* 一键误触会静默丢掉全部排队消息且不可恢复,复用 ConfirmDialog 先例
|
||||||
|
* (同 tryForceSend 的 confirmFn 注入形态),不引 window.confirm(仓风格)。 */
|
||||||
|
async function handleClearQueue() {
|
||||||
|
const convId = store.state.activeConversationId
|
||||||
|
if (!convId) return
|
||||||
|
const confirmed = await confirmDialog(t('aiChat.clearQueueConfirm'))
|
||||||
|
if (confirmed) store.clearQueue(convId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 队列消息编辑(只编 text)+ 立即发送(插队)──
|
||||||
|
|
||||||
|
/** 当前正在编辑的队列项 index;null=非编辑态 */
|
||||||
|
const editingQueueIdx = ref<number | null>(null)
|
||||||
|
/** 编辑态输入框文本(回车写回 / ESC 取消还原) */
|
||||||
|
const editingQueueText = ref('')
|
||||||
|
|
||||||
|
// UX-P3: 队列编辑态(editingQueueIdx/editingQueueText)绑定旧会话队列在 activeQueue
|
||||||
|
// 视图内的下标,切会话后 activeQueue 已换成新会话的集合,残留 idx/text 会错挂到
|
||||||
|
// 新会话同下标队列项上,切会话即重置(原随父 watch activeConversationId,拆分后本组件自管)。
|
||||||
|
watch(() => store.state.activeConversationId, () => {
|
||||||
|
if (editingQueueIdx.value !== null) cancelEditQueued()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 进入编辑态:记录 index + 快照当前 text(activeQueue 视图内下标) */
|
||||||
|
function startEditQueued(idx: number) {
|
||||||
|
const item = activeQueue.value[idx]
|
||||||
|
if (!item) return
|
||||||
|
editingQueueIdx.value = idx
|
||||||
|
editingQueueText.value = item.text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消编辑:还原非编辑态,不写回 */
|
||||||
|
function cancelEditQueued() {
|
||||||
|
editingQueueIdx.value = null
|
||||||
|
editingQueueText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P1-1 队列编辑框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||||
|
* 不当提交/取消处理,防半截拼音被写回队列项。原 @keydown.enter/.esc 内联修饰符挂不了守卫,
|
||||||
|
* 收敛到本函数统一判。 */
|
||||||
|
function onQueueEditKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.isComposing || e.keyCode === 229) return
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
saveEditQueued()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelEditQueued()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存编辑:回车/失焦触发,委托 store.editQueued 写回 text */
|
||||||
|
function saveEditQueued() {
|
||||||
|
if (editingQueueIdx.value === null) return
|
||||||
|
store.editQueued(editingQueueIdx.value, editingQueueText.value)
|
||||||
|
editingQueueIdx.value = null
|
||||||
|
editingQueueText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即发送:委托 store.sendQueuedNow(移除+stop+sendMessage) */
|
||||||
|
async function handleSendQueuedNow(idx: number) {
|
||||||
|
await store.sendQueuedNow(idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** I4 队列回填:取出该条填回输入框重新编辑(从队列移除,可改完再发)。
|
||||||
|
* cancelQueued 内部经 activeQueueRealIndex 做过滤视图下标映射,越界 no-op;
|
||||||
|
* 项携带技能时按名找回 SkillInfo 回挂 chip,找不到(技能已卸载)只回填文本,
|
||||||
|
* 与队列原地编辑只保 text 同一取舍。 */
|
||||||
|
async function backfillQueued(idx: number) {
|
||||||
|
const item = activeQueue.value[idx]
|
||||||
|
if (!item) return
|
||||||
|
// UX-P3: 输入框已有未发送草稿(文本/图片/技能 chip 任一非空)时回填会静默覆盖,
|
||||||
|
// 先 confirm 确认;取消则直接返回,队列项原样保留(尚未 cancelQueued)。
|
||||||
|
if (props.hasUnsentInput()) {
|
||||||
|
if (!await confirmDialog(t('aiChat.backfillOverwriteConfirm'))) return
|
||||||
|
}
|
||||||
|
const skill = item.skill
|
||||||
|
? store.state.skills.find(s => s.name === item.skill) ?? null
|
||||||
|
: null
|
||||||
|
store.cancelQueued(idx)
|
||||||
|
emit('fill-draft', item.text, skill)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// V7 排队时钟 interval 随组件卸载清理(watch 随卸载停但 interval 本体不自停,须显式清)
|
||||||
|
if (queueTickTimer !== null) { clearInterval(queueTickTimer); queueTickTimer = null }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ═══ 待发送队列(样式随区块自 AiChat.vue 迁入,零视觉变更) ═══ */
|
||||||
|
.ai-queue {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 8%, transparent);
|
||||||
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
}
|
||||||
|
.ai-queue--timeout {
|
||||||
|
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||||
|
}
|
||||||
|
.ai-queue-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ai-queue-title { font-size: 11px; color: var(--df-text-dim); font-weight: 600; }
|
||||||
|
.ai-queue--timeout .ai-queue-title { color: var(--df-warning); }
|
||||||
|
.ai-queue-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.ai-queue-force {
|
||||||
|
background: var(--df-warning); border: none; color: #000; cursor: pointer;
|
||||||
|
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: calc(var(--df-radius) - 2px);
|
||||||
|
}
|
||||||
|
.ai-queue-force:hover { filter: brightness(1.1); }
|
||||||
|
.ai-queue-clear { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 11px; padding: 0; }
|
||||||
|
.ai-queue-clear:hover { color: var(--df-text); }
|
||||||
|
.ai-queue-list { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.ai-queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border-radius: calc(var(--df-radius) - 2px);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ai-queue-skill { color: var(--df-accent); font-weight: 600; flex-shrink: 0; }
|
||||||
|
/* V7 排队时钟:每项已等待时长 */
|
||||||
|
.ai-queue-wait { color: var(--df-text-dim); font-size: 10px; flex-shrink: 0; opacity: 0.7; }
|
||||||
|
.ai-queue-text {
|
||||||
|
color: var(--df-text);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-queue-x { background: none; border: none; color: var(--df-text-dim); cursor: pointer; font-size: 14px; line-height: 1; padding: 0 2px; flex-shrink: 0; }
|
||||||
|
.ai-queue-x:hover { color: var(--df-danger); }
|
||||||
|
/* 队列项编辑/立即发送按钮 + 编辑态 input */
|
||||||
|
.ai-queue-btn {
|
||||||
|
background: none; border: none; color: var(--df-text-dim); cursor: pointer;
|
||||||
|
font-size: 11px; line-height: 1; padding: 0 4px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ai-queue-btn:hover { color: var(--df-accent); }
|
||||||
|
.ai-queue-edit-input {
|
||||||
|
flex: 1; min-width: 0; font-size: 12px; padding: 1px 4px;
|
||||||
|
background: var(--df-bg-card); color: var(--df-text);
|
||||||
|
border: 0.5px solid var(--df-accent);
|
||||||
|
border-radius: calc(var(--df-radius) - 2px);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
/* 聚焦可见环:边框恒为 accent 聚焦无变化,且 scoped 选择器特异性高于全局 :focus-visible 兜底,须就地补环,仅键盘聚焦显示 */
|
||||||
|
.ai-queue-edit-input:focus-visible { outline: 2px solid var(--df-accent); outline-offset: 1px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<!-- I12(2026-10-05):键盘快捷键速查。Teleport 到 body 防 .ai-panel overflow 裁剪;
|
||||||
|
主窗/分离窗各自实例独立渲染,入口在 TopBar ... 菜单(emit show-shortcuts)。
|
||||||
|
C4b 拆分自 AiChat.vue(visible 状态留父——onGlobalKeydown Esc 仲裁需读写,开/关经 props/emits) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="ai-shortcuts-mask" @click.self="emit('close')">
|
||||||
|
<div class="ai-shortcuts-panel" role="dialog" :aria-label="t('aiChat.shortcutsTitle')">
|
||||||
|
<div class="ai-shortcuts-head">
|
||||||
|
<span class="ai-shortcuts-title">{{ t('aiChat.shortcutsTitle') }}</span>
|
||||||
|
<button class="ai-shortcuts-close" :aria-label="t('aiChat.scCloseOverlay')" @click="emit('close')">✕</button>
|
||||||
|
</div>
|
||||||
|
<ul class="ai-shortcuts-list">
|
||||||
|
<li v-for="s in shortcuts" :key="s.key" class="ai-shortcuts-row">
|
||||||
|
<span class="ai-shortcuts-key">{{ s.key }}</span>
|
||||||
|
<span class="ai-shortcuts-act">{{ s.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 面板显隐(状态留父:TopBar emit show-shortcuts 置 true,onGlobalKeydown Esc 分支仲裁) */
|
||||||
|
visible: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 键位表 shortcuts 与父 AiChat onGlobalKeydown 同族——键区调整时两边同步,防速查与真实键位漂移。
|
||||||
|
// computed:随 locale 即时翻译(仅面板打开时消费,开销可忽略)。
|
||||||
|
const shortcuts = computed(() => [
|
||||||
|
{ key: 'Ctrl+N', label: t('aiChat.scNewConv') },
|
||||||
|
{ key: 'Ctrl+I', label: t('aiChat.scFocusInput') },
|
||||||
|
{ key: 'Ctrl+R', label: t('aiChat.scRegenerate') },
|
||||||
|
{ key: 'Ctrl+L', label: t('aiChat.scClearChat') },
|
||||||
|
{ key: 'Ctrl+K', label: t('aiChat.scSearch') },
|
||||||
|
{ key: 'Ctrl+B', label: t('aiChat.scToggleSidebar') },
|
||||||
|
{ key: 'Esc', label: t('aiChat.scCloseOverlay') },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ═══ I12 快捷键速查面板(Teleport 到 body,scoped 对 Teleport 内容仍生效——
|
||||||
|
Vue scoped 把 data 属性打在编译期节点上,与挂载位置无关;样式随区块自 AiChat.vue 迁入) ═══ */
|
||||||
|
.ai-shortcuts-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ai-shortcuts-panel {
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-lg, 8px);
|
||||||
|
box-shadow: var(--df-shadow-sm);
|
||||||
|
width: 320px;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.ai-shortcuts-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.ai-shortcuts-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
|
.ai-shortcuts-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
}
|
||||||
|
.ai-shortcuts-close:hover { color: var(--df-text); background: var(--df-bg-card); }
|
||||||
|
.ai-shortcuts-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ai-shortcuts-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ai-shortcuts-key {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
padding: 2px 8px;
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
|
.ai-shortcuts-act { color: var(--df-text-secondary); }
|
||||||
|
</style>
|
||||||
@@ -108,7 +108,9 @@ function onDismiss() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态 */
|
/* 浮层 — 右上角 fixed,z-index 高于 AI 面板与其他模态
|
||||||
|
wave2(2026-10-18):danger 系透明彩底范式(待审批=需用户处置的阻断信号)——
|
||||||
|
弹层底 bg-card+边框+10px 圆角,边框 color-mix 30%+左缘 3px 强调,标题同色 12px/600 */
|
||||||
.approval-overlay {
|
.approval-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
@@ -121,10 +123,10 @@ function onDismiss() {
|
|||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
background: var(--df-bg-card);
|
background: var(--df-bg-card);
|
||||||
border: 0.5px solid var(--df-accent);
|
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||||
border-left: 3px solid var(--df-accent);
|
border-left: 3px solid var(--df-danger);
|
||||||
border-radius: var(--df-radius-md);
|
border-radius: var(--df-radius-lg);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--df-accent-soft);
|
box-shadow: var(--df-shadow-md), 0 0 0 1px color-mix(in srgb, var(--df-danger) 12%, transparent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease);
|
transition: transform 0.18s var(--df-ease), border-color 0.18s var(--df-ease), box-shadow 0.18s var(--df-ease);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -132,8 +134,8 @@ function onDismiss() {
|
|||||||
|
|
||||||
.approval-overlay:hover {
|
.approval-overlay:hover {
|
||||||
transform: translateX(-2px);
|
transform: translateX(-2px);
|
||||||
border-color: var(--df-accent);
|
border-color: color-mix(in srgb, var(--df-danger) 55%, transparent);
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5), 0 0 0 2px var(--df-accent);
|
box-shadow: var(--df-shadow-md), 0 0 0 2px color-mix(in srgb, var(--df-danger) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.approval-overlay-icon {
|
.approval-overlay-icon {
|
||||||
@@ -144,8 +146,8 @@ function onDismiss() {
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--df-accent-bg);
|
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||||
color: var(--df-accent);
|
color: var(--df-danger);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,15 +174,15 @@ function onDismiss() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.approval-overlay-title {
|
.approval-overlay-title {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--df-text);
|
color: var(--df-danger);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.approval-overlay-desc {
|
.approval-overlay-desc {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -190,8 +192,8 @@ function onDismiss() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: var(--df-h-icon-btn);
|
||||||
height: 22px;
|
height: var(--df-h-icon-btn);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--df-radius-sm);
|
border-radius: var(--df-radius-sm);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -202,7 +204,7 @@ function onDismiss() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.approval-overlay-close:hover {
|
.approval-overlay-close:hover {
|
||||||
background: var(--df-sidebar-hover);
|
background: var(--df-bg-card-hover);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="ci-status" v-if="statuses.length > 0">
|
|
||||||
<div class="ci-header">
|
|
||||||
<span class="ci-title">{{ $t('aiChat.ciTitle') }}</span>
|
|
||||||
<span class="ci-summary" :class="summaryClass">{{ summaryText }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ci-list">
|
|
||||||
<a
|
|
||||||
v-for="check in statuses"
|
|
||||||
:key="check.name"
|
|
||||||
:href="check.url || undefined"
|
|
||||||
class="ci-item"
|
|
||||||
:class="'ci-item--' + check.status"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<span class="ci-item-icon">{{ statusIcon(check.status) }}</span>
|
|
||||||
<span class="ci-item-name">{{ check.name }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, onMounted } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
interface CheckStatus {
|
|
||||||
name: string
|
|
||||||
status: string
|
|
||||||
url: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
repoUrl: string
|
|
||||||
commitSha: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const statuses = ref<CheckStatus[]>([])
|
|
||||||
|
|
||||||
const summaryClass = computed(() => {
|
|
||||||
const hasFailure = statuses.value.some(s => s.status === 'failure')
|
|
||||||
const hasPending = statuses.value.some(s => s.status === 'pending')
|
|
||||||
if (hasFailure) return 'ci-summary--fail'
|
|
||||||
if (hasPending) return 'ci-summary--pending'
|
|
||||||
return 'ci-summary--pass'
|
|
||||||
})
|
|
||||||
|
|
||||||
const summaryText = computed(() => {
|
|
||||||
const pass = statuses.value.filter(s => s.status === 'success').length
|
|
||||||
const fail = statuses.value.filter(s => s.status === 'failure').length
|
|
||||||
const pending = statuses.value.filter(s => s.status === 'pending').length
|
|
||||||
if (fail > 0) return t('aiChat.ciNFailed', { n: fail })
|
|
||||||
if (pending > 0) return t('aiChat.ciNPending', { n: pending })
|
|
||||||
return t('aiChat.ciNPassed', { n: pass })
|
|
||||||
})
|
|
||||||
|
|
||||||
function statusIcon(status: string): string {
|
|
||||||
switch (status) {
|
|
||||||
case 'success': return '✓'
|
|
||||||
case 'failure': return '✗'
|
|
||||||
case 'pending': return '⏳'
|
|
||||||
default: return '○'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
statuses.value = await invoke<CheckStatus[]>('get_commit_status', {
|
|
||||||
repoUrl: props.repoUrl,
|
|
||||||
commitSha: props.commitSha,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
statuses.value = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ci-status {
|
|
||||||
border: 1px solid var(--df-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 8px 0;
|
|
||||||
background: var(--df-bg-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.ci-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.ci-title { color: var(--df-text); }
|
|
||||||
.ci-summary--pass { color: var(--df-success); }
|
|
||||||
.ci-summary--fail { color: var(--df-danger); }
|
|
||||||
.ci-summary--pending { color: var(--df-warning); }
|
|
||||||
.ci-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.ci-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
background: var(--df-bg);
|
|
||||||
border: 1px solid var(--df-border);
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
.ci-item--success { border-color: var(--df-success); }
|
|
||||||
.ci-item--failure { border-color: var(--df-danger); }
|
|
||||||
.ci-item--pending { border-color: var(--df-warning); }
|
|
||||||
.ci-item-icon { font-size: 10px; font-weight: 500; }
|
|
||||||
.ci-item:hover { opacity: 0.8; }
|
|
||||||
</style>
|
|
||||||
+384
-97
@@ -21,7 +21,7 @@
|
|||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
class="ai-input"
|
class="ai-input"
|
||||||
:class="{ 'ai-input--editing': !!editingMsgId }"
|
:class="{ 'ai-input--editing': !!editingMsgId, 'ai-input--dragover': dragHover }"
|
||||||
:placeholder="editingMsgId
|
:placeholder="editingMsgId
|
||||||
? $t('aiChat.placeholderEditing')
|
? $t('aiChat.placeholderEditing')
|
||||||
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
|
: (pendingSkill ? (pendingSkill.argument_hint || $t('aiChat.placeholderWithSkill')) : $t('aiChat.placeholderDefault'))"
|
||||||
@@ -30,7 +30,12 @@
|
|||||||
@beforeinput="onBeforeInputText"
|
@beforeinput="onBeforeInputText"
|
||||||
@input="onInputText"
|
@input="onInputText"
|
||||||
@paste="onPaste"
|
@paste="onPaste"
|
||||||
|
@blur="onInputBlur"
|
||||||
|
@click="onCaretMove"
|
||||||
|
@keyup="onCaretMove"
|
||||||
@dragover="onDragOver"
|
@dragover="onDragOver"
|
||||||
|
@dragenter="onDragEnter"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
@@ -45,6 +50,9 @@
|
|||||||
@select="selectMention"
|
@select="selectMention"
|
||||||
@update:mention-index="mentionIndex = $event"
|
@update:mention-index="mentionIndex = $event"
|
||||||
/>
|
/>
|
||||||
|
<!-- 2026-10-18 wave2: 工具行(workbench .inp-row 范式)——textarea 下方分隔线,
|
||||||
|
左侧功能钮 + 右侧发送/停止钮;纯布局重排,按钮类名/事件/禁用判定零变化 -->
|
||||||
|
<div class="ai-input-row">
|
||||||
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
|
<!-- 2026-08-06:📎 图片选择按钮(tauri 原生对话框选图 → 已配置上传端点则上传换 URL,否则本地 base64 引用) -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -115,12 +123,14 @@
|
|||||||
class="ai-send-btn"
|
class="ai-send-btn"
|
||||||
:class="{ 'ai-send-btn--active': canSend }"
|
:class="{ 'ai-send-btn--active': canSend }"
|
||||||
:disabled="!canSend"
|
:disabled="!canSend"
|
||||||
:aria-label="$t('aiChat.queueSendNow')"
|
:title="$t('aiChat.send')"
|
||||||
|
:aria-label="$t('aiChat.send')"
|
||||||
@click="handleSend"
|
@click="handleSend"
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
|
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
|
||||||
<EnrichmentPanel
|
<EnrichmentPanel
|
||||||
:has-enrichment="hasEnrichment"
|
:has-enrichment="hasEnrichment"
|
||||||
@@ -132,6 +142,12 @@
|
|||||||
/>
|
/>
|
||||||
<!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) -->
|
<!-- 定时/待发箱抽屉(⏰ 入口;Teleport 到 body 渲染,此处仅挂组件) -->
|
||||||
<OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" />
|
<OutboxDrawer :open="outboxOpen" @close="outboxOpen = false" />
|
||||||
|
<!-- F2-4(走查-信息UX 面二):示例卡覆盖未发送草稿的二次确认弹层(组件自持,见 setup 注释) -->
|
||||||
|
<ConfirmDialog
|
||||||
|
:visible="confirmState.visible"
|
||||||
|
:msg="confirmState.msg"
|
||||||
|
@result="answerConfirm"
|
||||||
|
/>
|
||||||
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -145,7 +161,7 @@
|
|||||||
// - 出错 emit('error', { msg, type }) → 父 showToast(单一 toast 源)
|
// - 出错 emit('error', { msg, type }) → 父 showToast(单一 toast 源)
|
||||||
// - 示例问题卡片在父(空状态区),父调 expose sendExamplePrompt 触发本组件发送
|
// - 示例问题卡片在父(空状态区),父调 expose sendExamplePrompt 触发本组件发送
|
||||||
// store 单例共享:useAiStore(sendMessage/editMessage/stopChat)、useProjectStore(@ 联想源)
|
// store 单例共享:useAiStore(sendMessage/editMessage/stopChat)、useProjectStore(@ 联想源)
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, nextTick, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { useProjectStore } from '../../stores/project'
|
import { useProjectStore } from '../../stores/project'
|
||||||
@@ -157,11 +173,15 @@ import { getConvDraft, setConvDraft, classifySendError, convDraftAppends, takeCo
|
|||||||
import { extractImageUrlParts } from '../../composables/ai/utils'
|
import { extractImageUrlParts } from '../../composables/ai/utils'
|
||||||
import { uploadFile } from '../../composables/ai/fileUpload'
|
import { uploadFile } from '../../composables/ai/fileUpload'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
|
// 2026-10-19 拖拽/粘贴非图片文件落盘:ai_save_dropped_file IPC(后端 chat.rs:503)
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import SkillMention from './SkillMention.vue'
|
import SkillMention from './SkillMention.vue'
|
||||||
import ImageInput from './ImageInput.vue'
|
import ImageInput from './ImageInput.vue'
|
||||||
import MentionPopover from './MentionPopover.vue'
|
import MentionPopover from './MentionPopover.vue'
|
||||||
import EnrichmentPanel from './EnrichmentPanel.vue'
|
import EnrichmentPanel from './EnrichmentPanel.vue'
|
||||||
import OutboxDrawer from './OutboxDrawer.vue'
|
import OutboxDrawer from './OutboxDrawer.vue'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import { useConfirm } from '../../composables/useConfirm'
|
||||||
import { taskApi } from '../../api/task'
|
import { taskApi } from '../../api/task'
|
||||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||||
import type { MentionItem } from './MentionItem'
|
import type { MentionItem } from './MentionItem'
|
||||||
@@ -197,13 +217,16 @@ const outboxOpen = ref(false)
|
|||||||
// 约束:
|
// 约束:
|
||||||
// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。
|
// - 限制单次最多 6 张(防超长上下文/超限请求);超过 toast 提示。
|
||||||
// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。
|
// - 限制单张 10MB(纯文本 base64 估算 ≥ 13M 字符;超限 toast 提示跳过)。
|
||||||
// - 仅接受 image/* MIME;非图片(文本粘贴/文件拖入)走原生行为不拦。
|
// - 仅接受 image/* MIME;非图片文件 2026-10-19 起走 addAttachmentFile 落盘引用行
|
||||||
|
// (不再放行原生塞文本),纯文本粘贴仍走原生不拦。
|
||||||
// - 2026-08-06:新增"📎 图片"按钮(tauri dialog 选图)→ 已配置上传端点则上传拿 URL
|
// - 2026-08-06:新增"📎 图片"按钮(tauri dialog 选图)→ 已配置上传端点则上传拿 URL
|
||||||
// (走 url 模式,provider 直传/Anthropic 预拉);未配置或上传失败回退 base64(dataUrl)。
|
// (走 url 模式,provider 直传/Anthropic 预拉);未配置或上传失败回退 base64(dataUrl)。
|
||||||
// pendingImages 元素 url/dataUrl 二选一:url=上传服务返回的公开地址,dataUrl=base64。
|
// pendingImages 元素 url/dataUrl 二选一:url=上传服务返回的公开地址,dataUrl=base64。
|
||||||
const MAX_PENDING_IMAGES = 6
|
const MAX_PENDING_IMAGES = 6
|
||||||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限)
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10MB(解码后字节上限)
|
||||||
const pendingImages = ref<{ id: string; dataUrl?: string; url?: string; mediaType: string }[]>([])
|
// uploading=true 为上传中的占位 chip(url/dataUrl 未定,渲染 loading 态不入发送快照)
|
||||||
|
interface PendingImage { id: string; dataUrl?: string; url?: string; mediaType: string; uploading?: boolean }
|
||||||
|
const pendingImages = ref<PendingImage[]>([])
|
||||||
|
|
||||||
/** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */
|
/** 读 File → base64 data URL(供 <img> 预览 + ContentPart.image base64 字段) */
|
||||||
function readImageFile(file: File): Promise<string> {
|
function readImageFile(file: File): Promise<string> {
|
||||||
@@ -215,28 +238,40 @@ function readImageFile(file: File): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 image File 加入 pendingImages;超限/超大小跳过并 toast。已配置端点则上传拿 URL,否则回退 base64。 */
|
/** 把 image File 加入 pendingImages;超大小跳过并 toast。已配置端点则上传拿 URL,否则回退 base64。
|
||||||
|
* 走查 P1:入列先同步 push「上传中」占位 chip(复用缩略图盒+loading 态),完成原位替换/失败移除,
|
||||||
|
* 治大图/慢网上传期间零反馈像「没贴上」;数量上限复核移到实际落定前——上传是异步窗口,两批
|
||||||
|
* 并发粘贴都能过入口检查而双双穿透,在落定点复核才治本。上传失败回退本地引用时轻提示告知。 */
|
||||||
async function addImageFile(file: File): Promise<void> {
|
async function addImageFile(file: File): Promise<void> {
|
||||||
if (!file.type.startsWith('image/')) return
|
if (!file.type.startsWith('image/')) return
|
||||||
if (file.size > MAX_IMAGE_BYTES) {
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
emit('error', { msg: t('aiChat.imageTooLarge'), type: 'warning' })
|
emit('error', { msg: t('aiChat.imageTooLarge'), type: 'warning' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
pendingImages.value.push({ id, mediaType: file.type, uploading: true })
|
||||||
|
// 占位 chip 原位落定(用户中途点 × 移除则静默丢弃);落定前复核上限(占位也计数)
|
||||||
|
const settle = (img: PendingImage): void => {
|
||||||
|
if (!pendingImages.value.some(p => p.id === id)) return
|
||||||
if (pendingImages.value.length >= MAX_PENDING_IMAGES) {
|
if (pendingImages.value.length >= MAX_PENDING_IMAGES) {
|
||||||
|
removePendingImage(id)
|
||||||
emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' })
|
emit('error', { msg: t('aiChat.imageLimit', { n: MAX_PENDING_IMAGES }), type: 'warning' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
pendingImages.value[pendingImages.value.findIndex(p => p.id === id)] = img
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 已配置上传端点则上传 → URL 引用(公开可达,OpenAI 直传 / Anthropic 桌面预拉)。
|
// 已配置上传端点则上传 → URL 引用(公开可达,OpenAI 直传 / Anthropic 桌面预拉)。
|
||||||
// 未配置或失败回退 base64(dataUrl),不阻断图片输入。
|
// 未配置或失败回退 base64(dataUrl),不阻断图片输入。
|
||||||
const { url } = await uploadFile(file)
|
const { url } = await uploadFile(file)
|
||||||
pendingImages.value.push({ id, url, mediaType: file.type })
|
settle({ id, url, mediaType: file.type })
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
const dataUrl = await readImageFile(file)
|
const dataUrl = await readImageFile(file)
|
||||||
pendingImages.value.push({ id, dataUrl, mediaType: file.type })
|
settle({ id, dataUrl, mediaType: file.type })
|
||||||
|
emit('error', { msg: t('aiChat.imageUploadFallback'), type: 'info' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
removePendingImage(id)
|
||||||
emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' })
|
emit('error', { msg: t('aiChat.imageReadFailed'), type: 'error' })
|
||||||
console.error('[AI] 图片读取失败:', e)
|
console.error('[AI] 图片读取失败:', e)
|
||||||
}
|
}
|
||||||
@@ -303,6 +338,36 @@ async function insertTextFileDraft(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拖拽/粘贴的非图片文件落盘(2026-10-19 用户反馈「发送文件变成一堆文本」根治):
|
||||||
|
* 前端预拦 20MB(省一次 IPC,后端 chat.rs 同上限兜底)→ 字节经 ai_save_dropped_file
|
||||||
|
* IPC 落盘 attachments/(hash 去重+原名保真,media_store::store_attachment)→ 换回本机
|
||||||
|
* 绝对路径插引用行进草稿(与 I9 insertTextFileDraft 同款追加+光标回末尾手法)。
|
||||||
|
* AI 经 read_file 按需读取,与飞书文件入站同范式。引用行走 i18n 模板,`[附件]` 不带
|
||||||
|
* `:名` 结构且 mention span 仅由 selectMention 显式记录(无文本正则解析),不会误捕。 */
|
||||||
|
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 // 对齐后端 ai_save_dropped_file 上限
|
||||||
|
async function addAttachmentFile(file: File): Promise<void> {
|
||||||
|
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||||
|
emit('error', { msg: t('aiChat.attachmentTooLarge'), type: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// data 传 JSON number 数组(后端 Vec<u8> 可反序列化)
|
||||||
|
const buf = await file.arrayBuffer()
|
||||||
|
const data = Array.from(new Uint8Array(buf))
|
||||||
|
const savedPath = await invoke<string>('ai_save_dropped_file', { fileName: file.name, data })
|
||||||
|
inputText.value += '\n' + t('aiChat.attachmentRefLine', { name: file.name, path: savedPath }) + '\n'
|
||||||
|
await nextTick()
|
||||||
|
inputEl.value?.focus()
|
||||||
|
const el = inputEl.value
|
||||||
|
if (el) el.selectionStart = el.selectionEnd = el.value.length
|
||||||
|
} catch (e) {
|
||||||
|
// 落盘失败(空文件/超限/IO 错):后端错误字符串入 {msg} 细节,不阻断后续文件
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
emit('error', { msg: t('aiChat.attachmentSaveFailed', { msg }), type: 'error' })
|
||||||
|
console.error('[AI] 附件落盘失败:', file.name, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */
|
/** "📎 图片"按钮:tauri 原生文件对话框选图(多选,仅图片)→ 逐个 addImageFile。 */
|
||||||
async function onPickImage(): Promise<void> {
|
async function onPickImage(): Promise<void> {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
@@ -342,48 +407,102 @@ function removePendingImage(id: string): void {
|
|||||||
pendingImages.value = pendingImages.value.filter(p => p.id !== id)
|
pendingImages.value = pendingImages.value.filter(p => p.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 粘贴:ClipboardEvent 检测 image 类型条目,加入待发送列表(不插入文本占位) */
|
/** 粘贴:ClipboardEvent 收集全部 file 条目——图片走 addImageFile 进待发送列表,
|
||||||
|
* 非图片走 addAttachmentFile 落盘插引用行(2026-10-19 根治「发文件变一堆文本」);
|
||||||
|
* 剪贴板无文件(纯文本/字符串)不拦,原生插入行为不变。
|
||||||
|
* 走查:富文本「图+文」混合剪贴板(如网页图文一起复制)原实现只收文件、preventDefault
|
||||||
|
* 后把字符串条目静默丢弃,文案凭空丢失。现补收 text/plain 条目,拦下默认行为后手动
|
||||||
|
* 插回光标处(text/html 是同内容富文本重复源,不收防双插)。 */
|
||||||
async function onPaste(e: ClipboardEvent): Promise<void> {
|
async function onPaste(e: ClipboardEvent): Promise<void> {
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
const files: File[] = []
|
const images: File[] = []
|
||||||
|
const attachments: File[] = []
|
||||||
|
const textChunks: string[] = []
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const it = items[i]
|
const it = items[i]
|
||||||
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
// 字符串条目:只取 text/plain(走文件接管链时手动补插);getAsString 异步,包 Promise 收集
|
||||||
|
if (it.kind === 'string') {
|
||||||
|
if (it.type === 'text/plain') {
|
||||||
|
const s = await new Promise<string>((resolve) => { it.getAsString(resolve) })
|
||||||
|
if (s) textChunks.push(s)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// kind==='file' 才是文件条目;MIME 以 File.type 为准
|
||||||
|
if (it.kind !== 'file') continue
|
||||||
const f = it.getAsFile()
|
const f = it.getAsFile()
|
||||||
if (f) files.push(f)
|
if (!f) continue
|
||||||
|
if (f.type.startsWith('image/')) images.push(f)
|
||||||
|
else attachments.push(f)
|
||||||
}
|
}
|
||||||
}
|
if (images.length === 0 && attachments.length === 0) return
|
||||||
if (files.length === 0) return
|
// 含文件:阻止默认粘贴(防原生把文本文件内容/图片二进制塞进 textarea,病灶根因),逐个处理
|
||||||
// 含图片:阻止默认粘贴(避免把图片的二进制当文本塞进 textarea),逐个加入
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
for (const f of files) await addImageFile(f)
|
if (textChunks.length > 0) insertTextAtCursor(textChunks.join('\n'))
|
||||||
|
for (const f of images) await addImageFile(f)
|
||||||
|
for (const f of attachments) await addAttachmentFile(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拖拽进入:仅允许图片,preventDefault 让 drop 事件触发 */
|
/** 光标处插入文本并置尾(原生粘贴被 preventDefault 接管后补插文本条目用);
|
||||||
|
* 程序赋值不派发 input,mention span 须手动平移(与 applyListEdit 同款手法) */
|
||||||
|
function insertTextAtCursor(text: string): void {
|
||||||
|
const old = inputText.value
|
||||||
|
const el = inputEl.value
|
||||||
|
if (!el) {
|
||||||
|
inputText.value = old + text
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const start = el.selectionStart ?? old.length
|
||||||
|
const end = el.selectionEnd ?? start
|
||||||
|
inputText.value = old.slice(0, start) + text + old.slice(end)
|
||||||
|
shiftMentionSpans(old, inputText.value)
|
||||||
|
nextTick(() => {
|
||||||
|
el.focus()
|
||||||
|
const pos = start + text.length
|
||||||
|
el.setSelectionRange(pos, pos)
|
||||||
|
autoResize()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拖拽悬停:drag 项含文件即 preventDefault 让 drop 事件触发(copy/move 光标提示)。
|
||||||
|
* dragover 阶段 types 只给 'Files' 不给具体 MIME,文件(无论图片/非图片)统一接管;
|
||||||
|
* 2026-10-19 起非图片文件也走落盘引用,不再放行原生塞文本。 */
|
||||||
function onDragOver(e: DragEvent): void {
|
function onDragOver(e: DragEvent): void {
|
||||||
if (!e.dataTransfer) return
|
if (!e.dataTransfer) return
|
||||||
// 判断当前是否含 image/* 类型(copy/move 光标提示)
|
const hasFiles = Array.from(e.dataTransfer.types).includes('Files')
|
||||||
const hasImage = Array.from(e.dataTransfer.types).includes('Files')
|
if (hasFiles) {
|
||||||
if (hasImage) {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.dataTransfer.dropEffect = 'copy'
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拖拽放下:取出图片 File,加入待发送 */
|
// 走查:拖拽悬停落点高亮——文件拖入输入框时描边提示可放;textarea 无子元素,
|
||||||
async function onDrop(e: DragEvent): Promise<void> {
|
// dragenter/dragleave 成对触发无冒泡误清问题,离开/落下即撤
|
||||||
const files = e.dataTransfer?.files
|
const dragHover = ref(false)
|
||||||
if (!files || files.length === 0) return
|
function onDragEnter(e: DragEvent): void {
|
||||||
// 仅处理图片文件(非图片拖入走原生不拦,但这里 preventDefault 已无副作用)
|
if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) dragHover.value = true
|
||||||
const images = Array.from(files).filter(f => f.type.startsWith('image/'))
|
}
|
||||||
if (images.length === 0) return
|
function onDragLeave(): void {
|
||||||
e.preventDefault()
|
dragHover.value = false
|
||||||
for (const f of images) await addImageFile(f)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
|
/** 拖拽放下:有文件即 preventDefault 接管——图片走 addImageFile,非图片走 addAttachmentFile。
|
||||||
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
|
* 病灶:此前非图片直接 return 不拦,WebView2 原生行为把文本文件内容整段塞进输入框
|
||||||
|
* (2026-10-19 用户反馈「发送文件变成一堆文本」),现统一落盘为附件引用行。 */
|
||||||
|
async function onDrop(e: DragEvent): Promise<void> {
|
||||||
|
const files = e.dataTransfer?.files
|
||||||
|
dragHover.value = false
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
for (const f of Array.from(files)) {
|
||||||
|
if (f.type.startsWith('image/')) await addImageFile(f)
|
||||||
|
else await addAttachmentFile(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送可用:有文本或已就绪图片(上传中占位不算,无 url/dataUrl 入列会产出空 image 片)或已选技能(纯技能调用允许空文本) */
|
||||||
|
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.some(p => !p.uploading) || !!pendingSkill.value)
|
||||||
|
|
||||||
// 按钮状态:streaming=true 且(本会话 conv_state='generating' 或最近 300ms 有文本 delta)→ 'stop'(红);
|
// 按钮状态:streaming=true 且(本会话 conv_state='generating' 或最近 300ms 有文本 delta)→ 'stop'(红);
|
||||||
// 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false,
|
// 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false,
|
||||||
@@ -418,7 +537,11 @@ const currentModelSupportsVision = computed<boolean>(() => {
|
|||||||
if (!chosen) return true
|
if (!chosen) return true
|
||||||
return (chosen.modalities ?? []).includes('vision')
|
return (chosen.modalities ?? []).includes('vision')
|
||||||
})
|
})
|
||||||
const showImageIgnoreWarn = computed(() => pendingImages.value.length > 0 && !currentModelSupportsVision.value)
|
// 走查:警示不只覆盖粘贴图——输入文本里的图片 URL 发送时同样转 image 片被非视觉模型忽略,
|
||||||
|
// 一并纳入(判定复用发送链同款 extractImageUrlParts,口径单一)
|
||||||
|
const showImageIgnoreWarn = computed(() =>
|
||||||
|
!currentModelSupportsVision.value
|
||||||
|
&& (pendingImages.value.length > 0 || extractImageUrlParts(inputText.value).length > 0))
|
||||||
|
|
||||||
// ── 技能 `/` 联想 ──
|
// ── 技能 `/` 联想 ──
|
||||||
const skillOpen = ref(false)
|
const skillOpen = ref(false)
|
||||||
@@ -485,6 +608,23 @@ function detectMentionTrigger() {
|
|||||||
mentionStart.value = -1
|
mentionStart.value = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 走查:@ 浮层收口——textarea 失焦即关(浮层项点击已在 MentionPopover 用 mousedown.prevent
|
||||||
|
// 防「mousedown 先于 blur」竞态:焦点不离开 textarea,click 选择照常触发),点外部区域/Tab
|
||||||
|
// 离开不再悬挂浮层
|
||||||
|
function onInputBlur(): void {
|
||||||
|
mentionOpen.value = false
|
||||||
|
mentionStart.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 走查:光标移出 @ 触发区间即关——方向键/点击移动光标不触发 input,detectMentionTrigger
|
||||||
|
// 不会被复检;keyup/click 后复核。浮层导航键(↑↓ 在 keydown 改高亮,keyup 复检会把
|
||||||
|
// mentionIndex 重置回 0)与提交键跳过,防导航失灵
|
||||||
|
function onCaretMove(e: Event): void {
|
||||||
|
if (!mentionOpen.value) return
|
||||||
|
if (e.type === 'keyup' && ['ArrowUp', 'ArrowDown', 'Enter', 'Escape', 'Tab'].includes((e as KeyboardEvent).key)) return
|
||||||
|
detectMentionTrigger()
|
||||||
|
}
|
||||||
|
|
||||||
function selectSkill(s: SkillInfo) {
|
function selectSkill(s: SkillInfo) {
|
||||||
pendingSkill.value = s
|
pendingSkill.value = s
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
@@ -678,26 +818,25 @@ const enrichmentDetailText = computed(() => {
|
|||||||
function dismissEnrichment() {
|
function dismissEnrichment() {
|
||||||
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
|
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
|
||||||
if (projectSpans.length === 0) return
|
if (projectSpans.length === 0) return
|
||||||
// 从 inputText 中移除所有 [@项目: xxx] 标记
|
// 走查:按 span 区间从后往前删——原实现循环内 text.replace,首个标记删除后文本变短,
|
||||||
|
// 后续 span.start 还是原始偏移,再 replace 可能匹配到错位内容误删相邻文本。
|
||||||
|
// span.start 是 selectMention 时刻的原始偏移,倒序删除不影响前方偏移,逐段精确裁掉。
|
||||||
let text = inputText.value
|
let text = inputText.value
|
||||||
for (const span of projectSpans) {
|
const spans = [...projectSpans].sort((a, b) => b.start - a.start)
|
||||||
const label = text.slice(span.start, span.start + span.length)
|
for (const span of spans) {
|
||||||
// 替换标记 + 前后可能的空格为单个空格
|
// 标记两侧紧邻空白一并吞掉(对齐原 \s*…\s* → ' ' 语义),换成单个空格保持词距
|
||||||
// M2: 原尾段 \s* 在模板字符串里是单反斜杠 → 解析成字面 s(零或多个 s 字母),
|
let start = span.start
|
||||||
// 应为 \\s*(双反斜杠 → 正则 \s*),与首段 \s* 对称匹配尾部空白。
|
let end = span.start + span.length
|
||||||
const pattern = new RegExp(`\\s*${escapeRegex(label)}\\s*`)
|
while (start > 0 && /\s/.test(text[start - 1])) start--
|
||||||
text = text.replace(pattern, ' ').trim()
|
while (end < text.length && /\s/.test(text[end])) end++
|
||||||
|
text = text.slice(0, start) + ' ' + text.slice(end)
|
||||||
}
|
}
|
||||||
inputText.value = text
|
inputText.value = text.trim()
|
||||||
// 清除 project 类型的 mentionSpan
|
// 清除 project 类型的 mentionSpan
|
||||||
pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project')
|
pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project')
|
||||||
enrichmentExpanded.value = false
|
enrichmentExpanded.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegex(s: string): string {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
// P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。
|
// P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。
|
||||||
// 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。
|
// 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。
|
||||||
watch(
|
watch(
|
||||||
@@ -909,12 +1048,8 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
selectSkill(filteredSkills.value[skillIndex.value])
|
selectSkill(filteredSkills.value[skillIndex.value])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
// (走查:此处原有一个 Escape 分支——上方 skillOpen+Escape 通用分支已先行消费并 return,
|
||||||
e.preventDefault()
|
// 本分支不可达,删除。@ 浮层的同名分支可达,保留。)
|
||||||
e.stopPropagation()
|
|
||||||
skillOpen.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通
|
// UX-P3: 技能联想浮层开着但无匹配时,Enter 原会落到下方发送分支把 "/xxx" 原文当普通
|
||||||
// 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。
|
// 消息发出。改为不发送 + toast 提示无此技能;确要发送斜杠原文可先 Esc 关浮层再 Enter。
|
||||||
@@ -949,6 +1084,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 走查:@ 实体引用浮层开着但无匹配时,Enter 原会穿透到下方发送分支把 "@xxx" 原文当消息
|
||||||
|
// 发出。对齐技能侧 skillNoMatchQuery 范式:不发送 + toast 提示;确要发原文先 Esc 关浮层。
|
||||||
|
if (mentionOpen.value && e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('error', { msg: t('aiChat.mentionNoMatchQuery', { q: mentionQuery.value.trim() }), type: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
// 列表续写(开关 df-ai-list-input,默认关):空项退格退出 / Shift+Enter 续写补号。
|
// 列表续写(开关 df-ai-list-input,默认关):空项退格退出 / Shift+Enter 续写补号。
|
||||||
// 插在技能·@ 浮层导航之后(浮层开时不拦,用户意图是选实体)、Enter 发送之前;
|
// 插在技能·@ 浮层导航之后(浮层开时不拦,用户意图是选实体)、Enter 发送之前;
|
||||||
// 只绑 Shift+Enter(换行链),Enter 发送语义不变。关闭时整段短路 → 零行为变更。
|
// 只绑 Shift+Enter(换行链),Enter 发送语义不变。关闭时整段短路 → 零行为变更。
|
||||||
@@ -977,8 +1120,8 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致,
|
/** 输入框最大高度(CSS .ai-input max-height 同源;I11:原 JS 120 vs CSS 200 不一致已统一 200。
|
||||||
* 用户手工拉伸到 200px 后任意按键触发 autoResize 被压回 120px,违反 resize:vertical 预期) */
|
* 走查:resize:vertical 已随塌回根治移除,本上限即 auto-grow 唯一天花板) */
|
||||||
const INPUT_MAX_HEIGHT = 200
|
const INPUT_MAX_HEIGHT = 200
|
||||||
|
|
||||||
function autoResize() {
|
function autoResize() {
|
||||||
@@ -1003,6 +1146,8 @@ async function handleSend() {
|
|||||||
if (inputEl.value) inputEl.value.style.height = 'auto'
|
if (inputEl.value) inputEl.value.style.height = 'auto'
|
||||||
try {
|
try {
|
||||||
await store.editMessage(text)
|
await store.editMessage(text)
|
||||||
|
// 编辑成功即消费草稿备份:cancel-edit 链的 clearInput 照旧清空,不回填编辑前的旧稿
|
||||||
|
editBackupDraft = null
|
||||||
emit('cancel-edit')
|
emit('cancel-edit')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[AI] 编辑失败:', e)
|
console.error('[AI] 编辑失败:', e)
|
||||||
@@ -1016,18 +1161,20 @@ async function handleSend() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const skill = pendingSkill.value
|
const skill = pendingSkill.value
|
||||||
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)
|
// 无文本、无图片、未选技能时忽略;选了技能允许空文本(纯技能调用)。
|
||||||
if (!text && !skill && pendingImages.value.length === 0) return
|
// 走查:上传中的占位 chip 不计入(无 url/dataUrl,入快照会产出空 image 片)
|
||||||
|
const settledImages = pendingImages.value.filter(p => !p.uploading)
|
||||||
|
if (!text && !skill && settledImages.length === 0) return
|
||||||
// B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮),
|
// B2: 纯发图(无文本无技能)+ 非视觉模型 → 后端会拒绝(占位串不喂模型不浪费一轮),
|
||||||
// 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状
|
// 前端先行拦截给 i18n 提示省一轮 IPC;图文混合(有文本)不受限,维持占位后缀现状
|
||||||
if (!text && !skill && pendingImages.value.length > 0 && !currentModelSupportsVision.value) {
|
if (!text && !skill && settledImages.length > 0 && !currentModelSupportsVision.value) {
|
||||||
emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' })
|
emit('error', { msg: t('aiChat.imageOnlyBlocked'), type: 'warning' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。
|
// F-260614-05 Phase 2b: 快照待发送图片,构造 ContentPart[]([Text?, Image...])。
|
||||||
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
|
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
|
||||||
// user 消息渲染多模态图,后端请求仍走 content 文本路径。
|
// user 消息渲染多模态图,后端请求仍走 content 文本路径。
|
||||||
const snapshotImgs = pendingImages.value.slice()
|
const snapshotImgs = settledImages
|
||||||
// Input Augmentation: 快照 pending mention 区间(同 parts 快照,失败时回填)。
|
// Input Augmentation: 快照 pending mention 区间(同 parts 快照,失败时回填)。
|
||||||
// 关键:span.start 是相对原始 inputText(可能含前导空格)的偏移;text = inputText.trim()
|
// 关键:span.start 是相对原始 inputText(可能含前导空格)的偏移;text = inputText.trim()
|
||||||
// 已剥前导空白。若不修正偏移,trim 掉的前导空白会让所有 chip start 错位 → 渲染降级纯文本。
|
// 已剥前导空白。若不修正偏移,trim 掉的前导空白会让所有 chip start 错位 → 渲染降级纯文本。
|
||||||
@@ -1078,8 +1225,11 @@ async function handleSend() {
|
|||||||
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
|
// 生成中不再拦截:sendMessage 会把消息排入待发送队列,完成后自动续发
|
||||||
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
|
// 先清输入(无论立即发还是入队);队列满/IPC 失败时 catch 回填
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
|
// F2-2:草稿已消费,立即落盘删项(不等防抖——快速关窗 300ms 内会残留已发送稿)
|
||||||
|
persistDraftNow()
|
||||||
pendingSkill.value = null
|
pendingSkill.value = null
|
||||||
pendingImages.value = []
|
// 走查:发送只消费已就绪图片,上传中的占位 chip 保留在列(落定后随下一条消息发送)
|
||||||
|
pendingImages.value = pendingImages.value.filter(p => p.uploading)
|
||||||
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
|
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
|
||||||
pendingMentionSpans.value = []
|
pendingMentionSpans.value = []
|
||||||
skillOpen.value = false
|
skillOpen.value = false
|
||||||
@@ -1096,9 +1246,9 @@ async function handleSend() {
|
|||||||
console.error('[AI] 发送失败:', e)
|
console.error('[AI] 发送失败:', e)
|
||||||
inputText.value = text
|
inputText.value = text
|
||||||
pendingSkill.value = skill
|
pendingSkill.value = skill
|
||||||
// 发送失败:回填图片供重试(快照原样还原)
|
// 发送失败:回填图片供重试(快照原样还原;上传中占位已在列,追加不重复)
|
||||||
if (parts) {
|
if (parts) {
|
||||||
pendingImages.value = snapshotImgs
|
pendingImages.value = [...pendingImages.value, ...snapshotImgs]
|
||||||
}
|
}
|
||||||
// 发送失败:回填 mention 区间供重试(快照原样还原,区间仍与回填文本对齐)
|
// 发送失败:回填 mention 区间供重试(快照原样还原,区间仍与回填文本对齐)
|
||||||
if (snapshotSpans.length) {
|
if (snapshotSpans.length) {
|
||||||
@@ -1113,14 +1263,27 @@ async function handleSend() {
|
|||||||
emit('sent')
|
emit('sent')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致) */
|
/** 点击示例问题卡片:填入输入框并发送(复用 handleSend 同款发送路径,错误回退一致)。
|
||||||
|
* F2-4(走查-信息UX 面二):输入框已有未发送草稿(文本/图片/技能 chip 任一)时先确认,
|
||||||
|
* 取消则原样保留草稿——对齐父级 backfillQueued 的覆盖确认语义,不再静默替换。 */
|
||||||
async function sendExamplePrompt(i18nKey: string) {
|
async function sendExamplePrompt(i18nKey: string) {
|
||||||
|
if (hasUnsentInput() && !(await confirmDialog(t('aiChat.exampleOverwriteConfirm')))) return
|
||||||
inputText.value = t(i18nKey)
|
inputText.value = t(i18nKey)
|
||||||
await handleSend()
|
await handleSend()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── F2-4:覆盖确认弹层(仓风格不引 window.confirm;AiChat 的 confirmDialog 状态机在父级
|
||||||
|
// 经模板绑定,子组件复用需父接线 → 本组件自持一份 useConfirm + ConfirmDialog,
|
||||||
|
// 行为/样式与父级 backfillQueued / clearQueue 确认同范式) ──
|
||||||
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
|
|
||||||
// ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ──
|
// ── 父调用 expose(父 startEdit/cancelEdit/beforeunload 调用) ──
|
||||||
|
|
||||||
|
// 走查 P1:进入编辑态前的草稿备份(null=无编辑进行中/备份已消费)。
|
||||||
|
// startEditFocus 写入,cancelEdit 链回填,编辑成功发送时清空(照旧清空不回填)。
|
||||||
|
// 记 convId:取消可能由切会话触发,落盘归属须按备份时的会话判定(见 clearInput)
|
||||||
|
let editBackupDraft: { convId: string; text: string } | null = null
|
||||||
|
|
||||||
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
|
/** UX-09 进入编辑态:回填末条 user 内容到输入框,聚焦,光标置末 */
|
||||||
function startEditFocus(msg: AiMessage): void {
|
function startEditFocus(msg: AiMessage): void {
|
||||||
// UX 走查-对话域 B3:带图消息禁止进入编辑态。editMessage IPC(ai_chat_edit)只回填/替换
|
// UX 走查-对话域 B3:带图消息禁止进入编辑态。editMessage IPC(ai_chat_edit)只回填/替换
|
||||||
@@ -1132,6 +1295,15 @@ function startEditFocus(msg: AiMessage): void {
|
|||||||
emit('error', { msg: t('aiChat.editImageBlocked'), type: 'warning' })
|
emit('error', { msg: t('aiChat.editImageBlocked'), type: 'warning' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 走查 P1:覆盖为消息原文前,先按既有 per-conv 草稿范式(内存 Map + 落盘镜像)备份当前
|
||||||
|
// 未发送草稿,取消编辑时回填——否则草稿被消息原文顶掉且 300ms 防抖把它落盘覆盖,
|
||||||
|
// 一次 Esc 就丢稿。已有备份(编辑态内再点另一条编辑)不覆盖,保住用户最初的草稿。
|
||||||
|
const convId = store.state.activeConversationId
|
||||||
|
if (editBackupDraft === null && convId) {
|
||||||
|
editBackupDraft = { convId, text: inputText.value }
|
||||||
|
setConvDraft(convId, inputText.value)
|
||||||
|
persistDraft(convId, inputText.value)
|
||||||
|
}
|
||||||
inputText.value = msg.content
|
inputText.value = msg.content
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
inputEl.value?.focus()
|
inputEl.value?.focus()
|
||||||
@@ -1140,21 +1312,33 @@ function startEditFocus(msg: AiMessage): void {
|
|||||||
const len = el.value.length
|
const len = el.value.length
|
||||||
el.setSelectionRange(len, len)
|
el.setSelectionRange(len, len)
|
||||||
el.style.height = 'auto'
|
el.style.height = 'auto'
|
||||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
// F2-3(走查-信息UX):编辑态回填高度复用 INPUT_MAX_HEIGHT,与 autoResize 同源;
|
||||||
|
// 原硬编码 120 使长草稿进编辑态首屏被压矮,任一按键才弹回 200
|
||||||
|
el.style.height = Math.min(el.scrollHeight, INPUT_MAX_HEIGHT) + 'px'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UX-09 取消编辑态:清空输入框 + 重置高度(父置 editingMsgId=null 后调) */
|
/** UX-09 取消编辑态:回填进入编辑前的草稿备份(无备份=照旧清空)+ 重置高度(父置 editingMsgId=null 后调)。
|
||||||
|
* 走查 P1:原实现无条件清空落盘,把用户未发送草稿一并抹掉;备份在编辑成功发送时已消费,
|
||||||
|
* 成功链到这里照旧清空落盘,行为不变。切会话触发的取消(activeConversationId 已变为新会话)
|
||||||
|
* 此处落盘会把旧会话草稿错写进新会话键下——跳过,由本组件 conv watcher 以旧会话 id 落盘。 */
|
||||||
function clearInput(): void {
|
function clearInput(): void {
|
||||||
inputText.value = ''
|
const backup = editBackupDraft
|
||||||
|
editBackupDraft = null
|
||||||
|
inputText.value = backup?.text ?? ''
|
||||||
pendingSkill.value = null
|
pendingSkill.value = null
|
||||||
pendingImages.value = []
|
pendingImages.value = []
|
||||||
pendingMentionSpans.value = []
|
pendingMentionSpans.value = []
|
||||||
skillOpen.value = false
|
skillOpen.value = false
|
||||||
mentionOpen.value = false
|
mentionOpen.value = false
|
||||||
mentionStart.value = -1
|
mentionStart.value = -1
|
||||||
if (inputEl.value) inputEl.value.style.height = 'auto'
|
autoResize()
|
||||||
|
// F2-2:落盘收口(取消编辑=回填备份稿;成功发送=空串删项,不残留旧稿到下次重启)。
|
||||||
|
// 会话切换场景:备份归属会话 ≠ 当前活跃会话,交给 conv watcher 落盘防错键
|
||||||
|
if (!backup || backup.convId === store.state.activeConversationId) {
|
||||||
|
persistDraftNow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
|
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
|
||||||
@@ -1184,12 +1368,78 @@ function focus(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
|
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
|
||||||
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
|
// 会话内真相源在 aiShared.convDrafts(内存 Map);F2-2(走查-信息UX 面二)追加落盘镜像:
|
||||||
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
|
// 键入过程防抖写 localStorage,重启/换装后经回退链恢复。父组件 AiChat 的
|
||||||
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
|
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 回填草稿备份
|
||||||
|
// (见 clearInput),本 watcher 后执行,旧会话保存到的即恢复后的草稿,草稿跨编辑态不丢。
|
||||||
|
|
||||||
|
// F2-2:per-conv 草稿 localStorage 镜像(df-ai-* 键约定;单 JSON 对象,LRU 限 20 条,
|
||||||
|
// 空串收敛删项——与内存 Map 同语义)。读写全程 try/catch:持久化是尽力而为,
|
||||||
|
// 解析失败/配额满不阻断输入主链。
|
||||||
|
const DRAFTS_KEY = 'df-ai-conv-drafts'
|
||||||
|
const DRAFTS_MAX = 20
|
||||||
|
|
||||||
|
function readDraftsStore(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(localStorage.getItem(DRAFTS_KEY) || '{}')
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as Record<string, string>) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistDraft(convId: string, text: string): void {
|
||||||
|
const all = readDraftsStore()
|
||||||
|
if (text) {
|
||||||
|
// 删后重插把该会话挪到键序末尾,Object 键序=写入序,溢出时从头淘汰即 LRU
|
||||||
|
delete all[convId]
|
||||||
|
all[convId] = text
|
||||||
|
const keys = Object.keys(all)
|
||||||
|
for (let i = 0; i < keys.length - DRAFTS_MAX; i++) delete all[keys[i]]
|
||||||
|
} else {
|
||||||
|
delete all[convId]
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DRAFTS_KEY, JSON.stringify(all))
|
||||||
|
} catch {
|
||||||
|
// 配额满等:放弃本次落盘,内存草稿不受影响
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖落盘:逐键写盘不必要,300ms 静默期合并;发送/清空/切会话/卸载走立即落盘收口
|
||||||
|
let draftPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function persistDraftNow(): void {
|
||||||
|
if (draftPersistTimer) {
|
||||||
|
clearTimeout(draftPersistTimer)
|
||||||
|
draftPersistTimer = null
|
||||||
|
}
|
||||||
|
const convId = store.state.activeConversationId
|
||||||
|
if (convId) persistDraft(convId, inputText.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(inputText, () => {
|
||||||
|
if (draftPersistTimer) clearTimeout(draftPersistTimer)
|
||||||
|
draftPersistTimer = setTimeout(persistDraftNow, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
// F2-2:卸载(面板收起/关窗)前冲刷在途防抖,防最后 300ms 键入的草稿丢失
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (draftPersistTimer) persistDraftNow()
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => store.state.activeConversationId, (nv, ov) => {
|
watch(() => store.state.activeConversationId, (nv, ov) => {
|
||||||
if (ov) setConvDraft(ov, inputText.value)
|
// immediate:挂载时活跃会话已就位(分离窗/恢复会话直开)也走恢复链,不只在切换时
|
||||||
|
if (ov) {
|
||||||
|
setConvDraft(ov, inputText.value)
|
||||||
|
persistDraft(ov, inputText.value)
|
||||||
|
}
|
||||||
inputText.value = getConvDraft(nv)
|
inputText.value = getConvDraft(nv)
|
||||||
|
// 内存 Map 无记录时回退落盘稿(重启后 Map 为空;同会话内 Map 恒比落盘新,优先)
|
||||||
|
if (!inputText.value && nv) {
|
||||||
|
const persisted = readDraftsStore()[nv]
|
||||||
|
if (typeof persisted === 'string') inputText.value = persisted
|
||||||
|
}
|
||||||
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
|
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
|
||||||
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
|
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
|
||||||
pendingMentionSpans.value = []
|
pendingMentionSpans.value = []
|
||||||
@@ -1205,7 +1455,7 @@ watch(() => store.state.activeConversationId, (nv, ov) => {
|
|||||||
mentionStart.value = -1
|
mentionStart.value = -1
|
||||||
autoResize()
|
autoResize()
|
||||||
})
|
})
|
||||||
})
|
}, { immediate: true })
|
||||||
|
|
||||||
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
|
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
|
||||||
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
|
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
|
||||||
@@ -1259,32 +1509,36 @@ defineExpose({
|
|||||||
零视觉变更。原 AiChat scoped 不渗透子组件根,输入框/技能联想/发送按钮元素失配 → 本组件自持。
|
零视觉变更。原 AiChat scoped 不渗透子组件根,输入框/技能联想/发送按钮元素失配 → 本组件自持。
|
||||||
CSS 变量(--df-*)由根级 global.css 提供,共享同一份。.ai-input-area 容器留父级 AiChat scoped。) ═══ -->
|
CSS 变量(--df-*)由根级 global.css 提供,共享同一份。.ai-input-area 容器留父级 AiChat scoped。) ═══ -->
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 2026-10-18 wave2: 输入盒迁移 workbench .inp-box 范式——bg-card 底+0.5px 边框+9px 圆角,
|
||||||
|
focus-within 边框转 info;纵向布局让全宽图片预览行自然换行(替代原 flex-wrap 手法) */
|
||||||
.ai-input-wrap {
|
.ai-input-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
/* F-260614-05 Phase 2b: flex-wrap 让全宽的图片预览行强制 textarea+按钮换行到下一行 */
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: var(--df-bg-card);
|
background: var(--df-bg-card);
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
border-radius: var(--df-radius-lg);
|
border-radius: 9px;
|
||||||
padding: 6px 8px;
|
padding: 9px 12px;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* F-260614-05 Phase 2b: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */
|
/* F-260614-05 Phase 2b: 图片预览样式已提取到 styles/components.css 全局(ImageInput.vue 共享) */
|
||||||
|
|
||||||
.ai-input-wrap:focus-within {
|
.ai-input-wrap:focus-within {
|
||||||
border-color: color-mix(in srgb, var(--df-accent) 40%, transparent);
|
border-color: var(--df-info);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */
|
/* ── 技能 chip / 联想浮层样式已提取到 styles/components.css 全局(SkillMention.vue + MentionPopover.vue 共享)── */
|
||||||
|
|
||||||
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
|
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
|
||||||
|
|
||||||
|
/* textarea 透明无边框占满输入盒首行(workbench .inp-box textarea 范式);
|
||||||
|
autosize 高度参数(min/height/max-height)为行为契约,禁动。
|
||||||
|
走查:resize:vertical 已移除(显式 none 兜 UA 默认 both)——autoResize 随内容重算会把
|
||||||
|
手工拉伸高度塌回,两案(记住拉伸高度需 ResizeObserver 辨识拖拽 vs 去掉 resize)取改动
|
||||||
|
小者:单一 auto-grow 语义(20→200px),杜绝塌回 */
|
||||||
.ai-input {
|
.ai-input {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -1292,7 +1546,7 @@ defineExpose({
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: var(--df-font-sans);
|
font-family: var(--df-font-sans);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
resize: vertical;
|
resize: none;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
@@ -1301,6 +1555,26 @@ defineExpose({
|
|||||||
.ai-input::placeholder {
|
.ai-input::placeholder {
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 走查:拖拽悬停落点高亮(outline 不占布局防 0.5px 抖动;accent-soft 弱底+accent 虚线描边) */
|
||||||
|
.ai-input--dragover {
|
||||||
|
outline: 1px dashed var(--df-accent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
background: var(--df-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具行(workbench .inp-row 范式):textarea 下方 --df-border 分隔线,左功能钮+右发送钮 */
|
||||||
|
.ai-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 0.5px solid var(--df-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发送钮(workbench .isend 范式):info→accent 135° 渐变+深色 icon+600 字重;
|
||||||
|
禁用态 opacity .4(禁用判定逻辑不动)。原 --active 实心紫规则并入基类,类名绑定保留无害 */
|
||||||
.ai-send-btn {
|
.ai-send-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1309,38 +1583,37 @@ defineExpose({
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
background: var(--df-bg-raised);
|
background: linear-gradient(135deg, var(--df-info), var(--df-accent));
|
||||||
color: var(--df-text-dim);
|
color: color-mix(in srgb, var(--df-bg) 70%, #000);
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: filter 0.15s, opacity 0.15s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
.ai-send-btn:not(:disabled):hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
/* 功能钮统一 28px 高 bg-card 边框钮(workbench 工具行左侧范式),hover 边框亮 */
|
||||||
.ai-img-pick-btn {
|
.ai-img-pick-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border: none;
|
border: 0.5px solid var(--df-border);
|
||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
background: transparent;
|
background: var(--df-bg-card);
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 4px;
|
|
||||||
}
|
}
|
||||||
.ai-img-pick-btn:hover {
|
.ai-img-pick-btn:hover {
|
||||||
background: var(--df-bg-raised);
|
background: var(--df-bg-card-hover);
|
||||||
|
border-color: var(--df-border-strong);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
.ai-send-btn--active {
|
|
||||||
background: var(--df-accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.ai-send-btn--active:hover {
|
|
||||||
background: var(--df-accent-hover);
|
|
||||||
}
|
|
||||||
.ai-send-btn:disabled {
|
.ai-send-btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -1364,20 +1637,34 @@ defineExpose({
|
|||||||
.ai-send-btn--retry:hover {
|
.ai-send-btn--retry:hover {
|
||||||
background: #d63a3f;
|
background: #d63a3f;
|
||||||
}
|
}
|
||||||
|
/* 命令提示 chip(workbench .icmd 范式):mono 10px --df-text-dim */
|
||||||
.ai-input-hint {
|
.ai-input-hint {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
|
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
|
||||||
|
|
||||||
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
|
/* B1: 非视觉模型贴图警示条(待发送图片预览行下方,warning 色轻量提示,不抢输入框注意力) */
|
||||||
.ai-img-warn-chip {
|
.ai-img-warn-chip {
|
||||||
font-size: 10.5px;
|
font-size: 10px;
|
||||||
color: var(--df-warning);
|
color: var(--df-warning);
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 2026-10-18 wave2: 草稿/mention 弹层对齐 workbench 弹层范式 ──
|
||||||
|
SkillMention/MentionPopover 共用 styles/components.css 的 .ai-skill-* 全局基类(禁碰),
|
||||||
|
经 :deep 在此集中覆写一份(两子组件弹层同源,避免各自 scoped 重复):
|
||||||
|
8px 圆角+浅阴影;选中项 bg-card-hover+左 2px 强调边(inset 阴影实现,零布局位移) */
|
||||||
|
.ai-input-wrap :deep(.ai-skill-popover) {
|
||||||
|
border-radius: var(--df-radius-md);
|
||||||
|
box-shadow: var(--df-shadow-sm);
|
||||||
|
}
|
||||||
|
.ai-input-wrap :deep(.ai-skill-item--active) {
|
||||||
|
background: var(--df-bg-card-hover);
|
||||||
|
box-shadow: inset 2px 0 0 var(--df-accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="conflict-resolver" v-if="conflicts.length > 0">
|
|
||||||
<div class="conflict-header">
|
|
||||||
<span class="conflict-title">⚠ {{ t('aiChat.conflictsPending', { n: conflicts.length }) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-for="c in conflicts" :key="c.id" class="conflict-item">
|
|
||||||
<div
|
|
||||||
class="conflict-file conflict-file--toggle"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
:aria-expanded="isExpanded(c.id)"
|
|
||||||
@click="toggleConflict(c.id)"
|
|
||||||
@keydown.enter.prevent="toggleConflict(c.id)"
|
|
||||||
@keydown.space.prevent="toggleConflict(c.id)"
|
|
||||||
>
|
|
||||||
<span class="conflict-arrow" :class="{ 'conflict-arrow--open': isExpanded(c.id) }">▸</span>
|
|
||||||
<span class="conflict-type-badge" :class="'badge--' + c.conflict_type">
|
|
||||||
{{ c.conflict_type === 'semantic' ? '语义' : '文件' }}
|
|
||||||
</span>
|
|
||||||
<code class="conflict-filepath">{{ c.file_path }}</code>
|
|
||||||
<span class="conflict-toggle-text">{{ isExpanded(c.id) ? t('aiChat.collapse') : t('aiChat.expand') }}</span>
|
|
||||||
</div>
|
|
||||||
<template v-if="isExpanded(c.id)">
|
|
||||||
<div class="conflict-diffs" v-if="c.diff_a || c.diff_b">
|
|
||||||
<div class="diff-panel diff-panel--a">
|
|
||||||
<div class="diff-panel-label">Agent A ({{ c.subtask_a || '?' }})</div>
|
|
||||||
<pre class="diff-content">{{ c.diff_a || '(无改动)' }}</pre>
|
|
||||||
</div>
|
|
||||||
<div class="diff-panel diff-panel--b">
|
|
||||||
<div class="diff-panel-label">Agent B ({{ c.subtask_b || '?' }})</div>
|
|
||||||
<pre class="diff-content">{{ c.diff_b || '(无改动)' }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="conflict-actions">
|
|
||||||
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'a' })">
|
|
||||||
{{ t('aiChat.acceptA') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'b' })">
|
|
||||||
{{ t('aiChat.acceptB') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'merged' })">
|
|
||||||
{{ t('aiChat.mergeBoth') }}
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'manual' })">
|
|
||||||
{{ t('aiChat.manualResolve') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import type { ConflictInfo } from '@/api/types'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
conflicts: ConflictInfo[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'resolve', payload: { id: string; resolution: string }): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 多冲突折叠态:以稳定 id 为 key(避免 index 在列表变化时错位)
|
|
||||||
const expandedConflicts = ref<Set<string>>(new Set())
|
|
||||||
|
|
||||||
// 阈值:冲突数 >= 3 时默认全折叠(M6 撑爆对话区只在多冲突场景发生);
|
|
||||||
// 单/双冲突信息量小,直接展开省一次点击
|
|
||||||
const COLLAPSE_THRESHOLD = 3
|
|
||||||
|
|
||||||
function isExpanded(id: string): boolean {
|
|
||||||
return expandedConflicts.value.has(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleConflict(id: string): void {
|
|
||||||
const next = new Set(expandedConflicts.value)
|
|
||||||
if (next.has(id)) {
|
|
||||||
next.delete(id)
|
|
||||||
} else {
|
|
||||||
next.add(id)
|
|
||||||
}
|
|
||||||
expandedConflicts.value = next
|
|
||||||
}
|
|
||||||
|
|
||||||
// 冲突列表变化(新增/减少)时重置默认态:< 阈值全展开,否则全折叠
|
|
||||||
const defaultExpanded = computed(() => props.conflicts.length < COLLAPSE_THRESHOLD)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.conflicts.map((c) => c.id).join('|'),
|
|
||||||
() => {
|
|
||||||
const next = new Set<string>()
|
|
||||||
if (defaultExpanded.value) {
|
|
||||||
props.conflicts.forEach((c) => next.add(c.id))
|
|
||||||
}
|
|
||||||
expandedConflicts.value = next
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.conflict-resolver {
|
|
||||||
border: 1px solid var(--df-warning, #ffc107);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
margin: 8px 0;
|
|
||||||
background: var(--df-bg-secondary);
|
|
||||||
}
|
|
||||||
.conflict-header {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
color: var(--df-warning, #ffc107);
|
|
||||||
}
|
|
||||||
.conflict-item {
|
|
||||||
border-top: 1px solid var(--df-border);
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
.conflict-file {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.conflict-file--toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.conflict-file--toggle:hover .conflict-filepath,
|
|
||||||
.conflict-file--toggle:hover .conflict-toggle-text {
|
|
||||||
color: var(--df-accent);
|
|
||||||
}
|
|
||||||
.conflict-arrow {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
transition: transform 0.15s ease;
|
|
||||||
width: 10px;
|
|
||||||
}
|
|
||||||
.conflict-arrow--open {
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
.conflict-toggle-text {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
}
|
|
||||||
.conflict-type-badge {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
|
|
||||||
.badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
|
|
||||||
.conflict-file code { font-size: 12px; color: var(--df-text); }
|
|
||||||
.conflict-diffs {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.diff-panel {
|
|
||||||
border: 1px solid var(--df-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.diff-panel--a { border-left: 3px solid var(--df-accent); }
|
|
||||||
.diff-panel--b { border-left: 3px solid var(--df-success); }
|
|
||||||
.diff-panel-label {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: var(--df-bg);
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
}
|
|
||||||
.diff-content {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
margin: 0;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.conflict-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -116,17 +116,20 @@ async function openSource(b: ContextBrief) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
|
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
|
||||||
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */
|
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律。
|
||||||
|
* 草稿模板句走 i18n(原硬编码中文,en 环境不再漏出中文) */
|
||||||
function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||||
const convId = store.state.activeConversationId
|
const convId = store.state.activeConversationId
|
||||||
if (!convId) return
|
if (!convId) return
|
||||||
pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`)
|
pushConvDraftAppend(convId, t('aiChat.contextBriefsDraft', { project: g.source_project, title: b.title, summary: b.summary }))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* wave2(2026-10-18):旧 token 全量迁 --df-*;统一透明彩底卡范式,accent 系(脉络=系统功能信号)
|
||||||
|
——边框 color-mix 30% + 底 6% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||||
.ctx-briefs {
|
.ctx-briefs {
|
||||||
border-bottom: 1px solid var(--border, #e0e0e0);
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -138,29 +141,36 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
|||||||
padding: 3px 2px;
|
padding: 3px 2px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--df-accent);
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.ctx-briefs-count {
|
.ctx-briefs-count {
|
||||||
background: var(--accent-soft, rgba(64, 128, 255, 0.15));
|
background: var(--df-accent-soft);
|
||||||
color: var(--accent, #4080ff);
|
color: var(--df-accent);
|
||||||
border-radius: 8px;
|
border-radius: var(--df-radius-md);
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.ctx-briefs-body {
|
.ctx-briefs-body {
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-bottom: 4px;
|
padding: 6px 8px;
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
|
||||||
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.ctx-briefs-empty {
|
.ctx-briefs-empty {
|
||||||
color: var(--text-tertiary, #999);
|
color: var(--df-text-dim);
|
||||||
padding: 6px 2px;
|
padding: 6px 2px;
|
||||||
}
|
}
|
||||||
.ctx-briefs-project {
|
.ctx-briefs-project {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary, #333);
|
color: var(--df-text);
|
||||||
|
font-size: 12px;
|
||||||
padding: 5px 2px 2px;
|
padding: 5px 2px 2px;
|
||||||
}
|
}
|
||||||
.ctx-briefs-focus-dot {
|
.ctx-briefs-focus-dot {
|
||||||
@@ -169,12 +179,12 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
|||||||
height: 5px;
|
height: 5px;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent, #4080ff);
|
background: var(--df-accent);
|
||||||
vertical-align: 2px;
|
vertical-align: 2px;
|
||||||
}
|
}
|
||||||
.ctx-briefs-item {
|
.ctx-briefs-item {
|
||||||
padding: 4px 2px;
|
padding: 4px 2px;
|
||||||
border-top: 1px dashed var(--border, #eee);
|
border-top: 0.5px dashed var(--df-border);
|
||||||
}
|
}
|
||||||
.ctx-briefs-item-head {
|
.ctx-briefs-item-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -183,7 +193,7 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
|||||||
}
|
}
|
||||||
.ctx-briefs-item-title {
|
.ctx-briefs-item-title {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-primary, #333);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
.ctx-briefs-item-link {
|
.ctx-briefs-item-link {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -194,29 +204,31 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.ctx-briefs-item-link:hover {
|
.ctx-briefs-item-link:hover {
|
||||||
color: var(--accent, #4080ff);
|
color: var(--df-accent);
|
||||||
}
|
}
|
||||||
.ctx-briefs-item-time {
|
.ctx-briefs-item-time {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
color: var(--text-tertiary, #999);
|
color: var(--df-text-dim);
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
}
|
}
|
||||||
.ctx-briefs-item-summary {
|
.ctx-briefs-item-summary {
|
||||||
color: var(--text-secondary, #666);
|
color: var(--df-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
margin: 2px 0;
|
margin: 2px 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.ctx-briefs-bring {
|
.ctx-briefs-bring {
|
||||||
border: 1px solid var(--border, #ddd);
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||||
background: none;
|
background: transparent;
|
||||||
color: var(--accent, #4080ff);
|
color: var(--df-accent);
|
||||||
border-radius: 4px;
|
border-radius: var(--df-radius-sm);
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.ctx-briefs-bring:hover {
|
.ctx-briefs-bring:hover {
|
||||||
background: var(--accent-soft, rgba(64, 128, 255, 0.1));
|
background: var(--df-accent-soft);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,212 +0,0 @@
|
|||||||
<template>
|
|
||||||
<!-- F-260619-03 Phase B + path_auth 审批链阶段1: 路径授权弹窗(LLM 想访问白名单外目录,
|
|
||||||
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
|
|
||||||
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
|
|
||||||
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
|
||||||
每项独立四按钮:点"本次" → ai_authorize_dir(decision=once, 写 once 层单次授权,执行后清)→ 后端执行工具 + try_continue。
|
|
||||||
点"当前会话" → ai_authorize_dir(decision=session, 写 session 层会话授权,切会话清)→ 执行 + 续 loop。
|
|
||||||
点"始终放行" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
|
||||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
|
||||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
|
||||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
|
||||||
<div v-for="item in visibleDirAuths" :key="item.id" ref="dirAuthRefs" class="ai-dir-auth">
|
|
||||||
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
|
||||||
<span class="ai-dir-auth-hint">
|
|
||||||
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
|
|
||||||
</span>
|
|
||||||
<div class="ai-dir-auth-actions">
|
|
||||||
<button
|
|
||||||
class="ai-dir-auth-btn ai-dir-auth-btn--once"
|
|
||||||
:disabled="actingIds.has(item.id)"
|
|
||||||
@click="handleAuthorize(item.id, 'once')"
|
|
||||||
>{{ $t('aiChat.dirAuthOnce') }}</button>
|
|
||||||
<button
|
|
||||||
class="ai-dir-auth-btn ai-dir-auth-btn--session"
|
|
||||||
:disabled="actingIds.has(item.id)"
|
|
||||||
@click="handleAuthorize(item.id, 'session')"
|
|
||||||
>{{ $t('aiChat.dirAuthSession') }}</button>
|
|
||||||
<button
|
|
||||||
class="ai-dir-auth-btn ai-dir-auth-btn--always"
|
|
||||||
:disabled="actingIds.has(item.id)"
|
|
||||||
@click="handleAuthorize(item.id, 'always')"
|
|
||||||
>{{ $t('aiChat.dirAuthAlways') }}</button>
|
|
||||||
<button
|
|
||||||
class="ai-dir-auth-btn ai-dir-auth-btn--deny"
|
|
||||||
:disabled="actingIds.has(item.id)"
|
|
||||||
@click="handleAuthorize(item.id, 'deny')"
|
|
||||||
>{{ $t('aiChat.dirAuthDeny') }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- P1-e-c: deny 拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试;对齐 ToolCard/ApprovalPopup) -->
|
|
||||||
<ConfirmDialog
|
|
||||||
:visible="confirmState.visible"
|
|
||||||
:msg="confirmState.msg"
|
|
||||||
:danger-label="$t('common.confirm')"
|
|
||||||
@result="answerConfirm"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { reactive, computed, watch, nextTick, ref } from 'vue'
|
|
||||||
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
|
|
||||||
}>()
|
|
||||||
|
|
||||||
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、
|
|
||||||
// generatingConvs 含 conv(useAiEvents handleEvent 对非完成/错误事件 add)。若守卫仍 && streaming,
|
|
||||||
// 挂起弹窗永不显示 → loop 无声卡死(用户发消息无响应)。去 streaming,isGenerating(conv) 单条件覆盖。
|
|
||||||
// 多会话并发 — isGenerating(active) 替代单值比对
|
|
||||||
const isViewingGenerating = computed(() =>
|
|
||||||
store.isGenerating(store.state.activeConversationId),
|
|
||||||
)
|
|
||||||
|
|
||||||
// pendingDirAuths 非空 + 当前视图正在生成(防切走后误显)双重守卫。
|
|
||||||
// 阶段1:数组化后,visibleDirAuths 是 pendingDirAuths 的派生视图(按 conv 过滤当前会话挂起项),
|
|
||||||
// 与 showDirAuthCard 合并为单一 computed(非空即显)。
|
|
||||||
const visibleDirAuths = computed(() => {
|
|
||||||
if (!isViewingGenerating.value) return []
|
|
||||||
// 仅显当前会话的挂起项(active conv);其他会话的挂起不在本视图弹窗。
|
|
||||||
const active = store.state.activeConversationId
|
|
||||||
return pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === active)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 阶段1:逐 id 追踪"处理中"态(数组多挂起并存,单 boolean 不够)。
|
|
||||||
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
|
|
||||||
const actingIds = reactive(new Set<string>())
|
|
||||||
|
|
||||||
// M8:卡片根元素 ref 数组(v-for ref 收集)。挂起弹窗出现在输入区上方,常被消息流/进度条挤出视口,
|
|
||||||
// 用户看不到按钮 → 挂起无声卡死。新增项时滚动定位到卡片让审批入口立即可见。
|
|
||||||
const dirAuthRefs = ref<HTMLElement[]>([])
|
|
||||||
|
|
||||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
|
||||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
|
||||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
|
||||||
async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
|
|
||||||
if (actingIds.has(id)) return
|
|
||||||
actingIds.add(id)
|
|
||||||
// P1-e-c: deny 拒绝前二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试;与 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) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
|
||||||
emit('toast', { msg: t('aiChat.dirAuthFailed', { msg }), type: 'error' })
|
|
||||||
actingIds.delete(id) // IPC 失败:复位该 id,允许重试
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// F-260620 根治(授权按钮永转圈):IPC 成功后 try_continue spawn 续跑 loop,续跑 loop 可能立刻又
|
|
||||||
// 触发 AiDirAuthRequired(同 id 不可能,但其他 id 可能)置新 pendingDirAuths 项;该 id 的项会由
|
|
||||||
// AiApprovalResult 按 id filter 移除。这里不依赖 pendingDirAuths 状态复位 actingIds——改为
|
|
||||||
// watch pendingDirAuths,某 id 离开数组(已被后端消费)即删 actingIds,精准复位。
|
|
||||||
}
|
|
||||||
|
|
||||||
/** pendingDirAuths 变化时,离开数组(已被后端 AiApprovalResult/AiCompleted/AiError 按 id 移除)的
|
|
||||||
* id 从 actingIds 删,精准复位"处理中"态(数组化后多挂起,单 watch===null 模式不再适用)。 */
|
|
||||||
watch(() => pendingDirAuths.value, (items) => {
|
|
||||||
const liveIds = new Set(items.map(p => p.id))
|
|
||||||
for (const id of [...actingIds]) {
|
|
||||||
if (!liveIds.has(id)) actingIds.delete(id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/** M8:visibleDirAuths 出现新挂起项时,scrollIntoView 让审批入口立刻可见(挂起弹窗在输入区上方,
|
|
||||||
* 易被消息流/进度条挤出视口,用户不滚动看不到 → 挂起无声卡死)。仅新增项触发滚动,避免已可见项
|
|
||||||
* 反复跳动打扰。 */
|
|
||||||
watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
|
|
||||||
await nextTick()
|
|
||||||
// 滚到最新(数组末)项,block:'center' 让按钮组居中可见
|
|
||||||
const last = dirAuthRefs.value[dirAuthRefs.value.length - 1]
|
|
||||||
if (last) last.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ai-dir-auth {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
|
||||||
border-radius: var(--df-radius);
|
|
||||||
}
|
|
||||||
.ai-dir-auth-text {
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--df-warning);
|
|
||||||
}
|
|
||||||
.ai-dir-auth-hint {
|
|
||||||
font-size: 10.5px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
opacity: 0.9;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.ai-dir-auth-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 2px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.ai-dir-auth-btn {
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: calc(var(--df-radius) - 2px);
|
|
||||||
transition: filter 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.ai-dir-auth-btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.ai-dir-auth-btn--once {
|
|
||||||
background: var(--df-accent);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.ai-dir-auth-btn--session {
|
|
||||||
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
|
|
||||||
color: var(--df-accent);
|
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
|
||||||
}
|
|
||||||
.ai-dir-auth-btn--session:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.ai-dir-auth-btn--always {
|
|
||||||
background: var(--df-warning);
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
.ai-dir-auth-btn--always:hover:not(:disabled) { filter: brightness(1.1); }
|
|
||||||
.ai-dir-auth-btn--deny {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
}
|
|
||||||
.ai-dir-auth-btn--deny:hover:not(:disabled) {
|
|
||||||
color: var(--df-text);
|
|
||||||
border-color: var(--df-text-dim);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -34,7 +34,7 @@ const examplePrompts = [
|
|||||||
<div class="ai-empty">
|
<div class="ai-empty">
|
||||||
<template v-if="!hasProvider">
|
<template v-if="!hasProvider">
|
||||||
<div class="ai-empty-icon">
|
<div class="ai-empty-icon">
|
||||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
|
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
|
||||||
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
|
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
|
||||||
@@ -46,7 +46,7 @@ const examplePrompts = [
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="ai-empty-icon">
|
<div class="ai-empty-icon">
|
||||||
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
|
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
|
||||||
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
|
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
|
||||||
@@ -73,7 +73,8 @@ const examplePrompts = [
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; }
|
/* wave2:空态图标统一 dim 色(经 currentColor 接管 stroke),引导文字 secondary 层级 */
|
||||||
|
.ai-empty-icon { margin-bottom: 4px; opacity: 0.4; color: var(--df-text-dim); }
|
||||||
.ai-empty-text {
|
.ai-empty-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -81,7 +82,7 @@ const examplePrompts = [
|
|||||||
}
|
}
|
||||||
.ai-empty-sub {
|
.ai-empty-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-secondary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
@@ -117,7 +118,7 @@ const examplePrompts = [
|
|||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
background: var(--df-bg-card);
|
background: var(--df-bg-card);
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
font-size: 11.5px;
|
font-size: 11px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -139,6 +139,6 @@ function toggleExpanded(current: boolean) {
|
|||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
font-family: var(--df-font-mono, ui-monospace, monospace);
|
font-family: var(--df-font-mono);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
复用 MaxRoundsCard 模式:per-conv 守卫(pendingHelp.conversationId === active)+ 行为已记入本地 ref。
|
复用 MaxRoundsCard 模式:per-conv 守卫(pendingHelp.conversationId === active)+ 行为已记入本地 ref。
|
||||||
显 reason(原因)+ context(最近错误 sample)+ options 按钮供用户选(换思路/授权路径/人工接管)。
|
显 reason(原因)+ context(最近错误 sample)+ options 按钮供用户选(换思路/授权路径/人工接管)。
|
||||||
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
||||||
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||||
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
||||||
<div v-if="helpActive" ref="helpRef" class="ai-help-required">
|
<div v-if="helpActive" ref="helpRef" class="ai-help-required">
|
||||||
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
||||||
@@ -64,7 +64,7 @@ watch(helpActive, async (p) => {
|
|||||||
|
|
||||||
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
||||||
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
||||||
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
* 路径授权链路已退役(path_auth 2026-10-25),无需此卡代发。
|
||||||
* 选后清 pendingHelp,卡片隐藏(对齐 MaxRoundsCard 点继续/停止后由事件清的语义,此处无续跑事件故立即清)。 */
|
* 选后清 pendingHelp,卡片隐藏(对齐 MaxRoundsCard 点继续/停止后由事件清的语义,此处无续跑事件故立即清)。 */
|
||||||
function handleSelectOption(opt: string): void {
|
function handleSelectOption(opt: string): void {
|
||||||
void opt // option 仅区分按钮文案,后端 loop 已终止无独立 IPC 续跑,选即清卡片
|
void opt // option 仅区分按钮文案,后端 loop 已终止无独立 IPC 续跑,选即清卡片
|
||||||
@@ -74,30 +74,30 @@ function handleSelectOption(opt: string): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */
|
/* wave2(2026-10-18):统一透明彩底卡范式,danger 系(断路器熔断=最高警示级)——
|
||||||
|
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||||
.ai-help-required {
|
.ai-help-required {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
background: color-mix(in srgb, var(--df-danger) 8%, transparent);
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
}
|
}
|
||||||
.ai-help-required-text {
|
.ai-help-required-text {
|
||||||
font-size: 11.5px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--df-warning);
|
color: var(--df-danger);
|
||||||
}
|
}
|
||||||
.ai-help-required-hint {
|
.ai-help-required-hint {
|
||||||
font-size: 10.5px;
|
font-size: 12px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-secondary);
|
||||||
opacity: 0.9;
|
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
/* M7:context 含错误堆栈,长栈撑高卡片。等宽字体利堆栈行对齐;max-height + overflow-y:auto
|
/* M7:context 含错误堆栈,长栈撑高卡片。等宽字体利堆栈行对齐;max-height + overflow-y:auto
|
||||||
短内容自动不显滚动条,长堆栈滚动扫看(比折叠实用,无需展开按钮)。 */
|
短内容自动不显滚动条,长堆栈滚动扫看(比折叠实用,无需展开按钮)。 */
|
||||||
font-family: var(--df-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
font-family: var(--df-font-mono);
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
@@ -109,15 +109,15 @@ function handleSelectOption(opt: string): void {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.ai-help-required-btn {
|
.ai-help-required-btn {
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
|
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||||
background: var(--df-warning);
|
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||||
color: #000;
|
color: var(--df-danger);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: calc(var(--df-radius) - 2px);
|
border-radius: calc(var(--df-radius) - 2px);
|
||||||
transition: filter 0.15s var(--df-ease);
|
transition: background 0.15s var(--df-ease);
|
||||||
}
|
}
|
||||||
.ai-help-required-btn:hover { filter: brightness(1.1); }
|
.ai-help-required-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 待发送图片列表(url 模式用 url 预览,base64 模式用 dataUrl) */
|
/** 待发送图片列表(url 模式用 url 预览,base64 模式用 dataUrl;uploading=上传中占位 chip) */
|
||||||
pendingImages: { id: string; dataUrl?: string; url?: string; mediaType: string }[]
|
pendingImages: { id: string; dataUrl?: string; url?: string; mediaType: string; uploading?: boolean }[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -21,7 +21,16 @@ const emit = defineEmits<{
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="pendingImages.length" class="ai-img-preview-row">
|
<div v-if="pendingImages.length" class="ai-img-preview-row">
|
||||||
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
|
<div v-for="img in pendingImages" :key="img.id" class="ai-img-preview">
|
||||||
|
<!-- 走查:上传中占位 chip——url/dataUrl 未定时渲染 loading 态,不渲染空 src <img> -->
|
||||||
|
<div
|
||||||
|
v-if="img.uploading && !img.url && !img.dataUrl"
|
||||||
|
class="ai-img-preview-thumb ai-img-thumb--uploading"
|
||||||
|
>
|
||||||
|
<span class="ai-thumb-spinner" aria-hidden="true"></span>
|
||||||
|
<span class="ai-thumb-uploading-text">{{ t('aiChat.imageUploading') }}</span>
|
||||||
|
</div>
|
||||||
<img
|
<img
|
||||||
|
v-else
|
||||||
:src="img.url || img.dataUrl"
|
:src="img.url || img.dataUrl"
|
||||||
:alt="t('aiChat.imagePreviewAlt')"
|
:alt="t('aiChat.imagePreviewAlt')"
|
||||||
class="ai-img-preview-thumb"
|
class="ai-img-preview-thumb"
|
||||||
@@ -37,3 +46,30 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 走查:上传中占位态(与缩略图同尺寸盒,转圈+文案)。仅本组件消费的私有态样式,不入公共库 */
|
||||||
|
.ai-img-thumb--uploading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: var(--df-bg-card-hover);
|
||||||
|
}
|
||||||
|
.ai-thumb-spinner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1.5px solid var(--df-border);
|
||||||
|
border-top-color: var(--df-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ai-thumb-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes ai-thumb-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.ai-thumb-uploading-text {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
//! - select(item):选中实体 → 替换 @query 段为 [类型: 名] 标记
|
//! - select(item):选中实体 → 替换 @query 段为 [类型: 名] 标记
|
||||||
//! - update:mentionIndex:鼠标 hover 时同步高亮下标
|
//! - update:mentionIndex:鼠标 hover 时同步高亮下标
|
||||||
|
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import type { MentionItem } from './MentionItem'
|
import type { MentionItem } from './MentionItem'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
/** 联想候选(扁平,项目 + 任务 + 灵感) */
|
/** 联想候选(扁平,项目 + 任务 + 灵感) */
|
||||||
mentionItems: MentionItem[]
|
mentionItems: MentionItem[]
|
||||||
/** 按类型分组的候选(模板按组渲染组标题) */
|
/** 按类型分组的候选(模板按组渲染组标题) */
|
||||||
@@ -34,11 +35,18 @@ const emit = defineEmits<{
|
|||||||
/** 鼠标 hover 时同步高亮下标 */
|
/** 鼠标 hover 时同步高亮下标 */
|
||||||
(e: 'update:mentionIndex', index: number): void
|
(e: 'update:mentionIndex', index: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 走查:键盘 ↑↓ 高亮项滚随——候选列表超高时选中项保持可见(block:nearest 无多余滚动)
|
||||||
|
const popRef = ref<HTMLElement | null>(null)
|
||||||
|
watch(() => props.mentionIndex, async (idx) => {
|
||||||
|
await nextTick()
|
||||||
|
popRef.value?.querySelector<HTMLElement>(`[data-mention-idx="${idx}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) -->
|
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) -->
|
||||||
<div class="ai-skill-popover">
|
<div ref="popRef" class="ai-skill-popover">
|
||||||
<div v-if="!mentionItems.length" class="ai-skill-empty">
|
<div v-if="!mentionItems.length" class="ai-skill-empty">
|
||||||
{{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
|
{{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -50,6 +58,8 @@ const emit = defineEmits<{
|
|||||||
:key="item.uid"
|
:key="item.uid"
|
||||||
class="ai-skill-item"
|
class="ai-skill-item"
|
||||||
:class="{ 'ai-skill-item--active': mentionFlatIndex(item.uid) === mentionIndex }"
|
:class="{ 'ai-skill-item--active': mentionFlatIndex(item.uid) === mentionIndex }"
|
||||||
|
:data-mention-idx="mentionFlatIndex(item.uid)"
|
||||||
|
@mousedown.prevent
|
||||||
@click="emit('select', item)"
|
@click="emit('select', item)"
|
||||||
@mouseenter="emit('update:mentionIndex', mentionFlatIndex(item.uid))"
|
@mouseenter="emit('update:mentionIndex', mentionFlatIndex(item.uid))"
|
||||||
>
|
>
|
||||||
@@ -84,7 +94,7 @@ const emit = defineEmits<{
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
/* 类型标签小色块(项目/任务区分) */
|
/* 类型标签小色块(项目/任务区分)。2026-10-18: 原底色引用未定义 token 静默失效,修正为 bg-card-hover */
|
||||||
.ai-mention-item-type {
|
.ai-mention-item-type {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
@@ -92,7 +102,7 @@ const emit = defineEmits<{
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
background: var(--df-bg-hover);
|
background: var(--df-bg-card-hover);
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
}
|
}
|
||||||
.ai-mention-item-type--project {
|
.ai-mention-item-type--project {
|
||||||
|
|||||||
@@ -35,11 +35,19 @@ const props = defineProps<{
|
|||||||
msg: AiMessage
|
msg: AiMessage
|
||||||
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
|
/** M1 扩展:超长文本可折叠(1500 字阈值判定与折叠态由父级统一持有,本组件纯展示) */
|
||||||
|
longFoldable?: boolean
|
||||||
|
/** 当前是否折叠展示(longFoldable 为 true 时有意义) */
|
||||||
|
longFolded?: boolean
|
||||||
|
/** 折叠态按钮文案(父级 collapsedLongLabel 产出,含已折叠量;展开态复用 aiChat.collapse) */
|
||||||
|
foldLabel?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'copy', msg: AiMessage): void
|
(e: 'copy', msg: AiMessage): void
|
||||||
(e: 'start-edit', msg: AiMessage): void
|
(e: 'start-edit', msg: AiMessage): void
|
||||||
|
/** M1 扩展:折叠条点击,展开/收起状态由父级(toggleLong)处理 */
|
||||||
|
(e: 'toggle-long'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// ── Input Augmentation: 按 mention 区间切段 ──
|
// ── Input Augmentation: 按 mention 区间切段 ──
|
||||||
@@ -112,7 +120,7 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
|||||||
<div class="ai-msg-avatar ai-msg-avatar--user">
|
<div class="ai-msg-avatar ai-msg-avatar--user">
|
||||||
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
<svg aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-msg-bubble ai-msg-bubble--user">
|
<div class="ai-msg-bubble ai-msg-bubble--user" :class="{ 'ai-msg-bubble--collapsed': longFolded }">
|
||||||
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
|
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
|
||||||
<template v-if="skill">
|
<template v-if="skill">
|
||||||
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span>
|
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span>
|
||||||
@@ -129,6 +137,12 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
|||||||
<img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
<img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- M1 扩展:超长文本折叠条(视觉/交互对齐 assistant 侧同款;点击经父级 toggleLong 切换) -->
|
||||||
|
<button
|
||||||
|
v-if="longFoldable"
|
||||||
|
class="ai-msg-bubble-toggle"
|
||||||
|
@click.stop="emit('toggle-long')"
|
||||||
|
>{{ longFolded ? (foldLabel || t('aiChat.collapse')) : t('aiChat.collapse') }}</button>
|
||||||
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
|
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
|
||||||
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" :aria-label="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
|
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" :aria-label="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
@@ -149,29 +163,34 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
/* 头像通用 */
|
/* 头像通用 — workbench .av 范式:24px 圆角方块渐变,用户 warning→danger 系,
|
||||||
|
字色随主题底色反转保证渐变上对比;内部字符 9px 700(当前为 svg 图标,字号字重为字符兜底) */
|
||||||
.ai-msg-avatar {
|
.ai-msg-avatar {
|
||||||
width: 22px;
|
width: 24px;
|
||||||
height: 22px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: var(--df-radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.ai-msg-avatar--user {
|
.ai-msg-avatar--user {
|
||||||
background: var(--df-info-bg, rgba(100,181,246,0.15));
|
background: linear-gradient(135deg, color-mix(in srgb, var(--df-warning) 70%, var(--df-danger)), var(--df-danger));
|
||||||
color: var(--df-info, #64b5f6);
|
color: var(--df-bg);
|
||||||
}
|
}
|
||||||
/* 气泡通用 */
|
/* 气泡通用 — workbench 聊天范式:用户气泡=bg-card 底+0.5px 边框+9px 圆角。
|
||||||
|
9px 圆角与 9px 13px 内距均为 u-agent workbench 移植值,勿规整 */
|
||||||
.ai-msg-bubble {
|
.ai-msg-bubble {
|
||||||
border-radius: 10px;
|
border-radius: 9px;
|
||||||
padding: 8px 12px;
|
padding: 9px 13px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
}
|
}
|
||||||
.ai-msg-bubble--user {
|
.ai-msg-bubble--user {
|
||||||
background: var(--df-info-bg, rgba(100,181,246,0.1));
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
/* chip */
|
/* chip */
|
||||||
@@ -181,13 +200,13 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
background: var(--df-accent-bg, rgba(108,99,255,0.1));
|
background: var(--df-accent-bg);
|
||||||
color: var(--df-accent, #6c63ff);
|
color: var(--df-accent);
|
||||||
}
|
}
|
||||||
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
|
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
|
||||||
.ai-msg-chip--skill {
|
.ai-msg-chip--skill {
|
||||||
background: var(--df-accent-bg, rgba(108,99,255,0.18));
|
background: var(--df-accent-bg);
|
||||||
color: var(--df-accent, #6c63ff);
|
color: var(--df-accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
/* 图片 */
|
/* 图片 */
|
||||||
@@ -201,6 +220,47 @@ const imageParts = computed(() => msgImageParts(props.msg))
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
/* lightbox 可放大暗示:点击开大图由父级 MessageList 容器级委托统一处理 */
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
/* M1 扩展:超长 user 文本折叠(样式对齐 MessageList .ai-msg-bubble--collapsed 同款;
|
||||||
|
user 气泡有 --df-bg-card 底,渐变淡出随之;本组件 scoped,父级样式渗透不进内部节点) */
|
||||||
|
.ai-msg-bubble--user {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ai-msg-bubble--collapsed {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ai-msg-bubble--collapsed::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(transparent, var(--df-bg-card));
|
||||||
|
pointer-events: none; /* 让点击穿透到下方展开按钮 */
|
||||||
|
}
|
||||||
|
/* 展开按钮:气泡底部居中,置于渐变遮罩之上(同 MessageList .ai-msg-bubble-toggle) */
|
||||||
|
.ai-msg-bubble-toggle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
padding: 2px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--df-accent);
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ai-msg-bubble-toggle:hover {
|
||||||
|
background: var(--df-bg-card-hover);
|
||||||
}
|
}
|
||||||
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑仅 canEdit 时渲染) */
|
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑仅 canEdit 时渲染) */
|
||||||
.ai-copy-btn,
|
.ai-copy-btn,
|
||||||
|
|||||||
+302
-184
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,17 @@
|
|||||||
数据走 useAiOutbox(后端命令未接线时静默降级为空列表,抽屉可用不崩)。 -->
|
数据走 useAiOutbox(后端命令未接线时静默降级为空列表,抽屉可用不崩)。 -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="open" class="outbox-overlay" @click.self="emit('close')">
|
<div v-if="open" class="outbox-overlay" @click.self="emit('close')">
|
||||||
<div class="outbox-panel" role="dialog" aria-modal="true" :aria-label="t('aiChat.outboxTitle')">
|
<!-- 走查:开抽屉聚焦面板 + Tab 焦点圈闭(tabindex=-1 使面板可编程聚焦;
|
||||||
|
role=dialog + aria-modal 已有,补键盘可达闭环) -->
|
||||||
|
<div
|
||||||
|
ref="panelRef"
|
||||||
|
class="outbox-panel"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="t('aiChat.outboxTitle')"
|
||||||
|
tabindex="-1"
|
||||||
|
@keydown="onPanelKeydown"
|
||||||
|
>
|
||||||
<div class="outbox-header">
|
<div class="outbox-header">
|
||||||
<h3 class="outbox-title">{{ t('aiChat.outboxTitle') }}</h3>
|
<h3 class="outbox-title">{{ t('aiChat.outboxTitle') }}</h3>
|
||||||
<button type="button" class="outbox-close" :aria-label="t('common.close')" @click="emit('close')">✕</button>
|
<button type="button" class="outbox-close" :aria-label="t('common.close')" @click="emit('close')">✕</button>
|
||||||
@@ -19,6 +29,8 @@
|
|||||||
<span class="outbox-badge" :class="'outbox-badge--' + item.status">{{ statusLabel(item.status) }}</span>
|
<span class="outbox-badge" :class="'outbox-badge--' + item.status">{{ statusLabel(item.status) }}</span>
|
||||||
<span class="outbox-text" :title="item.text">{{ summarize(item.text) }}</span>
|
<span class="outbox-text" :title="item.text">{{ summarize(item.text) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 走查:failed 行展示后端 last_error 失败原因,不再只有一个「失败」徽章无从排查 -->
|
||||||
|
<div v-if="item.status === 'failed' && item.last_error" class="outbox-row-error">{{ item.last_error }}</div>
|
||||||
<!-- 行操作仅活跃项(pending/sending/failed)展示;终态行只读留档 -->
|
<!-- 行操作仅活跃项(pending/sending/failed)展示;终态行只读留档 -->
|
||||||
<div v-if="isActive(item)" class="outbox-row-actions">
|
<div v-if="isActive(item)" class="outbox-row-actions">
|
||||||
<template v-if="editingId === item.id">
|
<template v-if="editingId === item.id">
|
||||||
@@ -66,6 +78,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 走查:取消定时消息二次确认弹层(自持 useConfirm + ConfirmDialog,同 ChatInput/QueuePanel
|
||||||
|
范式——useConfirm 非单例,发起组件须自渲染一份) -->
|
||||||
|
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -73,10 +88,12 @@
|
|||||||
// OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。
|
// OutboxDrawer:定时/待发消息统一列表(跨会话),支持 改期/立即发送/取消 + 底部新建。
|
||||||
// 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局);
|
// 挂载点在 ChatInput.vue(⏰ 入口按钮旁,Teleport 自脱离输入区布局);
|
||||||
// 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。
|
// 后端 ai_outbox_* 命令未接线时 invoke 静默降级(useAiOutbox 内),抽屉空态可用。
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox'
|
import { useAiOutbox, type AiOutboxItem } from '../../composables/ai/useAiOutbox'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
|
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||||
|
import { useConfirm } from '../../composables/useConfirm'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 抽屉开合(ChatInput ⏰ 按钮控制;开时拉取列表,关时仅隐藏不销毁缓存) */
|
/** 抽屉开合(ChatInput ⏰ 按钮控制;开时拉取列表,关时仅隐藏不销毁缓存) */
|
||||||
@@ -91,11 +108,58 @@ const { t } = useI18n()
|
|||||||
const store = useAiStore()
|
const store = useAiStore()
|
||||||
const { items, loading, refresh, enqueue, cancel, reschedule, sendNow } = useAiOutbox()
|
const { items, loading, refresh, enqueue, cancel, reschedule, sendNow } = useAiOutbox()
|
||||||
|
|
||||||
|
// ── 走查:确认弹层(取消定时消息不可逆,对齐 ChatInput/QueuePanel 自持范式) ──
|
||||||
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
|
|
||||||
|
// 走查:开抽屉聚焦面板 + Tab 焦点圈闭(键盘可达闭环;Esc 归下方 document 级仲裁)
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 进入抽屉拉取(操作后由 composable 自动失效重拉,此处只管开合时机)
|
// 进入抽屉拉取(操作后由 composable 自动失效重拉,此处只管开合时机)
|
||||||
watch(() => props.open, (v) => {
|
watch(() => props.open, (v) => {
|
||||||
if (v) void refresh()
|
if (v) {
|
||||||
|
void refresh()
|
||||||
|
nextTick(() => panelRef.value?.focus())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 走查:简易 Tab trap——焦点在面板内 Tab 到尾元素回绕到首,Shift+Tab 反向;
|
||||||
|
// 焦点意外漏出面板时一并发回面板,防 Tab 落进遮罩后的页面
|
||||||
|
function onPanelKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key !== 'Tab' || !panelRef.value) return
|
||||||
|
const focusables = panelRef.value.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||||
|
)
|
||||||
|
if (focusables.length === 0) return
|
||||||
|
const first = focusables[0]
|
||||||
|
const last = focusables[focusables.length - 1]
|
||||||
|
const active = document.activeElement
|
||||||
|
// 面板容器自身(open 时的初始焦点)视为「未入列」:Tab 前进到首元素、Shift+Tab 回绕到尾元素
|
||||||
|
const inside = active !== panelRef.value && active instanceof Node && panelRef.value.contains(active)
|
||||||
|
if (e.shiftKey && (active === first || !inside)) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!e.shiftKey && (active === last || !inside)) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// F6-2(走查-信息UX 面六):Esc 关抽屉(与遮罩点击/✕ 同出口,键盘可达)。
|
||||||
|
// 监听挂 document:AiChat 全局 Esc 仲裁在 window 层,document 先于 window 收到冒泡,
|
||||||
|
// 此处消费即收口(stopPropagation),防「关抽屉的同时误关分离窗」一次按键双效果;
|
||||||
|
// IME 组词中的 Esc(229/isComposing)归输入法,不关抽屉(仓内三处同款守卫);
|
||||||
|
// 确认弹层可见时 Esc 归弹层(ConfirmDialog 自带 document 级消费),不关抽屉。
|
||||||
|
function onEscClose(e: KeyboardEvent): void {
|
||||||
|
if (!props.open || e.key !== 'Escape') return
|
||||||
|
if (e.isComposing || e.keyCode === 229) return
|
||||||
|
if (confirmState.value.visible) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onEscClose)
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onEscClose))
|
||||||
|
|
||||||
// ── 列表展示 ──
|
// ── 列表展示 ──
|
||||||
|
|
||||||
// 排序:活跃在前(sending→pending→failed),终态沉底;同级按 due_at 升序(尽快项 due null 排最前)
|
// 排序:活跃在前(sending→pending→failed),终态沉底;同级按 due_at 升序(尽快项 due null 排最前)
|
||||||
@@ -161,7 +225,11 @@ function startReschedule(item: AiOutboxItem): void {
|
|||||||
|
|
||||||
async function confirmReschedule(item: AiOutboxItem): Promise<void> {
|
async function confirmReschedule(item: AiOutboxItem): Promise<void> {
|
||||||
const due = editDue.value ? new Date(editDue.value).getTime() : NaN
|
const due = editDue.value ? new Date(editDue.value).getTime() : NaN
|
||||||
if (Number.isNaN(due)) return
|
// 走查:非法值(NaN)原静默 return,用户不知道点保存为何无反应;提示后留在编辑态可改
|
||||||
|
if (Number.isNaN(due)) {
|
||||||
|
notice.value = t('aiChat.outboxDueInvalid')
|
||||||
|
return
|
||||||
|
}
|
||||||
acting.value = true
|
acting.value = true
|
||||||
const ok = await reschedule(item.id, due)
|
const ok = await reschedule(item.id, due)
|
||||||
acting.value = false
|
acting.value = false
|
||||||
@@ -180,6 +248,8 @@ async function onSendNow(item: AiOutboxItem): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onCancel(item: AiOutboxItem): Promise<void> {
|
async function onCancel(item: AiOutboxItem): Promise<void> {
|
||||||
|
// 走查:取消把条目直接置终态不可恢复,补二次确认(对齐清空队列/删对话确认范式)
|
||||||
|
if (!(await confirmDialog(t('aiChat.outboxCancelConfirm')))) return
|
||||||
acting.value = true
|
acting.value = true
|
||||||
const ok = await cancel(item.id)
|
const ok = await cancel(item.id)
|
||||||
acting.value = false
|
acting.value = false
|
||||||
@@ -230,7 +300,8 @@ async function onCreate(): Promise<void> {
|
|||||||
.outbox-panel {
|
.outbox-panel {
|
||||||
width: min(420px, 92vw);
|
width: min(420px, 92vw);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--df-bg-card);
|
/* wave2:抽屉底 bg-raised(比 bg-card 抬一级,与遮罩分层) */
|
||||||
|
background: var(--df-bg-raised);
|
||||||
border-left: 0.5px solid var(--df-border);
|
border-left: 0.5px solid var(--df-border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -259,7 +330,7 @@ async function onCreate(): Promise<void> {
|
|||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
}
|
}
|
||||||
.outbox-close:hover {
|
.outbox-close:hover {
|
||||||
background: var(--df-bg-raised);
|
background: var(--df-bg-card-hover);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
.outbox-list {
|
.outbox-list {
|
||||||
@@ -309,8 +380,8 @@ async function onCreate(): Promise<void> {
|
|||||||
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
|
border-color: color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||||
}
|
}
|
||||||
.outbox-badge--failed {
|
.outbox-badge--failed {
|
||||||
color: #e5484d;
|
color: var(--df-danger);
|
||||||
border-color: color-mix(in srgb, #e5484d 40%, transparent);
|
border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||||
}
|
}
|
||||||
.outbox-text {
|
.outbox-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -319,6 +390,13 @@ async function onCreate(): Promise<void> {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* 走查:failed 行失败原因(last_error 原始串,danger 色弱化呈现;anywhere 断行兜长串) */
|
||||||
|
.outbox-row-error {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-danger);
|
||||||
|
opacity: 0.9;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.outbox-row-actions {
|
.outbox-row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -336,7 +414,7 @@ async function onCreate(): Promise<void> {
|
|||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.outbox-act:hover:not(:disabled) {
|
.outbox-act:hover:not(:disabled) {
|
||||||
background: var(--df-bg-raised);
|
background: var(--df-bg-card-hover);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
.outbox-act:disabled {
|
.outbox-act:disabled {
|
||||||
@@ -353,12 +431,12 @@ async function onCreate(): Promise<void> {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.outbox-act--danger:hover:not(:disabled) {
|
.outbox-act--danger:hover:not(:disabled) {
|
||||||
color: #e5484d;
|
color: var(--df-danger);
|
||||||
border-color: color-mix(in srgb, #e5484d 40%, transparent);
|
border-color: color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||||
}
|
}
|
||||||
.outbox-dt {
|
.outbox-dt {
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
background: var(--df-bg-raised);
|
background: var(--df-bg-card);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
@@ -382,7 +460,7 @@ async function onCreate(): Promise<void> {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
background: var(--df-bg-raised);
|
background: var(--df-bg-card);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--df-font-sans);
|
font-family: var(--df-font-sans);
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
//!
|
//!
|
||||||
//! 职责:显示 /@ 联想浮层(Filtered skill list)、选中/清除技能、关联 i18n
|
//! 职责:显示 /@ 联想浮层(Filtered skill list)、选中/清除技能、关联 i18n
|
||||||
|
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { SkillInfo } from '../../api/types'
|
import type { SkillInfo } from '../../api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
/** 联想浮层是否打开 */
|
/** 联想浮层是否打开 */
|
||||||
skillOpen: boolean
|
skillOpen: boolean
|
||||||
/** 过滤后的技能列表 */
|
/** 过滤后的技能列表 */
|
||||||
@@ -26,6 +27,13 @@ const emit = defineEmits<{
|
|||||||
(e: 'clear'): void
|
(e: 'clear'): void
|
||||||
(e: 'update:skillIndex', index: number): void
|
(e: 'update:skillIndex', index: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 走查:键盘 ↑↓ 高亮项滚随——技能列表超高时选中项保持可见(block:nearest 无多余滚动)
|
||||||
|
const popRef = ref<HTMLElement | null>(null)
|
||||||
|
watch(() => props.skillIndex, async (idx) => {
|
||||||
|
await nextTick()
|
||||||
|
popRef.value?.querySelector<HTMLElement>(`[data-skill-idx="${idx}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,7 +48,7 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 技能联想浮层 -->
|
<!-- 技能联想浮层 -->
|
||||||
<div v-if="skillOpen" class="ai-skill-popover">
|
<div v-if="skillOpen" ref="popRef" class="ai-skill-popover">
|
||||||
<div v-if="!filteredSkills.length" class="ai-skill-empty">
|
<div v-if="!filteredSkills.length" class="ai-skill-empty">
|
||||||
{{ skillQuery.length ? t('aiChat.skillNoMatch') : t('aiChat.skillNotLoaded') }}
|
{{ skillQuery.length ? t('aiChat.skillNoMatch') : t('aiChat.skillNotLoaded') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +57,7 @@ const emit = defineEmits<{
|
|||||||
:key="s.path"
|
:key="s.path"
|
||||||
class="ai-skill-item"
|
class="ai-skill-item"
|
||||||
:class="{ 'ai-skill-item--active': i === skillIndex }"
|
:class="{ 'ai-skill-item--active': i === skillIndex }"
|
||||||
|
:data-skill-idx="i"
|
||||||
@click="emit('select', s)"
|
@click="emit('select', s)"
|
||||||
@mouseenter="emit('update:skillIndex', i)"
|
@mouseenter="emit('update:skillIndex', i)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 无人值守边界配置卡(P1 总装简化):授权面收敛单一档——风险上限固定 Medium、
|
// 无人值守边界配置卡(P1 总装简化):授权面收敛单一档——风险上限固定 Medium、
|
||||||
// 目录白名单不开放(空=不额外扩权,全局 allowed_dirs 照常生效),破坏性命令恒禁止;
|
// 目录白名单不开放(全局路径授权已退役为默认全放行,2026-10-25),破坏性命令恒禁止;
|
||||||
// 可调项只剩预算两项(最大轮次/最长运行时长),各带「不限」勾选(0 直传,后端 0=不限)。
|
// 可调项只剩预算两项(最大轮次/最长运行时长),各带「不限」勾选(0 直传,后端 0=不限)。
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -276,7 +276,8 @@ const echoSummary = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力) */
|
/* 输入区上方的轻量折叠条,视觉克制度对齐 .ai-input-hint(不抢输入框焦点注意力)
|
||||||
|
wave2(2026-10-18):info 系透明彩底范式(授权边界=信息/配置信号,非警示) */
|
||||||
.ai-boundary {
|
.ai-boundary {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -292,7 +293,7 @@ const echoSummary = computed(() => {
|
|||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
border-radius: var(--df-radius-sm);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.ai-boundary-toggle:hover { color: var(--df-text); }
|
.ai-boundary-toggle:hover { color: var(--df-text); }
|
||||||
@@ -301,26 +302,29 @@ const echoSummary = computed(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ai-boundary-chevron.is-open { transform: rotate(90deg); }
|
.ai-boundary-chevron.is-open { transform: rotate(90deg); }
|
||||||
.ai-boundary-toggle-label { font-weight: 600; }
|
/* 标题同色(info)12px 600 */
|
||||||
|
.ai-boundary-toggle-label { font-weight: 600; font-size: 12px; color: var(--df-info); }
|
||||||
.ai-boundary-echo {
|
.ai-boundary-echo {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--df-accent);
|
color: var(--df-info);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.ai-boundary-body {
|
.ai-boundary-body {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 7px;
|
gap: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: color-mix(in srgb, var(--df-accent) 5%, transparent);
|
background: color-mix(in srgb, var(--df-info) 6%, transparent);
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
|
border: 0.5px solid color-mix(in srgb, var(--df-info) 30%, transparent);
|
||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
}
|
}
|
||||||
.ai-boundary-locked {
|
.ai-boundary-locked {
|
||||||
font-size: 10.5px;
|
font-size: 10px;
|
||||||
color: var(--df-warning);
|
color: var(--df-warning);
|
||||||
}
|
}
|
||||||
.ai-boundary-loadfail {
|
.ai-boundary-loadfail {
|
||||||
@@ -328,7 +332,7 @@ const echoSummary = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 10.5px;
|
font-size: 10px;
|
||||||
color: var(--df-danger);
|
color: var(--df-danger);
|
||||||
}
|
}
|
||||||
.ai-boundary-row {
|
.ai-boundary-row {
|
||||||
@@ -350,7 +354,7 @@ const echoSummary = computed(() => {
|
|||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ai-boundary-unlimited input { accent-color: var(--df-accent); margin: 0; }
|
.ai-boundary-unlimited input { accent-color: var(--df-info); margin: 0; }
|
||||||
.ai-boundary-unlimited:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
|
.ai-boundary-unlimited:has(input:disabled) { cursor: not-allowed; opacity: 0.6; }
|
||||||
.ai-boundary-input {
|
.ai-boundary-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -363,7 +367,7 @@ const echoSummary = computed(() => {
|
|||||||
border-radius: calc(var(--df-radius) - 2px);
|
border-radius: calc(var(--df-radius) - 2px);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-accent) 40%, transparent); }
|
.ai-boundary-input:focus { border-color: color-mix(in srgb, var(--df-info) 40%, transparent); }
|
||||||
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ai-boundary-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.ai-boundary-input--num { flex: 0 0 90px; }
|
.ai-boundary-input--num { flex: 0 0 90px; }
|
||||||
.ai-boundary-mini-btn {
|
.ai-boundary-mini-btn {
|
||||||
@@ -413,10 +417,11 @@ const echoSummary = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
/* 一次授权钮:info 系透明底描边钮(对应色操作钮范式) */
|
||||||
.ai-boundary-confirm {
|
.ai-boundary-confirm {
|
||||||
border: none;
|
border: 0.5px solid color-mix(in srgb, var(--df-info) 40%, transparent);
|
||||||
background: var(--df-accent);
|
background: color-mix(in srgb, var(--df-info) 10%, transparent);
|
||||||
color: #fff;
|
color: var(--df-info);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
@@ -424,7 +429,7 @@ const echoSummary = computed(() => {
|
|||||||
border-radius: calc(var(--df-radius) - 2px);
|
border-radius: calc(var(--df-radius) - 2px);
|
||||||
transition: background 0.15s var(--df-ease);
|
transition: background 0.15s var(--df-ease);
|
||||||
}
|
}
|
||||||
.ai-boundary-confirm:hover:not(:disabled) { background: var(--df-accent-hover); }
|
.ai-boundary-confirm:hover:not(:disabled) { background: color-mix(in srgb, var(--df-info) 18%, transparent); }
|
||||||
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ai-boundary-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.ai-boundary-clear {
|
.ai-boundary-clear {
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
@@ -442,7 +447,7 @@ const echoSummary = computed(() => {
|
|||||||
.ai-boundary-field.is-error span { color: var(--df-danger); }
|
.ai-boundary-field.is-error span { color: var(--df-danger); }
|
||||||
/* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */
|
/* A11: 脏编辑提示条(切会话保留编辑时的可见说明) */
|
||||||
.ai-boundary-dirty {
|
.ai-boundary-dirty {
|
||||||
font-size: 10.5px;
|
font-size: 10px;
|
||||||
color: var(--df-warning);
|
color: var(--df-warning);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -99,15 +99,16 @@ async function handleStopArchive(): Promise<void> {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 复用 HelpRequiredCard 样式基调(warning 色系,停靠属"需用户拍板"信号,视觉一致) */
|
/* wave2(2026-10-18):统一透明彩底卡范式,danger 系(非正常终局停靠=需用户拍板信号)——
|
||||||
|
边框 color-mix 30% + 底 8% + 标题同色 12px/600 + 正文 secondary 12px + 透明底描边钮 */
|
||||||
.ai-task-parked {
|
.ai-task-parked {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
background: color-mix(in srgb, var(--df-danger) 8%, transparent);
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
}
|
}
|
||||||
.ai-task-parked-head {
|
.ai-task-parked-head {
|
||||||
@@ -115,37 +116,39 @@ async function handleStopArchive(): Promise<void> {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
/* stop_kind 标签:透明 danger 描边 chip;fatal 连续失败重入耗尽=实底警示级最高 */
|
||||||
.ai-task-parked-tag {
|
.ai-task-parked-tag {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border-radius: calc(var(--df-radius) - 2px);
|
border-radius: calc(var(--df-radius) - 2px);
|
||||||
background: var(--df-warning);
|
background: color-mix(in srgb, var(--df-danger) 12%, transparent);
|
||||||
color: #000;
|
border: 0.5px solid color-mix(in srgb, var(--df-danger) 30%, transparent);
|
||||||
|
color: var(--df-danger);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
.ai-task-parked-tag--fatal {
|
.ai-task-parked-tag--fatal {
|
||||||
background: var(--df-danger);
|
background: var(--df-danger);
|
||||||
|
border-color: var(--df-danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.ai-task-parked-title {
|
.ai-task-parked-title {
|
||||||
font-size: 11.5px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--df-warning);
|
color: var(--df-danger);
|
||||||
}
|
}
|
||||||
.ai-task-parked-reason {
|
.ai-task-parked-reason {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-secondary);
|
||||||
opacity: 0.9;
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
/* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */
|
/* preserved 行与 reason 同色系但更弱一级:保证信息在,不与真实原因抢视觉权重 */
|
||||||
.ai-task-parked-preserved {
|
.ai-task-parked-preserved {
|
||||||
font-size: 10.5px;
|
font-size: 11px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
opacity: 0.8;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
.ai-task-parked-actions {
|
.ai-task-parked-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -155,17 +158,17 @@ async function handleStopArchive(): Promise<void> {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.ai-task-parked-btn {
|
.ai-task-parked-btn {
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
|
border: 0.5px solid color-mix(in srgb, var(--df-danger) 40%, transparent);
|
||||||
background: var(--df-warning);
|
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||||
color: #000;
|
color: var(--df-danger);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: calc(var(--df-radius) - 2px);
|
border-radius: calc(var(--df-radius) - 2px);
|
||||||
transition: filter 0.15s var(--df-ease);
|
transition: background 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
||||||
}
|
}
|
||||||
.ai-task-parked-btn:hover { filter: brightness(1.1); }
|
.ai-task-parked-btn:hover { background: color-mix(in srgb, var(--df-danger) 16%, transparent); }
|
||||||
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ai-task-parked-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.ai-task-parked-btn--muted {
|
.ai-task-parked-btn--muted {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
+155
-1410
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<!-- ── L3 完整上下文面板 ──
|
||||||
|
c1c 拆分自 TopBar.vue,零行为变更。纯展示:数据(info=ContextInfo)由父派生传入,
|
||||||
|
展开态由父持有(五面板互斥)。 -->
|
||||||
|
<div v-if="info.goals.length || info.summaryCount > 0" class="ai-context-inline">
|
||||||
|
<button type="button" class="ai-context-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.viewContext')" :aria-label="$t('aiChat.viewContext')">
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><circle cx="12" cy="8" r="1" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<Transition name="ai-pop">
|
||||||
|
<div v-if="expanded" class="ai-context-inline-list">
|
||||||
|
<!-- 系统提示 -->
|
||||||
|
<div class="ai-context-section">
|
||||||
|
<div class="ai-context-section-title">{{ $t('aiChat.contextSystemPrompt') }}</div>
|
||||||
|
<div class="ai-context-section-body">
|
||||||
|
<div class="ai-context-row">
|
||||||
|
<span class="ai-context-label">{{ $t('aiChat.goalsCount', { n: info.goals.length }) }}</span>
|
||||||
|
<span class="ai-context-value">{{ info.goals.join('; ') || $t('aiChat.none') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-context-row">
|
||||||
|
<span class="ai-context-label">{{ $t('aiChat.contextEnvironment') }}</span>
|
||||||
|
<span class="ai-context-value">{{ info.osInfo }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ai-context-row">
|
||||||
|
<span class="ai-context-label">{{ $t('aiChat.contextCompressedSummary') }}</span>
|
||||||
|
<span class="ai-context-value">{{ info.summaryCount }} {{ $t('aiChat.contextCountUnit') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 增强上下文 -->
|
||||||
|
<div class="ai-context-section">
|
||||||
|
<div class="ai-context-section-title">{{ $t('aiChat.contextEnrichment') }}</div>
|
||||||
|
<div class="ai-context-section-body">
|
||||||
|
<div class="ai-context-row">
|
||||||
|
<span class="ai-context-label">{{ $t('aiChat.projectLabel') }}</span>
|
||||||
|
<span class="ai-context-value">{{ info.projectNames || $t('aiChat.none') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContextInfo } from './types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 完整上下文数据(父 computed:conv.pinned_goals + 摘要计数 + 项目名) */
|
||||||
|
info: ContextInfo
|
||||||
|
/** 面板展开态(父持有,五面板互斥) */
|
||||||
|
expanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 徽章点击(父 togglePanel('context') 做互斥编排) */
|
||||||
|
(e: 'toggle'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
|
||||||
|
.ai-tool-badge {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--df-text);
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: normal;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.ai-tool-badge:focus-visible {
|
||||||
|
outline: 1.5px solid var(--df-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
|
||||||
|
.ai-tool-badge:hover {
|
||||||
|
background: var(--df-bg-card-hover);
|
||||||
|
}
|
||||||
|
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
|
||||||
|
.ai-tool-badge.is-active {
|
||||||
|
color: var(--df-accent);
|
||||||
|
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||||
|
background: var(--df-accent-bg);
|
||||||
|
}
|
||||||
|
/* ═══ L3 完整上下文(底部) ═══ */
|
||||||
|
.ai-context-inline {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ai-context-inline-list {
|
||||||
|
position: absolute;
|
||||||
|
left: auto; right: 0;
|
||||||
|
top: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--df-bg-raised);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.ai-context-section {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
|
}
|
||||||
|
.ai-context-section:last-child { border-bottom: none; }
|
||||||
|
.ai-context-section-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--df-accent);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ai-context-section-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.ai-context-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ai-context-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
.ai-context-value {
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
/* context 面板补 max-height 滚动(原无上限,增强上下文多时同理) */
|
||||||
|
.ai-context-inline-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
|
||||||
|
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
|
||||||
|
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
|
||||||
|
.ai-pop-enter-from,
|
||||||
|
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 🎯 对话目标面板(对话透明化 L1:Goal visibility)。
|
||||||
|
c1c 拆分自 TopBar.vue,零行为变更。目标加载/删除/回写 store 自含;
|
||||||
|
展开态由父持有(五面板互斥),清空目标时经 close 事件请求收起(原 :918 语义)。 -->
|
||||||
|
<div v-if="goals.length" class="ai-goals-inline">
|
||||||
|
<button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': expanded }" @click="emit('toggle')" :title="$t('aiChat.viewGoals')">
|
||||||
|
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||||
|
{{ goals.filter(g => g.status === 'active').length }}/{{ goals.length }}
|
||||||
|
</button>
|
||||||
|
<Transition name="ai-pop">
|
||||||
|
<div v-if="expanded" class="ai-goals-inline-list">
|
||||||
|
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item" :class="{ 'ai-goal-completed': g.status === 'completed' }">
|
||||||
|
<span v-if="g.status === 'active'" class="ai-goal-dot"></span>
|
||||||
|
<span v-else class="ai-goal-check">✓</span>
|
||||||
|
<span class="ai-goal-text">{{ g.text }}</span>
|
||||||
|
<button class="ai-goal-remove" @click.stop="removeGoal(i)" :aria-label="$t('common.delete')">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { aiApi } from '../../../api/ai'
|
||||||
|
import { useAiStore } from '../../../stores/ai'
|
||||||
|
import type { GoalEntry } from '../../../api/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 面板展开态(父持有,五面板互斥) */
|
||||||
|
expanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 徽章点击(父 togglePanel('goals') 做互斥编排) */
|
||||||
|
(e: 'toggle'): void
|
||||||
|
/** 请求收起面板(目标清空时;父置 expanded=false) */
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useAiStore()
|
||||||
|
|
||||||
|
const goals = ref<GoalEntry[]>([])
|
||||||
|
|
||||||
|
/** 监听 activeConversationId 变化,加载目标 */
|
||||||
|
watch(() => store.state.activeConversationId, (convId) => {
|
||||||
|
loadGoals(convId)
|
||||||
|
}, { immediate: true })
|
||||||
|
/** 消息变化后也刷新目标 */
|
||||||
|
watch(() => store.state.messages.length, () => {
|
||||||
|
setTimeout(() => loadGoals(store.state.activeConversationId), 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadGoals(convId: string | null) {
|
||||||
|
if (!convId) { goals.value = []; return }
|
||||||
|
const conv = store.state.conversations.find(c => c.id === convId)
|
||||||
|
// 兼容旧格式(string[]):后端迁移过渡期可能返回纯文本数组
|
||||||
|
const raw = conv?.pinned_goals ?? []
|
||||||
|
goals.value = raw.map((g: any) =>
|
||||||
|
typeof g === 'string' ? { text: g, status: 'active' as const } : g
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除第 i 个目标 */
|
||||||
|
async function removeGoal(i: number) {
|
||||||
|
const convId = store.state.activeConversationId
|
||||||
|
if (!convId) return
|
||||||
|
const prev = goals.value
|
||||||
|
const next = prev.filter((_, idx) => idx !== i)
|
||||||
|
goals.value = next
|
||||||
|
try {
|
||||||
|
// 跨端修复(走查批E):原 next.map(g => g.text) 把 GoalEntry 降级 string[],后端逐条
|
||||||
|
// GoalEntry::new 重置 active,已完成目标(status='completed')的完成态被抹。改传完整
|
||||||
|
// GoalEntry[](text/status 原样透传,snake_case),与后端 ai_update_conversation_goals
|
||||||
|
// 的 Vec<GoalEntry> 反序列化字段一一对应。
|
||||||
|
await aiApi.updateConversationGoals(convId, next)
|
||||||
|
} catch (e) {
|
||||||
|
// 失败回滚乐观删除(对齐 useAiConversations.withConvOp 回写范式),面板不收
|
||||||
|
goals.value = prev
|
||||||
|
console.error('[goals] 更新目标列表失败,已回滚:', e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next.length === 0) emit('close')
|
||||||
|
// UX 走查-对话域 B4:删除后同步回写 store 的 conv.pinned_goals(对齐 AiCompleted 回写
|
||||||
|
// useAiLifecycleEvents:126-129 的结构)。不回写则 loadGoals 从 store 重读旧列表,
|
||||||
|
// 已删目标在下次 messages.length watch 触发刷新时"复活"。
|
||||||
|
const conv = store.state.conversations.find(c => c.id === convId)
|
||||||
|
if (conv) conv.pinned_goals = next
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 底部工具通用徽标(共享工具类,五面板各自带副本) */
|
||||||
|
.ai-tool-badge {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--df-text);
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
/* UX第五轮 P2-T1:span→button 键盘可达后,归零 button 默认样式保持原视觉 */
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: normal;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.ai-tool-badge:focus-visible {
|
||||||
|
outline: 1.5px solid var(--df-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.ai-tool-badge svg { vertical-align: middle; } /* 图标在行高内垂直居中(与数字同基线)*/
|
||||||
|
.ai-tool-badge:hover {
|
||||||
|
background: var(--df-bg-card-hover);
|
||||||
|
}
|
||||||
|
/* 面板激活态徽章(accent 描边,当前展开的面板入口可见) */
|
||||||
|
.ai-tool-badge.is-active {
|
||||||
|
color: var(--df-accent);
|
||||||
|
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||||
|
background: var(--df-accent-bg);
|
||||||
|
}
|
||||||
|
/* 目标计数(底部) */
|
||||||
|
.ai-goals-inline {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ai-goals-inline-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: auto; right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 360px;
|
||||||
|
background: var(--df-bg-raised);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.ai-goal-inline-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.ai-goal-inline-item:hover {
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
}
|
||||||
|
.ai-goal-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-goal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--df-success); flex-shrink: 0; }
|
||||||
|
.ai-goal-check { color: var(--df-success); font-weight: 500; flex-shrink: 0; }
|
||||||
|
.ai-goal-remove { border: none; background: transparent; color: var(--df-text-dim); cursor: pointer; padding: 0 2px; font-size: 13px; line-height: 1; flex-shrink: 0; }
|
||||||
|
.ai-goal-remove:hover {
|
||||||
|
color: var(--df-danger);
|
||||||
|
}
|
||||||
|
/* goals 面板补 max-height 滚动(原无上限,目标多时无限拉高遮对话) */
|
||||||
|
.ai-goals-inline-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
/* ═══ 底部面板 popover 过渡(ux-9d:平滑出入动画,对齐 more-menu popout) ═══ */
|
||||||
|
.ai-pop-enter-active { transition: opacity 0.16s var(--df-ease), transform 0.16s var(--df-ease); }
|
||||||
|
.ai-pop-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
|
||||||
|
.ai-pop-enter-from,
|
||||||
|
.ai-pop-leave-to { opacity: 0; transform: translateY(-4px) scale(0.98); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<!-- TopBar 头部左侧区(侧栏开关 + 助手标题 + 审批角标 + 挂机引擎态徽章)。
|
||||||
|
自 TopBar.vue c1c 拆分迁入,零行为变更;挂机轮询/熔断复位/设置跳转随区块整体迁移。 -->
|
||||||
|
<div class="ai-header-left">
|
||||||
|
<button class="ai-btn-icon" @click="emit('toggle-sidebar')" :title="$t('aiChat.conversationList')" :aria-label="$t('aiChat.conversationList')">
|
||||||
|
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="ai-header-icon">
|
||||||
|
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="ai-header-title">{{ $t('ai.assistant') }}</span>
|
||||||
|
<button
|
||||||
|
v-if="pendingApprovalCount > 0"
|
||||||
|
type="button"
|
||||||
|
class="ai-approval-badge df-badge df-badge--danger"
|
||||||
|
:title="$t('aiChat.pendingApprovalHint')"
|
||||||
|
@click="emit('scroll-to-pending')"
|
||||||
|
>
|
||||||
|
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
|
||||||
|
</button>
|
||||||
|
<!-- 挂机引擎态徽章:开启=「挂机中」(在途附「执行中」),熔断=红徽点击复位;关闭态不显示 -->
|
||||||
|
<button
|
||||||
|
v-if="unattendedStatus?.breaker_tripped"
|
||||||
|
type="button"
|
||||||
|
class="ai-unattended-badge df-badge df-badge--danger"
|
||||||
|
:title="$t('aiChat.unattendedResetHint')"
|
||||||
|
@click="resetUnattendedBreaker"
|
||||||
|
>
|
||||||
|
{{ $t('aiChat.unattendedTripped') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else-if="unattendedStatus?.enabled"
|
||||||
|
type="button"
|
||||||
|
class="ai-unattended-badge df-badge df-badge--accent"
|
||||||
|
:title="$t('aiChat.unattendedBadgeHint')"
|
||||||
|
@click="jumpToUnattendedSettings"
|
||||||
|
>
|
||||||
|
🛌 {{ $t('aiChat.unattendedOn') }}<template v-if="unattendedStatus.in_flight_conv"> · {{ $t('aiChat.unattendedExecuting') }}</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ── 挂机引擎态徽章(P1 总装):消费 unattended_status,开启/熔断时 30s 级轮询刷新 ──
|
||||||
|
// 关闭态不显示徽章,轮询降频到 2 分钟(仍能感知设置页里的开启动作;单次 KV 级纯读,开销可忽略)
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { useAppSettingsStore } from '../../../stores/appSettings'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 待审批数(审批角标显隐 + 计数;父 computed) */
|
||||||
|
pendingApprovalCount: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 折叠/展开会话侧栏(store.toggleSidebar,父转发保持 store 单一调用源) */
|
||||||
|
(e: 'toggle-sidebar'): void
|
||||||
|
/** 点击审批角标滚动到首个待审批(父 scrollToFirstPending) */
|
||||||
|
(e: 'scroll-to-pending'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface UnattendedEngineStatus {
|
||||||
|
enabled: boolean
|
||||||
|
claim_scope: string
|
||||||
|
in_flight_conv: string | null
|
||||||
|
breaker_tripped: boolean
|
||||||
|
poll_secs: number
|
||||||
|
}
|
||||||
|
const UNATTENDED_ACTIVE_MS = 30_000
|
||||||
|
const UNATTENDED_IDLE_MS = 120_000
|
||||||
|
const router = useRouter()
|
||||||
|
const appSettings = useAppSettingsStore()
|
||||||
|
const unattendedStatus = ref<UnattendedEngineStatus | null>(null)
|
||||||
|
let unattendedTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
// 卸载守卫:onBeforeUnmount 清定时器后,在途 invoke 的续延还会回来排下一轮,须拦住防卸载后空转
|
||||||
|
let unattendedDisposed = false
|
||||||
|
async function refreshUnattended() {
|
||||||
|
if (unattendedDisposed) return
|
||||||
|
try {
|
||||||
|
unattendedStatus.value = await invoke<UnattendedEngineStatus>('unattended_status')
|
||||||
|
} catch { /* IPC 失败保留上次状态,下轮重试 */ }
|
||||||
|
if (unattendedDisposed) return
|
||||||
|
const s = unattendedStatus.value
|
||||||
|
const delay = s?.enabled || s?.breaker_tripped ? UNATTENDED_ACTIVE_MS : UNATTENDED_IDLE_MS
|
||||||
|
unattendedTimer = setTimeout(() => void refreshUnattended(), delay)
|
||||||
|
}
|
||||||
|
/** 点击徽章跳设置页挂机区块(集成类首面板;activeCategory 由 appSettings 持久化驱动) */
|
||||||
|
function jumpToUnattendedSettings() {
|
||||||
|
void appSettings.set('df-settings-active-category', 'integrations')
|
||||||
|
void router.push('/settings')
|
||||||
|
}
|
||||||
|
/** 熔断复位:开关 false→true(引擎在关态轮询里清熔断与失败计数,再开即恢复领单) */
|
||||||
|
async function resetUnattendedBreaker() {
|
||||||
|
try {
|
||||||
|
await invoke('unattended_set_enabled', { enabled: false })
|
||||||
|
await invoke('unattended_set_enabled', { enabled: true })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[topbar] 挂机熔断复位失败:', e)
|
||||||
|
}
|
||||||
|
void refreshUnattended()
|
||||||
|
}
|
||||||
|
onMounted(() => { void refreshUnattended() })
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unattendedDisposed = true
|
||||||
|
if (unattendedTimer) { clearTimeout(unattendedTimer); unattendedTimer = null }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ai-header-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
.ai-header-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--df-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
/* 审批徽标(待审批>0 时显于头部,点击跳首个审批卡)
|
||||||
|
2026-10-18 wave2:迁 .df-badge--danger 透明彩底徽章(色由全局公共类接管,此处只留布局) */
|
||||||
|
.ai-approval-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.15s var(--df-ease);
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
.ai-approval-badge:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.ai-approval-badge:focus-visible {
|
||||||
|
outline: 1.5px solid var(--df-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
/* 挂机引擎态徽章(开启=accent / 熔断=danger,均由 df-badge 变体接管,此处只留布局) */
|
||||||
|
.ai-unattended-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
appearance: none;
|
||||||
|
transition: opacity 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.ai-unattended-badge:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
/* ── 图标按钮(共享工具类):wave2 样式仅存在于原 TopBar scoped(全局 components.css
|
||||||
|
副本为抽取时旧样式,逐字不一致),拆分子件各自带本副本防视觉回归 ── */
|
||||||
|
.ai-btn-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-md);
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s var(--df-ease), color 0.15s var(--df-ease), background 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.ai-btn-icon:hover {
|
||||||
|
border-color: var(--df-border-strong);
|
||||||
|
background: var(--df-bg-card-hover);
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
}
|
||||||
|
.ai-btn-icon--active {
|
||||||
|
border-color: color-mix(in srgb, var(--df-accent) 45%, transparent);
|
||||||
|
background: var(--df-accent-bg);
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
.ai-btn-icon:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ai-btn-icon:disabled:hover {
|
||||||
|
border-color: var(--df-border);
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user