新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user