新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(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:
@@ -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