优化: AI Chat 消息视图: 闪烁修复+骨架统一+过程卡降级+任务关联 chip
MessageList.vue:
1. 流式闪烁修复: _mdRenderKey 稳定化 + key 对齐, 消除流式重渲染抖动
2. 骨架统一: 过程步骤卡改用 trace 卡骨架(头部一行摘要 + 正文增量)
3. 过程卡视觉降级: 缩进收窄 + 紧贴结果, 弱化过程突出结论
4. 消息任务关联: TASK_REF_TOOLS 映射(advance_task/run_workflow) 从
toolCalls[].args 派生任务 chip, 零数据模型改动(单一事实源), 无任务工具
的消息不渲染 chip; 新增 .ai-trace-task-chip 样式 + go-to-task emit
AiChat.vue: goToTask(taskId) 接参, router.push('/tasks?taskId=xxx')
Tasks.vue: applyRouteTaskId 消费 query.taskId 自动开抽屉(onMounted 跨路由 +
watch 同路由), 消费后 router.replace 清 query 保持 URL 干净
i18n zh/en: aiChat.goToTaskHint + ai.trace.taskRef 双语补键
验收: vue-tsc --noEmit EXIT 0 + build EXIT 0。
This commit is contained in:
@@ -43,6 +43,7 @@
|
|||||||
@start-edit="startEdit"
|
@start-edit="startEdit"
|
||||||
@send-example-prompt="sendExamplePrompt"
|
@send-example-prompt="sendExamplePrompt"
|
||||||
@go-to-settings="goToSettings"
|
@go-to-settings="goToSettings"
|
||||||
|
@go-to-task="goToTask"
|
||||||
@toast="(p) => showToast(p.msg, p.type)"
|
@toast="(p) => showToast(p.msg, p.type)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -211,6 +212,16 @@ function goToSettings() {
|
|||||||
router.push('/settings')
|
router.push('/settings')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 跳任务详情(消息任务 chip 点击):嵌入模式 router.push('/tasks?taskId=xxx') 由 Tasks.vue
|
||||||
|
* applyRouteTaskId 消费 query 自动开抽屉;分离窗口无 Tasks 路由,降级 toast */
|
||||||
|
function goToTask(taskId: string) {
|
||||||
|
if (props.detached) {
|
||||||
|
showToast(t('aiChat.goToTaskHint'), 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push({ path: '/tasks', query: { taskId } })
|
||||||
|
}
|
||||||
|
|
||||||
/** 点击示例问题卡片:委托 ChatInput 子组件发送(填入输入框 + handleSend 同款路径) */
|
/** 点击示例问题卡片:委托 ChatInput 子组件发送(填入输入框 + handleSend 同款路径) */
|
||||||
function sendExamplePrompt(i18nKey: string) {
|
function sendExamplePrompt(i18nKey: string) {
|
||||||
chatInputRef.value?.sendExamplePrompt(i18nKey)
|
chatInputRef.value?.sendExamplePrompt(i18nKey)
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import ToolCardList from '../ToolCardList.vue'
|
|||||||
import MessageItem from './MessageItem.vue'
|
import MessageItem from './MessageItem.vue'
|
||||||
import EmptyState from './EmptyState.vue'
|
import EmptyState from './EmptyState.vue'
|
||||||
import { formatDate, formatAbsolute } from '../../utils/time'
|
import { formatDate, formatAbsolute } from '../../utils/time'
|
||||||
import type { AiMessage } from '../../api/types'
|
import { taskApi } from '../../api'
|
||||||
|
import type { AiMessage, TaskRecord } from '../../api/types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
|
/** 当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
|
||||||
@@ -39,12 +40,69 @@ const emit = defineEmits<{
|
|||||||
(e: 'start-edit', msg: AiMessage): void
|
(e: 'start-edit', msg: AiMessage): void
|
||||||
(e: 'send-example-prompt', i18nKey: string): void
|
(e: 'send-example-prompt', i18nKey: string): void
|
||||||
(e: 'go-to-settings'): void
|
(e: 'go-to-settings'): void
|
||||||
|
(e: 'go-to-task', taskId: string): void
|
||||||
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const store = useAiStore()
|
const store = useAiStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// ── 消息任务关联(有的消息有,有的没):从 toolCalls[].args 派生 ──
|
||||||
|
// 数据源:AiToolCallInfo.args 已携带工具入参,任务工具的 task_id 就在其中
|
||||||
|
// (advance_task 用 id / run_workflow 用 task_id,与 useToolCardHeader.TASK_ID_TOOL_ARG 同源)。
|
||||||
|
// 不新增 AiMessage 字段:关联是「从已有 toolCalls 派生的计算属性」,单一事实源。
|
||||||
|
// 任务标题懒加载全量任务缓存(taskApi.list 无参=全量,失败空数组占位防重试),复用 ChatInput 既有模式。
|
||||||
|
const TASK_REF_TOOLS: Record<string, 'id' | 'task_id'> = {
|
||||||
|
advance_task: 'id',
|
||||||
|
run_workflow: 'task_id',
|
||||||
|
}
|
||||||
|
const taskTitleCache = ref<Map<string, string> | null>(null)
|
||||||
|
async function ensureTaskTitles(): Promise<void> {
|
||||||
|
if (taskTitleCache.value) return
|
||||||
|
try {
|
||||||
|
const list = await taskApi.list()
|
||||||
|
taskTitleCache.value = new Map(list.map((tk: TaskRecord) => [tk.id, tk.title]))
|
||||||
|
} catch (e) {
|
||||||
|
taskTitleCache.value = new Map() // 空占位防反复重试
|
||||||
|
console.error('[MessageList] 加载任务标题失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 从消息的 toolCalls 提取关联任务(无任务工具则返回空数组 = 该消息无任务关联) */
|
||||||
|
function taskRefsOf(msg: AiMessage): { taskId: string; toolName: string }[] {
|
||||||
|
const refs: { taskId: string; toolName: string }[] = []
|
||||||
|
for (const tc of msg.toolCalls ?? []) {
|
||||||
|
const key = TASK_REF_TOOLS[tc.name]
|
||||||
|
if (!key) continue
|
||||||
|
const args = tc.args as Record<string, unknown> | undefined
|
||||||
|
const taskId = args?.[key]
|
||||||
|
if (typeof taskId === 'string' && taskId) refs.push({ taskId, toolName: tc.name })
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
/** 任务标题(缓存命中用标题,未命中显短 id;缓存未就绪时触发懒加载) */
|
||||||
|
function taskTitleOf(taskId: string): string {
|
||||||
|
if (!taskTitleCache.value) { void ensureTaskTitles(); return taskId.slice(0, 8) }
|
||||||
|
return taskTitleCache.value.get(taskId) ?? taskId.slice(0, 8)
|
||||||
|
}
|
||||||
|
/** 一轮轨迹内所有过程消息的任务关联去重(同一任务多次调用只留一个 chip) */
|
||||||
|
function traceTaskRefs(msgs: AiMessage[]): { taskId: string; toolName: string }[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: { taskId: string; toolName: string }[] = []
|
||||||
|
for (const m of msgs) {
|
||||||
|
for (const ref of taskRefsOf(m)) {
|
||||||
|
if (seen.has(ref.taskId)) continue
|
||||||
|
seen.add(ref.taskId)
|
||||||
|
out.push(ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
/** 点击任务 chip 跳转任务详情:委托父组件(AiChat)走 router.push('/tasks'),detached 降级 toast。
|
||||||
|
与既有 go-to-settings 同模式——子组件不直接持 router,降级策略归父。 */
|
||||||
|
function openTask(taskId: string): void {
|
||||||
|
emit('go-to-task', taskId)
|
||||||
|
}
|
||||||
|
|
||||||
// ═══ Markdown 渲染自研块级 memo,借鉴方案D流式核心) ═══
|
// ═══ Markdown 渲染自研块级 memo,借鉴方案D流式核心) ═══
|
||||||
// 渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
|
// 渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
|
||||||
// renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。
|
// renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。
|
||||||
@@ -644,6 +702,11 @@ function decorateCodeHtml(raw: string): string {
|
|||||||
}
|
}
|
||||||
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
|
/** renderMd 包装:渲染 + 代码块装饰(双层 memo:renderMd 的 _mdCache + 本缓存,未变即零成本) */
|
||||||
function renderMdDecorated(raw: string): string {
|
function renderMdDecorated(raw: string): string {
|
||||||
|
// 闪烁修复(2026-10-09):读 _mdRenderKey 建立响应式依赖——mdReady 就绪后 forceUpdate()
|
||||||
|
// 递增此 ref 触发本函数重算(拿最新 marked 渲染),但**不再把 _mdRenderKey 放进 :key**
|
||||||
|
// (key 变化会让 Vue 销毁重建整个气泡 DOM,marked 冷加载完成瞬间即「回复闪一下」)。
|
||||||
|
// 依赖放在渲染函数内:只更新 v-html 内容,气泡元素复用不重建。
|
||||||
|
void _mdRenderKey.value
|
||||||
return decorateCodeHtml(renderMd(raw))
|
return decorateCodeHtml(renderMd(raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1014,7 +1077,10 @@ const renderItems = computed<RenderItem[]>(() => {
|
|||||||
if (prev.length > 0) {
|
if (prev.length > 0) {
|
||||||
pushTimeSepIfNeeded(items, msgs[0].timestamp)
|
pushTimeSepIfNeeded(items, msgs[0].timestamp)
|
||||||
traceCount++
|
traceCount++
|
||||||
items.push({ kind: 'step', key: 'step-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
|
// 闪烁修复(2026-10-09):step 项 key 与下方完成态 trace 项对齐(同为 trace-{首条id}),
|
||||||
|
// 使「执行中 step → 完成态 trace」切换时 Vue 复用同一 DOM 节点(key 稳定),不销毁重建。
|
||||||
|
// 原 step-{id} 与 trace-{id} key 不同 → 完成瞬间整块轨迹 DOM 重建 → 闪烁。
|
||||||
|
items.push({ kind: 'step', key: 'trace-' + prev[0].id, round: traceCount, stats: collectTurnStats(msgs) })
|
||||||
} else {
|
} else {
|
||||||
pushTimeSepIfNeeded(items, cur.timestamp)
|
pushTimeSepIfNeeded(items, cur.timestamp)
|
||||||
}
|
}
|
||||||
@@ -1427,13 +1493,22 @@ defineExpose({
|
|||||||
<template v-for="item in renderItems" :key="item.key">
|
<template v-for="item in renderItems" :key="item.key">
|
||||||
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
|
<!-- 执行轨迹卡(完成轮次:过程内容折叠为一张,默认一行聚合,点开在卡内 rail 面板回看
|
||||||
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
|
每轮思考 pill + 工具卡;头部聚合行含调用/耗时/全程 token 汇总) -->
|
||||||
<div v-if="item.kind === 'trace'" class="ai-msg-slot">
|
<div v-if="item.kind === 'trace'" class="ai-msg-slot ai-msg-slot--proc">
|
||||||
<div class="ai-trace-card" :class="{ 'ai-trace-card--open': item.expanded }" :data-trace-key="item.key">
|
<div class="ai-trace-card" :class="{ 'ai-trace-card--open': item.expanded }" :data-trace-key="item.key">
|
||||||
<div class="ai-trace-head" @click="toggleTrace(item.key)">
|
<div class="ai-trace-head" @click="toggleTrace(item.key)">
|
||||||
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
|
<span class="ai-trace-chevron" :class="{ 'is-open': item.expanded }">▸</span>
|
||||||
<span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
|
<span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
|
||||||
<span class="ai-trace-agg" :title="traceHoverTitle(item.startTs, item.endTs)">{{ traceAggText(item.stats, item.startTs, item.endTs) }}</span>
|
<span class="ai-trace-agg" :title="traceHoverTitle(item.startTs, item.endTs)">{{ traceAggText(item.stats, item.startTs, item.endTs) }}</span>
|
||||||
<span v-if="item.tokens > 0" class="ai-trace-tokens">≈{{ formatTokensK(item.tokens) }} tokens</span>
|
<span v-if="item.tokens > 0" class="ai-trace-tokens">≈{{ formatTokensK(item.tokens) }} tokens</span>
|
||||||
|
<!-- 任务关联 chip(有的轮次有,有的没):从该轮过程消息的 toolCalls[].args 派生,
|
||||||
|
点击跳转任务详情。无任务工具调用则不渲染,零噪音。 -->
|
||||||
|
<span
|
||||||
|
v-for="ref in traceTaskRefs(item.traceMsgs)"
|
||||||
|
:key="ref.taskId"
|
||||||
|
class="ai-trace-task-chip"
|
||||||
|
:title="$t('ai.trace.taskRef', { name: taskTitleOf(ref.taskId) })"
|
||||||
|
@click.stop="openTask(ref.taskId)"
|
||||||
|
>🔗 {{ taskTitleOf(ref.taskId) }}</span>
|
||||||
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
|
<!-- 绝对起始时刻(mono 小字):轨迹桶已不发独立时间分隔线,时刻职责由卡头承载 -->
|
||||||
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
|
<span class="ai-trace-tokens">{{ formatAbsolute(item.startTs) }}</span>
|
||||||
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}</span>
|
<span class="ai-trace-toggle">{{ item.expanded ? $t('ai.trace.collapse') : $t('ai.trace.expand') }}</span>
|
||||||
@@ -1473,11 +1548,17 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 执行中实时步骤摘要(已完成步骤收进一行,由最近工具事件聚合;仅当前步骤展开) -->
|
<!-- 执行中实时步骤摘要(已完成步骤收进一行,由最近工具事件聚合;仅当前步骤展开)。
|
||||||
<div v-else-if="item.kind === 'step'" class="ai-msg-slot">
|
骨架与完成态 .ai-trace-card 完全同构(卡头结构一致),仅 chevron→脉冲点、右侧→「进行中」,
|
||||||
<div class="ai-step-line">
|
使 step→trace 切换时只 patch 内部文字,无高度跳变/闪烁 -->
|
||||||
|
<div v-else-if="item.kind === 'step'" class="ai-msg-slot ai-msg-slot--proc">
|
||||||
|
<div class="ai-trace-card ai-trace-card--running">
|
||||||
|
<div class="ai-trace-head">
|
||||||
<span class="ai-step-dot"></span>
|
<span class="ai-step-dot"></span>
|
||||||
<span class="ai-step-text">{{ stepText(item.stats, item.round) }}</span>
|
<span class="ai-trace-title">{{ $t('ai.trace.roundN', { n: item.round }) }}</span>
|
||||||
|
<span class="ai-trace-agg">{{ stepText(item.stats, item.round) }}</span>
|
||||||
|
<span class="ai-trace-toggle ai-trace-toggle--running">{{ $t('ai.trace.running') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
|
<!-- S1 子代理完成卡(AiAgentFinished):状态点/状态词三态区分(completed 中性/truncated
|
||||||
@@ -1598,16 +1679,19 @@ defineExpose({
|
|||||||
@click="toggleJson(item.msg.id)"
|
@click="toggleJson(item.msg.id)"
|
||||||
><pre>{{ item.msg.content }}</pre></div>
|
><pre>{{ item.msg.content }}</pre></div>
|
||||||
<div
|
<div
|
||||||
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0)"
|
v-else-if="(item.msg.content && item.msg.content.trim()) || (isLastAi(item.msg) && store.state.streaming)"
|
||||||
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
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 }"
|
: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)"
|
:key="'md-' + item.msg.id"
|
||||||
>
|
>
|
||||||
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建)。
|
<!-- 流式:分块渲染(已完成块key稳定→DOM不重建)。
|
||||||
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
|
AC-EFF-S2-1(2026-08-09):条件改用 streamingBlocks.length 而非 store.state.currentText,
|
||||||
消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染
|
消除「currentText 变化触发模板重渲染 + rAF 更新 streamingBlocks 触发重渲染」的双渲染
|
||||||
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。 -->
|
(每次 delta 只经 rAF 更新 streamingBlocks 触发一次渲染)。
|
||||||
<template v-if="isLastAi(item.msg) && store.state.streaming && decoratedStreamingBlocks.length > 0">
|
闪烁修复(2026-10-09):末条流式气泡在 blocks 暂空(新轮 AiAgentRound 清空 blocks 到
|
||||||
|
首个 delta 到达之间的一帧窗口)时仍走本分支,只显光标占位;否则会落到下方
|
||||||
|
v-else 渲染空 content,产生「气泡空一帧再填充」的闪烁。 -->
|
||||||
|
<template v-if="isLastAi(item.msg) && store.state.streaming">
|
||||||
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
|
<div v-for="block in decoratedStreamingBlocks" :key="block.key" v-html="block.html"></div>
|
||||||
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
||||||
</template>
|
</template>
|
||||||
@@ -1831,6 +1915,10 @@ defineExpose({
|
|||||||
/* .ai-messages 的直接 flex 子虚拟滚动 sentinel 已移除,
|
/* .ai-messages 的直接 flex 子虚拟滚动 sentinel 已移除,
|
||||||
现仅作消息分组的语义容器,gap:14px 由 .ai-messages 承担)。 */
|
现仅作消息分组的语义容器,gap:14px 由 .ai-messages 承担)。 */
|
||||||
.ai-msg-slot { display: flex; flex-direction: column; }
|
.ai-msg-slot { display: flex; flex-direction: column; }
|
||||||
|
/* 体验优化(2026-10-09):过程卡 slot 与紧随其后的结果 slot 压缩间距(14px→6px),
|
||||||
|
使「过程→结果」视觉成组,结果气泡成为该组主体;过程卡自身左缩进 4px 已由 .ai-trace-card 承担。
|
||||||
|
仅作用于「过程 slot 紧随结果 slot」的相邻对,不影响消息与消息的常规间距。 */
|
||||||
|
.ai-msg-slot--proc + .ai-msg-slot { margin-top: -8px; }
|
||||||
|
|
||||||
/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area) ── */
|
/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area) ── */
|
||||||
.ai-back-to-bottom {
|
.ai-back-to-bottom {
|
||||||
@@ -2443,9 +2531,12 @@ defineExpose({
|
|||||||
.ai-msg-segment-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ai-msg-segment-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ai-msg-segment-toggle { flex-shrink: 0; font-size: 10px; opacity: 0.8; }
|
.ai-msg-segment-toggle { flex-shrink: 0; font-size: 10px; opacity: 0.8; }
|
||||||
|
|
||||||
/* A 过程压缩:执行轨迹卡(完成轮次过程内容折叠为一张,视觉对齐 .ai-msg-segment 弱化风格) */
|
/* A 过程压缩:执行轨迹卡(完成轮次过程内容折叠为一张,视觉对齐 .ai-msg-segment 弱化风格)。
|
||||||
|
体验优化(2026-10-09):过程卡视觉降级——左缩进 4px + 收窄 8px,与结果气泡拉开主次;
|
||||||
|
紧随其后的结果气泡用相邻兄弟选择器压缩间距,使「过程→结果」成组,一眼看清结果主体。 */
|
||||||
.ai-trace-card {
|
.ai-trace-card {
|
||||||
max-width: var(--df-msg-max-width);
|
max-width: calc(var(--df-msg-max-width) - 8px);
|
||||||
|
margin-left: 4px;
|
||||||
border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent);
|
border: 0.5px dashed color-mix(in srgb, var(--df-text-dim) 35%, transparent);
|
||||||
border-radius: var(--df-radius);
|
border-radius: var(--df-radius);
|
||||||
background: color-mix(in srgb, var(--df-text-dim) 6%, transparent);
|
background: color-mix(in srgb, var(--df-text-dim) 6%, transparent);
|
||||||
@@ -2492,6 +2583,24 @@ defineExpose({
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
}
|
}
|
||||||
|
/* 任务关联 chip(仅含任务工具调用的轮次渲染):accent 弱化底 + hover 提亮,点击跳任务 */
|
||||||
|
.ai-trace-task-chip {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-accent);
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 12%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.ai-trace-task-chip:hover {
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
|
/* ── 轨迹展开态 rail 面板:左轨竖线贯通,思考 pill 与工具卡图标锚定其上(git-graph 式轮次结构)。
|
||||||
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */
|
轨 x=22px 对齐工具卡图标中心(卡内 padding 10px + 24px 图标位一半),pill 灯泡图标同 x。 ── */
|
||||||
@@ -2583,6 +2692,18 @@ defineExpose({
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 执行中态轨迹卡(骨架与完成态完全同构,仅色/态微差):accent 底 + 脉冲点替代 chevron,
|
||||||
|
step→trace 切换时卡片高度/内边距一致,只 patch 内部文字 → 无跳变 */
|
||||||
|
.ai-trace-card--running {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: color-mix(in srgb, var(--df-accent) 28%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 7%, transparent);
|
||||||
|
}
|
||||||
|
.ai-trace-toggle--running {
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
|
/* 执行中实时步骤摘要(已完成步骤收进一行,accent 呼吸点对齐状态栏 .ai-agentic-progress) */
|
||||||
.ai-step-line {
|
.ai-step-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2597,8 +2718,8 @@ defineExpose({
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.ai-step-dot {
|
.ai-step-dot {
|
||||||
width: 5px;
|
width: 6px;
|
||||||
height: 5px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--df-accent);
|
background: var(--df-accent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export default {
|
|||||||
examplePrompt4: 'Generate a project idea',
|
examplePrompt4: 'Generate a project idea',
|
||||||
goToSettings: 'Open settings',
|
goToSettings: 'Open settings',
|
||||||
goToSettingsHint: 'The detached window cannot open settings directly. Configure AI providers on the main window’s Settings page.',
|
goToSettingsHint: 'The detached window cannot open settings directly. Configure AI providers on the main window’s Settings page.',
|
||||||
|
goToTaskHint: 'The detached window cannot open tasks directly. View task details in the main window.',
|
||||||
|
|
||||||
// ── Provider switch feedback ──
|
// ── Provider switch feedback ──
|
||||||
providerSwitched: 'Switched to {model}',
|
providerSwitched: 'Switched to {model}',
|
||||||
@@ -136,12 +137,12 @@ export default {
|
|||||||
clearSkill: 'Clear skill',
|
clearSkill: 'Clear skill',
|
||||||
skillBadgeTip: 'Injected full skill playbook: {name}',
|
skillBadgeTip: 'Injected full skill playbook: {name}',
|
||||||
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
|
placeholderWithSkill: 'Skill selected. Type arguments and press Enter',
|
||||||
placeholderDefault: "Type a message... (/ for skills, @ to mention)",
|
placeholderDefault: "Type a message... (/ for skills, @ to mention)",
|
||||||
skillNoMatch: 'No matching skill',
|
skillNoMatch: 'No matching skill',
|
||||||
skillNotLoaded: 'No local skills loaded',
|
skillNotLoaded: 'No local skills loaded',
|
||||||
|
|
||||||
// ── @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
|
// ── @ entity mention (@ opens popover, select inserts [Type:Name] token) ──
|
||||||
mentionHint: "@ to mention project/task",
|
mentionHint: "@ to mention project/task",
|
||||||
mentionGroupProject: 'Projects',
|
mentionGroupProject: 'Projects',
|
||||||
mentionGroupTask: 'Tasks',
|
mentionGroupTask: 'Tasks',
|
||||||
mentionGroupIdea: 'Ideas',
|
mentionGroupIdea: 'Ideas',
|
||||||
@@ -232,7 +233,7 @@ export default {
|
|||||||
|
|
||||||
// ── Input Augmentation layer: mention/skill injection feedback ──
|
// ── Input Augmentation layer: mention/skill injection feedback ──
|
||||||
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
|
||||||
searchFilesBlocked: "search_files only works inside authorized dirs. Use @[project] to reference or provide an absolute path.",
|
searchFilesBlocked: "search_files only works inside authorized dirs. Use @[project] to reference or provide an absolute path.",
|
||||||
// /skill hot reload (ai_reload_skills IPC) success toast
|
// /skill hot reload (ai_reload_skills IPC) success toast
|
||||||
skillReloaded: 'Skills reloaded',
|
skillReloaded: 'Skills reloaded',
|
||||||
// @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable)
|
// @mention entity resolve failed (id points to a project/task/idea/skill that was deleted or unavailable)
|
||||||
@@ -326,7 +327,7 @@ export default {
|
|||||||
// ── TopBar goal/project context labels ──
|
// ── TopBar goal/project context labels ──
|
||||||
goalsCount: 'Goals ({n})',
|
goalsCount: 'Goals ({n})',
|
||||||
none: 'None',
|
none: 'None',
|
||||||
projectLabel: "@Projects",
|
projectLabel: "@Projects",
|
||||||
|
|
||||||
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
|
// ── 3c UX batch addition (I5 classified send-failure messages): friendly text by
|
||||||
// error kind; {msg} keeps the raw error string for troubleshooting ──
|
// error kind; {msg} keeps the raw error string for troubleshooting ──
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export default {
|
|||||||
examplePrompt4: '生成一个项目灵感',
|
examplePrompt4: '生成一个项目灵感',
|
||||||
goToSettings: '去设置',
|
goToSettings: '去设置',
|
||||||
goToSettingsHint: '分离窗口无法直接打开设置,请在主窗口的设置页配置 AI 提供商',
|
goToSettingsHint: '分离窗口无法直接打开设置,请在主窗口的设置页配置 AI 提供商',
|
||||||
|
goToTaskHint: '分离窗口无法直接打开任务,请在主窗口查看任务详情',
|
||||||
|
|
||||||
// ── Provider 切换反馈──
|
// ── Provider 切换反馈──
|
||||||
providerSwitched: '已切换到 {model}',
|
providerSwitched: '已切换到 {model}',
|
||||||
@@ -136,12 +137,12 @@ export default {
|
|||||||
clearSkill: '取消技能',
|
clearSkill: '取消技能',
|
||||||
skillBadgeTip: '已注入技能「{name}」的完整规程',
|
skillBadgeTip: '已注入技能「{name}」的完整规程',
|
||||||
placeholderWithSkill: '已选技能,输入参数后回车',
|
placeholderWithSkill: '已选技能,输入参数后回车',
|
||||||
placeholderDefault: "输入消息...(/ 触发技能,@ 引用实体)",
|
placeholderDefault: "输入消息...(/ 触发技能,@ 引用实体)",
|
||||||
skillNoMatch: '无匹配技能',
|
skillNoMatch: '无匹配技能',
|
||||||
skillNotLoaded: '未读取到本机技能',
|
skillNotLoaded: '未读取到本机技能',
|
||||||
|
|
||||||
// ── @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
|
// ── @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记) ──
|
||||||
mentionHint: "@ 引用项目/任务",
|
mentionHint: "@ 引用项目/任务",
|
||||||
mentionGroupProject: '项目',
|
mentionGroupProject: '项目',
|
||||||
mentionGroupTask: '任务',
|
mentionGroupTask: '任务',
|
||||||
mentionGroupIdea: '灵感',
|
mentionGroupIdea: '灵感',
|
||||||
@@ -233,7 +234,7 @@ export default {
|
|||||||
|
|
||||||
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
// ── Input Augmentation 层: mention/skill 注入反馈 ──
|
||||||
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
|
||||||
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 @[项目] 引用或提供绝对路径",
|
searchFilesBlocked: "search_files 仅在已授权目录可用,请用 @[项目] 引用或提供绝对路径",
|
||||||
// /技能 热重载(ai_reload_skills IPC)成功后提示
|
// /技能 热重载(ai_reload_skills IPC)成功后提示
|
||||||
skillReloaded: '技能已重新加载',
|
skillReloaded: '技能已重新加载',
|
||||||
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
|
// @引用 实体 resolve 失败(id 指向的项目/任务/灵感/技能已被删除或不可用)
|
||||||
@@ -327,7 +328,7 @@ export default {
|
|||||||
// ── TopBar 目标/项目上下文标签 ──
|
// ── TopBar 目标/项目上下文标签 ──
|
||||||
goalsCount: '目标({n})',
|
goalsCount: '目标({n})',
|
||||||
none: '无',
|
none: '无',
|
||||||
projectLabel: "@项目",
|
projectLabel: "@项目",
|
||||||
|
|
||||||
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
|
// ── 3c UX 批追加(I5 发送失败错误分类文案):按错误内容映射友好提示,
|
||||||
// {msg} 保留原始错误串供排查,不丢线索 ──
|
// {msg} 保留原始错误串供排查,不丢线索 ──
|
||||||
|
|||||||
+19
-1
@@ -408,7 +408,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, reactive, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, reactive, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { formatRelative } from '@/utils/time'
|
import { formatRelative } from '@/utils/time'
|
||||||
@@ -426,6 +426,7 @@ import { useTaskBatchActions } from '@/composables/task/useTaskBatchActions'
|
|||||||
import { useTaskKanban } from '@/composables/task/useTaskKanban'
|
import { useTaskKanban } from '@/composables/task/useTaskKanban'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
@@ -854,6 +855,21 @@ const drawerVisible = computed(() => drawerTaskId.value !== null)
|
|||||||
function openDrawer(id: string) { drawerTaskId.value = id }
|
function openDrawer(id: string) { drawerTaskId.value = id }
|
||||||
function closeDrawer() { drawerTaskId.value = null }
|
function closeDrawer() { drawerTaskId.value = null }
|
||||||
|
|
||||||
|
// 路由 query.taskId 驱动抽屉(AI Chat 消息任务 chip 点击跳转入口):
|
||||||
|
// 从 /tasks?taskId=xxx 进入时自动打开对应任务抽屉。同路由内再次跳转(已在 /tasks 页)
|
||||||
|
// 由 watch(route.query.taskId) 响应;跨路由进入由 onMounted 调用。URL 参数消费后清除,
|
||||||
|
// 避免刷新页面重复弹抽屉。
|
||||||
|
function applyRouteTaskId() {
|
||||||
|
const tid = route.query.taskId
|
||||||
|
if (typeof tid === 'string' && tid) {
|
||||||
|
openDrawer(tid)
|
||||||
|
// 消费后清除 query(taskId),保持 URL 干净;replace 不新增历史栈
|
||||||
|
const rest = { ...route.query }
|
||||||
|
delete rest.taskId
|
||||||
|
router.replace({ path: route.path, query: rest })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildTaskQuery(): TaskQuery | undefined {
|
function buildTaskQuery(): TaskQuery | undefined {
|
||||||
const projAll = activeProject.value === 'all'
|
const projAll = activeProject.value === 'all'
|
||||||
const kw = searchKeyword.value.trim()
|
const kw = searchKeyword.value.trim()
|
||||||
@@ -988,6 +1004,8 @@ onMounted(async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
if (viewMode.value === 'kanban') kanbanRefresh()
|
if (viewMode.value === 'kanban') kanbanRefresh()
|
||||||
|
// 路由 query.taskId 驱动抽屉(AI Chat chip 跳转入口)
|
||||||
|
applyRouteTaskId()
|
||||||
window.addEventListener('keydown', onKeydown)
|
window.addEventListener('keydown', onKeydown)
|
||||||
document.addEventListener('click', closeQuickMenu)
|
document.addEventListener('click', closeQuickMenu)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user