- 渲染: 结果/过程分离(气泡只放最终结果,过程轮叙述+工具卡收进轨迹卡默认收起可展开——治『流式后正文消失』且不再 N 气泡打扰) - 渲染 P0: v-memo 定向 dep(只有真正渲染流式块的 item 失效,其余取占位) + renderItems 桶 memo(签名覆盖全消费输入,item 引用稳定) + 指纹 watch 降为 O(1) toolEpoch - TopBar 三处 O(n) messages computed 合并单次遍历;md 缓存改有界 LRU(除满即全清抖动);useTurnTrace 补 never 穷尽 + default;MessageItem 模板去重 - 无障碍(四批累计): 可点容器键盘化(含 role/tabindex/keydown/aria-expanded) + icon-only aria-label 约 60 处 + 装饰图标 aria-hidden + 52 个滚动容器 overscroll-behavior:contain + 设置开关聚焦可见环 + outline 零补偿站点补 :focus-visible - UX 走查项: 灵感 IDEA-10/11、知识 KNOW-6/7/9、项目详情 O8、审计 SA-4、设置 SA-6/26/27/28(脏表单/冗余 save/clamp/删除二次确认) - 文件查看专项: 切工程状态残留 F5/F6、FileTree 去重与展开反馈 F7/F15/F17、切文件头部残留 F18、R 状态着色 F19;FilePreview diff 文件头(---/+++)误当增删渲染修复 + 大纲行高亮死样式修复 + 图片失败文案 i18n 化 - 同步 IO 收口(异步上下文内阻塞): unattended 看板读写改 tokio::fs、fetch_url 探活改 spawn_blocking、http/generate_image 清理改 tokio::fs - 子代理 P3 批量: 任务书钉扎上限/截断文案区分父子语境/环境画像日期/temperature 可覆盖/model 回退上报/lang 透传 - crate 清理: df-execute·df-types 注释与格式、df-ideas 过时注释、df-nodes subflow 夸大描述、df-project 死变量
224 lines
8.2 KiB
Vue
224 lines
8.2 KiB
Vue
<script setup lang="ts">
|
|
//! 单条「用户」消息渲染 — 从 MessageList.vue 提取,降低 God 文件复杂度
|
|
//!
|
|
//! 职责(单一,仅 user 角色):渲染用户消息气泡,
|
|
//! 含 Mention chip + 图片附件。
|
|
//!
|
|
//! 设计说明:assistant / system / error 三角色由 MessageList 内联渲染
|
|
//! (含流式分块、工具卡片、token 用量、错误重试、压缩摘要置顶等聚合逻辑),
|
|
//! 本组件专注 user,不做 AI 消息渲染,避免两套渲染入口并存导致的死代码与职责混乱。
|
|
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import type { AiMessage, ContentPart, MentionSpan } from '../../api/types'
|
|
import { useAiStore } from '../../stores/ai'
|
|
|
|
const { t } = useI18n()
|
|
const store = useAiStore()
|
|
|
|
/**
|
|
* 前导 /技能名 解析(问题3 技能调用留痕的渲染侧):
|
|
* 落库文本形如 `/skill-name 正文`(后端 chat.rs user_content 统一加前缀),命中本机
|
|
* 已安装技能名单才渲染为技能 chip——普通以 / 开头的文本(路径、markdown)不误判。
|
|
* 返回 null = 非技能调用,走原纯文本路径零回归。
|
|
*/
|
|
function leadingSkill(msg: AiMessage): { name: string; rest: string } | null {
|
|
if (msg.role !== 'user') return null
|
|
const m = /^\/([A-Za-z0-9_-]+)(?:\s([\s\S]*))?$/.exec(msg.content)
|
|
if (!m) return null
|
|
const known = store.state.skills.some((s) => s.name === m[1])
|
|
if (!known) return null
|
|
return { name: m[1], rest: m[2] ?? '' }
|
|
}
|
|
|
|
const props = defineProps<{
|
|
msg: AiMessage
|
|
/** 是否显示编辑按钮(父合成:末条 user + 非生成中 + 非编辑中) */
|
|
canEdit?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'copy', msg: AiMessage): void
|
|
(e: 'start-edit', msg: AiMessage): void
|
|
}>()
|
|
|
|
// ── Input Augmentation: 按 mention 区间切段 ──
|
|
type UserContentSegment =
|
|
| { type: 'text'; text: string }
|
|
| { type: 'chip'; span: MentionSpan }
|
|
|
|
function segmentUserContent(msg: AiMessage): UserContentSegment[] {
|
|
const content = msg.content
|
|
const spans = msg.mentionSpans
|
|
if (!spans || spans.length === 0) {
|
|
return [{ type: 'text', text: content }]
|
|
}
|
|
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
|
|
if (start < 0 || end > content.length) continue
|
|
if (start < cursor) continue
|
|
if (content.slice(start, end) !== span.label) continue
|
|
if (start > cursor) {
|
|
segments.push({ type: 'text', text: content.slice(cursor, start) })
|
|
}
|
|
segments.push({ type: 'chip', span })
|
|
cursor = end
|
|
}
|
|
if (cursor < content.length) {
|
|
segments.push({ type: 'text', text: content.slice(cursor) })
|
|
}
|
|
if (segments.length === 0 && content.length > 0) {
|
|
return [{ type: 'text', text: content }]
|
|
}
|
|
return segments
|
|
}
|
|
|
|
// ── ContentPart 图片处理 ──
|
|
type ImageContentPart = Extract<ContentPart, { type: 'image' }>
|
|
function msgImageParts(msg: AiMessage): ImageContentPart[] {
|
|
if (!msg.parts) return []
|
|
return msg.parts.filter((p): p is ImageContentPart => p.type === 'image')
|
|
}
|
|
function imageSrc(img: ImageContentPart): string {
|
|
if (img.base64) {
|
|
const mt = img.media_type || 'image/png'
|
|
return `data:${mt};base64,${img.base64}`
|
|
}
|
|
return img.url || ''
|
|
}
|
|
function imgAlt(img: ImageContentPart): string {
|
|
return img.alt || t('aiChat.imagePreviewAlt')
|
|
}
|
|
|
|
// 模板中同一渲染内多次引用的纯派生值 → computed 缓存一次求值
|
|
// (纯函数仅依赖 props.msg / store.state.skills 等响应式源,缓存语义与原先逐次调用等价)
|
|
const skill = computed(() => leadingSkill(props.msg))
|
|
const segments = computed(() => segmentUserContent(props.msg))
|
|
const imageParts = computed(() => msgImageParts(props.msg))
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="ai-msg"
|
|
:class="'ai-msg--' + msg.role"
|
|
:data-msg-id="msg.id"
|
|
>
|
|
<!-- 用户消息(本组件唯一渲染角色;assistant/system/error 由 MessageList 内联渲染) -->
|
|
<div class="ai-msg-user">
|
|
<div class="ai-msg-avatar ai-msg-avatar--user">
|
|
<svg aria-hidden="true" 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">
|
|
<!-- 问题3 技能调用留痕:前导 /技能名 命中已安装技能 → 紫色 chip 标识,替代原文前缀显示 -->
|
|
<template v-if="skill">
|
|
<span class="ai-msg-chip ai-msg-chip--skill" :title="t('aiChat.skillBadgeTip', { name: skill!.name })">⚡ /{{ skill!.name }}</span>
|
|
<template v-if="skill!.rest">{{ ' ' + skill!.rest }}</template>
|
|
</template>
|
|
<template v-else>
|
|
<template v-for="(seg, idx) in segments" :key="msg.id + '-seg-' + idx">
|
|
<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>
|
|
</template>
|
|
<template v-if="imageParts.length">
|
|
<div class="ai-msg-images">
|
|
<img v-for="(img, idx) in imageParts" :key="msg.id + '-img-' + idx" :src="imageSrc(img)" :alt="imgAlt(img)" class="ai-msg-image" loading="lazy" />
|
|
</div>
|
|
</template>
|
|
<!-- UX-09:末条 user 消息编辑按钮(hover 显示),点击经 start-edit 链回填输入框编辑重发 -->
|
|
<button v-if="canEdit" class="ai-msg-edit-btn" :title="t('aiChat.editMessage')" :aria-label="t('aiChat.editMessage')" @click.stop="emit('start-edit', msg)">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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>
|
|
<button class="ai-copy-btn" :title="t('aiChat.copyMsg')" :aria-label="t('aiChat.copyMsg')" @click.stop="emit('copy', msg)">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* 用户消息:头像在右(整条靠右) */
|
|
.ai-msg-user {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: flex-start;
|
|
flex-direction: row-reverse;
|
|
}
|
|
/* 头像通用 */
|
|
.ai-msg-avatar {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.ai-msg-avatar--user {
|
|
background: var(--df-info-bg, rgba(100,181,246,0.15));
|
|
color: var(--df-info, #64b5f6);
|
|
}
|
|
/* 气泡通用 */
|
|
.ai-msg-bubble {
|
|
border-radius: 10px;
|
|
padding: 8px 12px;
|
|
word-break: break-word;
|
|
max-width: 80%;
|
|
}
|
|
.ai-msg-bubble--user {
|
|
background: var(--df-info-bg, rgba(100,181,246,0.1));
|
|
color: var(--df-text);
|
|
}
|
|
/* chip */
|
|
.ai-msg-chip {
|
|
display: inline-block;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
margin: 0 2px;
|
|
background: var(--df-accent-bg, rgba(108,99,255,0.1));
|
|
color: var(--df-accent, #6c63ff);
|
|
}
|
|
/* 技能调用徽章(问题3):与 mention chip 同视觉家族,亮一档紫色底 + ⚡ 前缀区分 */
|
|
.ai-msg-chip--skill {
|
|
background: var(--df-accent-bg, rgba(108,99,255,0.18));
|
|
color: var(--df-accent, #6c63ff);
|
|
font-weight: 600;
|
|
}
|
|
/* 图片 */
|
|
.ai-msg-images {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
margin-top: 4px;
|
|
}
|
|
.ai-msg-image {
|
|
max-width: 200px;
|
|
max-height: 200px;
|
|
border-radius: 6px;
|
|
}
|
|
/* 复制/编辑按钮(hover 显示,同栏同视觉;编辑仅 canEdit 时渲染) */
|
|
.ai-copy-btn,
|
|
.ai-msg-edit-btn {
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
color: var(--df-text-dim);
|
|
}
|
|
.ai-msg-user:hover .ai-copy-btn,
|
|
.ai-msg-user:hover .ai-msg-edit-btn,
|
|
/* UX第五轮 P2-M1:键盘聚焦可达(hover-only 修复)——Tab 聚到按钮或按钮内聚焦时显形 */
|
|
.ai-msg-user:focus-within .ai-copy-btn,
|
|
.ai-msg-user:focus-within .ai-msg-edit-btn {
|
|
opacity: 1;
|
|
}
|
|
</style>
|