优化: aichat 打磨收尾(切换信任保留 + save并发串行 + 后台授权守卫 + 分离窗口失效 + 气泡截图复制)
This commit is contained in:
@@ -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: 错误气泡操作入口 ──
|
||||
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
|
||||
// 经 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>
|
||||
</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 v-else-if="isLastAi(item.msg) && isViewingGenerating" class="ai-msg-bubble ai-msg-bubble--ai">
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ?? {})
|
||||
}
|
||||
|
||||
/** 后端原始错误转用户友好提示 */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 瞬态失败) */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 项并行写无冲突。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -144,6 +144,10 @@ export default {
|
||||
copyMsg: '复制消息',
|
||||
copied: '已复制',
|
||||
copyFailed: '复制失败',
|
||||
// 气泡复制为图片(html2canvas 渲染 → 剪贴板;失败降级下载)
|
||||
copyAsImage: '复制为图片',
|
||||
copiedAsImage: '已复制为图片',
|
||||
copyAsImageFailed: '复制为图片失败',
|
||||
|
||||
// ── 消息操作栏(UX-02) ──
|
||||
regenerate: '重新生成',
|
||||
|
||||
Reference in New Issue
Block a user