重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符 串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参, 增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
This commit is contained in:
@@ -343,6 +343,12 @@ pub const TOOL_RESULT_HEAD_LINES: usize = 5;
|
|||||||
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
|
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
|
||||||
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
|
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
|
||||||
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
|
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
|
||||||
|
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
|
||||||
|
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
|
||||||
|
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
|
||||||
|
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
|
||||||
|
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
|
||||||
|
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
|
||||||
|
|
||||||
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
|
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
|
||||||
///
|
///
|
||||||
@@ -376,11 +382,12 @@ pub fn should_summarize_tool_result(
|
|||||||
///
|
///
|
||||||
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
|
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
|
||||||
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||||
// JSON 感知压缩:识别对象中的大数组并截断
|
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
||||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
||||||
if let Some(obj) = val.as_object_mut() {
|
if let Some(obj) = val.as_object_mut() {
|
||||||
let mut truncated = false;
|
let mut truncated = false;
|
||||||
for (_key, field) in obj.iter_mut() {
|
for (_key, field) in obj.iter_mut() {
|
||||||
|
// 数组截断
|
||||||
if let Some(arr) = field.as_array() {
|
if let Some(arr) = field.as_array() {
|
||||||
if arr.len() > TOOL_RESULT_MAX_ARRAY {
|
if arr.len() > TOOL_RESULT_MAX_ARRAY {
|
||||||
*field = serde_json::Value::Array(
|
*field = serde_json::Value::Array(
|
||||||
@@ -389,16 +396,40 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
|||||||
truncated = true;
|
truncated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 字符串字段:先按行数截断,若不足再按字符数截断
|
||||||
|
if let Some(s) = field.as_str() {
|
||||||
|
let lines: Vec<&str> = s.lines().collect();
|
||||||
|
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||||
|
if lines.len() > kept {
|
||||||
|
let mut out: Vec<&str> = Vec::new();
|
||||||
|
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
|
||||||
|
out.push("... (压缩中间内容) ...");
|
||||||
|
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
|
||||||
|
*field = serde_json::Value::String(out.join("\n"));
|
||||||
|
truncated = true;
|
||||||
|
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
|
||||||
|
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||||
|
// 按字符数截断保留头尾,保证压缩至少生效。
|
||||||
|
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
|
||||||
|
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
|
||||||
|
*field = serde_json::Value::String(format!(
|
||||||
|
"{}...(截断,原始 {} 字符)...{}",
|
||||||
|
head, s.chars().count(), tail
|
||||||
|
));
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if truncated {
|
if truncated {
|
||||||
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
|
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
|
||||||
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
|
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// JSON 解析成功但无数组超限 → 原样返回(非 JSON 的走下行行数截断)
|
// JSON 解析成功但无需截断 → 原样返回
|
||||||
// 注意:不 return,继续行数判断(如 read_file 的 content 字段虽在 JSON 内但实际包含文件全文,行数压缩仍有价值)
|
return content.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非 JSON 纯文本:按行数截断
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
if lines.is_empty() {
|
if lines.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
@@ -408,6 +439,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
|||||||
let total = lines.len();
|
let total = lines.len();
|
||||||
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||||
if total <= kept_boundary {
|
if total <= kept_boundary {
|
||||||
|
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||||
|
// 按字符数截断保证压缩至少生效。
|
||||||
|
let char_count = content.chars().count();
|
||||||
|
if char_count > TOOL_RESULT_CHAR_LIMIT {
|
||||||
|
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
|
||||||
|
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
|
||||||
|
return format!(
|
||||||
|
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
|
||||||
|
tool_name, char_count, head, tail
|
||||||
|
);
|
||||||
|
}
|
||||||
return content.to_string();
|
return content.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,18 +944,35 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_key_info_single_line_no_newline_unchanged() {
|
fn extract_key_info_single_line_short_no_newline_unchanged() {
|
||||||
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回
|
// 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
|
||||||
let content = "single line no newline";
|
let content = "single line no newline";
|
||||||
assert_eq!(extract_key_info(content, "read_file"), content);
|
assert_eq!(extract_key_info(content, "read_file"), content);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_key_info_single_huge_line_no_newline_unchanged() {
|
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||||||
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分)
|
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||||
let content = "x".repeat(50_000);
|
let content = "x".repeat(50_000);
|
||||||
let result = extract_key_info(&content, "read_file");
|
let result = extract_key_info(&content, "read_file");
|
||||||
assert_eq!(result, content, "单行无换行应原样返回(即使超长)");
|
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||||||
|
assert!(result.contains("已压缩"), "应含压缩标记");
|
||||||
|
assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
|
||||||
|
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
|
||||||
|
assert!(result.contains("(截断)"), "应含截断标记");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_key_info_json_huge_string_field_truncated() {
|
||||||
|
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||||
|
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||||||
|
let large = "z".repeat(10_000);
|
||||||
|
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||||||
|
let result = extract_key_info(&content, "read_file");
|
||||||
|
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
|
||||||
|
assert!(result.contains("_truncated"), "应含 _truncated 标记");
|
||||||
|
assert!(result.contains("src/main.rs"), "应保留 path 字段");
|
||||||
|
assert!(result.contains("(截断)"), "应含截断标记");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
|||||||
pinned: row.get::<_, i32>("pinned")? != 0,
|
pinned: row.get::<_, i32>("pinned")? != 0,
|
||||||
prompt_tokens: row.get("prompt_tokens")?,
|
prompt_tokens: row.get("prompt_tokens")?,
|
||||||
completion_tokens: row.get("completion_tokens")?,
|
completion_tokens: row.get("completion_tokens")?,
|
||||||
|
pinned_goals: row.get("pinned_goals")?,
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
})
|
})
|
||||||
@@ -159,24 +160,24 @@ impl_repo!(
|
|||||||
from_row => |row| ai_conversation_from_row(row),
|
from_row => |row| ai_conversation_from_row(row),
|
||||||
insert => |conn, rec| {
|
insert => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at)
|
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, created_at, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||||
params![
|
params![
|
||||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||||
if rec.pinned { 1i32 } else { 0i32 },
|
if rec.pinned { 1i32 } else { 0i32 },
|
||||||
rec.prompt_tokens, rec.completion_tokens,
|
rec.prompt_tokens, rec.completion_tokens,
|
||||||
rec.created_at, rec.updated_at
|
rec.pinned_goals, rec.created_at, rec.updated_at
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
update => |conn, rec| {
|
update => |conn, rec| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11",
|
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, updated_at = ?11 WHERE id = ?12",
|
||||||
params![
|
params![
|
||||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||||
if rec.pinned { 1i32 } else { 0i32 },
|
if rec.pinned { 1i32 } else { 0i32 },
|
||||||
rec.prompt_tokens, rec.completion_tokens,
|
rec.prompt_tokens, rec.completion_tokens,
|
||||||
rec.updated_at, rec.id
|
rec.pinned_goals, rec.updated_at, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
|
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
|
||||||
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
|
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
|
||||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [
|
let steps: [(i32, fn(&Connection) -> Result<()>); 32] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -75,6 +75,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(29, migrate_v29),
|
(29, migrate_v29),
|
||||||
(30, migrate_v30),
|
(30, migrate_v30),
|
||||||
(31, migrate_v31),
|
(31, migrate_v31),
|
||||||
|
(32, migrate_v32),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -902,6 +903,20 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
||||||
|
///
|
||||||
|
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
|
||||||
|
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
||||||
|
fn migrate_v32(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';"
|
||||||
|
)?;
|
||||||
|
tracing::info!("v32: ai_conversations 加 pinned_goals 列");
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?;
|
||||||
|
tracing::info!("迁移 v32 完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||||
///
|
///
|
||||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ pub struct AiConversationRecord {
|
|||||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||||
|
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -529,7 +529,6 @@ pub async fn ai_approve(
|
|||||||
tool_call_id: String,
|
tool_call_id: String,
|
||||||
approved: bool,
|
approved: bool,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
authz_debug(&format!("[ai_approve] 入口 tool_call_id={} approved={}", tool_call_id, approved));
|
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
|
|
||||||
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
||||||
@@ -756,13 +755,7 @@ pub async fn ai_approve(
|
|||||||
|
|
||||||
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
|
/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
|
||||||
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。
|
||||||
fn authz_debug(msg: &str) {
|
|
||||||
use std::io::Write;
|
|
||||||
let log_path = std::env::temp_dir().join("devflow-authz-debug.log");
|
|
||||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log_path) {
|
|
||||||
let _ = writeln!(f, "{}", msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。
|
/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。
|
||||||
///
|
///
|
||||||
@@ -852,7 +845,6 @@ pub async fn ai_authorize_dir(
|
|||||||
decision = %decision,
|
decision = %decision,
|
||||||
"[AI-AUTHZ] ai_authorize_dir 入口"
|
"[AI-AUTHZ] ai_authorize_dir 入口"
|
||||||
);
|
);
|
||||||
authz_debug(&format!("[ai_authorize_dir] 入口 tool_call_id={} decision={}", tool_call_id, decision));
|
|
||||||
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
|
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
|
||||||
let approval = {
|
let approval = {
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
|
|||||||
@@ -221,12 +221,17 @@ pub async fn ai_conversation_list(
|
|||||||
let models: Vec<String> = r.models.as_deref()
|
let models: Vec<String> = r.models.as_deref()
|
||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
let pinned_goals: Vec<String> = r.pinned_goals
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"title": r.title,
|
"title": r.title,
|
||||||
"provider_id": r.provider_id,
|
"provider_id": r.provider_id,
|
||||||
"model": r.model,
|
"model": r.model,
|
||||||
"models": models,
|
"models": models,
|
||||||
|
"pinned_goals": pinned_goals,
|
||||||
"archived": r.archived,
|
"archived": r.archived,
|
||||||
"pinned": r.pinned,
|
"pinned": r.pinned,
|
||||||
"prompt_tokens": r.prompt_tokens,
|
"prompt_tokens": r.prompt_tokens,
|
||||||
@@ -311,6 +316,12 @@ pub async fn ai_conversation_switch(
|
|||||||
conv.agent_language = None;
|
conv.agent_language = None;
|
||||||
conv.iteration_used = 0;
|
conv.iteration_used = 0;
|
||||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||||
|
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
||||||
|
let pinned_goals: Vec<String> = record.pinned_goals
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
conv.pinned_goals = pinned_goals;
|
||||||
}
|
}
|
||||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||||
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
|
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
|
||||||
@@ -439,6 +450,30 @@ pub async fn ai_conversation_set_pinned(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)
|
||||||
|
///
|
||||||
|
/// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。
|
||||||
|
/// goals 是完整替换(非增量),前端增/删后传全量 new Vec。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ai_update_conversation_goals(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
conversation_id: String,
|
||||||
|
goals: Vec<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
||||||
|
// 写 DB
|
||||||
|
state.ai_conversations
|
||||||
|
.update_field(&conversation_id, "pinned_goals", &goals_json)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
// 同步内存 per_conv(若已加载)
|
||||||
|
let mut session = state.ai_session.lock().await;
|
||||||
|
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
|
||||||
|
conv.pinned_goals = goals;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 导出对话为指定格式(UX-18:对话导出)
|
/// 导出对话为指定格式(UX-18:对话导出)
|
||||||
///
|
///
|
||||||
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
|
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ pub(crate) async fn save_conversation(
|
|||||||
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
|
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
|
||||||
// conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
|
// conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
|
||||||
// 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
|
// 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
|
||||||
let (persist_msgs, provider_id, created_at) = {
|
let (persist_msgs, provider_id, created_at, pinned_goals) = {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
||||||
for m in &mut msgs {
|
for m in &mut msgs {
|
||||||
@@ -185,6 +185,7 @@ pub(crate) async fn save_conversation(
|
|||||||
msgs,
|
msgs,
|
||||||
session.active_provider_id.clone(),
|
session.active_provider_id.clone(),
|
||||||
session.active_conv_created_at.clone(),
|
session.active_conv_created_at.clone(),
|
||||||
|
session.conv(conv_id).pinned_goals.clone(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,6 +225,8 @@ pub(crate) async fn save_conversation(
|
|||||||
if !list.iter().any(|x| x == m) { list.push(m.to_string()); }
|
if !list.iter().any(|x| x == m) { list.push(m.to_string()); }
|
||||||
rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string()));
|
rec.models = Some(serde_json::to_string(&list).unwrap_or_else(|_| "[]".to_string()));
|
||||||
}
|
}
|
||||||
|
// G1 目标钉扎持久化:将 per_conv.pinned_goals 写入 DB
|
||||||
|
rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
|
||||||
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
|
// update_full 仍写 messages 列(保留旧值,本批不改 messages 字段),写元数据 + updated_at
|
||||||
if let Err(e) = conv_repo.update_full(&rec).await {
|
if let Err(e) = conv_repo.update_full(&rec).await {
|
||||||
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
|
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
|
||||||
@@ -250,6 +253,7 @@ pub(crate) async fn save_conversation(
|
|||||||
pinned: false,
|
pinned: false,
|
||||||
prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
|
prompt_tokens: usage.map(|u| u.prompt_tokens as i64),
|
||||||
completion_tokens: usage.map(|u| u.completion_tokens as i64),
|
completion_tokens: usage.map(|u| u.completion_tokens as i64),
|
||||||
|
pinned_goals: Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())),
|
||||||
created_at: conv_created.clone(),
|
created_at: conv_created.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ pub fn run() {
|
|||||||
commands::ai::ai_conversation_rename,
|
commands::ai::ai_conversation_rename,
|
||||||
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_conversation_export,
|
commands::ai::ai_conversation_export,
|
||||||
commands::ai::ai_list_skills,
|
commands::ai::ai_list_skills,
|
||||||
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
|
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
|
||||||
|
|||||||
@@ -301,11 +301,19 @@ export const aiApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。
|
/** 导出对话为指定格式(UX-18:对话导出;消费 batch46 ai_conversation_export IPC)。
|
||||||
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
|
* 后端返回导出内容 String(markdown/json/txt),前端落 Blob 下载或复制剪贴板。
|
||||||
* @param format 'markdown' | 'json' | 'txt'
|
* @param format 'markdown' | 'json' | 'txt'
|
||||||
*/
|
*/
|
||||||
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
|
exportConversation(conversationId: string, format: 'markdown' | 'json' | 'txt'): Promise<string> {
|
||||||
return invoke('ai_conversation_export', { conversationId, format })
|
return invoke('ai_conversation_export', { conversationId, format })
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)。
|
||||||
|
* 接收完整目标列表(非增量,前端增/删后传全量 new Vec)。
|
||||||
|
*/
|
||||||
|
updateConversationGoals(conversationId: string, goals: string[]): Promise<void> {
|
||||||
|
return invoke('ai_update_conversation_goals', { conversationId, goals })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -572,6 +572,7 @@ export interface AiConversationSummary {
|
|||||||
title: string | null
|
title: string | null
|
||||||
provider_id: string | null
|
provider_id: string | null
|
||||||
model: string | null
|
model: string | null
|
||||||
|
pinned_goals?: string[]
|
||||||
archived: boolean
|
archived: boolean
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
prompt_tokens?: number | null
|
prompt_tokens?: number | null
|
||||||
|
|||||||
@@ -54,21 +54,21 @@
|
|||||||
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
<div v-if="tc.kind === 'path'" class="ai-tool-actions ai-tool-actions--path">
|
||||||
<button class="ai-tool-btn ai-tool-btn--approve"
|
<button class="ai-tool-btn ai-tool-btn--approve"
|
||||||
:disabled="approving"
|
:disabled="approving"
|
||||||
@click="onAuthorize('once')">
|
@click="onAuthorizeOnce">
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{{ $t('aiChat.dirAuthOnce') }}
|
{{ $t('aiChat.dirAuthOnce') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ai-tool-btn ai-tool-btn--always"
|
<button class="ai-tool-btn ai-tool-btn--always"
|
||||||
:disabled="approving"
|
:disabled="approving"
|
||||||
@click="onAuthorize('always')">
|
@click="onAuthorizeAlways">
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{{ $t('aiChat.dirAuthAlways') }}
|
{{ $t('aiChat.dirAuthAlways') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||||
:disabled="approving"
|
:disabled="approving"
|
||||||
@click="onAuthorize('deny')">
|
@click="onDeny">
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
{{ $t('aiChat.dirAuthDeny') }}
|
{{ $t('aiChat.dirAuthDeny') }}
|
||||||
@@ -77,14 +77,14 @@
|
|||||||
<div v-else class="ai-tool-actions">
|
<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(true)">
|
@click="onApprove">
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{{ $t('aiTool.approve') }}
|
{{ $t('aiTool.approve') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||||
:disabled="approving"
|
:disabled="approving"
|
||||||
@click="onApprove(false)">
|
@click="onReject">
|
||||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
{{ $t('aiTool.reject') }}
|
{{ $t('aiTool.reject') }}
|
||||||
@@ -124,10 +124,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, reactive, watch, onBeforeUnmount } from 'vue'
|
import { computed, toRef } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
|
||||||
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
|
|
||||||
import {
|
import {
|
||||||
shouldKeepOpen,
|
shouldKeepOpen,
|
||||||
parseResult,
|
parseResult,
|
||||||
@@ -137,6 +135,7 @@ import {
|
|||||||
} from '@/composables/ai/useToolCard'
|
} from '@/composables/ai/useToolCard'
|
||||||
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
import { parseDiffLines } from '@/composables/ai/useToolCardRender'
|
||||||
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
import { useToolCardHeader } from '@/composables/ai/useToolCardHeader'
|
||||||
|
import { useToolApproval } from '@/composables/ai/useToolApproval'
|
||||||
import type { AiToolCallInfo } from '@/api/types'
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
import ToolResultBody from './ToolResultBody.vue'
|
import ToolResultBody from './ToolResultBody.vue'
|
||||||
@@ -165,141 +164,24 @@ const emit = defineEmits<{
|
|||||||
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
|
approve: [{ id: string; approved: boolean; decision?: 'once' | 'always' | 'deny' }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// AE-2025-05:本卡自治二次确认状态机(每张 ToolCard 各持一份 confirmState,与 AiChat 父级隔离)。
|
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
|
||||||
|
|
||||||
// 头部显示逻辑(工具名/审批参数语义化回显)抽离至 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 态(B-260616-08)。点击后置 true 禁用两按钮防重复点击 + spinner;
|
const {
|
||||||
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。兜底计时到点复位 + toast 提示
|
approving,
|
||||||
* (对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
|
approveToast,
|
||||||
*/
|
waitSecs,
|
||||||
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
|
waitLevel,
|
||||||
const approving = ref(false)
|
formatWaitTime,
|
||||||
let approvingTimer: ReturnType<typeof setTimeout> | null = null
|
onApprove,
|
||||||
|
onReject,
|
||||||
// ── 审批挂起时长显示(BUG-260624-02) ──
|
onAuthorizeOnce,
|
||||||
// 背景:AI 调用需审批的工具(patch_file/write_file 等)后,ToolCard 弹审批卡常驻
|
onAuthorizeAlways,
|
||||||
// 直到用户批/拒。用户可能走开忘了批,卡片无任何时长提示。
|
onDeny,
|
||||||
// 方案选型(A/B/C 三选一,选 C):
|
confirmState,
|
||||||
// A 不做(删 todo)——挂起审批容易被遗忘,AI loop 卡死
|
answerConfirm,
|
||||||
// B 加超时+倒计时——后端加 timer + 超时后行为难定义(自动拒=丢改动/自动批=危险)
|
} = useToolApproval(toRef(props, 'tc'), emit)
|
||||||
// C 显示挂起时长(本方案)——纯前端展示,不动后端,不改审批行为
|
|
||||||
// 实现:进入 pending_approval 时记录起始时刻,每秒刷新 waitSecs。
|
|
||||||
// >2分钟变橙色,>5分钟变红色,提示用户尽快审批。
|
|
||||||
const pendingSince = ref<number | null>(null)
|
|
||||||
const waitSecs = ref(0)
|
|
||||||
let waitTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
function startPendingTimer() {
|
|
||||||
if (pendingSince.value !== null) return
|
|
||||||
pendingSince.value = Date.now()
|
|
||||||
waitSecs.value = 0
|
|
||||||
if (waitTimer) clearInterval(waitTimer)
|
|
||||||
waitTimer = setInterval(() => {
|
|
||||||
if (pendingSince.value !== null) {
|
|
||||||
waitSecs.value = Math.floor((Date.now() - pendingSince.value) / 1000)
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
function stopPendingTimer() {
|
|
||||||
if (waitTimer) { clearInterval(waitTimer); waitTimer = null }
|
|
||||||
pendingSince.value = null
|
|
||||||
waitSecs.value = 0
|
|
||||||
}
|
|
||||||
function formatWaitTime(secs: number): string {
|
|
||||||
const m = Math.floor(secs / 60)
|
|
||||||
const s = secs % 60
|
|
||||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
|
|
||||||
if (waitSecs.value >= 300) return 'urgent' // 5分钟
|
|
||||||
if (waitSecs.value >= 120) return 'long' // 2分钟
|
|
||||||
return 'normal'
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── 审批超时兜底 toast(本卡自管,分离窗口运行时 App 根 toast 不在 DOM) ──
|
|
||||||
const approveToast = reactive({ visible: false, msg: '' })
|
|
||||||
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
function showApproveTimeoutToast(): void {
|
|
||||||
approveToast.msg = t('aiTool.approveLoadingTimeout')
|
|
||||||
approveToast.visible = true
|
|
||||||
if (_approveToastTimer) clearTimeout(_approveToastTimer)
|
|
||||||
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
|
|
||||||
const HIGH_RISK_TOOLS = new Set<string>([
|
|
||||||
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
|
||||||
'http_request',
|
|
||||||
])
|
|
||||||
|
|
||||||
/** High 风险工具的二次确认文案(按操作类型) */
|
|
||||||
function highRiskConfirmMsg(name: string): string {
|
|
||||||
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
|
||||||
if (name === 'http_request') return t('aiTool.confirmHighHttp')
|
|
||||||
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
|
||||||
return t('aiTool.confirmHighDelete')
|
|
||||||
}
|
|
||||||
return t('aiTool.confirmHighGeneric')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onApprove(approved: boolean) {
|
|
||||||
if (approving.value) return // 防重入
|
|
||||||
// AE-2025-05:High 风险 + 批准 → 先二次确认;拒绝不再二次确认(无害,加确认反增摩擦)
|
|
||||||
if (approved && HIGH_RISK_TOOLS.has(props.tc.name)) {
|
|
||||||
const ok = await confirmDialog(highRiskConfirmMsg(props.tc.name))
|
|
||||||
if (!ok) return
|
|
||||||
}
|
|
||||||
approving.value = true
|
|
||||||
approvingTimer = setTimeout(() => {
|
|
||||||
approving.value = false
|
|
||||||
approvingTimer = null
|
|
||||||
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
|
|
||||||
showApproveTimeoutToast()
|
|
||||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
|
||||||
emit('approve', { id: props.tc.id, approved })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* path_auth 审批链阶段3b:path 类(路径授权挂起)三选项处理。
|
|
||||||
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
|
|
||||||
* 与 onApprove 同款 loading + 超时兜底,但 emit 带 decision(approved=false 仅占位,
|
|
||||||
* 实际语义由 decision 决定;调用方 store.approveToolCall 按 decision 走 authorizeDir)。
|
|
||||||
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,once/always/deny 三选项
|
|
||||||
* 本身就是用户明示决策,二次确认反增摩擦)。
|
|
||||||
*/
|
|
||||||
async function onAuthorize(decision: 'once' | 'always' | 'deny') {
|
|
||||||
if (approving.value) return // 防重入
|
|
||||||
approving.value = true
|
|
||||||
approvingTimer = setTimeout(() => {
|
|
||||||
approving.value = false
|
|
||||||
approvingTimer = null
|
|
||||||
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', props.tc.id)
|
|
||||||
showApproveTimeoutToast()
|
|
||||||
}, APPROVE_LOADING_TIMEOUT_MS)
|
|
||||||
// approved=false 仅占位:store.approveToolCall 据 decision 走 authorizeDir,approved 入参被忽略。
|
|
||||||
emit('approve', { id: props.tc.id, approved: false, decision })
|
|
||||||
}
|
|
||||||
|
|
||||||
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。原 cmdOutputExpanded/
|
|
||||||
// httpBodyExpanded 初始化逻辑已随结果区迁入 ToolResultBody,此处仅留 approving 复位。
|
|
||||||
watch(() => props.tc.status, (s) => {
|
|
||||||
if (s !== 'pending_approval') {
|
|
||||||
approving.value = false
|
|
||||||
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
|
|
||||||
stopPendingTimer()
|
|
||||||
} else {
|
|
||||||
startPendingTimer()
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (approvingTimer) clearTimeout(approvingTimer)
|
|
||||||
if (_approveToastTimer) clearTimeout(_approveToastTimer)
|
|
||||||
stopPendingTimer()
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
/** 一次解析结果供失败判定复用(头部 isFailed 用于卡片 border/status-dot 着色) */
|
||||||
const parsed = computed(() => parseResult(props.tc.result))
|
const parsed = computed(() => parseResult(props.tc.result))
|
||||||
|
|||||||
@@ -120,6 +120,25 @@
|
|||||||
<svg 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 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>
|
||||||
|
<!-- ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) -->
|
||||||
|
<div v-if="hasEnrichment" class="ai-enrichment-bar">
|
||||||
|
<button
|
||||||
|
class="ai-enrichment-toggle"
|
||||||
|
:class="{ 'ai-enrichment-toggle--expanded': enrichmentExpanded }"
|
||||||
|
@click="enrichmentExpanded = !enrichmentExpanded"
|
||||||
|
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
||||||
|
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
||||||
|
>
|
||||||
|
<svg class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||||
|
<path d="M2 3l3 4 3-4" />
|
||||||
|
</svg>
|
||||||
|
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="enrichmentExpanded" class="ai-enrichment-panel">
|
||||||
|
<div class="ai-enrichment-label">{{ $t('aiChat.enrichmentLabel') }}</div>
|
||||||
|
<pre class="ai-enrichment-body"><code>{{ enrichmentDetailText }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -382,6 +401,90 @@ const mentionStart = ref(-1)
|
|||||||
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
|
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
|
||||||
const pendingMentionSpans = ref<MentionSpan[]>([])
|
const pendingMentionSpans = ref<MentionSpan[]>([])
|
||||||
|
|
||||||
|
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
|
||||||
|
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
|
||||||
|
// 在输入区下方显式 badge + 可展开的上下文参考面板。
|
||||||
|
interface ProjectEnrichment {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
path: string | null
|
||||||
|
taskCount: number
|
||||||
|
ideaCount: number
|
||||||
|
inProgressTasks: { title: string; status: string }[]
|
||||||
|
pendingIdeas: { title: string; status: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichmentExpanded = ref(false)
|
||||||
|
|
||||||
|
/** @项目 mentions 经 stores 反查后的 enrichment 摘要列表 */
|
||||||
|
const projectEnrichments = computed<ProjectEnrichment[]>(() => {
|
||||||
|
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
|
||||||
|
if (projectSpans.length === 0) return []
|
||||||
|
const projects = (projectsStore.projects || []) as ProjectRecord[]
|
||||||
|
const tasks = (projectsStore.tasks || []) as TaskRecord[]
|
||||||
|
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
|
||||||
|
const results: ProjectEnrichment[] = []
|
||||||
|
for (const span of projectSpans) {
|
||||||
|
const project = projects.find(p => p.id === span.refId)
|
||||||
|
if (!project) continue
|
||||||
|
const relTasks = tasks.filter(t => t.project_id === project.id)
|
||||||
|
// ideas linking to this project via promoted_to (project name matching)
|
||||||
|
const relIdeas = ideas.filter(i => i.promoted_to === project.name || i.promoted_to === project.id)
|
||||||
|
const running = relTasks.filter(t => t.status === 'in_progress').slice(0, 20)
|
||||||
|
const pending = relIdeas.filter(i => i.status === 'pending_review').slice(0, 20)
|
||||||
|
results.push({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status as string,
|
||||||
|
path: project.path,
|
||||||
|
taskCount: relTasks.length,
|
||||||
|
ideaCount: relIdeas.length,
|
||||||
|
inProgressTasks: running.map(t => ({ title: t.title, status: t.status as string })),
|
||||||
|
pendingIdeas: pending.map(i => ({ title: i.title, status: i.status as string })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 是否有 enrichment 可展示 */
|
||||||
|
const hasEnrichment = computed(() => projectEnrichments.value.length > 0)
|
||||||
|
|
||||||
|
/** 概要 badge 文本(汇总所有 @项目 entrichment) */
|
||||||
|
const enrichmentBadgeText = computed(() => {
|
||||||
|
const list = projectEnrichments.value
|
||||||
|
if (!list.length) return ''
|
||||||
|
const totalTasks = list.reduce((s, p) => s + p.taskCount, 0)
|
||||||
|
const totalIdeas = list.reduce((s, p) => s + p.ideaCount, 0)
|
||||||
|
const names = list.map(p => p.name).join('、')
|
||||||
|
return t('aiChat.enrichmentBadge', { project: names, tasks: totalTasks, ideas: totalIdeas })
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 展开后的 enrichment 正文(mimic 后端 build_augmentation_segment 格式) */
|
||||||
|
const enrichmentDetailText = computed(() => {
|
||||||
|
const list = projectEnrichments.value
|
||||||
|
if (!list.length) return ''
|
||||||
|
const lines: string[] = []
|
||||||
|
for (const p of list) {
|
||||||
|
lines.push(`【项目】${p.name}(状态: ${p.status})`)
|
||||||
|
if (p.path) lines.push(`目录: ${p.path}`)
|
||||||
|
if (p.inProgressTasks.length > 0) {
|
||||||
|
lines.push(`进行中任务(${p.taskCount}):`)
|
||||||
|
for (const t of p.inProgressTasks) {
|
||||||
|
lines.push(` - ${t.title} (${t.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.pendingIdeas.length > 0) {
|
||||||
|
lines.push(`待评估灵感(${p.ideaCount}):`)
|
||||||
|
for (const i of p.pendingIdeas) {
|
||||||
|
lines.push(` - ${i.title} (${i.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
})
|
||||||
|
|
||||||
const mentionGroupLabel = computed(() => ({
|
const mentionGroupLabel = computed(() => ({
|
||||||
project: t('aiChat.mentionGroupProject'),
|
project: t('aiChat.mentionGroupProject'),
|
||||||
task: t('aiChat.mentionGroupTask'),
|
task: t('aiChat.mentionGroupTask'),
|
||||||
@@ -972,4 +1075,70 @@ defineExpose({
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
|
||||||
|
.ai-enrichment-bar {
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ai-enrichment-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 10%, transparent);
|
||||||
|
color: var(--df-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ai-enrichment-toggle:hover {
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
.ai-enrichment-toggle--expanded {
|
||||||
|
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
.ai-enrichment-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.ai-enrichment-badge {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--df-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ai-enrichment-panel {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--df-bg);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ai-enrichment-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ai-enrichment-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--df-text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: var(--df-font-mono, ui-monospace, monospace);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,34 +20,12 @@
|
|||||||
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
|
⏳ {{ $t('aiChat.pendingApprovalCount', { n: pendingApprovalCount }) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- F-01 阶段6: 模型选择器(自动/指定)。
|
<!-- 模型选择器(对话透明化 L1:折叠为紧凑下拉) -->
|
||||||
自动模式=路由器选(现状零变化);指定模式=用户从下拉选 model_id,override 穿透主对话。
|
<div class="ai-model-picker" :title="$t('aiChat.specifyModeHint') || ''">
|
||||||
provider 无 model_configs(空池)时:下拉空 + 强制自动(toggle 禁用)。
|
|
||||||
仅展示 active provider 的 enabled model_configs。 -->
|
|
||||||
<div class="ai-model-picker" :title="modelPickerHint">
|
|
||||||
<div class="ai-model-mode">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ai-model-mode-btn"
|
|
||||||
:class="{ 'ai-model-mode-btn--active': !isSpecifyMode }"
|
|
||||||
:disabled="!enabledModels.length"
|
|
||||||
@click="setAutoMode"
|
|
||||||
>{{ $t('aiChat.autoMode') }}</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="ai-model-mode-btn"
|
|
||||||
:class="{ 'ai-model-mode-btn--active': isSpecifyMode }"
|
|
||||||
:disabled="!enabledModels.length"
|
|
||||||
:title="enabledModels.length ? $t('aiChat.specifyModeHint') : $t('aiChat.noModels')"
|
|
||||||
@click="setSpecifyMode"
|
|
||||||
>{{ $t('aiChat.specifyMode') }}</button>
|
|
||||||
</div>
|
|
||||||
<select
|
<select
|
||||||
v-if="enabledModels.length"
|
v-if="enabledModels.length"
|
||||||
class="ai-model-select"
|
class="ai-model-select ai-model-select--compact"
|
||||||
:value="selectedModelId"
|
:value="selectedModelId"
|
||||||
:disabled="!isSpecifyMode"
|
|
||||||
:title="isSpecifyMode ? '' : $t('aiChat.autoModeHint')"
|
|
||||||
@change="onModelSelect($event)"
|
@change="onModelSelect($event)"
|
||||||
>
|
>
|
||||||
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
|
<option v-for="m in enabledModels" :key="m.model_id" :value="m.model_id">
|
||||||
@@ -114,20 +92,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 提供商状态 -->
|
<!-- 下半栏:provider + goals 并排 -->
|
||||||
<div
|
<div class="ai-bottom-bar">
|
||||||
class="ai-provider-bar"
|
<!-- 提供商状态 -->
|
||||||
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
|
<div
|
||||||
v-if="store.state.providers.length > 0"
|
class="ai-provider-bar"
|
||||||
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
|
:class="{ 'ai-provider-bar--switchable': store.state.providers.length > 1 }"
|
||||||
@click="cycleProvider"
|
v-if="store.state.providers.length > 0"
|
||||||
>
|
:title="store.state.providers.length > 1 ? $t('aiChat.clickToSwitchProvider') : ''"
|
||||||
<span class="provider-dot"></span>
|
@click="cycleProvider"
|
||||||
<span class="provider-name">{{ activeProviderName }}</span>
|
>
|
||||||
</div>
|
<span class="provider-dot"></span>
|
||||||
<div class="ai-provider-bar ai-provider-empty" v-else>
|
<span class="provider-name">{{ activeProviderName }}</span>
|
||||||
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
|
</div>
|
||||||
|
<div class="ai-provider-bar ai-provider-empty" v-else>
|
||||||
|
<span class="provider-hint">{{ $t('aiChat.providerNotConfigured') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 目标入口(扁平化,不抢眼) -->
|
||||||
|
<div v-if="goals.length" class="ai-goals-inline">
|
||||||
|
<span class="ai-goals-inline-badge" @click="goalsExpanded = !goalsExpanded">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||||
|
{{ goals.length }}
|
||||||
|
</span>
|
||||||
|
<div v-if="goalsExpanded" class="ai-goals-inline-list">
|
||||||
|
<div v-for="(g, i) in goals" :key="i" class="ai-goal-inline-item">
|
||||||
|
<span>{{ g }}</span>
|
||||||
|
<button class="ai-goal-remove" @click="removeGoal(i)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model 名,2s 后淡出) -->
|
<!-- UX-2025-14: Provider 切换临时 bar(切换后展示当前 model 名,2s 后淡出) -->
|
||||||
<Transition name="ai-provider-switch">
|
<Transition name="ai-provider-switch">
|
||||||
<div v-if="providerBarVisible" class="ai-provider-switch-bar">
|
<div v-if="providerBarVisible" class="ai-provider-switch-bar">
|
||||||
@@ -144,6 +140,7 @@
|
|||||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, computed, 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 { aiApi } from '../../api/ai'
|
||||||
import type { ModelConfig } from '../../api/types'
|
import type { ModelConfig } from '../../api/types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -194,9 +191,9 @@ const activeProviderName = computed(() => {
|
|||||||
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
|
return active?.name || store.state.providers[0]?.name || t('aiChat.notConfigured')
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── F-01 阶段6: 顶部模型选择器(自动/指定) ──
|
// ── 模型选择器(对话透明化 L1:折叠为紧凑下拉,直接选择模型) ──
|
||||||
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
|
// active provider 的 model_configs(用户在 Settings 拉取过的,仅 enabled 项)。
|
||||||
// 空池(provider 未拉取/无 enabled 模型)时下拉为空 + 强制自动模式。
|
// 空池(provider 未拉取/无 enabled 模型)时下拉为空。
|
||||||
const activeProviderRecord = computed(() =>
|
const activeProviderRecord = computed(() =>
|
||||||
store.state.providers.find(p => p.id === store.state.activeProvider)
|
store.state.providers.find(p => p.id === store.state.activeProvider)
|
||||||
|| store.state.providers.find(p => p.is_default)
|
|| store.state.providers.find(p => p.is_default)
|
||||||
@@ -206,51 +203,59 @@ const activeProviderRecord = computed(() =>
|
|||||||
const enabledModels = computed(() => {
|
const enabledModels = computed(() => {
|
||||||
const p = activeProviderRecord.value
|
const p = activeProviderRecord.value
|
||||||
if (!p?.model_configs) return []
|
if (!p?.model_configs) return []
|
||||||
// weight 降序(权重最高优先);slice 先复制避免 sort 污染 store 原 model_configs。
|
|
||||||
// 决定下拉顺序 + 默认选中([0]=权重最高),对齐用户诉求「默认选中权重最高、不随机/不摇摆」。
|
|
||||||
return p.model_configs
|
return p.model_configs
|
||||||
.filter(m => m.enabled)
|
.filter(m => m.enabled)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
|
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
|
||||||
})
|
})
|
||||||
// 指定模式 = store.modelOverride 非 null(模块级 ref,发送链路读它透传后端)。
|
|
||||||
const isSpecifyMode = computed(() => store.modelOverride.value !== null)
|
|
||||||
const selectedModelId = computed(() => store.modelOverride.value || '')
|
const selectedModelId = computed(() => store.modelOverride.value || '')
|
||||||
/** 当前选中模型的 model_id(指定模式);自动模式为空 */
|
|
||||||
const modelPickerHint = computed(() => {
|
|
||||||
if (!enabledModels.value.length) return t('aiChat.noModels')
|
|
||||||
return isSpecifyMode.value
|
|
||||||
? t('aiChat.specifyModeHint')
|
|
||||||
: t('aiChat.autoModeHint')
|
|
||||||
})
|
|
||||||
/** 下拉选项 label:model_id/label(cost/intel 维度已去,UX-260618-04) */
|
|
||||||
function modelOptionLabel(m: ModelConfig): string {
|
function modelOptionLabel(m: ModelConfig): string {
|
||||||
return m.label || m.model_id
|
return m.label || m.model_id
|
||||||
}
|
}
|
||||||
function setAutoMode() {
|
|
||||||
store.modelOverride.value = null
|
|
||||||
}
|
|
||||||
function setSpecifyMode() {
|
|
||||||
if (!enabledModels.value.length) return
|
|
||||||
// 进入指定模式默认选第一个 enabled 模型(若已非空且在池中则保留)
|
|
||||||
const cur = store.modelOverride.value
|
|
||||||
const inPool = cur && enabledModels.value.some(m => m.model_id === cur)
|
|
||||||
if (!inPool) {
|
|
||||||
store.modelOverride.value = enabledModels.value[0]?.model_id || null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onModelSelect(e: Event) {
|
function onModelSelect(e: Event) {
|
||||||
const v = (e.target as HTMLSelectElement).value
|
const v = (e.target as HTMLSelectElement).value
|
||||||
store.modelOverride.value = v || null
|
store.modelOverride.value = v || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// UX-09 + 2026-06-18: 切换会话默认进 specify 模式并选中权重最高 model(用户诉求:默认确定选中、
|
// 切换会话默认选中权重最高 model
|
||||||
// 不随机/不摇摆)。从 AiChat.vue 迁移(原与 editingMsgId/cancelEdit 同 watch,拆分后此 watch
|
|
||||||
// 仅管 modelOverride 默认值;编辑态取消留在 AiChat)。零行为变更。
|
|
||||||
watch(() => store.state.activeConversationId, () => {
|
watch(() => store.state.activeConversationId, () => {
|
||||||
store.modelOverride.value = enabledModels.value[0]?.model_id || null
|
store.modelOverride.value = enabledModels.value[0]?.model_id || null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 🎯 对话目标面板(对话透明化 L1:Goal visibility) ──
|
||||||
|
// 从最新 switch 返回的 conversation summary 中取 pinned_goals,
|
||||||
|
// 增删操作经后端 IPC 持久化到 DB 并同步 per_conv 内存。
|
||||||
|
const goals = ref<string[]>([])
|
||||||
|
/** 目标列表展开/折叠 */
|
||||||
|
const goalsExpanded = ref(false)
|
||||||
|
|
||||||
|
/** 监听 activeConversationId 变化,加载目标的精简实现:从 store.conversations 查找当前会话的目标。 */
|
||||||
|
/** 监听 activeConversationId 变化,加载目标 */
|
||||||
|
watch(() => store.state.activeConversationId, (convId) => {
|
||||||
|
loadGoals(convId)
|
||||||
|
}, { immediate: true })
|
||||||
|
/** 消息变化后也刷新目标(发消息→save_conversation→DB→store 需重读) */
|
||||||
|
watch(() => store.state.messages.length, () => {
|
||||||
|
// loadConversations 异步,等 store 刷新后重读
|
||||||
|
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)
|
||||||
|
goals.value = conv?.pinned_goals ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除第 i 个目标:删除后持久化全量列表到后端。 */
|
||||||
|
async function removeGoal(i: number) {
|
||||||
|
const convId = store.state.activeConversationId
|
||||||
|
if (!convId) return
|
||||||
|
const next = [...goals.value]
|
||||||
|
next.splice(i, 1)
|
||||||
|
goals.value = next
|
||||||
|
await aiApi.updateConversationGoals(convId, next)
|
||||||
|
}
|
||||||
|
|
||||||
// 循环切换 Provider(provider bar 点击)
|
// 循环切换 Provider(provider bar 点击)
|
||||||
function cycleProvider() {
|
function cycleProvider() {
|
||||||
const ps = store.state.providers
|
const ps = store.state.providers
|
||||||
@@ -331,45 +336,15 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══ F-01 阶段6: 模型选择器(自动/指定 toggle + 下拉) ═══ */
|
/* ═══ 模型选择器(对话透明化 L1:紧凑下拉) ═══ */
|
||||||
.ai-model-picker {
|
.ai-model-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.ai-model-mode {
|
|
||||||
display: inline-flex;
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.ai-model-mode-btn {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ai-model-mode-btn:hover:not(:disabled) {
|
|
||||||
background: var(--df-bg-card);
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
.ai-model-mode-btn--active {
|
|
||||||
background: var(--df-accent-bg);
|
|
||||||
color: var(--df-accent);
|
|
||||||
}
|
|
||||||
.ai-model-mode-btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.ai-model-select {
|
.ai-model-select {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 180px;
|
max-width: 180px;
|
||||||
@@ -383,6 +358,11 @@ onBeforeUnmount(() => {
|
|||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.ai-model-select--compact {
|
||||||
|
max-width: 130px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
.ai-model-select:disabled {
|
.ai-model-select:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -432,19 +412,88 @@ onBeforeUnmount(() => {
|
|||||||
animation: ai-btn-spin 0.9s linear infinite;
|
animation: ai-btn-spin 0.9s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══ Provider Bar ═══ */
|
/* ═══ 🎯 对话目标面板(对话透明化 L1) ═══ */
|
||||||
.ai-provider-bar {
|
.ai-goals-panel {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 6%, transparent);
|
||||||
|
}
|
||||||
|
.ai-goals-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 14px;
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.ai-goals-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
.ai-goals-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.ai-goal-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
.ai-goal-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ai-goal-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ai-goal-remove {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.ai-goal-item:hover .ai-goal-remove {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.ai-goal-remove:hover {
|
||||||
|
color: var(--df-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Provider Bar + Goals(并排) ═══ */
|
||||||
|
.ai-bottom-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px 14px;
|
||||||
border-bottom: 0.5px solid var(--df-border);
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
.ai-provider-bar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ai-provider-bar--switchable {
|
.ai-provider-bar--switchable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.ai-provider-bar--switchable:hover {
|
.ai-provider-bar--switchable:hover {
|
||||||
background: var(--df-bg-card);
|
color: var(--df-text-primary);
|
||||||
}
|
}
|
||||||
.provider-dot {
|
.provider-dot {
|
||||||
width: 5px;
|
width: 5px;
|
||||||
@@ -460,6 +509,58 @@ onBeforeUnmount(() => {
|
|||||||
.ai-provider-empty {
|
.ai-provider-empty {
|
||||||
background: var(--df-danger-bg);
|
background: var(--df-danger-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 目标入口(扁平低调) */
|
||||||
|
.ai-goals-inline {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ai-goals-inline-badge {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
background: transparent;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.ai-goals-inline-badge:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
}
|
||||||
|
.ai-goals-inline-list {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%; left: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 360px;
|
||||||
|
background: var(--df-bg, #fff);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-sm, 4px);
|
||||||
|
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;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ai-goal-inline-item:hover {
|
||||||
|
background: var(--df-bg-card, #f5f5f5);
|
||||||
|
}
|
||||||
|
.ai-goal-remove:hover {
|
||||||
|
color: var(--df-danger, #e5484d);
|
||||||
|
}
|
||||||
.provider-hint {
|
.provider-hint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--df-danger);
|
color: var(--df-danger);
|
||||||
|
|||||||
@@ -2,27 +2,39 @@
|
|||||||
<div v-if="dag" class="wf-dag">
|
<div v-if="dag" class="wf-dag">
|
||||||
<h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4>
|
<h4 class="wf-dag-title">{{ $t('taskDetail.workflowDagTitle') || '工作流结构' }}</h4>
|
||||||
|
|
||||||
<!-- 节点列表 -->
|
<!-- 拓扑分层渲染:同层并行,层间串行 -->
|
||||||
<div class="wf-dag-nodes">
|
<div class="wf-dag-layers">
|
||||||
<div v-for="node in dag.nodes" :key="node.id" class="wf-dag-node">
|
<div v-for="(layer, li) in layers" :key="li" class="wf-dag-layer">
|
||||||
<span class="wf-dag-node-icon">⬡</span>
|
<div class="wf-dag-layer-label">{{ $t('taskDetail.workflowLayer') || '层' }} {{ li + 1 }}</div>
|
||||||
<span class="wf-dag-node-name">{{ node.id }}</span>
|
<div class="wf-dag-layer-nodes">
|
||||||
<span class="wf-dag-node-type">{{ nodeTypeLabel[node.node_type] || node.node_type }}</span>
|
<div
|
||||||
|
v-for="node in layer"
|
||||||
|
:key="node.id"
|
||||||
|
class="wf-dag-node"
|
||||||
|
:class="nodeClass(node.id, props.nodeStatuses)"
|
||||||
|
>
|
||||||
|
<span class="wf-dag-node-status-dot"></span>
|
||||||
|
<span class="wf-dag-node-name">{{ node.id }}</span>
|
||||||
|
<span class="wf-dag-node-type">{{ nodeTypeLabel[node.node_type] || node.node_type }}</span>
|
||||||
|
<span v-if="nodeStatuses[node.id]" class="wf-dag-node-status">{{ statusLabel(nodeStatuses[node.id]) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 层间箭头 -->
|
||||||
|
<div v-if="li < layers.length - 1" class="wf-dag-layer-arrow">↓</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 边列表(带条件) -->
|
<!-- 边条件列表(折叠态,hover 节点时参考) -->
|
||||||
<div class="wf-dag-edges">
|
<details class="wf-dag-edges-detail">
|
||||||
<div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge">
|
<summary class="wf-dag-edges-summary">{{ $t('taskDetail.workflowEdgeDetail') || '边条件详情' }} ({{ dag.edges.length }})</summary>
|
||||||
<span class="wf-dag-edge-arrow">{{ edge.source }} → {{ edge.target }}</span>
|
<div class="wf-dag-edges">
|
||||||
<span v-if="edge.condition" class="wf-dag-edge-cond" :title="edge.condition">
|
<div v-for="(edge, i) in dag.edges" :key="i" class="wf-dag-edge">
|
||||||
{{ edge.condition }}
|
<span class="wf-dag-edge-arrow">{{ edge.source }} → {{ edge.target }}</span>
|
||||||
</span>
|
<span v-if="edge.condition" class="wf-dag-edge-cond" :title="edge.condition">{{ edge.condition }}</span>
|
||||||
<span v-else class="wf-dag-edge-cond wf-dag-edge-cond--uncond">
|
<span v-else class="wf-dag-edge-cond wf-dag-edge-cond--uncond">{{ $t('taskDetail.workflowUnconditional') || '无条件' }}</span>
|
||||||
无条件
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="wf-dag wf-dag--empty">
|
<div v-else class="wf-dag wf-dag--empty">
|
||||||
{{ $t('taskDetail.workflowDagEmpty') || '暂无工作流数据' }}
|
{{ $t('taskDetail.workflowDagEmpty') || '暂无工作流数据' }}
|
||||||
@@ -37,23 +49,23 @@ interface DagNode {
|
|||||||
node_type: string
|
node_type: string
|
||||||
config?: Record<string, unknown>
|
config?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EdgeDef {
|
interface EdgeDef {
|
||||||
source: string
|
source: string
|
||||||
target: string
|
target: string
|
||||||
condition?: string | null
|
condition?: string | null
|
||||||
}
|
}
|
||||||
|
export type NodeStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
dagJson: string
|
dagJson: string
|
||||||
}>()
|
nodeStatuses?: Record<string, NodeStatus>
|
||||||
|
}>(), {
|
||||||
|
nodeStatuses: () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
|
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
|
||||||
try {
|
try { return JSON.parse(props.dagJson) }
|
||||||
return JSON.parse(props.dagJson)
|
catch { return null }
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const nodeTypeLabel: Record<string, string> = {
|
const nodeTypeLabel: Record<string, string> = {
|
||||||
@@ -62,70 +74,120 @@ const nodeTypeLabel: Record<string, string> = {
|
|||||||
'ScriptNode': '脚本',
|
'ScriptNode': '脚本',
|
||||||
'AiSelfReviewNode': 'AI 自审',
|
'AiSelfReviewNode': 'AI 自审',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: NodeStatus): string {
|
||||||
|
return { pending: '⏳', running: '🔄', completed: '✅', failed: '❌', skipped: '⏭️' }[s] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeClass(id: string, statuses: Record<string, NodeStatus>): Record<string, boolean> {
|
||||||
|
const s = statuses[id]
|
||||||
|
return {
|
||||||
|
'wf-dag-node--running': s === 'running',
|
||||||
|
'wf-dag-node--completed': s === 'completed',
|
||||||
|
'wf-dag-node--failed': s === 'failed',
|
||||||
|
'wf-dag-node--skipped': s === 'skipped',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拓扑分层:从无入边的根节点开始,逐层推进 */
|
||||||
|
const layers = computed<(DagNode & { deps?: string[] })[][]>(() => {
|
||||||
|
if (!dag.value) return []
|
||||||
|
const d = dag.value
|
||||||
|
// 入边计数
|
||||||
|
const inDegree: Record<string, number> = {}
|
||||||
|
const edgeMap: Record<string, string[]> = {}
|
||||||
|
for (const n of d.nodes) {
|
||||||
|
inDegree[n.id] = 0
|
||||||
|
edgeMap[n.id] = []
|
||||||
|
}
|
||||||
|
for (const e of d.edges) {
|
||||||
|
if (!edgeMap[e.source]) edgeMap[e.source] = []
|
||||||
|
edgeMap[e.source].push(e.target)
|
||||||
|
inDegree[e.target] = (inDegree[e.target] || 0) + 1
|
||||||
|
}
|
||||||
|
// Kahn 拓扑排序
|
||||||
|
const layers: (DagNode & { deps?: string[] })[][] = []
|
||||||
|
let queue = Object.entries(inDegree).filter(([, d]) => d === 0).map(([id]) => id)
|
||||||
|
const visited = new Set<string>()
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const layer: DagNode[] = []
|
||||||
|
const next: string[] = []
|
||||||
|
for (const id of queue) {
|
||||||
|
if (visited.has(id)) continue
|
||||||
|
visited.add(id)
|
||||||
|
const node = d.nodes.find(n => n.id === id)
|
||||||
|
if (node) layer.push(node)
|
||||||
|
for (const target of (edgeMap[id] || [])) {
|
||||||
|
inDegree[target]--
|
||||||
|
if (inDegree[target] === 0) next.push(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (layer.length > 0) layers.push(layer)
|
||||||
|
queue = next
|
||||||
|
}
|
||||||
|
return layers
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wf-dag {
|
.wf-dag {
|
||||||
margin-top: 8px;
|
margin-top: 8px; padding: 8px 12px;
|
||||||
padding: 8px 12px;
|
|
||||||
background: var(--df-bg-card, #fafafa);
|
background: var(--df-bg-card, #fafafa);
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
border-radius: var(--df-radius-sm, 4px); font-size: 12px;
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
.wf-dag--empty {
|
.wf-dag--empty { color: var(--df-text-dim, #888); }
|
||||||
color: var(--df-text-dim, #888);
|
.wf-dag-title { margin: 0 0 8px; font-size: 13px; font-weight: 600; }
|
||||||
}
|
|
||||||
.wf-dag-title {
|
.wf-dag-layers { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||||
margin: 0 0 6px;
|
.wf-dag-layer { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||||
font-size: 13px;
|
.wf-dag-layer-label { font-size: 10px; color: var(--df-text-dim, #888); align-self: flex-start; }
|
||||||
font-weight: 600;
|
.wf-dag-layer-nodes {
|
||||||
}
|
display: flex; flex-wrap: wrap; justify-content: center; gap: 6px;
|
||||||
.wf-dag-nodes {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
.wf-dag-layer-arrow { font-size: 16px; color: var(--df-text-dim, #888); line-height: 1; }
|
||||||
|
|
||||||
.wf-dag-node {
|
.wf-dag-node {
|
||||||
display: inline-flex;
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
align-items: center;
|
padding: 4px 10px;
|
||||||
gap: 4px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
background: var(--df-bg, #fff);
|
background: var(--df-bg, #fff);
|
||||||
border: 0.5px solid var(--df-border, #ddd);
|
border: 1px solid var(--df-border, #ddd);
|
||||||
border-radius: var(--df-radius-sm, 4px);
|
border-radius: var(--df-radius-sm, 4px);
|
||||||
|
transition: border-color 0.3s, background 0.3s;
|
||||||
}
|
}
|
||||||
.wf-dag-node-icon { color: var(--df-text-dim, #888); }
|
.wf-dag-node-name { font-weight: 500; white-space: nowrap; }
|
||||||
.wf-dag-node-name { font-weight: 500; }
|
|
||||||
.wf-dag-node-type {
|
.wf-dag-node-type {
|
||||||
font-size: 10px;
|
font-size: 10px; color: var(--df-text-dim, #888);
|
||||||
color: var(--df-text-dim, #888);
|
background: var(--df-bg-card, #f0f0f0); padding: 0 4px; border-radius: 3px;
|
||||||
background: var(--df-bg-card, #f0f0f0);
|
|
||||||
padding: 0 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
.wf-dag-edges {
|
.wf-dag-node-status { font-size: 11px; }
|
||||||
display: flex;
|
.wf-dag-node-status-dot {
|
||||||
flex-direction: column;
|
width: 6px; height: 6px; border-radius: 50%;
|
||||||
gap: 3px;
|
background: var(--df-text-dim, #ccc);
|
||||||
}
|
}
|
||||||
.wf-dag-edge {
|
.wf-dag-node--running {
|
||||||
display: flex;
|
border-color: var(--df-info, #4a90e2);
|
||||||
align-items: center;
|
background: rgba(74,144,226,0.05);
|
||||||
gap: 6px;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
}
|
||||||
|
.wf-dag-node--running .wf-dag-node-status-dot {
|
||||||
|
background: var(--df-info, #4a90e2);
|
||||||
|
animation: pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.wf-dag-node--completed { border-color: var(--df-success, #3ddba0); }
|
||||||
|
.wf-dag-node--completed .wf-dag-node-status-dot { background: var(--df-success, #3ddba0); }
|
||||||
|
.wf-dag-node--failed { border-color: var(--df-danger, #e5484d); background: rgba(229,72,77,0.05); }
|
||||||
|
.wf-dag-node--failed .wf-dag-node-status-dot { background: var(--df-danger, #e5484d); }
|
||||||
|
.wf-dag-node--skipped { opacity: 0.5; }
|
||||||
|
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||||
|
|
||||||
|
.wf-dag-edges-detail { margin-top: 8px; }
|
||||||
|
.wf-dag-edges-summary { cursor: pointer; font-size: 11px; color: var(--df-info, #4a90e2); }
|
||||||
|
.wf-dag-edges { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
|
||||||
|
.wf-dag-edge { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; }
|
||||||
.wf-dag-edge-arrow { color: var(--df-text-primary); }
|
.wf-dag-edge-arrow { color: var(--df-text-primary); }
|
||||||
.wf-dag-edge-cond {
|
.wf-dag-edge-cond {
|
||||||
font-family: var(--df-font-mono);
|
font-family: var(--df-font-mono); font-size: 11px; padding: 1px 6px;
|
||||||
font-size: 11px;
|
background: rgba(74,144,226,0.08); border-radius: 3px; color: var(--df-info, #4a90e2);
|
||||||
padding: 1px 6px;
|
|
||||||
background: rgba(74, 144, 226, 0.08);
|
|
||||||
border-radius: 3px;
|
|
||||||
color: var(--df-info, #4a90e2);
|
|
||||||
}
|
|
||||||
.wf-dag-edge-cond--uncond {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--df-text-dim, #aaa);
|
|
||||||
}
|
}
|
||||||
|
.wf-dag-edge-cond--uncond { background: transparent; color: var(--df-text-dim, #aaa); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -578,8 +578,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
|
|||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: t('ai.responseIncomplete'),
|
content: t('ai.responseIncomplete'),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
} as AiMessage)
|
})
|
||||||
}
|
}
|
||||||
|
// 对话透明化 L1:刷新会话列表(更新 pinned_goals)
|
||||||
|
void loadConversations()
|
||||||
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
// token 用量记录(开关开时):lastTokenUsage 供当前回复展示,convTokenTotal 累加对话总量
|
||||||
if (isShowTokenUsage()) {
|
if (isShowTokenUsage()) {
|
||||||
state.lastTokenUsage = {
|
state.lastTokenUsage = {
|
||||||
|
|||||||
212
src/composables/ai/useToolApproval.ts
Normal file
212
src/composables/ai/useToolApproval.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* ToolCard 审批状态机 — 从 ToolCard.vue 抽离的审批 UI 逻辑。
|
||||||
|
*
|
||||||
|
* 职责:审批按钮 loading 态、审批挂起时长显示(BUG-260624-02)、
|
||||||
|
* 审批超时兜底 toast(UX-260617-14)、approve/reject/path_auth 三选项处理、
|
||||||
|
* status 变化自动复位 + pending 计时。
|
||||||
|
*
|
||||||
|
* 不含 store IPC 调用(useAiApproval 负责)和模板渲染——纯状态管理。
|
||||||
|
*/
|
||||||
|
import { ref, reactive, computed, watch, onBeforeUnmount, type Ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { STREAM_TIMEOUT_MS } from '@/composables/ai/useAiStream'
|
||||||
|
import type { AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批按钮 loading 超时兜底(B-260616-08)。
|
||||||
|
* 后端回事件使 tc.status 离开 pending_approval → watch 复位。
|
||||||
|
* 兜底计时到点复位 + toast 提示(对齐全局看门狗 STREAM_TIMEOUT_MS,后端无回执时给用户重试入口)。
|
||||||
|
*/
|
||||||
|
const APPROVE_LOADING_TIMEOUT_MS = STREAM_TIMEOUT_MS
|
||||||
|
|
||||||
|
/** AE-2025-05:High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
|
||||||
|
const HIGH_RISK_TOOLS = new Set<string>([
|
||||||
|
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
||||||
|
'http_request',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: any) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
|
|
||||||
|
// ── B-260616-08: 审批 loading 态 ──
|
||||||
|
const approving = ref(false)
|
||||||
|
let approvingTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// ── BUG-260624-02: 审批挂起时长显示 ──
|
||||||
|
const pendingSince = ref<number | null>(null)
|
||||||
|
const waitSecs = ref(0)
|
||||||
|
let waitTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function startPendingTimer() {
|
||||||
|
if (pendingSince.value !== null) return
|
||||||
|
pendingSince.value = Date.now()
|
||||||
|
waitSecs.value = 0
|
||||||
|
if (waitTimer) clearInterval(waitTimer)
|
||||||
|
waitTimer = setInterval(() => {
|
||||||
|
if (pendingSince.value !== null) {
|
||||||
|
waitSecs.value = Math.floor((Date.now() - pendingSince.value) / 1000)
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPendingTimer() {
|
||||||
|
if (waitTimer) { clearInterval(waitTimer); waitTimer = null }
|
||||||
|
pendingSince.value = null
|
||||||
|
waitSecs.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWaitTime(secs: number): string {
|
||||||
|
const m = Math.floor(secs / 60)
|
||||||
|
const s = secs % 60
|
||||||
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitLevel = computed<'normal' | 'long' | 'urgent'>(() => {
|
||||||
|
if (waitSecs.value >= 300) return 'urgent' // 5分钟
|
||||||
|
if (waitSecs.value >= 120) return 'long' // 2分钟
|
||||||
|
return 'normal'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── UX-260617-14: 审批超时兜底 toast ──
|
||||||
|
const approveToast = reactive({ visible: false, msg: '' })
|
||||||
|
let _approveToastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function showApproveTimeoutToast(): void {
|
||||||
|
approveToast.msg = t('aiTool.approveLoadingTimeout')
|
||||||
|
approveToast.visible = true
|
||||||
|
if (_approveToastTimer) clearTimeout(_approveToastTimer)
|
||||||
|
_approveToastTimer = setTimeout(() => { approveToast.visible = false }, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AE-2025-05:High 风险工具的二次确认文案(按操作类型) */
|
||||||
|
function highRiskConfirmMsg(name: string): string {
|
||||||
|
if (name === 'run_command') return t('aiTool.confirmHighExec')
|
||||||
|
if (name === 'http_request') return t('aiTool.confirmHighHttp')
|
||||||
|
if (name === 'delete_task' || name === 'delete_project' || name === 'purge_project' || name === 'delete_file' || name === 'restore_project') {
|
||||||
|
return t('aiTool.confirmHighDelete')
|
||||||
|
}
|
||||||
|
return t('aiTool.confirmHighGeneric')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批准按钮处理(risk 类专用)。
|
||||||
|
* AE-2025-05:High 风险工具 → 先二次确认(useConfirm 弹层)。
|
||||||
|
* loading + 超时兜底复用 B-260616-08 机制。
|
||||||
|
*/
|
||||||
|
async function onApprove() {
|
||||||
|
if (approving.value) return // 防重入
|
||||||
|
// AE-2025-05:High 风险 → 先二次确认
|
||||||
|
if (HIGH_RISK_TOOLS.has(tc.value.name)) {
|
||||||
|
const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name))
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
approving.value = true
|
||||||
|
approvingTimer = setTimeout(() => {
|
||||||
|
approving.value = false
|
||||||
|
approvingTimer = null
|
||||||
|
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
||||||
|
showApproveTimeoutToast()
|
||||||
|
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||||
|
emit('approve', { id: tc.value.id, approved: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒绝按钮处理(risk 类专用)。
|
||||||
|
* loading + 超时兜底复用 B-260616-08 机制。拒绝不做 High 风险二次确认(无害,加确认反增摩擦)。
|
||||||
|
*/
|
||||||
|
async function onReject() {
|
||||||
|
if (approving.value) return // 防重入
|
||||||
|
approving.value = true
|
||||||
|
approvingTimer = setTimeout(() => {
|
||||||
|
approving.value = false
|
||||||
|
approvingTimer = null
|
||||||
|
console.warn('[AI] 审批 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
||||||
|
showApproveTimeoutToast()
|
||||||
|
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||||
|
emit('approve', { id: tc.value.id, approved: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* path_auth 审批链阶段3b:path 类「仅本次会话授权」处理。
|
||||||
|
* 与 onApprove 同款 loading + 超时兜底,emit 带 decision='once'。
|
||||||
|
* path 类不做 High 风险二次确认(路径授权是显式白名单写入,非破坏性操作,三选项本身就是用户明示决策)。
|
||||||
|
*/
|
||||||
|
async function onAuthorizeOnce() {
|
||||||
|
if (approving.value) return
|
||||||
|
approving.value = true
|
||||||
|
approvingTimer = setTimeout(() => {
|
||||||
|
approving.value = false
|
||||||
|
approvingTimer = null
|
||||||
|
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
||||||
|
showApproveTimeoutToast()
|
||||||
|
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||||
|
emit('approve', { id: tc.value.id, approved: false, decision: 'once' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* path_auth 审批链阶段3b:path 类「写持久白名单」处理。
|
||||||
|
* 同 onAuthorizeOnce,decision='always'。
|
||||||
|
*/
|
||||||
|
async function onAuthorizeAlways() {
|
||||||
|
if (approving.value) return
|
||||||
|
approving.value = true
|
||||||
|
approvingTimer = setTimeout(() => {
|
||||||
|
approving.value = false
|
||||||
|
approvingTimer = null
|
||||||
|
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
||||||
|
showApproveTimeoutToast()
|
||||||
|
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||||
|
emit('approve', { id: tc.value.id, approved: false, decision: 'always' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* path_auth 审批链阶段3b:path 类「拒绝」处理。
|
||||||
|
* 同 onAuthorizeOnce,decision='deny'。
|
||||||
|
*/
|
||||||
|
async function onDeny() {
|
||||||
|
if (approving.value) return
|
||||||
|
approving.value = true
|
||||||
|
approvingTimer = setTimeout(() => {
|
||||||
|
approving.value = false
|
||||||
|
approvingTimer = null
|
||||||
|
console.warn('[AI] 路径授权 loading 超时,后端可能未回执,已复位允许重试:', tc.value.id)
|
||||||
|
showApproveTimeoutToast()
|
||||||
|
}, APPROVE_LOADING_TIMEOUT_MS)
|
||||||
|
emit('approve', { id: tc.value.id, approved: false, decision: 'deny' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// status 离开 pending_approval → 复位 approving(后端回执/转态时)。
|
||||||
|
watch(() => tc.value.status, (s) => {
|
||||||
|
if (s !== 'pending_approval') {
|
||||||
|
approving.value = false
|
||||||
|
if (approvingTimer) { clearTimeout(approvingTimer); approvingTimer = null }
|
||||||
|
stopPendingTimer()
|
||||||
|
} else {
|
||||||
|
startPendingTimer()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (approvingTimer) clearTimeout(approvingTimer)
|
||||||
|
if (_approveToastTimer) clearTimeout(_approveToastTimer)
|
||||||
|
stopPendingTimer()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
approving,
|
||||||
|
approveToast,
|
||||||
|
waitSecs,
|
||||||
|
waitLevel,
|
||||||
|
formatWaitTime,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
onAuthorizeOnce,
|
||||||
|
onAuthorizeAlways,
|
||||||
|
onDeny,
|
||||||
|
// 供模板 ConfirmDialog 绑定
|
||||||
|
confirmState,
|
||||||
|
answerConfirm,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,6 +170,12 @@ export default {
|
|||||||
// Same-name skill conflict (scan_skills returns conflicts; frontend warns, injection takes first by skills>commands>plugins priority)
|
// Same-name skill conflict (scan_skills returns conflicts; frontend warns, injection takes first by skills>commands>plugins priority)
|
||||||
skillConflict: 'Skill "{name}" has multiple definitions; using skills priority',
|
skillConflict: 'Skill "{name}" has multiple definitions; using skills priority',
|
||||||
|
|
||||||
|
// ── ⑥.4 Phase 4: @project expansion summary (input area preview) ──
|
||||||
|
enrichmentBadge: '📎 Referenced "{project}" ({tasks} tasks, {ideas} ideas)',
|
||||||
|
enrichmentExpand: 'Show context preview',
|
||||||
|
enrichmentCollapse: 'Hide context preview',
|
||||||
|
enrichmentLabel: 'Context preview',
|
||||||
|
|
||||||
|
|
||||||
// ── F-15 phase 2: manual context management (clear / compress) ──
|
// ── F-15 phase 2: manual context management (clear / compress) ──
|
||||||
// Buttons (Header action area: trash + compress icon)
|
// Buttons (Header action area: trash + compress icon)
|
||||||
|
|||||||
@@ -171,6 +171,12 @@ export default {
|
|||||||
// 同名技能冲突(scan_skills 返回 conflicts,前端提示,注入按 skills>commands>plugins 优先级取首份)
|
// 同名技能冲突(scan_skills 返回 conflicts,前端提示,注入按 skills>commands>plugins 优先级取首份)
|
||||||
skillConflict: '技能 {name} 存在多份定义,已采用 skills 优先级',
|
skillConflict: '技能 {name} 存在多份定义,已采用 skills 优先级',
|
||||||
|
|
||||||
|
// ── ⑥.4 Phase 4: @项目展开摘要(输入区预览 ──
|
||||||
|
enrichmentBadge: '📎 已关联「{project}」({tasks} 项任务, {ideas} 项灵感)',
|
||||||
|
enrichmentExpand: '展开上下文参考',
|
||||||
|
enrichmentCollapse: '收起上下文参考',
|
||||||
|
enrichmentLabel: '上下文参考',
|
||||||
|
|
||||||
|
|
||||||
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
|
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
|
||||||
// 按钮(Header 操作区:垃圾桶 + 压缩图标)
|
// 按钮(Header 操作区:垃圾桶 + 压缩图标)
|
||||||
|
|||||||
@@ -86,9 +86,11 @@
|
|||||||
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
|
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
|
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
|
||||||
<span v-else-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
|
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 工作流 DAG 结构 -->
|
||||||
|
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfNodeStatuses" />
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">{{ $t('taskDetail.priority') }}</span>
|
<span class="label">{{ $t('taskDetail.priority') }}</span>
|
||||||
<span class="value">
|
<span class="value">
|
||||||
@@ -169,6 +171,7 @@ import {
|
|||||||
import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types'
|
import type { TaskRecord, ProjectRecord, IdeaRecord, DfDataChangedPayload, WorkflowEventPayload } from '@/api/types'
|
||||||
import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
|
import TaskOutputCard from '@/components/task/TaskOutputCard.vue'
|
||||||
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
|
import WorkflowDagDisplay from '@/components/workflow/WorkflowDagDisplay.vue'
|
||||||
|
import type { NodeStatus } from '@/components/workflow/WorkflowDagDisplay.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -202,7 +205,9 @@ const wfFailedHint = computed(() => wfResult.value === 'failed')
|
|||||||
|
|
||||||
// 工作流 DAG 结构展示
|
// 工作流 DAG 结构展示
|
||||||
const wfDagJson = ref('')
|
const wfDagJson = ref('')
|
||||||
|
const wfNodeStatuses = ref<Record<string, NodeStatus>>({})
|
||||||
async function refreshWorkflowDag(execId: string) {
|
async function refreshWorkflowDag(execId: string) {
|
||||||
|
wfNodeStatuses.value = {}
|
||||||
try {
|
try {
|
||||||
const record = await workflowApi.getExecution(execId)
|
const record = await workflowApi.getExecution(execId)
|
||||||
if (record?.dag_json) wfDagJson.value = record.dag_json
|
if (record?.dag_json) wfDagJson.value = record.dag_json
|
||||||
@@ -365,12 +370,16 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
|
|||||||
// 用「已启动 + 已完成」近似 total,显示已完成/已启动进度)
|
// 用「已启动 + 已完成」近似 total,显示已完成/已启动进度)
|
||||||
const node = String(evt.node_id ?? '')
|
const node = String(evt.node_id ?? '')
|
||||||
wfRunningNode.value = node
|
wfRunningNode.value = node
|
||||||
|
wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'running' }
|
||||||
if (wfTotalNodes.value === 0) wfTotalNodes.value = 1
|
if (wfTotalNodes.value === 0) wfTotalNodes.value = 1
|
||||||
else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1)
|
else wfTotalNodes.value = Math.max(wfTotalNodes.value, wfDoneCount.value + 1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'node_completed': {
|
case 'node_completed': {
|
||||||
wfDoneCount.value += 1
|
wfDoneCount.value += 1
|
||||||
|
if (wfRunningNode.value) {
|
||||||
|
wfNodeStatuses.value = { ...wfNodeStatuses.value, [wfRunningNode.value]: 'completed' }
|
||||||
|
}
|
||||||
wfRunningNode.value = ''
|
wfRunningNode.value = ''
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -387,7 +396,12 @@ function handleWorkflowEvent(payload: WorkflowEventPayload) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'workflow_failed':
|
case 'workflow_failed':
|
||||||
case 'node_failed': {
|
case 'node_failed':
|
||||||
|
case 'workflow_failed': {
|
||||||
|
if (evt?.node_id) {
|
||||||
|
const node = String(evt.node_id)
|
||||||
|
wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'failed' }
|
||||||
|
}
|
||||||
if (evt?.type === 'workflow_failed') {
|
if (evt?.type === 'workflow_failed') {
|
||||||
wfAdvancing.value = false
|
wfAdvancing.value = false
|
||||||
wfRunningNode.value = ''
|
wfRunningNode.value = ''
|
||||||
|
|||||||
Reference in New Issue
Block a user