优化: 对话交互修复与界面完善
- 修输入法组词回车直发/分离窗误关/出错后按钮死锁 - 新建任务失败弹窗内联提示,会话切换乐观高亮 - 状态多选联动刷新不再冲空可见行 - 测试连接保留已配开关与权重,防抖按提供商分键 - 代码块复制/长回复折叠带量/队列回填/排队时钟 - 子代理完成卡渲染,死链卡片与引用清理 - 截图渲染换 pro 版依赖,样式 token 化收尾
This commit is contained in:
@@ -116,6 +116,7 @@ import { useAiStore } from '../../stores/ai'
|
||||
import { useProjectStore } from '../../stores/project'
|
||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||
import { textIdle } from '../../composables/ai/useAiPendingState'
|
||||
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
|
||||
import { extractImageUrlParts } from '../../composables/ai/utils'
|
||||
import { uploadFile } from '../../composables/ai/fileUpload'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
@@ -292,7 +293,12 @@ const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
|
||||
const convId = store.state.activeConversationId
|
||||
const cs = getConvState(convId)
|
||||
if (cs === 'stopping') return 'stopping'
|
||||
if (cs === 'error') return 'retry'
|
||||
// P1-3: conv_state='error' 是 AiError 收尾落的终态(useAiLifecycleEvents cleanup 先
|
||||
// setStreaming(false) 再置 error),此时本会话 loop 已死——「重试停止」按钮点 stopChat 对死
|
||||
// loop 无意义,却顶掉发送位,出错后鼠标用户点不了发送(Enter 不看按钮态仍可发,语义错位)。
|
||||
// 仅流式仍在跑(停止失败等 loop 未断的 error)时保留 retry 语义,否则回发送按钮,
|
||||
// 与 Enter/handleSend 对齐。
|
||||
if (cs === 'error') return store.state.streaming ? 'retry' : 'idle'
|
||||
const textActive = store.state.streaming && (cs === 'generating' || !textIdle.value)
|
||||
return textActive ? 'stop' : 'idle'
|
||||
})
|
||||
@@ -676,21 +682,29 @@ function selectMention(item: MentionItem) {
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// P1-1 输入法组词守卫:拼音/双拼组词未上屏时 Enter=选词、Esc=取消组词,均非用户发送/取消意图,
|
||||
// 直接放行给 IME。isComposing 覆盖标准浏览器;keyCode===229 兜部分环境 keydown 阶段不上报 isComposing。
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
// 各 Esc 消费分支 stopPropagation:P1-2 双层防,分离窗口 AiChat 的 window 级 Esc 会关整个窗口,
|
||||
// 内层已消费的 Esc 不再外抛(主防线是 AiChat 侧的 e.target 输入控件守卫,此处为辅)
|
||||
// 编辑态 Escape 取消编辑(最高优先级)
|
||||
if (props.editingMsgId && e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
emit('cancel-edit')
|
||||
return
|
||||
}
|
||||
// Escape 关闭技能联想浮层(无论是否有匹配项)
|
||||
if (skillOpen.value && e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
skillOpen.value = false
|
||||
return
|
||||
}
|
||||
// 已选技能 chip 态下 Escape 取消选中(浮层已关闭,这里兜底 chip 可取消路径)
|
||||
if (pendingSkill.value && e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
pendingSkill.value = null
|
||||
return
|
||||
}
|
||||
@@ -713,6 +727,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
skillOpen.value = false
|
||||
return
|
||||
}
|
||||
@@ -736,6 +751,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
return
|
||||
@@ -859,9 +875,10 @@ async function handleSend() {
|
||||
if (snapshotSpans.length) {
|
||||
pendingMentionSpans.value = snapshotSpans
|
||||
}
|
||||
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
|
||||
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象。
|
||||
// I5:按错误内容分类映射友好文案,原始错误串留在 {msg} 细节里不丢排查线索
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('error', { msg: t('aiChat.toastSendFail', { msg }), type: 'error' })
|
||||
emit('error', { msg: t(SEND_ERR_KEY[classifySendError(msg)], { msg }), type: 'error' })
|
||||
}
|
||||
await nextTick()
|
||||
emit('sent')
|
||||
@@ -902,11 +919,65 @@ function clearInput(): void {
|
||||
if (inputEl.value) inputEl.value.style.height = 'auto'
|
||||
}
|
||||
|
||||
/** I4 队列回填:父把取出的队列项文本填回输入框重新编辑。
|
||||
* 可选 skill:队列项携带技能名时由父按名找回 SkillInfo 回挂 chip;找回失败传 null 仅丢
|
||||
* chip(与队列原地编辑只保 text 同一取舍)。清 mention 区间/浮层态,光标置尾并聚焦。 */
|
||||
function fillDraft(text: string, skill: SkillInfo | null = null): void {
|
||||
pendingSkill.value = skill
|
||||
inputText.value = text
|
||||
pendingMentionSpans.value = []
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
nextTick(() => {
|
||||
inputEl.value?.focus()
|
||||
const el = inputEl.value
|
||||
if (el) {
|
||||
const len = el.value.length
|
||||
el.setSelectionRange(len, len)
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 聚焦输入框 */
|
||||
function focus(): void {
|
||||
inputEl.value?.focus()
|
||||
}
|
||||
|
||||
// ── V8 per-conv 草稿:切会话保存/恢复,防 A 会话未发送草稿串到 B 输入框 ──
|
||||
// 草稿真相源在 aiShared.convDrafts(内存 Map,不落 localStorage)。父组件 AiChat 的
|
||||
// activeConversationId watcher 注册更早先执行:编辑态切换时父先 cancelEdit 清输入,
|
||||
// 本 watcher 后执行,旧会话保存到的是清空后的空串,与既有「切会话丢弃编辑缓冲」语义一致。
|
||||
watch(() => store.state.activeConversationId, (nv, ov) => {
|
||||
if (ov) setConvDraft(ov, inputText.value)
|
||||
inputText.value = getConvDraft(nv)
|
||||
// 随输入框的临时态不跨会话携带:mention 区间偏移只对旧文本有效,照搬到恢复的文本会渲染错位
|
||||
// chip;技能 chip 与待发图片同理,残留到新会话会静默混入下一条消息
|
||||
pendingMentionSpans.value = []
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
pendingSkill.value = null
|
||||
pendingImages.value = []
|
||||
skillOpen.value = false
|
||||
// 恢复文本会触发输入框自身 watch 重算联想(文本以 @ 结尾时按旧光标位误拉起浮层),
|
||||
// nextTick 在该 watch 之后统一收口,并按恢复文本重算输入框高度
|
||||
nextTick(() => {
|
||||
mentionOpen.value = false
|
||||
mentionStart.value = -1
|
||||
autoResize()
|
||||
})
|
||||
})
|
||||
|
||||
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
|
||||
// other 复用既有 toastSendFail 文案,行为与改造前一致 ──
|
||||
const SEND_ERR_KEY: Record<SendErrorKind, string> = {
|
||||
rate_limit: 'aiChat.sendErrRateLimit',
|
||||
quota: 'aiChat.sendErrQuota',
|
||||
network: 'aiChat.sendErrNetwork',
|
||||
other: 'aiChat.toastSendFail',
|
||||
}
|
||||
|
||||
/**
|
||||
* 未发送输入保护。检测 inputText/pendingImages/pendingSkill 任一非空,
|
||||
* 用于 beforeunload 拦截(分离窗口关闭/刷新)与未来 onBeforeUnmount 拦截(面板切换)。
|
||||
@@ -923,6 +994,7 @@ defineExpose({
|
||||
focus,
|
||||
startEditFocus,
|
||||
clearInput,
|
||||
fillDraft,
|
||||
hasUnsentInput,
|
||||
sendExamplePrompt,
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
class="ai-conv-item"
|
||||
:class="{
|
||||
'ai-conv-item--active': conv.id === store.state.activeConversationId,
|
||||
'ai-conv-item--switching': conv.id === pendingSwitchId,
|
||||
'ai-conv-item--archived': conv.archived,
|
||||
'ai-conv-item--pinned': conv.pinned,
|
||||
}"
|
||||
@@ -49,6 +50,8 @@
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -102,8 +105,8 @@
|
||||
:key="conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="beginSwitch(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<input
|
||||
@@ -111,8 +114,7 @@
|
||||
:ref="setEditEl"
|
||||
v-model="editText"
|
||||
class="ai-conv-item-edit"
|
||||
@keyup.enter="commitRename(conv.id)"
|
||||
@keyup.esc="cancelRename"
|
||||
@keydown="onRenameKeydown($event, conv.id)"
|
||||
@blur="commitRename(conv.id)"
|
||||
@click.stop
|
||||
/>
|
||||
@@ -132,6 +134,8 @@
|
||||
><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -171,13 +175,15 @@
|
||||
:key="'a-' + conv.id"
|
||||
:ref="(el) => setItemEl(conv.id, el)"
|
||||
class="ai-conv-item ai-conv-item--archived"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="store.switchConversation(conv.id)"
|
||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--switching': conv.id === pendingSwitchId, 'ai-conv-item--pinned': conv.pinned }"
|
||||
@click="beginSwitch(conv.id)"
|
||||
>
|
||||
<div class="ai-conv-item-main">
|
||||
<span class="ai-conv-item-title" :title="conv.title || ''">
|
||||
{{ conv.title || t('aiChat.newConversation') }}
|
||||
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
|
||||
<!-- V1: 待审批徽标(橙点计数,复用既有 aiChat.pendingApprovalCount 文案) -->
|
||||
<span v-if="pendingCountOf(conv.id) > 0" class="ai-conv-approve-badge" :title="t('aiChat.pendingApprovalCount', { n: pendingCountOf(conv.id) })">{{ pendingCountOf(conv.id) }}</span>
|
||||
</span>
|
||||
<span class="ai-conv-item-time">{{ formatRelative(conv.updated_at) }}</span>
|
||||
</div>
|
||||
@@ -235,6 +241,8 @@
|
||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
// V1 只读消费:跨会话待审批真相源(state.pendingApprovals 仅是活跃会话投影,后台会话挂起不进投影)
|
||||
import { convPendingApprovals } from '../../composables/ai/aiShared'
|
||||
import { formatRelative } from '../../utils/time'
|
||||
import type { AiConversationSummary } from '../../api/types'
|
||||
|
||||
@@ -302,6 +310,20 @@ function cancelRename() {
|
||||
editingConvId.value = null
|
||||
}
|
||||
|
||||
/** P1-1 重命名框键盘处理:输入法组词中(isComposing/229)的 Enter/Esc 属 IME 选词/取消组词,
|
||||
* 不当重命名提交/取消处理,防标题被半截拼音覆盖。原 keyup.enter/.esc 改 keydown 统一挂守卫:
|
||||
* 组词上屏的回车到 keyup 阶段 isComposing 已复位守不住,keydown 才拦得到。 */
|
||||
function onRenameKeydown(e: KeyboardEvent, convId: string) {
|
||||
if (e.isComposing || e.keyCode === 229) return
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitRename(convId)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 会话项「更多」操作菜单(置顶/归档/删除)──
|
||||
const actionsOpenId = ref<string | null>(null)
|
||||
|
||||
@@ -321,13 +343,31 @@ function onActionsOutsideClick(e: MouseEvent) {
|
||||
actionsOpenId.value = null
|
||||
}
|
||||
|
||||
// ── 会话切换过渡态(UX走查 2026-10-03 P1):点击即预高亮目标项(乐观),IPC 返回后随
|
||||
// activeConversationId 落定接管高亮;失败/过期时 finally 清 pending,高亮自动回滚到原活跃项。
|
||||
// 治:大历史会话 IPC 数百 ms 期间点击项不高亮、原高亮不动、无任何进行时反馈,快速连点像失灵。
|
||||
const pendingSwitchId = ref<string | null>(null)
|
||||
function beginSwitch(id: string) {
|
||||
// 已活跃会话:switchConversation 内部同判短路,无过渡期,不挂 pending
|
||||
if (id === store.state.activeConversationId) {
|
||||
void store.switchConversation(id)
|
||||
return
|
||||
}
|
||||
pendingSwitchId.value = id
|
||||
void store.switchConversation(id).finally(() => {
|
||||
// 仅当 pending 仍是本次点击的 id 才清:快速连点 B 覆盖 A 后,A 的迟到返回不得清掉 B 的过渡态
|
||||
if (pendingSwitchId.value === id) pendingSwitchId.value = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
|
||||
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
|
||||
*/
|
||||
function onSelectSearchResult(id: string) {
|
||||
void store.switchConversation(id)
|
||||
// 走 beginSwitch 挂切换过渡态(乐观预高亮),与分组视图点击口径一致
|
||||
beginSwitch(id)
|
||||
store.state.searchQuery = ''
|
||||
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
|
||||
void nextTick(() => ensureActiveVisible())
|
||||
@@ -370,6 +410,30 @@ function ensureActiveVisible(): void {
|
||||
})
|
||||
}
|
||||
|
||||
// ── V1: 跨会话待审批徽标 ──
|
||||
// convPendingApprovals Map 本体非响应式(aiShared 注明渲染只依赖投影数组),故 computed 读 Map 的
|
||||
// 同时锚定三类响应式源触发重算:
|
||||
// - state.pendingApprovals(活跃会话投影,审批增删/切换恢复整体替换时必变)→ 活跃会话即时刷新;
|
||||
// - conversations 列表(新建/删除/排序刷新);
|
||||
// - pendingTick 2.5s 轮询兜底:后台会话新增/解除挂起不触碰任何响应式源(aiShared 只写裸 Map),
|
||||
// 轻量周期重算补偿(徽标属弱实时信息,秒级延迟可接受)。
|
||||
const pendingTick = ref(0)
|
||||
let pendingPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const pendingCountByConv = computed(() => {
|
||||
void store.state.pendingApprovals.length
|
||||
void store.state.conversations.length
|
||||
void pendingTick.value
|
||||
const out = new Map<string, number>()
|
||||
for (const [convId, arr] of convPendingApprovals) {
|
||||
if (arr.length > 0) out.set(convId, arr.length)
|
||||
}
|
||||
return out
|
||||
})
|
||||
function pendingCountOf(convId: string): number {
|
||||
return pendingCountByConv.value.get(convId) ?? 0
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
const bucketLabels = computed<Record<string, string>>(() => ({
|
||||
today: t('aiChat.today'),
|
||||
@@ -447,10 +511,14 @@ function onResizeStart(e: MouseEvent) {
|
||||
onMounted(() => {
|
||||
// 点击外部关闭更多操作菜单
|
||||
document.addEventListener('click', onActionsOutsideClick)
|
||||
// V1:后台会话待审批变化兜底轮询(见 pendingCountByConv 注)
|
||||
pendingPollTimer = setInterval(() => { pendingTick.value++ }, 2500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onActionsOutsideClick)
|
||||
// 释放待审批徽标轮询计时器
|
||||
if (pendingPollTimer) { clearInterval(pendingPollTimer); pendingPollTimer = null }
|
||||
// 拖拽中途卸载兜底摘临时 document listener(原 AiChat 同段迁移)
|
||||
document.removeEventListener('mousemove', onResizeMove)
|
||||
document.removeEventListener('mouseup', onResizeEnd)
|
||||
@@ -612,6 +680,12 @@ body.ai-sidebar-resizing * {
|
||||
.ai-conv-item--active {
|
||||
background: var(--df-sidebar-active);
|
||||
}
|
||||
/* 切换进行时(UX走查 P1):与活跃项同底色示"已选中"(乐观预高亮),降不透明度示"加载中";
|
||||
IPC 返回后 activeConversationId 落定、本类移除,视觉无缝衔接;失败回滚高亮回原活跃项 */
|
||||
.ai-conv-item--switching {
|
||||
background: var(--df-sidebar-active);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.ai-conv-item--archived .ai-conv-item-title {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -676,6 +750,21 @@ body.ai-sidebar-resizing * {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
/* V1: 跨会话待审批徽标(橙点计数,warning 色系与待审批语义一致;固定尺寸不挤标题换行) */
|
||||
.ai-conv-approve-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 3px;
|
||||
border-radius: 6px;
|
||||
background: var(--df-warning, #f0c75e);
|
||||
color: #000;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
cursor: help;
|
||||
}
|
||||
.ai-conv-item-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
<template>
|
||||
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更)。
|
||||
条件:pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||
点继续 → ai_continue_loop(后端续 max_iterations 轮)→ 卡片隐藏(等后端事件)。
|
||||
点停止 → ai_stop_loop(后端走 AiCompleted 收尾)→ 卡片隐藏。
|
||||
store 单例共享(state/aiApi),pendingMaxRounds 模块级 ref(useAiEvents)直接导入。
|
||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||
<div v-if="showMaxRoundsCard" ref="maxRoundsRef" class="ai-max-rounds">
|
||||
<span class="ai-max-rounds-text">{{ $t('aiChat.maxRoundsReached') }}</span>
|
||||
<span class="ai-max-rounds-hint">{{ $t('aiChat.maxRoundsHint') }}</span>
|
||||
<div class="ai-max-rounds-actions">
|
||||
<button
|
||||
class="ai-max-rounds-btn ai-max-rounds-btn--continue"
|
||||
:disabled="maxRoundsActing"
|
||||
@click="handleContinueLoop"
|
||||
>{{ $t('aiChat.continueLoop') }}</button>
|
||||
<button
|
||||
class="ai-max-rounds-btn ai-max-rounds-btn--stop"
|
||||
:disabled="maxRoundsActing"
|
||||
@click="handleStopLoop"
|
||||
>{{ $t('aiChat.stopLoop') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||
import { pendingMaxRounds } from '../../composables/ai/useAiPendingState'
|
||||
import { aiApi } from '../../api'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||
}>()
|
||||
|
||||
const store = useAiStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// F-260620 根治(达 max 挂起无声卡死):同 DirAuthDialog,去 streaming 依赖。
|
||||
// 达 max(agentic:1186 guard.disarm + AiMaxRoundsReached,generating 保持 true)发生在多轮迭代后,
|
||||
// 耗时长,130s 流式看门狗极易在迭代间隙超时清 streaming → 卡片因 streaming=false 不弹 → 用户看不到
|
||||
// "继续/停止" → generating 永真卡死。达 max 比 DirAuthAuth 更易触发(必经多轮)。去 streaming,
|
||||
// isGenerating(conv) 单条件覆盖挂起态(达 max 时 generatingConvs 含 conv,useAiEvents handleEvent 对非完成/错误事件 add)。
|
||||
// 多会话并发 — isGenerating(active) 替代单值比对
|
||||
const isViewingGenerating = computed(() =>
|
||||
store.isGenerating(store.state.activeConversationId),
|
||||
)
|
||||
|
||||
// L2 状态机(批2 1c):达 max 可靠弹判断改读 conv_state(generating),诊断痛②看门狗误清 streaming
|
||||
// 致卡片不弹的根解。conv_state 作新增派生源,旧 isGenerating 双轨过渡兜底——conv_state 未收到
|
||||
// 时(CONV_STATE_ENABLED=off 或老后端)回退 isGenerating 判断,不强制全替防回归。
|
||||
// 注:达 max 时后端仍 generating(conv_state=generating,审批挂起期亦是 generating),故读
|
||||
// conv_state===generating 覆盖挂起态;compressed(压缩派生)也视为活跃可弹(达 max 与压缩互斥,
|
||||
// 理论不并存,此处宽放以防边界)。
|
||||
const isViewingGeneratingState = computed(() => {
|
||||
const cs = getConvState(store.state.activeConversationId)
|
||||
if (cs === 'generating' || cs === 'compressed') return true
|
||||
if (cs === 'idle' || cs === 'error' || cs === 'stopping') return false
|
||||
// conv_state 未追踪过(null)→ 回退旧 isGenerating bool 判断
|
||||
return isViewingGenerating.value
|
||||
})
|
||||
|
||||
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
|
||||
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
|
||||
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
|
||||
// maxRoundsActing 本地 ref 持按钮 loading:点继续/停止后禁双按钮,等后端事件
|
||||
// (continueLoop→新一轮 AiAgentRound;stopLoop→AiCompleted)自然清卡片。
|
||||
const maxRoundsActing = ref(false)
|
||||
const showMaxRoundsCard = computed(() =>
|
||||
!!pendingMaxRounds.value
|
||||
&& pendingMaxRounds.value === store.state.activeConversationId
|
||||
&& isViewingGeneratingState.value,
|
||||
)
|
||||
|
||||
// M8:卡片根 ref。达 max 挂起卡位于输入区上方,多轮迭代后消息流已很长,卡片常被挤出视口 → 用户看不到
|
||||
// "继续/停止" → generating 永真卡死。卡片显示时 scrollIntoView 让操作入口立即可见。
|
||||
const maxRoundsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
||||
* 不主动清 pendingMaxRounds——由后端新一轮事件(AiAgentRound)与最终 AiCompleted/AiError
|
||||
* 在 useAiEvents 内清。IPC 失败回滚 acting 让用户可重试。 */
|
||||
async function handleContinueLoop(): Promise<void> {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
maxRoundsActing.value = true
|
||||
try {
|
||||
await aiApi.continueLoop(convId)
|
||||
} catch (e) {
|
||||
maxRoundsActing.value = false
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.continueLoopFailed', { msg }), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 点停止:调 ai_stop_loop。后端复位 generating + emit AiCompleted,useAiEvents 据此
|
||||
* 清 pendingMaxRounds。IPC 失败回滚 acting。 */
|
||||
async function handleStopLoop(): Promise<void> {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
maxRoundsActing.value = true
|
||||
try {
|
||||
await aiApi.stopLoop(convId)
|
||||
} catch (e) {
|
||||
maxRoundsActing.value = false
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
emit('toast', { msg: t('aiChat.stopLoopFailed', { msg }), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
/** pendingMaxRounds 离开暂停态(后端事件已清 → null)或切到非本会话挂起时复位 acting,允许下次再操作 */
|
||||
watch(() => pendingMaxRounds.value, (v) => {
|
||||
if (!v) maxRoundsActing.value = false
|
||||
})
|
||||
|
||||
/** M8:卡片显示(从无→有)时 scrollIntoView 让"继续/停止"按钮组立即进入视口。
|
||||
* 仅监 showMaxRoundsCard 真值翻转,不监 acting 等内部态避免已可见时反复跳。 */
|
||||
watch(showMaxRoundsCard, async (show) => {
|
||||
if (!show) return
|
||||
await nextTick()
|
||||
maxRoundsRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-max-rounds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
||||
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.ai-max-rounds-text {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: var(--df-warning);
|
||||
}
|
||||
.ai-max-rounds-hint {
|
||||
font-size: 10.5px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.ai-max-rounds-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ai-max-rounds-btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: calc(var(--df-radius) - 2px);
|
||||
transition: filter 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-max-rounds-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-max-rounds-btn--continue {
|
||||
background: var(--df-warning);
|
||||
color: #000;
|
||||
}
|
||||
.ai-max-rounds-btn--continue:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.ai-max-rounds-btn--stop {
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-max-rounds-btn--stop:hover:not(:disabled) {
|
||||
color: var(--df-text);
|
||||
border-color: var(--df-text-dim);
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||
import { loadMoreHistory, getLoadMoreState } from '../../composables/ai/useAiConversations'
|
||||
import { collectTurnStats, turnSummaryText, sumTurnTokens, formatTokensK, type TurnCallStats } from '../../composables/ai/useTurnTrace'
|
||||
import { getConvAgentFinished, type AgentFinishedCard } from '../../composables/ai/aiShared'
|
||||
import ToolCardList from '../ToolCardList.vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import EmptyState from './EmptyState.vue'
|
||||
@@ -540,16 +541,18 @@ async function copyMsgContent(msg: AiMessage): Promise<void> {
|
||||
|
||||
// ── 消息气泡复制为图片(html2canvas 渲染 PNG 写入剪贴板) ──
|
||||
// 原生 ClipboardItem 即可写图片到剪贴板,故不加 Tauri 剪贴板插件(少装依赖)
|
||||
// 用 html2canvas-pro 替代 html2canvas:后者不支持 color-mix/oklch 现代色彩函数,渲染气泡样式会抛错
|
||||
async function copyMsgAsImage(msg: AiMessage): Promise<void> {
|
||||
// msg.id 经 CSS.escape 转义:含特殊字符的 id 在属性选择器里会匹配失败(引号内转义合法)
|
||||
const bubble = messagesContainer.value?.querySelector<HTMLElement>(
|
||||
`.ai-msg[data-msg-id="${msg.id}"] .ai-msg-bubble`,
|
||||
`.ai-msg[data-msg-id="${CSS.escape(msg.id)}"] .ai-msg-bubble`,
|
||||
)
|
||||
if (!bubble) {
|
||||
emit('toast', { msg: t('aiChat.copyAsImageFailed'), type: 'error' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { default: html2canvas } = await import('html2canvas')
|
||||
const { default: html2canvas } = await import('html2canvas-pro')
|
||||
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')
|
||||
@@ -574,6 +577,72 @@ async function copyMsgAsImage(msg: AiMessage): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── R4 代码块复制:渲染层 html 装饰 + 容器级事件委托 ──
|
||||
// 代码块经 marked 渲染后以 v-html 注入,模板层无法逐块插按钮;改为在 html 字符串层把
|
||||
// <pre><code> 包进 .md-code-block 并注入复制按钮(发生在 renderMd 的 DOMPurify 消毒之后,
|
||||
// 注入节点不经消毒通道,仅静态结构无用户内容,安全)。点击走容器级事件委托(单监听器,
|
||||
// 不逐块绑定)。结果 memo 缓存:v-html 按字符串相等决定是否重写 DOM,不缓存会导致组件每次
|
||||
// 重渲染都重设 innerHTML,打破 AC-EFF-S2-1「已完成块 DOM 稳定」优化。
|
||||
const _decoCache = new Map<string, string>()
|
||||
const DECO_CACHE_LIMIT = 800
|
||||
function decorateCodeHtml(raw: string): string {
|
||||
if (!raw || !raw.includes('<pre><code')) return raw
|
||||
const hit = _decoCache.get(raw)
|
||||
if (hit !== undefined) return hit
|
||||
// marked 的 code renderer 统一输出 <pre><code class="hljs ...">…</code></pre>
|
||||
//(含缩进码块),代码内容已 HTML 转义,正则非贪婪到首个闭合标签不会越界
|
||||
const out = raw.replace(
|
||||
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
||||
'<div class="md-code-block"><button type="button" class="md-code-copy">复制</button><pre><code$1>$2</code></pre></div>',
|
||||
)
|
||||
if (_decoCache.size > DECO_CACHE_LIMIT) _decoCache.clear()
|
||||
_decoCache.set(raw, out)
|
||||
return out
|
||||
}
|
||||
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
|
||||
function renderMdDecorated(raw: string): string {
|
||||
return decorateCodeHtml(renderMd(raw))
|
||||
}
|
||||
|
||||
// 流式分块同走装饰(computed 缓存:块 html 未变时返回同串,v-html 不触发 DOM 重写)
|
||||
const decoratedStreamingBlocks = computed(() =>
|
||||
streamingBlocks.value.map(b => ({ key: b.key, html: decorateCodeHtml(b.html) })),
|
||||
)
|
||||
|
||||
/** 容器级事件委托:点击 .md-code-copy 复制所属代码块原文(pre.textContent 自动还原 HTML 转义) */
|
||||
async function onMdCodeCopyClick(e: MouseEvent): Promise<void> {
|
||||
const btn = (e.target as HTMLElement | null)?.closest('button.md-code-copy') as HTMLButtonElement | null
|
||||
if (!btn) return
|
||||
const code = btn.parentElement?.querySelector('pre')?.textContent ?? ''
|
||||
// 必须在 click 同步链内发起 clipboard 调用(浏览器安全限制);非 secure context 走
|
||||
// execCommand 兜底(对齐 copyMsgContent 降级链),两者均失败如实反馈
|
||||
let ok = false
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(code)
|
||||
ok = true
|
||||
}
|
||||
} catch { /* 权限拒绝/中断,落入 execCommand 兜底 */ }
|
||||
if (!ok) {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = code
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try { ok = document.execCommand('copy') } catch { ok = false }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
// 成功态短暂反馈(直接改按钮 DOM;下次 v-html 重渲染自然还原,无需状态机)
|
||||
const prev = btn.textContent
|
||||
btn.textContent = ok ? '已复制' : '复制失败'
|
||||
btn.classList.add(ok ? 'is-ok' : 'is-err')
|
||||
setTimeout(() => {
|
||||
btn.textContent = prev
|
||||
btn.classList.remove('is-ok', 'is-err')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
// ── 错误气泡操作入口 ──
|
||||
// errorType 经 useAiEvents push 错误消息时附带(types.ts AiMessage 未含此字段,
|
||||
// 经 cast 读写闭环在 AiChat.vue / useAiEvents 两端,不污染 types.ts 定义)。
|
||||
@@ -710,6 +779,18 @@ function shouldCollapseLong(msg: AiMessage): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
// R3:折叠条带量 —— 折叠态按钮显示「已折叠 N 字符 / 约 M tokens,点击展开」。
|
||||
// 折叠量按 M1 的标定估算(400px 视口 ≈ 阈值字符量,见 LONG_MSG_THRESHOLD 注),
|
||||
// 即 content.length - 阈值;token 为粗估(中英混合 1 token ≈ 2.5 字符),只供量级感知。
|
||||
// 文案硬编码中文:i18n locale 文件本批锁定(他代理并行追加,不新增键),
|
||||
// 展开态仍复用既有 aiChat.collapse 键。
|
||||
const CHAR_PER_TOKEN_EST = 2.5
|
||||
function collapsedLongLabel(msg: AiMessage): string {
|
||||
const folded = Math.max(0, (msg.content?.length ?? 0) - LONG_MSG_THRESHOLD)
|
||||
const tokens = Math.max(1, Math.round(folded / CHAR_PER_TOKEN_EST))
|
||||
return `已折叠 ${folded} 字符 / 约 ${tokens} tokens,点击展开`
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 工具结果折叠防御(对齐 miniapp apps/df-miniapp/src/pages/chat/index.vue:344-354
|
||||
* isToolResultJson):assistant content 为纯 JSON 工具结果(trim 后以 {/[ 开头且 JSON.parse
|
||||
@@ -828,6 +909,8 @@ type RenderItem =
|
||||
| { kind: 'step'; key: string; round: number; stats: TurnCallStats }
|
||||
// UX 密度改造:相邻消息间隔 ≥30 分钟或跨天的居中时间分隔线(替代 per-block 相对时间)
|
||||
| { kind: 'timesep'; key: string; ts: number }
|
||||
// S1 子代理完成卡(AiAgentFinished 事件):非消息数据(per-conv 存 aiShared),逐卡一项
|
||||
| { kind: 'agentFin'; key: string; card: AgentFinishedCard }
|
||||
|
||||
/**
|
||||
* 渲染项(A 过程压缩):在归档分段之上叠加轮次折叠。
|
||||
@@ -964,9 +1047,36 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
}
|
||||
}
|
||||
flushBucket()
|
||||
// S1 子代理完成卡追加在消息流末尾:子代理在父轮内运行,完成语义上晚于当轮内容;
|
||||
// 逐卡一项(key 按 agentId 稳定),同会话多子代理多卡共存,Vue diff 复用不闪。
|
||||
// 真相源是 aiShared.convAgentFinished(per-conv),切会话重进仍可见,非组件局部状态。
|
||||
for (const card of getConvAgentFinished(store.state.activeConversationId)) {
|
||||
items.push({ kind: 'agentFin', key: 'agentfin-' + card.agentId, card })
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// ── S1 子代理完成卡(AiAgentFinished) ──
|
||||
// 状态→色调三态:completed 中性(常态结果不抢视线)/ truncated 警示(结果不完整)/
|
||||
// failed 错误(需关注);未知的 status 字面量兜底中性,不误报成功也不误报失败。
|
||||
function agentFinTone(status: AgentFinishedCard['status']): 'ok' | 'warn' | 'err' {
|
||||
if (status === 'truncated') return 'warn'
|
||||
if (status === 'failed') return 'err'
|
||||
return 'ok'
|
||||
}
|
||||
// 状态词独立于颜色透出(色弱可用);键与后端 SubAgentStatus::as_str 字面量一一对应
|
||||
function agentFinStatusText(status: AgentFinishedCard['status']): string {
|
||||
if (status === 'truncated') return t('ai.agentFinished.truncated')
|
||||
if (status === 'failed') return t('ai.agentFinished.failed')
|
||||
return t('ai.agentFinished.completed')
|
||||
}
|
||||
// 摘要超长截断:单行细条承载,压平空白后按 160 字截断(对齐轨迹卡 500 字口径的轻量版),
|
||||
// 全文经 title hover 兜底不丢信息。
|
||||
function agentFinSummary(summary: string): string {
|
||||
const s = (summary || '').replace(/\s+/g, ' ').trim()
|
||||
return s.length > 160 ? s.slice(0, 160) + '…' : s
|
||||
}
|
||||
|
||||
// UX 密度改造:轮次导航条(仅多轮长会话 ≥3 张轨迹卡出现,sticky 置顶,只做跳转不做统计)
|
||||
const traceNav = computed(() =>
|
||||
renderItems.value.filter((it): it is Extract<RenderItem, { kind: 'trace' }> => it.kind === 'trace')
|
||||
@@ -1025,7 +1135,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<!-- Messages -->
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll">
|
||||
<div class="ai-messages" ref="messagesContainer" @scroll="handleMessagesScroll" @click="onMdCodeCopyClick">
|
||||
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
|
||||
<EmptyState
|
||||
v-if="store.state.messages.length === 0 && !store.state.streaming"
|
||||
@@ -1085,7 +1195,7 @@ defineExpose({
|
||||
<div
|
||||
v-if="expandedThinkIds.has(m.id) && m.content?.trim()"
|
||||
class="ai-think-full ai-md"
|
||||
v-html="renderMd(m.content)"
|
||||
v-html="renderMdDecorated(m.content)"
|
||||
></div>
|
||||
</template>
|
||||
<ToolCardList
|
||||
@@ -1107,6 +1217,18 @@ defineExpose({
|
||||
<span class="ai-step-text">{{ stepText(item.stats, item.round) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
|
||||
警示/failed 错误),摘要单行截断 + 轮数 + tokens;骨架对齐 ai-step-line/ai-retry-line
|
||||
轻量细条范式,非消息数据不进气泡 -->
|
||||
<div v-else-if="item.kind === 'agentFin'" class="ai-msg-slot">
|
||||
<div class="ai-agent-fin" :class="'ai-agent-fin--' + agentFinTone(item.card.status)">
|
||||
<span class="ai-agent-fin-dot"></span>
|
||||
<span class="ai-agent-fin-label">{{ $t('ai.agentFinished.title') }}</span>
|
||||
<span class="ai-agent-fin-status">{{ agentFinStatusText(item.card.status) }}</span>
|
||||
<span class="ai-agent-fin-summary" :title="item.card.summary">{{ agentFinSummary(item.card.summary) }}</span>
|
||||
<span class="ai-agent-fin-meta">{{ $t('ai.agentFinished.meta', { rounds: item.card.rounds, tokens: formatTokensK(item.card.tokens) }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 居中时间分隔线(UX 密度改造:相邻消息间隔 ≥30 分钟或跨天;当天只 HH:mm,跨天 MM-DD HH:mm) -->
|
||||
<div v-else-if="item.kind === 'timesep'" class="ai-time-sep">
|
||||
<span>{{ formatAbsolute(item.ts) }}</span>
|
||||
@@ -1164,7 +1286,7 @@ defineExpose({
|
||||
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) -->
|
||||
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
|
||||
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
|
||||
<div class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div>
|
||||
<div class="ai-msg-system-summary ai-md" v-html="renderMdDecorated(item.msg.content)"></div>
|
||||
</div>
|
||||
<!-- 用户消息(提取至 MessageItem.vue,仅 user 角色;assistant/system 由本组件内联渲染) -->
|
||||
<MessageItem
|
||||
@@ -1194,7 +1316,7 @@ defineExpose({
|
||||
@click="toggleJson(item.msg.id)"
|
||||
><pre>{{ item.msg.content }}</pre></div>
|
||||
<div
|
||||
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0)"
|
||||
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0)"
|
||||
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
||||
:class="{ 'ai-msg-bubble--error': item.msg.isError, 'ai-msg-bubble--collapsed': shouldCollapseLong(item.msg), 'ai-msg-bubble--answer': item.final === true && !item.msg.isError }"
|
||||
:key="'md-' + item.msg.id + '-' + (isLastAi(item.msg) ? _mdRenderKey : 0)"
|
||||
@@ -1203,18 +1325,18 @@ defineExpose({
|
||||
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
|
||||
消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染
|
||||
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。 -->
|
||||
<template v-if="isLastAi(item.msg) && store.state.streaming && streamingBlocks.length > 0">
|
||||
<div v-for="block in streamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||
<template v-if="isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0">
|
||||
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
||||
</template>
|
||||
<!-- 完成/历史:整段v-html(不变) -->
|
||||
<div v-else v-html="renderMd(item.msg.content)"></div>
|
||||
<!-- M1:超长 assistant 文本折叠后的底部展开按钮(仅非流式 + 长消息显示) -->
|
||||
<!-- 完成/历史:整段v-html(不变,经装饰层注入代码块复制按钮) -->
|
||||
<div v-else v-html="renderMdDecorated(item.msg.content)"></div>
|
||||
<!-- M1/R3:超长 assistant 文本折叠条(折叠态带量:已折叠 N 字符/约 M tokens) -->
|
||||
<button
|
||||
v-if="isLongMsg(item.msg) && !(isLastAi(item.msg) && store.state.streaming)"
|
||||
class="ai-msg-bubble-toggle"
|
||||
@click.stop="toggleLong(item.msg.id)"
|
||||
>{{ expandedLongIds.has(item.msg.id) ? $t('aiChat.collapse') : $t('aiChat.expand') }}</button>
|
||||
>{{ shouldCollapseLong(item.msg) ? collapsedLongLabel(item.msg) : $t('aiChat.collapse') }}</button>
|
||||
<button
|
||||
v-if="item.msg.content"
|
||||
class="ai-copy-btn ai-copy-btn--ai"
|
||||
@@ -1664,6 +1786,45 @@ defineExpose({
|
||||
right: 30px;
|
||||
}
|
||||
|
||||
/* ── R4 代码块复制按钮:节点经 v-html 注入,非 scoped 受控,经模板锚点 .ai-md :deep 命中。
|
||||
悬浮显示(代码块 hover 才现,右上角);is-ok/is-err 为复制反馈态(常显 1.2s 后由 JS 还原)。 ── */
|
||||
.ai-md :deep(.md-code-block) {
|
||||
position: relative;
|
||||
}
|
||||
.ai-md :deep(.md-code-copy) {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--df-text-dim);
|
||||
background: color-mix(in srgb, var(--df-bg) 82%, transparent);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s var(--df-ease), color 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-md :deep(.md-code-block:hover .md-code-copy) {
|
||||
opacity: 1;
|
||||
}
|
||||
.ai-md :deep(.md-code-copy:hover) {
|
||||
color: var(--df-accent);
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-ok) {
|
||||
opacity: 1;
|
||||
color: var(--df-success, #4caf50);
|
||||
border-color: var(--df-success, #4caf50);
|
||||
}
|
||||
.ai-md :deep(.md-code-copy.is-err) {
|
||||
opacity: 1;
|
||||
color: var(--df-danger);
|
||||
border-color: var(--df-danger);
|
||||
}
|
||||
|
||||
/* ── UX-02/消息操作栏(hover 显示 / 错误气泡常显) ── */
|
||||
.ai-msg-actions {
|
||||
display: flex;
|
||||
@@ -2092,4 +2253,62 @@ defineExpose({
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* S1 子代理完成卡(AiAgentFinished):三态色分层——completed 中性灰(常态结果不抢视线)
|
||||
/ truncated 琥珀(结果不完整,与 retry 细条同警示层)/ failed 红(需关注,对齐错误卡层)。
|
||||
骨架对齐 .ai-retry-line 轻量细条;摘要单行截断,全文经 title hover 查看 */
|
||||
.ai-agent-fin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: var(--df-msg-max-width);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius);
|
||||
background: color-mix(in srgb, var(--df-text-secondary) 6%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-agent-fin-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn {
|
||||
background: color-mix(in srgb, var(--df-warning, #f0c75e) 8%, transparent);
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-dot {
|
||||
background: var(--df-warning, #f0c75e);
|
||||
}
|
||||
.ai-agent-fin--err {
|
||||
background: color-mix(in srgb, var(--df-danger) 10%, transparent);
|
||||
}
|
||||
.ai-agent-fin--err .ai-agent-fin-dot {
|
||||
background: var(--df-danger);
|
||||
}
|
||||
.ai-agent-fin-label {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ai-agent-fin-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-agent-fin--warn .ai-agent-fin-status {
|
||||
color: var(--df-warning, #f0c75e);
|
||||
}
|
||||
.ai-agent-fin--err .ai-agent-fin-status {
|
||||
color: var(--df-danger);
|
||||
}
|
||||
.ai-agent-fin-summary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-agent-fin-meta {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user