新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
lxy
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11931 additions and 1033 deletions
+40 -10
View File
@@ -119,7 +119,7 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord } from '../../api/types'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
const props = defineProps<{
/** UX-09: 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */
@@ -252,17 +252,13 @@ const filteredSkills = computed(() => {
})
watch(inputText, (v) => {
if (pendingSkill.value) {
// 已选技能 chip 态不开任何联想浮层
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
return
}
// Input Augmentation: skill chip + @ 标记共存(两者正交 user intent)——删原 pendingSkill
// 非空时强制关 mention 的分支。skill chip 是独立 UI 态(已选技能),@ 是输入态(光标触发),
// 用户可同时持有已选技能并在文本里 @ 引用实体。
// / 技能联想(仅当输入以 / 开头且光标在首字符区)
skillOpen.value = v.startsWith('/')
if (skillOpen.value) skillIndex.value = 0
// UX-10: @ 实体引用(技能联想与 @ 联想互斥,二者同时仅一开)
// UX-10: @ 实体引用(技能联想与 @ 联想互斥,二者同时仅一开;视觉打架防护)
if (skillOpen.value) {
mentionOpen.value = false
mentionStart.value = -1
@@ -307,6 +303,8 @@ function detectMentionTrigger() {
function selectSkill(s: SkillInfo) {
pendingSkill.value = s
inputText.value = ''
// selectSkill 清空 inputText,pendingMentionSpans 指向的区间随之失效,一并清空防孤儿 span
pendingMentionSpans.value = []
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
@@ -338,6 +336,10 @@ const mentionOpen = ref(false)
const mentionIndex = ref(0)
// @ 触发符在 inputText 中的起始下标(用于选中后精确替换 @query 段)
const mentionStart = ref(-1)
// Input Augmentation: pending mention 区间元数据(selectMention 记录,handleSend 透传后清空)。
// 每个 span 描述用户气泡内一段 [类型: 名] 文本在 inputText 中的 {start,length} + kind/refId/label,
// 后端据 mentionSpans resolve 投影成 Augmentation 注入;MessageList 据此精确切区间渲染 chip。
const pendingMentionSpans = ref<MentionSpan[]>([])
const mentionGroupLabel = computed(() => ({
project: t('aiChat.mentionGroupProject'),
@@ -424,6 +426,16 @@ function selectMention(item: MentionItem) {
? `[${t('aiChat.mentionGroupTask')}: ${item.name}]`
: `[${t('aiChat.mentionGroupIdea')}: ${item.name}]`
inputText.value = before + label + ' ' + after
// Input Augmentation: 记 pending mention 区间(start=before.length,label 区间长度=label.length)。
// before 已包含此前所有插入的 [类型: 名] 标记,故 before.length 恰为本 label 在最终文本中的起点。
// refId 用实体 id(后端 resolve 用),label 即 chip 展示文本(== 后段校验用 == 文本)。
pendingMentionSpans.value.push({
start: before.length,
length: label.length,
kind: item.type,
refId: item.id,
label,
})
mentionOpen.value = false
mentionStart.value = -1
mentionIndex.value = 0
@@ -548,6 +560,15 @@ async function handleSend() {
// 后端 ai_chat_send IPC 当前不接 parts(Phase 2c 接入);本轮 parts 仅写本地 push 的
// user 消息渲染多模态图,后端请求仍走 content 文本路径。
const snapshotImgs = pendingImages.value.slice()
// Input Augmentation: 快照 pending mention 区间(同 parts 快照,失败时回填)。
// 关键:span.start 是相对原始 inputText(可能含前导空格)的偏移;text = inputText.trim()
// 已剥前导空白。若不修正偏移,trim 掉的前导空白会让所有 chip start 错位 → 渲染降级纯文本。
// 此处按剥掉的前导空白长度整体左移 start(尾部 trim 不影响已记录的 chip start;chip 不在尾部空白)。
const rawLeadingWs = inputText.value.length - inputText.value.replace(/^\s+/, '').length
const snapshotSpans = pendingMentionSpans.value.slice().map(sp => ({
...sp,
start: Math.max(0, sp.start - rawLeadingWs),
}))
const parts: ContentPart[] | undefined = snapshotImgs.length > 0
? [
// 文本片(非空时作首片,便于后端 2c 接入后 content 与 parts 文本一致;
@@ -573,6 +594,8 @@ async function handleSend() {
inputText.value = ''
pendingSkill.value = null
pendingImages.value = []
// Input Augmentation: 清空 mention 区间(与 inputText 清空同处,保证下条消息从空白态起步)
pendingMentionSpans.value = []
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
@@ -580,7 +603,9 @@ async function handleSend() {
// 同步清空输入框后立即发送:sendMessage 内部同步 push 用户消息,
// 清空与 push 合并到同一渲染周期 flush,消除"输入框已空但消息未显示"的间隙
try {
await store.sendMessage(text, skill?.name, false, parts)
// Input Augmentation: 透传 mentionSpans(L0 直接 doSend 注入 + 本地 user 消息挂 mentionSpans 渲染 chip;
// L1 入队续发当前不挂 spans,见 useAiSend.sendMessage 设计取舍注释)
await store.sendMessage(text, skill?.name, false, parts, snapshotSpans.length > 0 ? snapshotSpans : undefined)
} catch (e) {
console.error('[AI] 发送失败:', e)
inputText.value = text
@@ -589,6 +614,10 @@ async function handleSend() {
if (parts) {
pendingImages.value = snapshotImgs
}
// 发送失败:回填 mention 区间供重试(快照原样还原,区间仍与回填文本对齐)
if (snapshotSpans.length) {
pendingMentionSpans.value = snapshotSpans
}
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
const msg = e instanceof Error ? e.message : String(e)
emit('error', { msg: t('aiChat.toastSendFail', { msg }), type: 'error' })
@@ -625,6 +654,7 @@ function clearInput(): void {
inputText.value = ''
pendingSkill.value = null
pendingImages.value = []
pendingMentionSpans.value = []
skillOpen.value = false
mentionOpen.value = false
mentionStart.value = -1
+2 -2
View File
@@ -233,7 +233,7 @@
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { formatRelativeZh } from '../../utils/time'
import { formatRelative } from '../../utils/time'
import type { AiConversationSummary } from '../../api/types'
const emit = defineEmits<{
@@ -329,7 +329,7 @@ function onSelectSearchResult(id: string) {
}
function formatTime(ts: string): string {
return formatRelativeZh(ts)
return formatRelative(ts)
}
// ── 对话分组:今天 / 昨天 / 更早 ──
+54 -32
View File
@@ -1,41 +1,43 @@
<template>
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
条件:pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。
<!-- F-260619-03 Phase B + path_auth 审批链阶段1: 路径授权弹窗(LLM 想访问白名单外目录,
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
每项独立三按钮:点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。
点"未来都允许" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
store 单例共享(state/aiApi),pendingDirAuth 模块级 ref(useAiEvents)直接导入。
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
toast 经 emit 转父(保持单一 toast 源)。 -->
<div v-if="showDirAuthCard" class="ai-dir-auth">
<div v-for="item in visibleDirAuths" :key="item.id" class="ai-dir-auth">
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
<span class="ai-dir-auth-hint">
{{ $t('aiChat.dirAuthHint', { tool: pendingDirAuth?.tool, path: pendingDirAuth?.path }) }}
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
</span>
<div class="ai-dir-auth-actions">
<button
class="ai-dir-auth-btn ai-dir-auth-btn--once"
:disabled="dirAuthActing"
@click="handleAuthorize('once')"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'once')"
>{{ $t('aiChat.dirAuthOnce') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--always"
:disabled="dirAuthActing"
@click="handleAuthorize('always')"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'always')"
>{{ $t('aiChat.dirAuthAlways') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--deny"
:disabled="dirAuthActing"
@click="handleAuthorize('deny')"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'deny')"
>{{ $t('aiChat.dirAuthDeny') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { reactive, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingDirAuth } from '../../composables/ai/useAiEvents'
import { pendingDirAuths } from '../../composables/ai/useAiEvents'
import { aiApi } from '../../api'
const emit = defineEmits<{
@@ -45,37 +47,57 @@ const emit = defineEmits<{
const store = useAiStore()
const { t } = useI18n()
// 当前视图是否正在生成(切走后台生成时光标不显示,与 MaxRoundsCard 一致守卫)
// F-260620 根治(授权挂起无声卡死):挂起态弹窗显示只依赖 isGenerating(conv),不依赖 streaming。
// streaming 会被流式看门狗超时清(useAiStream onStreamTimeout),而 AiDirAuthRequired 挂起常发生在
// 首轮慢响应(看门狗已超时清 streaming)之后——此时 streaming=false 但后端 generating=true、
// generatingConvs 含 conv(useAiEvents handleEvent 对非完成/错误事件 add)。若守卫仍 && streaming,
// 挂起弹窗永不显示 → loop 无声卡死(用户发消息无响应)。去 streaming,isGenerating(conv) 单条件覆盖。
// F-09: 多会话并发 — isGenerating(active) 替代单值比对
const isViewingGenerating = computed(() =>
store.state.streaming && store.isGenerating(store.state.activeConversationId),
store.isGenerating(store.state.activeConversationId),
)
// pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。
const dirAuthActing = ref(false)
const showDirAuthCard = computed(() =>
pendingDirAuth.value !== null && isViewingGenerating.value,
)
// pendingDirAuths 非空 + 当前视图正在生成(防切走后误显)双重守卫。
// 阶段1:数组化后,visibleDirAuths 是 pendingDirAuths 的派生视图(按 conv 过滤当前会话挂起项),
// 与 showDirAuthCard 合并为单一 computed(非空即显)。
const visibleDirAuths = computed(() => {
if (!isViewingGenerating.value) return []
// 仅显当前会话的挂起项(active conv);其他会话的挂起不在本视图弹窗。
const active = store.state.activeConversationId
return pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === active)
})
/** 点三选项之一:调 ai_authorize_dir。后端 remove pending → 写授权/拒 → execute → try_continue。
* 不主动清 pendingDirAuth——由后端 AiApprovalResult/AiCompleted/AiError 在 useAiEvents 内清。
* IPC 失败回滚 acting 让用户可重试。 */
async function handleAuthorize(decision: 'once' | 'always' | 'deny'): Promise<void> {
const id = pendingDirAuth.value?.id
if (!id) return
dirAuthActing.value = true
// 阶段1:逐 id 追踪"处理中"态(数组多挂起并存,单 boolean 不够)。
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
const actingIds = reactive(new Set<string>())
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
async function handleAuthorize(id: string, decision: 'once' | 'always' | 'deny'): Promise<void> {
if (actingIds.has(id)) return
actingIds.add(id)
try {
await aiApi.authorizeDir(id, decision)
} catch (e) {
dirAuthActing.value = false
const msg = e instanceof Error ? e.message : String(e)
emit('toast', { msg: t('aiChat.dirAuthFailed', { msg }), type: 'error' })
actingIds.delete(id) // IPC 失败:复位该 id,允许重试
return
}
// F-260620 根治(授权按钮永转圈):IPC 成功后 try_continue spawn 续跑 loop,续跑 loop 可能立刻又
// 触发 AiDirAuthRequired(同 id 不可能,但其他 id 可能)置新 pendingDirAuths 项;该 id 的项会由
// AiApprovalResult 按 id filter 移除。这里不依赖 pendingDirAuths 状态复位 actingIds——改为
// watch pendingDirAuths,某 id 离开数组(已被后端消费)即删 actingIds,精准复位。
}
/** pendingDirAuth 离开挂起态(后端事件已清)时复位 acting,允许下次再操作 */
watch(() => pendingDirAuth.value, (v) => {
if (!v) dirAuthActing.value = false
/** pendingDirAuths 变化时,离开数组(已被后端 AiApprovalResult/AiCompleted/AiError 按 id 移除)的
* id 从 actingIds 删,精准复位"处理中"态(数组化后多挂起,单 watch===null 模式不再适用)。 */
watch(() => pendingDirAuths.value, (items) => {
const liveIds = new Set(items.map(p => p.id))
for (const id of [...actingIds]) {
if (!liveIds.has(id)) actingIds.delete(id)
}
})
</script>
+13 -5
View File
@@ -37,18 +37,26 @@ const emit = defineEmits<{
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)。
// F-09: 多会话并发 — isGenerating(active) 替代单值比对
const isViewingGenerating = computed(() =>
store.state.streaming && store.isGenerating(store.state.activeConversationId),
store.isGenerating(store.state.activeConversationId),
)
// pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。
// 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 && isViewingGenerating.value,
!!pendingMaxRounds.value
&& pendingMaxRounds.value === store.state.activeConversationId
&& isViewingGenerating.value,
)
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
@@ -82,7 +90,7 @@ async function handleStopLoop(): Promise<void> {
}
}
/** pendingMaxRounds 离开暂停态(后端事件已清)时复位 acting,允许下次再操作 */
/** pendingMaxRounds 离开暂停态(后端事件已清 → null)或切到非本会话挂起时复位 acting,允许下次再操作 */
watch(() => pendingMaxRounds.value, (v) => {
if (!v) maxRoundsActing.value = false
})
+129 -8
View File
@@ -18,9 +18,10 @@ import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useMarkdown } from '../../composables/useMarkdown'
import { useAppSettingsStore } from '../../stores/appSettings'
import ToolCardList from '../ToolCardList.vue'
import { formatRelativeZh, formatDate } from '../../utils/time'
import type { AiMessage, ContentPart } from '../../api/types'
import { formatRelative, formatDate } from '../../utils/time'
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
defineProps<{
/** UX-09:当前正在编辑的消息 id(父持有,经 ChatInput 透传);末条 user 编辑按钮据此隐藏 */
@@ -50,6 +51,14 @@ const {
getMarked,
getPurify,
} = useMarkdown()
// ═══ AR-1 流式 Markdown 渲染开关(保守方案 B 的可回退开关) ═══
// appSettings key `df-ai-streaming-md`,默认 true(开):流式走块级 memo + rAF 节流的
// Markdown 分块渲染(代码块/列表/标题流式过程中就有格式)。关(false):回退纯文本短路
// (escapeFallback,每 delta 全文转义,<br> 换行)——即 AR-1 修复前的原行为,供性能敏感/
// 极端掉帧场景降级。读走 appSettings 缓存(响应式,Settings 改动即时生效)。
const appSettings = useAppSettingsStore()
const streamingMdEnabled = computed(() => appSettings.get<boolean>('df-ai-streaming-md', true))
// _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null);
// 流式 parse 经 getter 取最新引用,行为零变化。
// 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2)
@@ -130,7 +139,9 @@ interface StreamBlock {
}
function renderStreamingBlocks(text: string): StreamBlock[] {
if (!text) return []
if (!mdReady.value || !getMarked() || !getPurify()) {
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
return [{ html: escapeFallback(text), key: 'fallback' }]
}
const blocks = splitBlocks(text)
@@ -138,13 +149,34 @@ function renderStreamingBlocks(text: string): StreamBlock[] {
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
return blocks.map((b, i) => {
const isTail = i === n - 1
const html = isTail ? parseBlockNoCache(b) : parseBlock(b)
// AR-1 代码块降级:末块若是未闭合的代码围栏(```/~~~ 数为奇数,说明代码块还没结束),
// 不走 marked.parse(未闭合围栏 → marked 推断为代码块 + hljs 对不完整代码高亮,
// 流式过程中每 delta 重 parse 会闪烁/错乱)。降级为纯文本(转义围栏原文),AiCompleted
// 后整段 renderMd 会做完整代码块渲染(此时围栏已闭合)。已完成块不受影响(围栏已闭合)。
const degrade = isTail && isUnclosedCodeFence(b)
const html = degrade
? escapeFallback(b)
: isTail ? parseBlockNoCache(b) : parseBlock(b)
// 已完成块用文本做 key(DOM 稳定不重建);末块用递增序号(每帧更新)
const key = isTail ? `tail-${++tailSeq}` : `b-${simpleHash(b)}`
return { html, key }
})
}
/// 末块未闭合代码围栏检测:统计行首(可选 ≤3 空格)的 ``` / ~~~ 围栏开/闭数量,
/// 奇数 = 有未闭合的代码块(流式还在写入该代码块)。仅末块调用,前块必已闭合(splitBlocks
/// 已把完整 code token 切成独立块)。对齐 marked 围栏规则(行首 ≤3 空格 + 3+ 反引号/波浪)。
function isUnclosedCodeFence(block: string): boolean {
let fenceCount = 0
const lines = block.split('\n')
for (const line of lines) {
// 行首 ≤3 空格 + 3+ 反引号或波浪(marked 围栏开关判定)
const m = /^[ ]{0,3}(`{3,}|~{3,})/.exec(line)
if (m) fenceCount++
}
return fenceCount % 2 === 1
}
/// 轻量字符串哈希:用于 block key 稳定性(非加密,仅避免长文本做 key)
function simpleHash(s: string): number {
let h = 5381
@@ -178,6 +210,62 @@ function renderContent(msg: AiMessage): string {
return renderMd(msg.content)
}
// ── Input Augmentation: 用户消息按 mention 区间切段(弃正则,纯元数据驱动) ──
// 解决 🔴HIGH-2:正则误匹配(用户手打 [项目: x] 格式 / AI 模仿格式 / 项目名含 ] / 嵌套)。
// 按 msg.mentionSpans(无则纯文本一段)按 start 升序切段:text → chip 替换。
// 每 span 安全校验:content.slice(start, start+length) === span.label 否则降级为 text
// (用户编辑破坏区间,如改了项目名/删了字符致偏移失效)。重叠/越界 span 也降级。
type UserContentSegment =
| { type: 'text'; text: string }
| { type: 'chip'; span: MentionSpan }
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
const content = msg.content
const spans = msg.mentionSpans
// 无 mentionSpans(历史消息/纯文本)→ 一段 text 零回归
if (!spans || spans.length === 0) {
return [{ type: 'text', text: content }]
}
// 按 start 升序排序(稳定;不影响原数组)
const sorted = [...spans].sort((a, b) => a.start - b.start)
const segments: UserContentSegment[] = []
let cursor = 0 // 已消费到的字符偏移
for (const span of sorted) {
const start = span.start
const end = start + span.length
// 越界:起点/终点超出 content 长度 → 降级跳过(区间已失效)
if (start < 0 || end > content.length) continue
// 与前段重叠:起点在已消费 cursor 之前 → 降级跳过(防区间相互覆盖产生错乱)
if (start < cursor) continue
// 安全校验:切片内容必须 === span.label(用户编辑破坏区间则不等 → 降级跳过该 span)
if (content.slice(start, end) !== span.label) continue
// 前导 text(若有)
if (start > cursor) {
segments.push({ type: 'text', text: content.slice(cursor, start) })
}
// chip 段
segments.push({ type: 'chip', span })
cursor = end
}
// 收尾 tail text(末个 span 之后剩余)
if (cursor < content.length) {
segments.push({ type: 'text', text: content.slice(cursor) })
}
// 极端:所有 span 全降级且 content 非空 → segments 可能为空(无前导/tail),补一段完整 text
if (segments.length === 0 && content.length > 0) {
return [{ type: 'text', text: content }]
}
return segments
}
// ── UX-2025-20: 空状态示例问题 ──
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
const examplePrompts = [
@@ -866,7 +954,16 @@ defineExpose({
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="ai-msg-bubble ai-msg-bubble--user">
{{ item.msg.content }}
<!-- Input Augmentation: 按 mention 区间切段渲染(chip 精确替换 [类型: 名] 标记,弃正则)。
无 mentionSpans 时 segmentUserContent 返回单段 text(零回归,等同原 {{ content }})。 -->
<template v-for="(seg, i) in segmentUserContent(item.msg)" :key="(item.msg.id) + '-seg-' + i">
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
<span
v-else
class="ai-msg-chip"
:class="'ai-msg-chip--' + seg.span.kind"
>{{ seg.span.label }}</span>
</template>
<!-- F-260614-05 Phase 2b: 多模态图片渲染(随消息气泡挂载/卸载;
B-260618-01 虚拟滚动已移除,气泡内 <img> 随消息恒渲染无裁剪) -->
<template v-if="msgImageParts(item.msg).length">
@@ -899,7 +996,7 @@ defineExpose({
<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="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<!-- UX-2025-13:消息时间戳(相对时间+hover绝对时间) -->
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelativeZh(item.msg.timestamp) }}</span>
<span class="ai-msg-time ai-msg-time--user" :title="formatDate(item.msg.timestamp)">{{ formatRelative(item.msg.timestamp) }}</span>
</div>
<!-- AI 消息 -->
@@ -995,11 +1092,13 @@ defineExpose({
</div>
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->
<!-- path_auth 审批链阶段3b:approve 事件透传 decision(path 类 once/always/deny),
store.approveToolCall 据 decision 走 authorizeDir,缺省走 approve。 -->
<ToolCardList
v-if="item.msg.toolCalls && item.msg.toolCalls.length > 0"
ref="toolCardListRef"
:toolCalls="item.msg.toolCalls"
@approve="({ id, approved }) => store.approveToolCall(id, approved)"
@approve="({ id, approved, decision }) => store.approveToolCall(id, approved, decision)"
@batch-approve="(decision) => store.batchApprove(decision)"
/>
@@ -1008,7 +1107,7 @@ defineExpose({
v-if="!(isLastAi(item.msg) && store.state.streaming)"
class="ai-msg-time"
:title="formatDate(item.msg.timestamp)"
>{{ formatRelativeZh(item.msg.timestamp) }}</span>
>{{ formatRelative(item.msg.timestamp) }}</span>
</div>
</div>
</div>
@@ -1214,6 +1313,28 @@ defineExpose({
overflow-x: auto;
}
/* ── Input Augmentation: 用户气泡内 mention chip(圆角徽标,复用 ChatInput .ai-skill-chip-name 风格) ──
用户气泡背景 = --df-accent(主题强调色,如深色蓝/紫),chip 用半透明白底+深字 保证对比度;
按 kind 区分颜色变体(project/task/idea/skill),与 @ mention 浮层 .ai-mention-item-type--* 视觉呼应。 */
.ai-msg-chip {
display: inline-block;
padding: 1px 6px;
margin: 0 1px;
border-radius: var(--df-radius);
font-size: 0.9em;
font-weight: 600;
line-height: 1.4;
background: rgba(255, 255, 255, 0.22);
color: #fff;
vertical-align: baseline;
white-space: nowrap;
}
/* kind 颜色变体(用更亮的底色区分,但保持白字可读) */
.ai-msg-chip--project { background: rgba(255, 255, 255, 0.32); }
.ai-msg-chip--task { background: rgba(255, 255, 255, 0.26); }
.ai-msg-chip--idea { background: rgba(255, 255, 255, 0.22); border: 1px solid rgba(255, 255, 255, 0.35); }
.ai-msg-chip--skill { background: rgba(0, 0, 0, 0.18); }
/* ── 消息时间戳(UX-2025-13) ── */
.ai-msg-time {
font-size: 10px;