优化: aichat 打磨收尾(切换信任保留 + save并发串行 + 后台授权守卫 + 分离窗口失效 + 气泡截图复制)
This commit is contained in:
Generated
+50
@@ -18,6 +18,7 @@
|
|||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"dompurify": "^3.4.10",
|
"dompurify": "^3.4.10",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"mermaid": "^11.16.0",
|
"mermaid": "^11.16.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
@@ -1861,6 +1862,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||||
@@ -1936,6 +1946,15 @@
|
|||||||
"layout-base": "^1.0.0"
|
"layout-base": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -2666,6 +2685,19 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -3107,6 +3139,15 @@
|
|||||||
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyexec": {
|
"node_modules/tinyexec": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||||
@@ -3171,6 +3212,15 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "14.0.1",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.1.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"dompurify": "^3.4.10",
|
"dompurify": "^3.4.10",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"mermaid": "^11.16.0",
|
"mermaid": "^11.16.0",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ pub async fn ai_conversation_create(
|
|||||||
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
|
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
|
||||||
// "当前活跃会话的临时授权",不跨会话继承)。新会话从空白临时授权开始。
|
// "当前活跃会话的临时授权",不跨会话继承)。新会话从空白临时授权开始。
|
||||||
drop(session);
|
drop(session);
|
||||||
state.clear_session_allowed_dirs().await;
|
state.clear_session_allowed_dirs_if_idle().await;
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
// 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
|
// 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
|
||||||
// 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
|
// 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
|
||||||
@@ -327,7 +327,7 @@ pub async fn ai_conversation_switch(
|
|||||||
// 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为"当前活跃会话的临时授权",
|
// 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为"当前活跃会话的临时授权",
|
||||||
// 不跨会话继承)。
|
// 不跨会话继承)。
|
||||||
drop(session);
|
drop(session);
|
||||||
state.clear_session_allowed_dirs().await;
|
state.clear_session_allowed_dirs_if_idle().await;
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
// 读点:改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否已在生成中
|
// 读点:改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否已在生成中
|
||||||
// (是否需要从 DB reload)。压缩期间后台 loop 仍持有 per_conv.messages,reload 会用 DB 旧快照
|
// (是否需要从 DB reload)。压缩期间后台 loop 仍持有 per_conv.messages,reload 会用 DB 旧快照
|
||||||
@@ -352,6 +352,8 @@ pub async fn ai_conversation_switch(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
||||||
|
// 首次切入(内存无该 conv 状态)才复位会话级临时态;切走再切回保留信任/语言/模型覆盖,避免信任遗忘重复审批。
|
||||||
|
let conv_was_present = session.conv_read(&conversation_id).is_some();
|
||||||
let conv = session.conv(&conversation_id);
|
let conv = session.conv(&conversation_id);
|
||||||
// reload 仅在内存无「未落库新消息」时才覆盖 per_conv.messages:
|
// reload 仅在内存无「未落库新消息」时才覆盖 per_conv.messages:
|
||||||
// 判据 = 内存消息数 > 已持久化计数 或 有全量重写标记(len > persisted_msg_count 或
|
// 判据 = 内存消息数 > 已持久化计数 或 有全量重写标记(len > persisted_msg_count 或
|
||||||
@@ -395,10 +397,12 @@ pub async fn ai_conversation_switch(
|
|||||||
};
|
};
|
||||||
conv.pinned_goals = pinned_goals;
|
conv.pinned_goals = pinned_goals;
|
||||||
}
|
}
|
||||||
// 会话级临时态复位(无论是否 restore messages,重入即复位)
|
// 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位
|
||||||
|
if !conv_was_present {
|
||||||
conv.model_override = None;
|
conv.model_override = None;
|
||||||
conv.session_trust.clear();
|
conv.session_trust.clear();
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -626,6 +630,8 @@ pub async fn ai_conversation_delete(
|
|||||||
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));
|
||||||
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
|
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
|
||||||
session.per_conv.remove(&conversation_id);
|
session.per_conv.remove(&conversation_id);
|
||||||
|
// 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。
|
||||||
|
session.save_locks.remove(&conversation_id);
|
||||||
// conv 删除同步清理生成状态条目,防已删 conv 残留活跃态致 id 复用时脏状态。
|
// conv 删除同步清理生成状态条目,防已删 conv 残留活跃态致 id 复用时脏状态。
|
||||||
state.conv_states.remove(&conversation_id);
|
state.conv_states.remove(&conversation_id);
|
||||||
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
|
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
|
||||||
@@ -639,7 +645,7 @@ pub async fn ai_conversation_delete(
|
|||||||
// F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
|
// F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
|
||||||
// "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。
|
// "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。
|
||||||
if was_active {
|
if was_active {
|
||||||
state.clear_session_allowed_dirs().await;
|
state.clear_session_allowed_dirs_if_idle().await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ pub(crate) async fn save_conversation(
|
|||||||
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
|
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
|
||||||
if let Err(_elapsed) = tokio::time::timeout(
|
if let Err(_elapsed) = tokio::time::timeout(
|
||||||
Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS),
|
Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS),
|
||||||
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at),
|
save_conversation_serialized(session_arc, db, conv_id, usage, model, touch_updated_at),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -208,6 +208,23 @@ pub(crate) async fn save_conversation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按 conv 串行化 save(取 per-conv 锁后执行 inner;tokio Mutex lock 可取消,超时放弃不持锁)。
|
||||||
|
async fn save_conversation_serialized(
|
||||||
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
|
db: &Arc<Database>,
|
||||||
|
conv_id: &str,
|
||||||
|
usage: Option<&df_ai::provider::TokenUsage>,
|
||||||
|
model: Option<&str>,
|
||||||
|
touch_updated_at: bool,
|
||||||
|
) {
|
||||||
|
let save_lock = {
|
||||||
|
let mut session = session_arc.lock().await;
|
||||||
|
session.save_lock(conv_id)
|
||||||
|
};
|
||||||
|
let _save_guard = save_lock.lock().await;
|
||||||
|
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。
|
/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。
|
||||||
async fn save_conversation_inner(
|
async fn save_conversation_inner(
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
|
|||||||
@@ -617,6 +617,8 @@ pub struct AiSession {
|
|||||||
/// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留
|
/// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留
|
||||||
/// (单条 ~40B,删除量级下可忽略)。
|
/// (单条 ~40B,删除量级下可忽略)。
|
||||||
pub deleted_convs: HashSet<String>,
|
pub deleted_convs: HashSet<String>,
|
||||||
|
/// 每会话 save 串行锁:同会话并发 save(loop 与 IPC)串行化,防全量重写互踩吞新行
|
||||||
|
pub save_locks: HashMap<String, Arc<tokio::sync::Mutex<()>>>,
|
||||||
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
|
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
|
||||||
pub namespace_store: df_ai::namespace_store::NamespaceStore,
|
pub namespace_store: df_ai::namespace_store::NamespaceStore,
|
||||||
}
|
}
|
||||||
@@ -630,6 +632,7 @@ impl AiSession {
|
|||||||
pending_approvals: HashMap::new(),
|
pending_approvals: HashMap::new(),
|
||||||
per_conv: HashMap::new(),
|
per_conv: HashMap::new(),
|
||||||
deleted_convs: HashSet::new(),
|
deleted_convs: HashSet::new(),
|
||||||
|
save_locks: HashMap::new(),
|
||||||
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
|
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -688,6 +691,14 @@ impl AiSession {
|
|||||||
pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> {
|
pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> {
|
||||||
self.per_conv.get(conv_id)
|
self.per_conv.get(conv_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取或惰性创建某会话的 save 串行锁(返回 Arc 供 save 全程持有)。
|
||||||
|
pub fn save_lock(&mut self, conv_id: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||||
|
self.save_locks
|
||||||
|
.entry(conv_id.to_string())
|
||||||
|
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -557,6 +557,13 @@ impl AppState {
|
|||||||
self.allowed_dirs.write().await.session.clear();
|
self.allowed_dirs.write().await.session.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅当无任何会话在生成时才清空会话临时授权(后台 loop 的文件工具依赖它,真并发下切走不能撤)。
|
||||||
|
pub async fn clear_session_allowed_dirs_if_idle(&self) {
|
||||||
|
if self.conv_states.active_convs().is_empty() {
|
||||||
|
self.clear_session_allowed_dirs().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// F-260619-03 Phase B: 追加本次单次授权目录(弹窗"本次"选项)。
|
/// F-260619-03 Phase B: 追加本次单次授权目录(弹窗"本次"选项)。
|
||||||
///
|
///
|
||||||
/// 写入 `allowed_dirs.once`(进程级内存,不落库)。handler 执行工具时 read lock
|
/// 写入 `allowed_dirs.once`(进程级内存,不落库)。handler 执行工具时 read lock
|
||||||
|
|||||||
@@ -518,6 +518,42 @@ async function copyMsgContent(msg: AiMessage): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ──
|
||||||
|
// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖)
|
||||||
|
async function copyMsgAsImage(msg: AiMessage): Promise<void> {
|
||||||
|
const bubble = messagesContainer.value?.querySelector<HTMLElement>(
|
||||||
|
`.ai-msg[data-msg-id="${msg.id}"] .ai-msg-bubble`,
|
||||||
|
)
|
||||||
|
if (!bubble) {
|
||||||
|
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { default: html2canvas } = await import('html2canvas')
|
||||||
|
const canvas = await html2canvas(bubble, { backgroundColor: null, useCORS: true, scale: 2 })
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||||
|
if (!blob) throw new Error('toBlob failed')
|
||||||
|
if (typeof ClipboardItem !== 'undefined') {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||||
|
emit('toast', { msg: t('aiChat.copiedAsImage'), type: 'info' })
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
// 剪贴板被拒/不可用时降级下载,保证用户仍能拿到图片
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'message.png'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
emit('toast', { msg: t('aiChat.copiedAsImage'), type: 'info' })
|
||||||
|
} catch {
|
||||||
|
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── UX-03: 错误气泡操作入口 ──
|
// ── UX-03: 错误气泡操作入口 ──
|
||||||
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
|
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
|
||||||
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
|
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
|
||||||
@@ -857,6 +893,14 @@ defineExpose({
|
|||||||
>
|
>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="item.msg.content"
|
||||||
|
class="ai-copy-btn ai-copy-btn--ai ai-copy-btn--image"
|
||||||
|
:title="$t('aiChat.copyAsImage')"
|
||||||
|
@click.stop="copyMsgAsImage(item.msg)"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 流式开始但尚无文本时显示光标 -->
|
<!-- 流式开始但尚无文本时显示光标 -->
|
||||||
<div v-else-if="isLastAi(item.msg) && isViewingGenerating" class="ai-msg-bubble ai-msg-bubble--ai">
|
<div v-else-if="isLastAi(item.msg) && isViewingGenerating" class="ai-msg-bubble ai-msg-bubble--ai">
|
||||||
@@ -1239,6 +1283,10 @@ defineExpose({
|
|||||||
.ai-copy-btn:hover {
|
.ai-copy-btn:hover {
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
}
|
}
|
||||||
|
/* 图片复制按钮排在文本复制按钮左侧(right 让位 22px 按钮 + 4px 间距) */
|
||||||
|
.ai-copy-btn--image {
|
||||||
|
right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── UX-09: 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */
|
/* ── UX-09: 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */
|
||||||
.ai-msg-edit-btn {
|
.ai-msg-edit-btn {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { t } from '@/i18n/i18n-helpers'
|
|||||||
import type { AiMessage, AiToolCallInfo, ConvState, MessageId } from '@/api/types'
|
import type { AiMessage, AiToolCallInfo, ConvState, MessageId } from '@/api/types'
|
||||||
|
|
||||||
/** 通知会话列表刷新(newConversation/deleteConversation/rename 等触发侧栏更新) */
|
/** 通知会话列表刷新(newConversation/deleteConversation/rename 等触发侧栏更新) */
|
||||||
export function notifyConversationChanged(): void {
|
export function notifyConversationChanged(payload?: { deletedConvId?: string }): void {
|
||||||
emit('ai-conversation-changed', {})
|
emit('ai-conversation-changed', payload ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后端原始错误转用户友好提示 */
|
/** 后端原始错误转用户友好提示 */
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
|
//! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。
|
||||||
|
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { convStates } from './aiShared'
|
import { convStates, getConvStreamState, setConvStreaming } from './aiShared'
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
|
|
||||||
/// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。
|
/// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。
|
||||||
@@ -56,14 +56,14 @@ export function setStreaming(
|
|||||||
|
|
||||||
// flag 关:回退散布语义,直接赋值不动其他。
|
// flag 关:回退散布语义,直接赋值不动其他。
|
||||||
if (!isGuardEnabled()) {
|
if (!isGuardEnabled()) {
|
||||||
state.streaming = value
|
if (convId) setConvStreaming(convId, value)
|
||||||
|
else state.streaming = value
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = state.streaming
|
const prev = convId ? (getConvStreamState(convId)?.streaming ?? false) : state.streaming
|
||||||
|
|
||||||
// 幂等观测:同值重复写不拒绝(resetStreamWatchdog 等副作用场景需合法重入),
|
// 幂等观测:同值重复写不拒绝,仅记 debug 日志供排查卡死场景。
|
||||||
// 仅记 debug 日志供排查「为何重复置位」的卡死场景。
|
|
||||||
if (prev === value) {
|
if (prev === value) {
|
||||||
console.debug(
|
console.debug(
|
||||||
`[AI-Guard] setStreaming 幂等(prev=${prev}, convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`,
|
`[AI-Guard] setStreaming 幂等(prev=${prev}, convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`,
|
||||||
@@ -74,9 +74,9 @@ export function setStreaming(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批4 双轨收口:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs
|
// 显式 convId 写目标会话 per-conv 流式态;缺省写当前活跃会话(单会话语义不变)。
|
||||||
// (bool 轨已退役)。streaming 单值是另一条过渡态(F-09 per-conv streaming 收口前保留)。
|
if (convId) setConvStreaming(convId, value)
|
||||||
state.streaming = value
|
else state.streaming = value
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -474,7 +474,7 @@ async function deleteConversation(id: string) {
|
|||||||
await loadConversations()
|
await loadConversations()
|
||||||
// 删的是活跃会话:回落相邻会话作为新活跃视图
|
// 删的是活跃会话:回落相邻会话作为新活跃视图
|
||||||
if (neighborId) await switchConversation(neighborId)
|
if (neighborId) await switchConversation(neighborId)
|
||||||
notifyConversationChanged()
|
notifyConversationChanged({ deletedConvId: id })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */
|
/** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ import { listen, emit } from '@tauri-apps/api/event'
|
|||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared'
|
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged } from './aiShared'
|
||||||
import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
|
import { resetStreamWatchdog, clearAllStreamWatchdogs } from './useAiStream'
|
||||||
|
import { setStreaming } from './streamingGuard'
|
||||||
import { loadConversations } from './useAiConversations'
|
import { loadConversations } from './useAiConversations'
|
||||||
import { handleStreamingEvent } from './useAiStreamingEvents'
|
import { handleStreamingEvent } from './useAiStreamingEvents'
|
||||||
import { handleToolEvent } from './useAiToolEvents'
|
import { handleToolEvent } from './useAiToolEvents'
|
||||||
@@ -140,7 +141,18 @@ export async function startListener() {
|
|||||||
_startPromise = (async () => {
|
_startPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
||||||
_unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() })
|
_unlistenConvChanged = await listen('ai-conversation-changed', (e) => {
|
||||||
|
void loadConversations()
|
||||||
|
// 主窗口删除了本窗口当前展示的会话:清空视图防陈旧幽灵(分离窗口独立 JS context,经事件同步)。
|
||||||
|
const deleted = (e.payload as { deletedConvId?: string } | undefined)?.deletedConvId
|
||||||
|
if (deleted && state.activeConversationId === deleted) {
|
||||||
|
state.activeConversationId = null
|
||||||
|
state.messages = []
|
||||||
|
clearConvStreamState(deleted)
|
||||||
|
setStreaming(false, { convId: deleted, reason: 'conv-deleted' })
|
||||||
|
convStates.delete(deleted)
|
||||||
|
}
|
||||||
|
})
|
||||||
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
|
_unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() })
|
||||||
emit('ai-client-ready', { lastConvId: state.activeConversationId })
|
emit('ai-client-ready', { lastConvId: state.activeConversationId })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -204,12 +204,21 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c
|
|||||||
if (!fromMainPanel) {
|
if (!fromMainPanel) {
|
||||||
state.currentText = (opts?.convId && localStorage.getItem(textKey(opts.convId))) || localStorage.getItem('df-ai-text') || ''
|
state.currentText = (opts?.convId && localStorage.getItem(textKey(opts.convId))) || localStorage.getItem('df-ai-text') || ''
|
||||||
}
|
}
|
||||||
|
// 双占位去重:末条已是空 assistant 占位(本会话流式占位)则复用,不重复 push。
|
||||||
|
const lastMsg = state.messages[state.messages.length - 1]
|
||||||
|
const lastIsStreamingPlaceholder = !!lastMsg
|
||||||
|
&& lastMsg.role === 'assistant'
|
||||||
|
&& !lastMsg.content?.trim()
|
||||||
|
&& !lastMsg.isError
|
||||||
|
&& !(lastMsg.toolCalls && lastMsg.toolCalls.length)
|
||||||
|
if (!lastIsStreamingPlaceholder) {
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: `ai-${nextMsgId()}` as MessageId,
|
id: `ai-${nextMsgId()}` as MessageId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: '',
|
content: '',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' })
|
setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' })
|
||||||
// 批4 双轨收口:恢复生成中态写 convStates(enum 真相源),替代旧 generatingConvs.add。
|
// 批4 双轨收口:恢复生成中态写 convStates(enum 真相源),替代旧 generatingConvs.add。
|
||||||
// setStreaming 联动已退役(批4),streaming 单值与 convStates 项并行写无冲突。
|
// setStreaming 联动已退役(批4),streaming 单值与 convStates 项并行写无冲突。
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ export default {
|
|||||||
copyMsg: 'Copy message',
|
copyMsg: 'Copy message',
|
||||||
copied: 'Copied',
|
copied: 'Copied',
|
||||||
copyFailed: 'Copy failed',
|
copyFailed: 'Copy failed',
|
||||||
|
// Copy bubble as image (html2canvas → clipboard; falls back to download)
|
||||||
|
copyAsImage: 'Copy as image',
|
||||||
|
copiedAsImage: 'Copied as image',
|
||||||
|
copyAsImageFailed: 'Failed to copy as image',
|
||||||
|
|
||||||
// ── Message actions bar (UX-02) ──
|
// ── Message actions bar (UX-02) ──
|
||||||
regenerate: 'Regenerate',
|
regenerate: 'Regenerate',
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ export default {
|
|||||||
copyMsg: '复制消息',
|
copyMsg: '复制消息',
|
||||||
copied: '已复制',
|
copied: '已复制',
|
||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
|
// 气泡复制为图片(html2canvas 渲染 → 剪贴板;失败降级下载)
|
||||||
|
copyAsImage: '复制为图片',
|
||||||
|
copiedAsImage: '已复制为图片',
|
||||||
|
copyAsImageFailed: '复制为图片失败',
|
||||||
|
|
||||||
// ── 消息操作栏(UX-02) ──
|
// ── 消息操作栏(UX-02) ──
|
||||||
regenerate: '重新生成',
|
regenerate: '重新生成',
|
||||||
|
|||||||
Reference in New Issue
Block a user