修复: 审批不超时+重启恢复 + 其他小改
- APPROVAL_TIMEOUT_MS=Infinity(已决策:审批不做超时) - V33 迁移: ai_conversations 加 pending_approvals 列 - save_conversation 持久化 pending_approvals - ai_conversation_switch 从 DB 恢复 pending_approvals - switchConversation 加 try/catch(对话不存在→建新) - @项目 关联添加取消按钮(×) - TopBar 底部无标题时图标右对齐 - TaskDetail.vue 删除重复 case
This commit is contained in:
@@ -85,6 +85,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
|||||||
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")?,
|
pinned_goals: row.get("pinned_goals")?,
|
||||||
|
pending_approvals: row.get("pending_approvals")?,
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
})
|
})
|
||||||
@@ -160,24 +161,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, pinned_goals, created_at, updated_at)
|
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||||
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.pinned_goals, rec.created_at, rec.updated_at
|
rec.pinned_goals, rec.pending_approvals, 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, pinned_goals = ?10, updated_at = ?11 WHERE id = ?12",
|
"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, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13",
|
||||||
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.pinned_goals, rec.updated_at, rec.id
|
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ 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<()>); 32] = [
|
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||||
|
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||||
|
let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -76,6 +78,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(30, migrate_v30),
|
(30, migrate_v30),
|
||||||
(31, migrate_v31),
|
(31, migrate_v31),
|
||||||
(32, migrate_v32),
|
(32, migrate_v32),
|
||||||
|
(33, migrate_v33),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -917,6 +920,28 @@ fn migrate_v32(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_v33(conn: &Connection) -> Result<()> {
|
||||||
|
// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)
|
||||||
|
let has_col: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) > 0 FROM pragma_table_info('ai_conversations') WHERE name = 'pending_approvals'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_col {
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE ai_conversations ADD COLUMN pending_approvals TEXT DEFAULT '{}';"
|
||||||
|
)?;
|
||||||
|
tracing::info!("v33: ai_conversations 加 pending_approvals 列(审批重启恢复)");
|
||||||
|
} else {
|
||||||
|
tracing::info!("v33: pending_approvals 列已存在,跳过");
|
||||||
|
}
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?;
|
||||||
|
tracing::info!("迁移 v33 完成");
|
||||||
|
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)。
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ pub struct AiConversationRecord {
|
|||||||
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 pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
||||||
|
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,29 @@ pub async fn ai_conversation_switch(
|
|||||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
|
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
|
||||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
|
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
|
||||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||||
|
|
||||||
|
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
||||||
|
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
||||||
|
if let Some(pending_json) = &record.pending_approvals {
|
||||||
|
if pending_json != "{}" && !pending_json.is_empty() {
|
||||||
|
if let Ok(restored) = serde_json::from_str::<
|
||||||
|
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
||||||
|
>(pending_json) {
|
||||||
|
let restored_count = restored.len();
|
||||||
|
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
||||||
|
session.pending_approvals.extend(restored);
|
||||||
|
tracing::info!(
|
||||||
|
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列)",
|
||||||
|
conversation_id, restored_count
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
||||||
|
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
||||||
drop(session);
|
drop(session);
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,18 @@ pub(crate) async fn save_conversation(
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
|
||||||
|
// 序列化为 JSON 对象(tool_call_id → PendingApproval),落库供重启/切回时恢复。
|
||||||
|
let pending_approvals_json = {
|
||||||
|
let session = session_arc.lock().await;
|
||||||
|
let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session.pending_approvals
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, a)| a.conversation_id.as_deref() == Some(conv_id))
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
};
|
||||||
|
|
||||||
// F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
|
// F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
|
||||||
// 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。
|
// 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。
|
||||||
// created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。
|
// created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。
|
||||||
@@ -227,6 +239,8 @@ pub(crate) async fn save_conversation(
|
|||||||
}
|
}
|
||||||
// G1 目标钉扎持久化:将 per_conv.pinned_goals 写入 DB
|
// G1 目标钉扎持久化:将 per_conv.pinned_goals 写入 DB
|
||||||
rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
|
rec.pinned_goals = Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
// 挂起审批快照持久化:每轮 save 同步当前 pending_approvals 快照
|
||||||
|
rec.pending_approvals = Some(pending_approvals_json.clone());
|
||||||
// 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}");
|
||||||
@@ -254,6 +268,7 @@ pub(crate) async fn save_conversation(
|
|||||||
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())),
|
pinned_goals: Some(serde_json::to_string(&pinned_goals).unwrap_or_else(|_| "[]".to_string())),
|
||||||
|
pending_approvals: Some(pending_approvals_json.clone()),
|
||||||
created_at: conv_created.clone(),
|
created_at: conv_created.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -847,7 +847,7 @@ impl Default for PerConvState {
|
|||||||
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
|
/// 原顶层 `diff` / `path_auth` 字段下沉进 `ApprovalKind` enum 变体(决策1:状态层合 +
|
||||||
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
|
/// 决策层分 —— kind 在 PendingApproval 内携带语义,但 ai_approve/ai_authorize_dir
|
||||||
/// 各只消费自己 kind,入口校验防误调)。
|
/// 各只消费自己 kind,入口校验防误调)。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PendingApproval {
|
pub struct PendingApproval {
|
||||||
pub tool_call_id: String,
|
pub tool_call_id: String,
|
||||||
pub tool_name: String,
|
pub tool_name: String,
|
||||||
@@ -880,7 +880,7 @@ pub struct PendingApproval {
|
|||||||
/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。
|
/// - `Risk { diff }`:普通 Med/High RiskLevel 审批,由 `ai_approve` 消费(一次性 approve/reject)。
|
||||||
/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览;
|
/// `diff`:AE-2025-03(路径 B)write_file 行级 unified diff,供前端审批卡预览;
|
||||||
/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。
|
/// 旧文件不存在(新建)或非 write_file / recovered 审批为 None。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub enum ApprovalKind {
|
pub enum ApprovalKind {
|
||||||
/// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。
|
/// F-260619-03 Phase B: 路径授权挂起(携带待授权目录列表)。
|
||||||
Path(PathAuthRequest),
|
Path(PathAuthRequest),
|
||||||
@@ -891,7 +891,7 @@ pub enum ApprovalKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。
|
/// F-260619-03 Phase B: 路径授权挂起请求(ApprovalKind::Path 标记)。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PathAuthRequest {
|
pub struct PathAuthRequest {
|
||||||
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
|
/// 待授权目录列表(规范化父目录,对齐 session_trust 目录粒度)。
|
||||||
/// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to
|
/// L1 补丁(rename_file 双路径漏校):收集所有未授权父目录,rename_file 的 from+to
|
||||||
|
|||||||
@@ -134,6 +134,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
|
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="ai-enrichment-dismiss" @click="dismissEnrichment" title="取消关联">×</button>
|
||||||
<div v-if="enrichmentExpanded" class="ai-enrichment-panel">
|
<div v-if="enrichmentExpanded" class="ai-enrichment-panel">
|
||||||
<div class="ai-enrichment-label">{{ $t('aiChat.enrichmentLabel') }}</div>
|
<div class="ai-enrichment-label">{{ $t('aiChat.enrichmentLabel') }}</div>
|
||||||
<pre class="ai-enrichment-body"><code>{{ enrichmentDetailText }}</code></pre>
|
<pre class="ai-enrichment-body"><code>{{ enrichmentDetailText }}</code></pre>
|
||||||
@@ -485,6 +486,28 @@ const enrichmentDetailText = computed(() => {
|
|||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 取消 @项目 关联:清除 mentionSpan + 移除输入框中的 [@项目: xxx] 标记 */
|
||||||
|
function dismissEnrichment() {
|
||||||
|
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
|
||||||
|
if (projectSpans.length === 0) return
|
||||||
|
// 从 inputText 中移除所有 [@项目: xxx] 标记
|
||||||
|
let text = inputText.value
|
||||||
|
for (const span of projectSpans) {
|
||||||
|
const label = text.slice(span.start, span.start + span.length)
|
||||||
|
// 替换标记 + 前后可能的空格为单个空格
|
||||||
|
const pattern = new RegExp(`\\s*${escapeRegex(label)}\s*`)
|
||||||
|
text = text.replace(pattern, ' ').trim()
|
||||||
|
}
|
||||||
|
inputText.value = text
|
||||||
|
// 清除 project 类型的 mentionSpan
|
||||||
|
pendingMentionSpans.value = pendingMentionSpans.value.filter(sp => sp.kind !== 'project')
|
||||||
|
enrichmentExpanded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
const mentionGroupLabel = computed(() => ({
|
const mentionGroupLabel = computed(() => ({
|
||||||
project: t('aiChat.mentionGroupProject'),
|
project: t('aiChat.mentionGroupProject'),
|
||||||
task: t('aiChat.mentionGroupTask'),
|
task: t('aiChat.mentionGroupTask'),
|
||||||
@@ -1078,6 +1101,8 @@ defineExpose({
|
|||||||
|
|
||||||
/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
|
/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
|
||||||
.ai-enrichment-bar {
|
.ai-enrichment-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
@@ -1111,6 +1136,20 @@ defineExpose({
|
|||||||
.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
|
.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
.ai-enrichment-dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.ai-enrichment-dismiss:hover {
|
||||||
|
color: var(--df-danger);
|
||||||
|
}
|
||||||
.ai-enrichment-badge {
|
.ai-enrichment-badge {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -538,26 +538,26 @@ onBeforeUnmount(() => {
|
|||||||
.ai-bottom-tools {
|
.ai-bottom-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 14px 4px;
|
padding: 0 14px 4px;
|
||||||
}
|
}
|
||||||
.ai-bottom-title {
|
.ai-bottom-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
margin-right: auto;
|
||||||
margin-right: 8px;
|
|
||||||
}
|
}
|
||||||
.ai-bottom-tools-right {
|
.ai-bottom-tools-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
/* ═══ Provider Bar (header 中间区) ═══ */
|
/* ═══ Provider Bar (header 中间区) ═══ */
|
||||||
.ai-provider-bar {
|
.ai-provider-bar {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function findToolCall(id: string): AiToolCallInfo | undefined {
|
|||||||
// ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ──
|
// ── 审批超时计时器(原 useAiSend,下沉打破 events↔send 循环依赖) ──
|
||||||
|
|
||||||
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝
|
/// 审批等待超时阈值(ms) — 用户 5 分钟内不处理则自动拒绝
|
||||||
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000
|
const APPROVAL_TIMEOUT_MS = Infinity // 已决策:不做超时,用户不审批就一直等
|
||||||
|
|
||||||
/** toolCallId → 审批超时计时器 */
|
/** toolCallId → 审批超时计时器 */
|
||||||
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
const _approvalTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { state } from '@/stores/ai'
|
|||||||
import { notifyConversationChanged } from './useAiEvents'
|
import { notifyConversationChanged } from './useAiEvents'
|
||||||
import { persistUiState } from './useAiPanel'
|
import { persistUiState } from './useAiPanel'
|
||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
import { nextMsgId, getConvState, clearConvStreamState } from './aiShared'
|
import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer } from './aiShared'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import type { AiMessage, AiToolCallInfo } from '@/api/types'
|
import type { AiConversationDetail, AiMessage, AiToolCallInfo } from '@/api/types'
|
||||||
|
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
@@ -92,7 +92,22 @@ let _latestSwitchId = 0
|
|||||||
export async function switchConversation(id: string) {
|
export async function switchConversation(id: string) {
|
||||||
const mySwitchId = ++_latestSwitchId
|
const mySwitchId = ++_latestSwitchId
|
||||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||||
const detail = await aiApi.switchConversation(id)
|
let detail: AiConversationDetail
|
||||||
|
try {
|
||||||
|
detail = await aiApi.switchConversation(id)
|
||||||
|
} catch {
|
||||||
|
// 对话不存在(已删除/未落库的虚 ID)→ 创建新对话替代
|
||||||
|
console.warn('[AI] switchConversation 失败,创建新对话:', id)
|
||||||
|
const created = await aiApi.createConversation()
|
||||||
|
if (mySwitchId !== _latestSwitchId) return
|
||||||
|
void appSettings.set('df-ai-active-conv', created.id)
|
||||||
|
// 用新对话 id 重走后续逻辑
|
||||||
|
detail = { id: created.id, title: null, messages: '[]' }
|
||||||
|
state.messages = []
|
||||||
|
notifyConversationChanged()
|
||||||
|
void loadConversations()
|
||||||
|
return
|
||||||
|
}
|
||||||
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
|
// 过期响应丢弃(用户已切到别的对话,防 A 后返回覆盖 B)
|
||||||
if (mySwitchId !== _latestSwitchId) return
|
if (mySwitchId !== _latestSwitchId) return
|
||||||
state.activeConversationId = id
|
state.activeConversationId = id
|
||||||
@@ -226,6 +241,9 @@ export async function switchConversation(id: string) {
|
|||||||
dir: tc.dir,
|
dir: tc.dir,
|
||||||
reason: tc.reason,
|
reason: tc.reason,
|
||||||
})
|
})
|
||||||
|
// 重新启动审批计时器(APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝,
|
||||||
|
// 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称)
|
||||||
|
startApprovalTimer(tc.id, tc.name, kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -396,8 +396,7 @@ 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) {
|
if (evt?.node_id) {
|
||||||
const node = String(evt.node_id)
|
const node = String(evt.node_id)
|
||||||
wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'failed' }
|
wfNodeStatuses.value = { ...wfNodeStatuses.value, [node]: 'failed' }
|
||||||
|
|||||||
Reference in New Issue
Block a user