diff --git a/package-lock.json b/package-lock.json index f823aeb..d791004 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "dagre": "^0.8.5", "dompurify": "^3.4.10", "highlight.js": "^11.11.1", + "html2canvas": "^1.4.1", "marked": "^18.0.5", "mermaid": "^11.16.0", "tslib": "^2.8.1", @@ -1861,6 +1862,15 @@ "dev": true, "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": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", @@ -1936,6 +1946,15 @@ "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": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -2666,6 +2685,19 @@ "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": { "version": "0.6.3", "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==", "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": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.3.0.tgz", @@ -3171,6 +3212,15 @@ "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": { "version": "14.0.1", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.1.tgz", diff --git a/package.json b/package.json index 057242c..3fd8604 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dagre": "^0.8.5", "dompurify": "^3.4.10", "highlight.js": "^11.11.1", + "html2canvas": "^1.4.1", "marked": "^18.0.5", "mermaid": "^11.16.0", "tslib": "^2.8.1", diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 879dbec..7d42674 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -203,7 +203,7 @@ pub async fn ai_conversation_create( // F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(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; // 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。 // 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留 @@ -327,7 +327,7 @@ pub async fn ai_conversation_switch( // 切换 active 会话 → 清空上一会话的临时授权目录(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; // 读点:改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否已在生成中 // (是否需要从 DB reload)。压缩期间后台 loop 仍持有 per_conv.messages,reload 会用 DB 旧快照 @@ -352,6 +352,8 @@ pub async fn ai_conversation_switch( } } else { // 目标 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); // reload 仅在内存无「未落库新消息」时才覆盖 per_conv.messages: // 判据 = 内存消息数 > 已持久化计数 或 有全量重写标记(len > persisted_msg_count 或 @@ -395,10 +397,12 @@ pub async fn ai_conversation_switch( }; conv.pinned_goals = pinned_goals; } - // 会话级临时态复位(无论是否 restore messages,重入即复位) - conv.model_override = None; - conv.session_trust.clear(); - conv.agent_language = None; + // 首次切入才复位会话级临时态(信任/语言/模型覆盖保留,防切回重复审批);停止信号与迭代计数始终复位 + if !conv_was_present { + conv.model_override = None; + conv.session_trust.clear(); + conv.agent_language = None; + } conv.iteration_used = 0; 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)); // 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。 session.per_conv.remove(&conversation_id); + // 顺带清理该 conv 的 save 串行锁条目(防 HashMap 无限增长)。 + session.save_locks.remove(&conversation_id); // conv 删除同步清理生成状态条目,防已删 conv 残留活跃态致 id 复用时脏状态。 state.conv_states.remove(&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 字段语义为 // "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。 if was_active { - state.clear_session_allowed_dirs().await; + state.clear_session_allowed_dirs_if_idle().await; } Ok(()) } diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 532d9d1..e433a25 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -196,7 +196,7 @@ pub(crate) async fn save_conversation( // 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。 if let Err(_elapsed) = tokio::time::timeout( 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 { @@ -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>, + db: &Arc, + 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 调用)。 async fn save_conversation_inner( session_arc: &Arc>, diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index a2444a4..4a62250 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -617,6 +617,8 @@ pub struct AiSession { /// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留 /// (单条 ~40B,删除量级下可忽略)。 pub deleted_convs: HashSet, + /// 每会话 save 串行锁:同会话并发 save(loop 与 IPC)串行化,防全量重写互踩吞新行 + pub save_locks: HashMap>>, /// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列) pub namespace_store: df_ai::namespace_store::NamespaceStore, } @@ -630,6 +632,7 @@ impl AiSession { pending_approvals: HashMap::new(), per_conv: HashMap::new(), deleted_convs: HashSet::new(), + save_locks: HashMap::new(), namespace_store: df_ai::namespace_store::NamespaceStore::default(), } } @@ -688,6 +691,14 @@ impl AiSession { pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> { self.per_conv.get(conv_id) } + + /// 取或惰性创建某会话的 save 串行锁(返回 Arc 供 save 全程持有)。 + pub fn save_lock(&mut self, conv_id: &str) -> Arc> { + self.save_locks + .entry(conv_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } } #[cfg(test)] diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 18a6586..8268c7f 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -557,6 +557,13 @@ impl AppState { 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: 追加本次单次授权目录(弹窗"本次"选项)。 /// /// 写入 `allowed_dirs.once`(进程级内存,不落库)。handler 执行工具时 read lock diff --git a/src/components/ai/MessageList.vue b/src/components/ai/MessageList.vue index 000152f..f67199b 100644 --- a/src/components/ai/MessageList.vue +++ b/src/components/ai/MessageList.vue @@ -518,6 +518,42 @@ async function copyMsgContent(msg: AiMessage): Promise { }) } +// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ── +// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖) +async function copyMsgAsImage(msg: AiMessage): Promise { + const bubble = messagesContainer.value?.querySelector( + `.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((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: 错误气泡操作入口 ── // errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段, // 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。 @@ -857,6 +893,14 @@ defineExpose({ > +
@@ -1239,6 +1283,10 @@ defineExpose({ .ai-copy-btn:hover { background: rgba(0, 0, 0, 0.55); } +/* 图片复制按钮排在文本复制按钮左侧(right 让位 22px 按钮 + 4px 间距) */ +.ai-copy-btn--image { + right: 30px; +} /* ── UX-09: 末条 user 消息编辑按钮(hover 显示,回填输入框编辑重生成) ── */ .ai-msg-edit-btn { diff --git a/src/composables/ai/aiShared.ts b/src/composables/ai/aiShared.ts index 4722c9a..ff36c94 100644 --- a/src/composables/ai/aiShared.ts +++ b/src/composables/ai/aiShared.ts @@ -18,8 +18,8 @@ import { t } from '@/i18n/i18n-helpers' import type { AiMessage, AiToolCallInfo, ConvState, MessageId } from '@/api/types' /** 通知会话列表刷新(newConversation/deleteConversation/rename 等触发侧栏更新) */ -export function notifyConversationChanged(): void { - emit('ai-conversation-changed', {}) +export function notifyConversationChanged(payload?: { deletedConvId?: string }): void { + emit('ai-conversation-changed', payload ?? {}) } /** 后端原始错误转用户友好提示 */ diff --git a/src/composables/ai/streamingGuard.ts b/src/composables/ai/streamingGuard.ts index 2fde054..e6997e1 100644 --- a/src/composables/ai/streamingGuard.ts +++ b/src/composables/ai/streamingGuard.ts @@ -21,7 +21,7 @@ //! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。 import { state } from '@/stores/ai' -import { convStates } from './aiShared' +import { convStates, getConvStreamState, setConvStreaming } from './aiShared' import { useAppSettingsStore } from '@/stores/appSettings' /// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。 @@ -56,14 +56,14 @@ export function setStreaming( // flag 关:回退散布语义,直接赋值不动其他。 if (!isGuardEnabled()) { - state.streaming = value + if (convId) setConvStreaming(convId, value) + else state.streaming = value return } - const prev = state.streaming + const prev = convId ? (getConvStreamState(convId)?.streaming ?? false) : state.streaming - // 幂等观测:同值重复写不拒绝(resetStreamWatchdog 等副作用场景需合法重入), - // 仅记 debug 日志供排查「为何重复置位」的卡死场景。 + // 幂等观测:同值重复写不拒绝,仅记 debug 日志供排查卡死场景。 if (prev === value) { console.debug( `[AI-Guard] setStreaming 幂等(prev=${prev}, convId=${convId ?? 'none'}, reason=${reason ?? 'unknown'})`, @@ -74,9 +74,9 @@ export function setStreaming( ) } - // 批4 双轨收口:generating 态真相归 convStates(enum),本 guard 不再联动 generatingConvs - // (bool 轨已退役)。streaming 单值是另一条过渡态(F-09 per-conv streaming 收口前保留)。 - state.streaming = value + // 显式 convId 写目标会话 per-conv 流式态;缺省写当前活跃会话(单会话语义不变)。 + if (convId) setConvStreaming(convId, value) + else state.streaming = value } /** diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 43eb2a6..271ff28 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -474,7 +474,7 @@ async function deleteConversation(id: string) { await loadConversations() // 删的是活跃会话:回落相邻会话作为新活跃视图 if (neighborId) await switchConversation(neighborId) - notifyConversationChanged() + notifyConversationChanged({ deletedConvId: id }) } /** 会话操作失败气泡的 i18n key(M30 族 rename/archive/pin + G3.4 new/delete + G3.3 switch 瞬态失败) */ diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index 1232777..666f08e 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -15,8 +15,9 @@ import { listen, emit } from '@tauri-apps/api/event' import { aiApi } from '@/api' import { useAppSettingsStore } from '@/stores/appSettings' 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 { setStreaming } from './streamingGuard' import { loadConversations } from './useAiConversations' import { handleStreamingEvent } from './useAiStreamingEvents' import { handleToolEvent } from './useAiToolEvents' @@ -140,7 +141,18 @@ export async function startListener() { _startPromise = (async () => { try { _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() }) emit('ai-client-ready', { lastConvId: state.activeConversationId }) } catch (err) { diff --git a/src/composables/ai/useAiWindow.ts b/src/composables/ai/useAiWindow.ts index 102ec3e..a889a37 100644 --- a/src/composables/ai/useAiWindow.ts +++ b/src/composables/ai/useAiWindow.ts @@ -204,12 +204,21 @@ export async function restoreGeneratingState(opts?: { fromMainPanel?: boolean; c if (!fromMainPanel) { state.currentText = (opts?.convId && localStorage.getItem(textKey(opts.convId))) || localStorage.getItem('df-ai-text') || '' } - state.messages.push({ - id: `ai-${nextMsgId()}` as MessageId, - role: 'assistant', - content: '', - timestamp: Date.now(), - }) + // 双占位去重:末条已是空 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({ + id: `ai-${nextMsgId()}` as MessageId, + role: 'assistant', + content: '', + timestamp: Date.now(), + }) + } setStreaming(true, { convId: gen, reason: 'restoreGeneratingState' }) // 批4 双轨收口:恢复生成中态写 convStates(enum 真相源),替代旧 generatingConvs.add。 // setStreaming 联动已退役(批4),streaming 单值与 convStates 项并行写无冲突。 diff --git a/src/i18n/en/aiChat.ts b/src/i18n/en/aiChat.ts index 4b3635b..99e3dad 100644 --- a/src/i18n/en/aiChat.ts +++ b/src/i18n/en/aiChat.ts @@ -143,6 +143,10 @@ export default { copyMsg: 'Copy message', copied: 'Copied', 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) ── regenerate: 'Regenerate', diff --git a/src/i18n/zh-CN/aiChat.ts b/src/i18n/zh-CN/aiChat.ts index 9fd19de..d4a41ff 100644 --- a/src/i18n/zh-CN/aiChat.ts +++ b/src/i18n/zh-CN/aiChat.ts @@ -144,6 +144,10 @@ export default { copyMsg: '复制消息', copied: '已复制', copyFailed: '复制失败', + // 气泡复制为图片(html2canvas 渲染 → 剪贴板;失败降级下载) + copyAsImage: '复制为图片', + copiedAsImage: '已复制为图片', + copyAsImageFailed: '复制为图片失败', // ── 消息操作栏(UX-02) ── regenerate: '重新生成',