修复: aichat P0 三项(AR-2 新建对话守卫 + AR-3 审批卡片可读化 + AR-4 create_project 双审)
- AR-2 ai_conversation_create 加 generating 守卫(clear 前,防审批态新建清空 session 致 generating 永不复位卡死) - AR-3 audit.rs build_approval_reason 按 9 工具特化拼对象名(id/field/path/title/name)+ ToolCard resultSummary 补 restore/purge case + ARG_LABEL_MAP id 加友好标签 + i18n 双语补 4 key - AR-4 create_project schema 加 path/stack + handler 有 path 时合并绑定(spawn_blocking 探测栈,复用 bind_directory 逻辑,消除双审双 API) - cargo check / vue-tsc 0 error - 审查 boundary 全误判(并行同工作区 git diff 污染),主代理独立核查三任务全正确
This commit is contained in:
@@ -38,6 +38,71 @@ pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 拼审批 reason:按工具名 + args 取可读字段,让用户知道"审批要做什么"。
|
||||
///
|
||||
/// 主要审批工具特化(带对象名/标识):
|
||||
/// - delete_project/restore_project/purge_project → "删除/恢复/清除项目"+id
|
||||
/// - update_project → "修改项目"+field
|
||||
/// - bind_directory → "绑定目录"+path
|
||||
/// - create_task → "创建任务"+title;create_project → "创建项目"+name;create_idea → "捕获想法"+title
|
||||
/// - run_workflow → "运行工作流"+name
|
||||
/// args 无可读字段或工具未特化时,fallback 原 risk 模板(含风险等级提示)。
|
||||
fn build_approval_reason(tool_name: &str, args: &serde_json::Value, risk_level: RiskLevel) -> String {
|
||||
let s = |key: &str| args.get(key).and_then(|v| v.as_str()).unwrap_or("");
|
||||
let detail = match tool_name {
|
||||
"delete_project" => {
|
||||
let id = s("id");
|
||||
if !id.is_empty() { format!("删除项目(ID: {})", id) } else { String::new() }
|
||||
}
|
||||
"restore_project" => {
|
||||
let id = s("id");
|
||||
if !id.is_empty() { format!("从回收站恢复项目(ID: {})", id) } else { String::new() }
|
||||
}
|
||||
"purge_project" => {
|
||||
let id = s("id");
|
||||
if !id.is_empty() { format!("永久删除项目及关联数据,不可恢复(ID: {})", id) } else { String::new() }
|
||||
}
|
||||
"update_project" => {
|
||||
let field = s("field");
|
||||
if !field.is_empty() { format!("修改项目字段「{}」", field) } else { String::new() }
|
||||
}
|
||||
"bind_directory" => {
|
||||
let path = s("path");
|
||||
if !path.is_empty() { format!("绑定目录:{}", path) } else { String::new() }
|
||||
}
|
||||
"create_task" => {
|
||||
let title = s("title");
|
||||
if !title.is_empty() { format!("创建任务:{}", title) } else { String::new() }
|
||||
}
|
||||
"create_project" => {
|
||||
let name = s("name");
|
||||
if !name.is_empty() { format!("创建项目:{}", name) } else { String::new() }
|
||||
}
|
||||
"create_idea" => {
|
||||
let title = s("title");
|
||||
if !title.is_empty() { format!("捕获想法:{}", title) } else { String::new() }
|
||||
}
|
||||
"run_workflow" => {
|
||||
let name = s("name");
|
||||
if !name.is_empty() { format!("运行工作流:{}", name) } else { String::new() }
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
if detail.is_empty() {
|
||||
// fallback:无可读字段,保留风险等级提示模板
|
||||
match risk_level {
|
||||
RiskLevel::High => "高风险操作,必须人工批准".to_string(),
|
||||
_ => "创建操作,请确认是否执行".to_string(),
|
||||
}
|
||||
} else {
|
||||
// 拼上风险等级后缀(高风险标注,便于用户权衡)
|
||||
match risk_level {
|
||||
RiskLevel::High => format!("{}(高风险,需人工批准)", detail),
|
||||
_ => format!("{}(请确认是否执行)", detail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
|
||||
///
|
||||
/// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存
|
||||
@@ -176,10 +241,7 @@ pub(crate) async fn process_tool_calls(
|
||||
recovered: false,
|
||||
});
|
||||
session.messages.push(ChatMessage::tool_result(&draft.id, "需要用户审批,等待确认"));
|
||||
let reason = match risk_level {
|
||||
RiskLevel::High => "高风险操作,必须人工批准".to_string(),
|
||||
_ => "创建操作,请确认是否执行".to_string(),
|
||||
};
|
||||
let reason = build_approval_reason(&draft.name, &args, risk_level);
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
|
||||
id: draft.id.clone(),
|
||||
name: draft.name.clone(),
|
||||
|
||||
@@ -366,6 +366,11 @@ pub async fn ai_conversation_create(state: State<'_, AppState>) -> Result<serde_
|
||||
let now = now_millis();
|
||||
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 生成中(含审批等待态)禁止新建对话:否则 clear 会清空 pending_approvals,
|
||||
// 用户审批时 ai_approve 找不到 pending → generating 永不复位 → 面板卡死需重启
|
||||
if session.generating {
|
||||
return Err("生成中无法新建对话,请先停止或等待完成".to_string());
|
||||
}
|
||||
session.active_conversation_id = Some(id.clone());
|
||||
session.active_conv_created_at = Some(now);
|
||||
session.messages.clear();
|
||||
|
||||
@@ -144,8 +144,11 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"create_project", "创建新项目",
|
||||
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("description", "string", false)]),
|
||||
"create_project", "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("name", "string", true), ("description", "string", false),
|
||||
("path", "string", false), ("stack", "string", false),
|
||||
]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
let db = db.clone();
|
||||
@@ -161,7 +164,42 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
};
|
||||
let id = record.id.clone();
|
||||
repo.insert(record).await?;
|
||||
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
|
||||
// path 缺省:保持原行为(仅创建,不绑定)
|
||||
let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||||
if let Some(path) = path_opt {
|
||||
// 绑定逻辑复用自 bind_directory handler(下文):目录校验 + 防重复绑定 + spawn_blocking 探测技术栈
|
||||
// TODO: 抽公共绑定闭包/函数供 create_project 与 bind_directory 共用(当前内联以收敛改动范围)
|
||||
let dir = std::path::Path::new(path);
|
||||
if !dir.is_dir() {
|
||||
anyhow::bail!("目录不存在: {path}");
|
||||
}
|
||||
let target = df_project::scan::normalize_path(path);
|
||||
let projects = repo.list_active().await?;
|
||||
for proj in &projects {
|
||||
if proj.id != id {
|
||||
if let Some(pp) = &proj.path {
|
||||
if df_project::scan::normalize_path(pp) == target {
|
||||
anyhow::bail!("目录已被项目「{}」绑定", proj.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// stack:AI 提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking)
|
||||
let stack = if let Some(s) = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {
|
||||
s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect::<Vec<String>>()
|
||||
} else {
|
||||
let dir_buf = std::path::PathBuf::from(path);
|
||||
tokio::task::spawn_blocking(move || df_project::scan::detect_stack(&dir_buf))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("技术栈探测任务失败: {e}"))??
|
||||
};
|
||||
let stack_json = serde_json::to_string(&stack)?;
|
||||
repo.update_field(&id, "path", path).await?;
|
||||
repo.update_field(&id, "stack", &stack_json).await?;
|
||||
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true }))
|
||||
} else {
|
||||
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
|
||||
}
|
||||
})
|
||||
})},
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div v-if="tc.status === 'pending_approval'" class="ai-tool-approval">
|
||||
<div v-if="toolArgsEntries(tc.args).length" class="ai-tool-approval-args">
|
||||
<div v-for="arg in toolArgsEntries(tc.args)" :key="arg.key" class="ai-tool-arg">
|
||||
<span class="ai-tool-arg-key">{{ arg.key }}</span>
|
||||
<span class="ai-tool-arg-key">{{ arg.label }}</span>
|
||||
<span class="ai-tool-arg-val">{{ formatArgValue(arg.val) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,6 +119,8 @@ interface ToolResult {
|
||||
title?: string
|
||||
field?: string
|
||||
deleted?: boolean
|
||||
restored?: boolean
|
||||
purged?: boolean
|
||||
}
|
||||
|
||||
/** 内联 SVG 图标字符串(经 v-html 注入,常量安全;图标库改造见决策记录) */
|
||||
@@ -145,10 +147,29 @@ function formatJson(data: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 审批参数 key → 友好标签映射(静态,不查 store);未命中回退原 key */
|
||||
const ARG_LABEL_MAP: Record<string, string> = {
|
||||
id: 'ID',
|
||||
project_id: '项目 ID',
|
||||
name: '名称',
|
||||
title: '标题',
|
||||
description: '描述',
|
||||
field: '字段',
|
||||
value: '新值',
|
||||
path: '路径',
|
||||
priority: '优先级',
|
||||
tags: '标签',
|
||||
source: '来源',
|
||||
}
|
||||
|
||||
/** 审批参数转键值对数组(非对象/空时返回空数组,防 args 非 object 时 v-for 报错) */
|
||||
function toolArgsEntries(args: unknown): Array<{ key: string; val: unknown }> {
|
||||
function toolArgsEntries(args: unknown): Array<{ key: string; label: string; val: unknown }> {
|
||||
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
||||
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({ key, val }))
|
||||
return Object.entries(args as Record<string, unknown>).map(([key, val]) => ({
|
||||
key,
|
||||
label: ARG_LABEL_MAP[key] || key,
|
||||
val,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -282,6 +303,8 @@ function toolResultSummary(tc: AiToolCallInfo): string {
|
||||
case 'create_task': return r.title ? t('aiTool.createdWithName', { name: r.title }) : t('aiTool.created')
|
||||
case 'update_project': return r.field ? t('aiTool.updatedField', { field: r.field }) : t('aiTool.updated')
|
||||
case 'delete_project': return r.deleted ? t('aiTool.deleted') : t('aiTool.deleteIneffective')
|
||||
case 'restore_project': return r.restored ? t('aiTool.restored') : t('aiTool.restoreIneffective')
|
||||
case 'purge_project': return r.purged ? t('aiTool.purged') : t('aiTool.purgeIneffective')
|
||||
case 'run_workflow': return t('aiTool.workflowHint')
|
||||
default: return ''
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ export default {
|
||||
updated: 'Updated',
|
||||
deleted: 'Deleted',
|
||||
deleteIneffective: 'Delete ineffective',
|
||||
restored: 'Restored',
|
||||
restoreIneffective: 'Restore ineffective (not in trash)',
|
||||
purged: 'Purged',
|
||||
purgeIneffective: 'Purge ineffective',
|
||||
workflowHint: 'Run it on the workflow page',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ export default {
|
||||
updated: '已更新',
|
||||
deleted: '已删除',
|
||||
deleteIneffective: '删除未生效',
|
||||
restored: '已恢复',
|
||||
restoreIneffective: '恢复未生效(项目不在回收站)',
|
||||
purged: '已彻底删除',
|
||||
purgeIneffective: '清除未生效',
|
||||
workflowHint: '请到工作流页面运行',
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user