优化: aichat 工具体验补强(切生成会话占位气泡治丢首响应/read_symbol符号级提示/失败引导/审批失败toast) + 修复 WorkflowDagDisplay 崩页与 aiChat i18n 缺key

This commit is contained in:
lxy
2026-08-09 21:34:53 +08:00
parent cad76ffdf2
commit 37040616bd
10 changed files with 335 additions and 21 deletions
+8
View File
@@ -188,6 +188,8 @@ const { toast, showToast } = useToast()
let _unlistenToolSlow: (() => void) | null = null
// AE-2025-04: 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式)
let _unlistenAutoApproved: (() => void) | null = null
// P1-e-d: 审批失败 toast unlistener(useAiApproval 审批 IPC 未送达经事件总线 emit,同 _unlistenToolSlow 模式)
let _unlistenAiToast: (() => void) | null = null
// 第二批抽离: inputText/inputEl/图片输入/技能/实体联想/handleSend 已迁移至 ChatInput.vue
// chatInputRef: 父调 expose(focus/startEditFocus/clearInput/hasUnsentInput/sendExamplePrompt)
@@ -466,6 +468,7 @@ onBeforeUnmount(() => {
store.stopContextListener()
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_unlistenAiToast) { _unlistenAiToast(); _unlistenAiToast = null } // P1-e-d
// toast timer 清理由 useToast composable 的 onUnmounted 自动管理
})
@@ -503,6 +506,11 @@ onMounted(async () => {
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
})
// P1-e-d: 审批失败 toast(useAiApproval.approveToolCall 审批 IPC 未送达经事件总线广播,
// 本组件 listen 弹本地 toast。主窗口/分离窗口各挂一份 AiChat,各自消费)
_unlistenAiToast = await listen<{ msg: string; type: 'info' | 'error' | 'warning' | 'success' }>('ai-toast', (e) => {
showToast(e.payload.msg, e.payload.type)
})
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
await initContextEventListeners()
// 恢复生成中态(刷新后/分离窗口接管):
@@ -91,8 +91,13 @@ const emit = defineEmits<{
}>()
const dag = computed<{ nodes: DagNode[]; edges: EdgeDef[] } | null>(() => {
try { return JSON.parse(props.dagJson) }
catch { return null }
try {
const parsed = JSON.parse(props.dagJson)
// 结构校验:dag_json 可能为 {} / 缺 nodes / nodes 非数组(执行记录无 DAG 或异常),解析成功但
// 缺字段会致下方 for..of 抛 "d.nodes is not iterable"。非预期结构 → null 显示空态而非崩页。
if (!parsed || !Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null
return parsed as { nodes: DagNode[]; edges: EdgeDef[] }
} catch { return null }
})
const nodeTypeLabel: Record<string, string> = {
+7 -1
View File
@@ -13,6 +13,7 @@
//! - _pendingApprovalIds:审批按钮防抖守卫(同 useAiSend 原实现)
import { ref } from 'vue'
import { emit } from '@tauri-apps/api/event'
import { aiApi } from '@/api'
import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers'
@@ -99,9 +100,14 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
console.error('[AI] 审批操作未送达后端:', e)
// P1-e-d:审批失败给用户可见反馈——composable 无组件上下文,经 Tauri 事件总线
// emit('ai-toast'),由 AiChat.vue listen 弹本地 toast(对齐 ai-tool-slow-toast 先例)。
// 文案复用 ai.approvalNotDelivered(与下方 tc.result 同源)。
const errMsg = e instanceof Error ? e.message : String(e)
void emit('ai-toast', { msg: t('ai.approvalNotDelivered', { error: errMsg }), type: 'error' })
if (tc) {
tc.status = 'completed'
tc.result = t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) })
tc.result = t('ai.approvalNotDelivered', { error: errMsg })
}
throw e
}
+23 -1
View File
@@ -11,7 +11,7 @@ import { persistUiState } from './useAiPanel'
import { setStreaming } from './streamingGuard'
import { nextMsgId, getConvState, clearConvStreamState, clearLastDelta, startApprovalTimer, clearAllApprovalTimers, switchingConvs, getConvStreamState, setConvCurrentText, notifyConversationChanged } from './aiShared'
import { t } from '@/i18n/i18n-helpers'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types'
import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId, MessageId } from '@/api/types'
const appSettings = useAppSettingsStore()
@@ -333,6 +333,28 @@ export async function switchConversation(id: string, force = false) {
state.messages = parseConvMessages(rawMsgs, 'loaded')
// parse 成功才置 active(保证 active 与 messages 一致)
state.activeConversationId = id
// CSW-P1-4: 切到 round0 进行中的会话时,后端该轮 per_conv 只有 user 消息(assistant 占位
// 轮末才入),末条是 user → 流式 currentText 无 assistant 承载(MessageList isLastAi 不命中,
// flushCurrentText 遇 user break)整轮回复丢失。补前端占位 assistant 气泡承接 currentText,
// 生成完成由 AiCompleted/AiAgentRound 收尾 flushCurrentText 回填 content。
// targetGen(非终止三态 generating/stopping/compressed)在上文已算,直接复用;
// round0-pending- 前缀防御判重(force 重刷同会话时 parseConvMessages 已整体重建,不叠加)。
// 字段满足 AiMessage 必填(id/role/content/timestamp),仅 id 因 branded MessageId 逐字段 cast。
const lastMsg = state.messages[state.messages.length - 1]
if (
targetGen
&& lastMsg
&& lastMsg.role === 'user'
&& !state.messages.some((m) => m.role === 'assistant' && m.id.startsWith('round0-pending-'))
) {
state.messages.push({
id: `round0-pending-${id}` as MessageId,
role: 'assistant',
content: '',
isError: false,
timestamp: Date.now(),
})
}
} catch (e) {
// 历史消息解析/映射失败原仅 state.messages=[] → 切换后空白用户不知原因。
// 改为推错误气泡提示 + 控制台日志(保留 state.messages 不再清空,避免空白无反馈)。
+3
View File
@@ -125,6 +125,9 @@ export default {
inputHint: 'Enter to send · Shift+Enter for newline',
// ── F-260614-05 Phase 2b: image input (paste / drag-drop) ──
pickImage: 'Pick image',
pickImageTitle: 'Pick image (or paste / drag-drop)',
imageFilter: 'Image files',
removeImage: 'Remove image',
imagePreviewAlt: 'Pending image',
imageTooLarge: 'Image too large (max 10MB), skipped',
+3
View File
@@ -126,6 +126,9 @@ export default {
// ── F-260614-05 Phase 2b: 图片输入(粘贴/拖拽) ──
// 粘贴/拖拽图片到输入框,发送时构造多模态 ContentPart[] 渲染到用户气泡
pickImage: '选择图片',
pickImageTitle: '选择图片(可粘贴/拖拽)',
imageFilter: '图片文件',
removeImage: '移除图片',
imagePreviewAlt: '待发送图片',
imageTooLarge: '图片过大(上限 10MB),已跳过',
+35
View File
@@ -129,6 +129,11 @@
{{ $t('aiChat.popupOpenPanel') }}
</button>
<!-- P1-e-d:审批失败 toast(窗口内自包含 useToast;独立 WebviewWindow 无根 toast,自管渲染) -->
<Transition name="ai-toast">
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
</Transition>
<!-- M5:path deny 二次确认(拒绝不可逆, ToolCard 同款;独立窗口本地 ConfirmDialog,
不复用主窗口 useConfirm reactive WebviewWindow JS 上下文隔离) -->
<ConfirmDialog
@@ -149,6 +154,7 @@ import { toolDisplayName } from '@/composables/ai/useToolCardHeader'
import { dispatchApprovalIPC } from '@/composables/ai/useToolApproval'
import { ApprovalPopupEvents } from '@/composables/ai/useApprovalPopup'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useToast } from '@/composables/useToast'
import type { AiToolCallInfo } from '@/api/types'
const { t } = useI18n()
@@ -157,6 +163,9 @@ const { t } = useI18n()
const approvals = ref<AiToolCallInfo[]>([])
/** 操作中 id(防抖 + 按钮加载态) */
const processingId = ref<string>('')
// P1-e-d: 审批失败 toast——独立 WebviewWindow 无共享组件树,窗口内自包含 useToast
// (不走跨窗口 emit 中转,失败反馈一定显示在用户当前关注的浮窗内)
const { toast, showToast } = useToast()
/** 拖动态(样式反馈) */
const isDragging = ref(false)
@@ -436,6 +445,8 @@ async function runApproval(tc: AiToolCallInfo, approved: boolean, decision?: 'on
await maybeAutoClose()
} catch (e) {
console.error('[ApprovalPopup] 审批失败:', e)
// P1-e-d:IPC 未送达给用户可见反馈(独立窗口自包含 toast;文案复用 ai.approvalNotDelivered)
showToast(t('ai.approvalNotDelivered', { error: e instanceof Error ? e.message : String(e) }), 'error')
} finally {
processingId.value = ''
}
@@ -802,4 +813,28 @@ function onDragEnd() { isDragging.value = false }
@keyframes df-spin {
to { transform: rotate(360deg); }
}
/* P1-e-d:审批失败 toast(自包含 useToast;独立窗口无根 toast 自管渲染。fixed 相对本窗口视口,
top 让出 header,不遮挡审批按钮区。样式对齐 AiChat .ai-toast 同款,scoped 防冲突) */
.ai-toast {
position: fixed;
top: 44px;
left: 50%;
transform: translateX(-50%);
padding: 6px 12px;
border-radius: var(--df-radius);
font-size: 11px;
font-weight: 600;
z-index: 100;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
max-width: 80%;
overflow-wrap: anywhere;
text-align: center;
}
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
.ai-toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
</style>