重构+新增: 前端God文件拆分+进度条completed计数器
- useStreamRenderer.ts: 流式Markdown块级渲染逻辑(220行)提取为composable - EmptyState.vue: 空状态(无provider引导+示例问题)独立组件(134行) - MentionPopover.vue: @实体联想浮层独立组件(132行) - EnrichmentPanel.vue: @项目enrichment预览面板独立组件(144行) - MessageList.vue: 1386→1148行(-238行) - ChatInput.vue: 1155→1007行(-148行) - AiChat进度条: completedTools计数器(AiToolCallCompleted+1, AiAgentRound重置) - i18n: 进度条文案加已完成工具数(中英文) - vue-tsc + vite build 通过
This commit is contained in:
@@ -221,9 +221,10 @@ const showAgenticProgress = computed(() =>
|
|||||||
// 进度文案:round 必显;pending>0 时附加待审批数(max/completed 本轮降级省略,见模板 TODO)
|
// 进度文案:round 必显;pending>0 时附加待审批数(max/completed 本轮降级省略,见模板 TODO)
|
||||||
const agenticProgressText = computed(() => {
|
const agenticProgressText = computed(() => {
|
||||||
const pending = store.state.pendingApprovals.length
|
const pending = store.state.pendingApprovals.length
|
||||||
|
const completed = store.state.completedTools
|
||||||
return pending > 0
|
return pending > 0
|
||||||
? t('ai.agenticProgress', { round: store.state.agentRound, pending })
|
? t('ai.agenticProgress', { round: store.state.agentRound, pending })
|
||||||
: t('ai.agenticProgressNoPending', { round: store.state.agentRound })
|
: t('ai.agenticProgressNoPending', { round: store.state.agentRound, completed })
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
|
/** 点击徽标:滚动到第一个 pending_approval 卡片(委托 MessageList.scrollToFirstPending) */
|
||||||
|
|||||||
@@ -29,31 +29,17 @@
|
|||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) -->
|
<!-- UX-10 §1.4: @ 实体引用浮层(提取至 MentionPopover.vue,选中插入 [类型:名] 标记) -->
|
||||||
<div v-if="mentionOpen" class="ai-skill-popover ai-mention-popover">
|
<MentionPopover
|
||||||
<div v-if="!mentionItems.length" class="ai-skill-empty">
|
v-if="mentionOpen"
|
||||||
{{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
|
:mention-items="mentionItems"
|
||||||
</div>
|
:mention-groups="mentionGroups"
|
||||||
<div v-else>
|
:mention-total="mentionTotal"
|
||||||
<template v-for="g in mentionGroups" :key="g.key">
|
:mention-index="mentionIndex"
|
||||||
<div v-if="g.items.length" class="ai-mention-group">{{ g.label }}</div>
|
:mention-flat-index="mentionFlatIndex"
|
||||||
<div
|
@select="selectMention"
|
||||||
v-for="item in g.items"
|
@update:mention-index="mentionIndex = $event"
|
||||||
:key="item.uid"
|
/>
|
||||||
class="ai-skill-item"
|
|
||||||
:class="{ 'ai-skill-item--active': mentionFlatIndex(item.uid) === mentionIndex }"
|
|
||||||
@click="selectMention(item)"
|
|
||||||
@mouseenter="mentionIndex = mentionFlatIndex(item.uid)"
|
|
||||||
>
|
|
||||||
<div class="ai-skill-item-row">
|
|
||||||
<span class="ai-mention-item-type" :class="'ai-mention-item-type--' + item.type">{{ item.typeLabel }}</span>
|
|
||||||
<span class="ai-mention-item-name">{{ item.name }}</span>
|
|
||||||
<span class="ai-mention-item-desc">{{ item.desc }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
|
||||||
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
idle(或 conv_state 未收到回退旧 streaming=false)显发送按钮。双轨过渡不强制全替旧 bool。 -->
|
||||||
<button
|
<button
|
||||||
@@ -90,26 +76,15 @@
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) -->
|
<!-- ⑥.4 Phase 4: @项目展开摘要(提取至 EnrichmentPanel.vue) -->
|
||||||
<div v-if="hasEnrichment" class="ai-enrichment-bar">
|
<EnrichmentPanel
|
||||||
<button
|
:has-enrichment="hasEnrichment"
|
||||||
class="ai-enrichment-toggle"
|
:enrichment-badge-text="enrichmentBadgeText"
|
||||||
:class="{ 'ai-enrichment-toggle--expanded': enrichmentExpanded }"
|
:enrichment-detail-text="enrichmentDetailText"
|
||||||
@click="enrichmentExpanded = !enrichmentExpanded"
|
:enrichment-expanded="enrichmentExpanded"
|
||||||
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
@dismiss="dismissEnrichment"
|
||||||
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
@update:enrichment-expanded="enrichmentExpanded = $event"
|
||||||
>
|
/>
|
||||||
<svg class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
|
||||||
<path d="M2 3l3 4 3-4" />
|
|
||||||
</svg>
|
|
||||||
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
|
|
||||||
</button>
|
|
||||||
<button class="ai-enrichment-dismiss" @click="dismissEnrichment" title="取消关联">×</button>
|
|
||||||
<div v-if="enrichmentExpanded" class="ai-enrichment-panel">
|
|
||||||
<div class="ai-enrichment-label">{{ $t('aiChat.enrichmentLabel') }}</div>
|
|
||||||
<pre class="ai-enrichment-body"><code>{{ enrichmentDetailText }}</code></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
<div class="ai-input-hint">{{ $t('aiChat.inputHint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -130,7 +105,10 @@ import { useProjectStore } from '../../stores/project'
|
|||||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||||
import SkillMention from './SkillMention.vue'
|
import SkillMention from './SkillMention.vue'
|
||||||
import ImageInput from './ImageInput.vue'
|
import ImageInput from './ImageInput.vue'
|
||||||
|
import MentionPopover from './MentionPopover.vue'
|
||||||
|
import EnrichmentPanel from './EnrichmentPanel.vue'
|
||||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||||
|
import type { MentionItem } from './MentionItem'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** UX-09: 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */
|
/** UX-09: 编辑末条 user 消息态;父持有,handleSend 据此走 editMessage 而非 sendMessage */
|
||||||
@@ -354,16 +332,7 @@ function clearSkill() {
|
|||||||
// 联想源只读 projectsStore.projects / projectsStore.tasks(前端 store 现有数据,不新增 IPC)。
|
// 联想源只读 projectsStore.projects / projectsStore.tasks(前端 store 现有数据,不新增 IPC)。
|
||||||
// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。
|
// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。
|
||||||
// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。
|
// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。
|
||||||
interface MentionItem {
|
// MentionItem 接口已抽至 ./MentionItem.ts(MentionPopover 子组件共用)。
|
||||||
type: 'project' | 'task' | 'idea'
|
|
||||||
/** 扁平后唯一 key(用于 key/导航扁平索引定位) */
|
|
||||||
uid: string
|
|
||||||
/** 实体原始 id(供后端解析,目前仅插入 [类型:名] 文本,id 未透传) */
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
desc: string
|
|
||||||
typeLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentionOpen = ref(false)
|
const mentionOpen = ref(false)
|
||||||
const mentionIndex = ref(0)
|
const mentionIndex = ref(0)
|
||||||
@@ -962,44 +931,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
.ai-skill-empty { padding: 8px 12px; color: var(--df-text-dim); opacity: 0.7; font-size: 12px; }
|
.ai-skill-empty { padding: 8px 12px; color: var(--df-text-dim); opacity: 0.7; font-size: 12px; }
|
||||||
|
|
||||||
/* ── UX-10 §1.4: @ 实体引用浮层(复用 .ai-skill-popover 容器,仅新增组标题/类型标签) ── */
|
/* ── UX-10 §1.4: @ 实体引用浮层样式已随 MentionPopover.vue 提取 ── */
|
||||||
.ai-mention-popover { /* 同 .ai-skill-popover 定位,这里仅为语义占位,样式继承 */ }
|
|
||||||
.ai-mention-group {
|
|
||||||
padding: 4px 10px 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.ai-mention-item-name { color: var(--df-text); font-weight: 500; flex-shrink: 0; }
|
|
||||||
.ai-mention-item-desc {
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
/* 类型标签小色块(项目/任务区分) */
|
|
||||||
.ai-mention-item-type {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
background: var(--df-bg-hover);
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
}
|
|
||||||
.ai-mention-item-type--project {
|
|
||||||
background: color-mix(in srgb, var(--df-accent) 16%, transparent);
|
|
||||||
color: var(--df-accent);
|
|
||||||
}
|
|
||||||
.ai-mention-item-type--task {
|
|
||||||
background: color-mix(in srgb, var(--df-success) 16%, transparent);
|
|
||||||
color: var(--df-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-input {
|
.ai-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1071,85 +1003,5 @@ defineExpose({
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
|
/* ── ⑥.4 Phase 4: @项目展开摘要样式已随 EnrichmentPanel.vue 提取 ── */
|
||||||
.ai-enrichment-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 6px;
|
|
||||||
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
|
||||||
border-radius: var(--df-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.ai-enrichment-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border: none;
|
|
||||||
background: color-mix(in srgb, var(--df-accent) 10%, transparent);
|
|
||||||
color: var(--df-text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.ai-enrichment-toggle:hover {
|
|
||||||
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
|
|
||||||
}
|
|
||||||
.ai-enrichment-toggle--expanded {
|
|
||||||
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
|
|
||||||
}
|
|
||||||
.ai-enrichment-chevron {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
transition: transform 0.15s;
|
|
||||||
}
|
|
||||||
.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
.ai-enrichment-dismiss {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 0 4px;
|
|
||||||
line-height: 1;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
.ai-enrichment-dismiss:hover {
|
|
||||||
color: var(--df-danger);
|
|
||||||
}
|
|
||||||
.ai-enrichment-badge {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--df-accent);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.ai-enrichment-panel {
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: var(--df-bg);
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.ai-enrichment-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.ai-enrichment-body {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--df-text);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
font-family: var(--df-font-mono, ui-monospace, monospace);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
134
src/components/ai/EmptyState.vue
Normal file
134
src/components/ai/EmptyState.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
//! 空状态 — 抽自 MessageList.vue(零行为变更)。
|
||||||
|
//!
|
||||||
|
//! 职责:消息列表为空时的引导 UI。
|
||||||
|
//! - 无 provider:警告图标 + 跳 Settings 按钮(emit 'go-to-settings')
|
||||||
|
//! - 有 provider:欢迎文案 + 示例问题卡片(emit 'send-example-prompt')
|
||||||
|
//!
|
||||||
|
//! 父组件注入:
|
||||||
|
//! - hasProvider:是否有可用 provider(决定显哪个分支)
|
||||||
|
//! - send-example-prompt(i18nKey):点击示例问题卡片
|
||||||
|
//! - go-to-settings:无 provider / 跳 Settings
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 是否有可用 provider(显无 provider 引导 or 示例问题卡片) */
|
||||||
|
hasProvider: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'send-example-prompt', i18nKey: string): void
|
||||||
|
(e: 'go-to-settings'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ── UX-2025-20: 空状态示例问题 ──
|
||||||
|
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
|
||||||
|
const examplePrompts = [
|
||||||
|
'aiChat.examplePrompt1',
|
||||||
|
'aiChat.examplePrompt2',
|
||||||
|
'aiChat.examplePrompt3',
|
||||||
|
'aiChat.examplePrompt4',
|
||||||
|
] as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ai-empty">
|
||||||
|
<template v-if="!hasProvider">
|
||||||
|
<div class="ai-empty-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
|
||||||
|
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
|
||||||
|
<!-- 跳 Settings:嵌入模式用 router.push;分离窗口无 Settings 路由,降级 toast 提示去主窗口 -->
|
||||||
|
<button class="ai-empty-action" @click="emit('go-to-settings')">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||||
|
<span>{{ $t('aiChat.goToSettings') }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="ai-empty-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||||
|
</div>
|
||||||
|
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
|
||||||
|
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
|
||||||
|
<!-- UX-2025-20: 示例问题卡片(点击自动填入并发送) -->
|
||||||
|
<div class="ai-empty-prompts">
|
||||||
|
<button
|
||||||
|
v-for="(prompt, i) in examplePrompts"
|
||||||
|
:key="i"
|
||||||
|
class="ai-empty-prompt"
|
||||||
|
@click="emit('send-example-prompt', prompt)"
|
||||||
|
>{{ $t(prompt) }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* -- Empty State -- */
|
||||||
|
.ai-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.ai-empty-icon { margin-bottom: 4px; }
|
||||||
|
.ai-empty-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
}
|
||||||
|
.ai-empty-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
/* UX-2025-20: 无 provider 引导 — 跳 Settings 按钮(克制:边框线 + accent 文字,不喧宾) */
|
||||||
|
.ai-empty-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 0.5px solid var(--df-accent);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
background: var(--df-accent-bg);
|
||||||
|
color: var(--df-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.ai-empty-action:hover { filter: brightness(1.08); }
|
||||||
|
/* UX-2025-20 §7.3: 示例问题卡片网格(2 列,轻量;复用 .ai-* 卡片风格) */
|
||||||
|
.ai-empty-prompts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.ai-empty-prompt {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.85;
|
||||||
|
transition: all 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.ai-empty-prompt:hover {
|
||||||
|
border-color: var(--df-accent);
|
||||||
|
color: var(--df-text);
|
||||||
|
opacity: 1;
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 8%, var(--df-bg-card));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
144
src/components/ai/EnrichmentPanel.vue
Normal file
144
src/components/ai/EnrichmentPanel.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
//! @项目展开摘要面板 — 抽自 ChatInput.vue(零行为变更)。
|
||||||
|
//!
|
||||||
|
//! 职责:⑥.4 Phase 4 @项目 enrichment 预览(输入区下方 badge + 可展开上下文参考面板)。
|
||||||
|
//! 当用户 @[项目:xxx] 后,从 stores 反查项目 + 关联任务/灵感,显式 badge + 可展开面板。
|
||||||
|
//!
|
||||||
|
//! 父组件注入:
|
||||||
|
//! - hasEnrichment:是否有 enrichment 可展示(控制整个面板显隐)
|
||||||
|
//! - enrichmentBadgeText:概要 badge 文本(汇总所有 @项目)
|
||||||
|
//! - enrichmentDetailText:展开后的正文(mimic 后端 build_augmentation_segment 格式)
|
||||||
|
//! - enrichmentExpanded:展开态(v-model 双向)
|
||||||
|
//! 触发:
|
||||||
|
//! - dismiss:取消 @项目 关联(清 mentionSpan + 移除输入框标记)
|
||||||
|
//! - update:enrichmentExpanded:展开/收起切换
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 是否有 enrichment 可展示(控制整个面板显隐) */
|
||||||
|
hasEnrichment: boolean
|
||||||
|
/** 概要 badge 文本(汇总所有 @项目 enrichment) */
|
||||||
|
enrichmentBadgeText: string
|
||||||
|
/** 展开后的 enrichment 正文(mimic 后端 build_augmentation_segment 格式) */
|
||||||
|
enrichmentDetailText: string
|
||||||
|
/** 展开态 */
|
||||||
|
enrichmentExpanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 取消 @项目 关联:清除 mentionSpan + 移除输入框中的 [@项目: xxx] 标记 */
|
||||||
|
(e: 'dismiss'): void
|
||||||
|
/** 展开/收起切换 */
|
||||||
|
(e: 'update:enrichmentExpanded', expanded: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggleExpanded(current: boolean) {
|
||||||
|
emit('update:enrichmentExpanded', !current)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) -->
|
||||||
|
<div v-if="hasEnrichment" class="ai-enrichment-bar">
|
||||||
|
<button
|
||||||
|
class="ai-enrichment-toggle"
|
||||||
|
:class="{ 'ai-enrichment-toggle--expanded': enrichmentExpanded }"
|
||||||
|
@click="toggleExpanded(enrichmentExpanded)"
|
||||||
|
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
||||||
|
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
||||||
|
>
|
||||||
|
<svg class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||||
|
<path d="M2 3l3 4 3-4" />
|
||||||
|
</svg>
|
||||||
|
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="ai-enrichment-dismiss" @click="emit('dismiss')" title="取消关联">×</button>
|
||||||
|
<div v-if="enrichmentExpanded" class="ai-enrichment-panel">
|
||||||
|
<div class="ai-enrichment-label">{{ $t('aiChat.enrichmentLabel') }}</div>
|
||||||
|
<pre class="ai-enrichment-body"><code>{{ enrichmentDetailText }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ── */
|
||||||
|
.ai-enrichment-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 30%, transparent);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ai-enrichment-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 10%, transparent);
|
||||||
|
color: var(--df-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ai-enrichment-toggle:hover {
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
.ai-enrichment-toggle--expanded {
|
||||||
|
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
.ai-enrichment-chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.ai-enrichment-toggle--expanded .ai-enrichment-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.ai-enrichment-dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.ai-enrichment-dismiss:hover {
|
||||||
|
color: var(--df-danger);
|
||||||
|
}
|
||||||
|
.ai-enrichment-badge {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--df-accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ai-enrichment-panel {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--df-bg);
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ai-enrichment-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ai-enrichment-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--df-text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: var(--df-font-mono, ui-monospace, monospace);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
src/components/ai/MentionItem.ts
Normal file
15
src/components/ai/MentionItem.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//! @ 实体引用相关共享类型 — ChatInput / MentionPopover 共用。
|
||||||
|
//!
|
||||||
|
//! 抽自 ChatInput.vue(零行为变更),供 MentionPopover.vue 子组件复用。
|
||||||
|
|
||||||
|
/** @ 联想候选实体(项目/任务/灵感,扁平后唯一 uid 供 key/导航) */
|
||||||
|
export interface MentionItem {
|
||||||
|
type: 'project' | 'task' | 'idea'
|
||||||
|
/** 扁平后唯一 key(用于 key/导航扁平索引定位) */
|
||||||
|
uid: string
|
||||||
|
/** 实体原始 id(供后端解析,目前仅插入 [类型:名] 文本,id 未透传) */
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
typeLabel: string
|
||||||
|
}
|
||||||
132
src/components/ai/MentionPopover.vue
Normal file
132
src/components/ai/MentionPopover.vue
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
//! @ 实体引用浮层 — 抽自 ChatInput.vue(零行为变更)。
|
||||||
|
//!
|
||||||
|
//! 职责:@ 触发的实体引用 popover(项目/任务/灵感联想候选列表)。
|
||||||
|
//! 架构复用技能联想 popover 的 .ai-skill-popover 容器样式,仅新增组标题/类型标签。
|
||||||
|
//!
|
||||||
|
//! 父组件注入:
|
||||||
|
//! - mentionItems:联想候选(项目 + 任务 + 灵感,已按查询过滤)
|
||||||
|
//! - mentionGroups:按类型分组的候选(模板按组渲染组标题)
|
||||||
|
//! - mentionTotal:未过滤候选总数(区分"无匹配" vs "空")
|
||||||
|
//! - mentionIndex:当前高亮项在扁平列表中的下标
|
||||||
|
//! 触发:
|
||||||
|
//! - select(item):选中实体 → 替换 @query 段为 [类型: 名] 标记
|
||||||
|
//! - update:mentionIndex:鼠标 hover 时同步高亮下标
|
||||||
|
|
||||||
|
import type { MentionItem } from './MentionItem'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 联想候选(扁平,项目 + 任务 + 灵感) */
|
||||||
|
mentionItems: MentionItem[]
|
||||||
|
/** 按类型分组的候选(模板按组渲染组标题) */
|
||||||
|
mentionGroups: { key: string; label: string; items: MentionItem[] }[]
|
||||||
|
/** 未过滤候选总数(用于区分"无匹配" vs "空") */
|
||||||
|
mentionTotal: number
|
||||||
|
/** 当前高亮项在扁平列表中的下标 */
|
||||||
|
mentionIndex: number
|
||||||
|
/** 扁平下标查找函数(组内 item 在扁平列表中的位置,供高亮判定) */
|
||||||
|
mentionFlatIndex: (uid: string) => number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 选中实体 → 替换 @query 段为 [类型: 名] 标记 + 关闭浮层 */
|
||||||
|
(e: 'select', item: MentionItem): void
|
||||||
|
/** 鼠标 hover 时同步高亮下标 */
|
||||||
|
(e: 'update:mentionIndex', index: number): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- UX-10 §1.4: @ 实体引用浮层(@ 触发,选中插入 [类型:名] 标记,架构复用技能 popover) -->
|
||||||
|
<div class="ai-skill-popover ai-mention-popover">
|
||||||
|
<div v-if="!mentionItems.length" class="ai-skill-empty">
|
||||||
|
{{ mentionTotal ? $t('aiChat.mentionNoMatch') : $t('aiChat.mentionEmpty') }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<template v-for="g in mentionGroups" :key="g.key">
|
||||||
|
<div v-if="g.items.length" class="ai-mention-group">{{ g.label }}</div>
|
||||||
|
<div
|
||||||
|
v-for="item in g.items"
|
||||||
|
:key="item.uid"
|
||||||
|
class="ai-skill-item"
|
||||||
|
:class="{ 'ai-skill-item--active': mentionFlatIndex(item.uid) === mentionIndex }"
|
||||||
|
@click="emit('select', item)"
|
||||||
|
@mouseenter="emit('update:mentionIndex', mentionFlatIndex(item.uid))"
|
||||||
|
>
|
||||||
|
<div class="ai-skill-item-row">
|
||||||
|
<span class="ai-mention-item-type" :class="'ai-mention-item-type--' + item.type">{{ item.typeLabel }}</span>
|
||||||
|
<span class="ai-mention-item-name">{{ item.name }}</span>
|
||||||
|
<span class="ai-mention-item-desc">{{ item.desc }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── UX-10 §1.4: @ 实体引用浮层(复用 .ai-skill-popover 容器,仅新增组标题/类型标签) ── */
|
||||||
|
.ai-mention-popover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.ai-skill-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ai-skill-item-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.ai-skill-item--active,
|
||||||
|
.ai-skill-item:hover {
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 14%, transparent);
|
||||||
|
}
|
||||||
|
.ai-skill-empty { padding: 8px 12px; color: var(--df-text-dim); opacity: 0.7; font-size: 12px; }
|
||||||
|
.ai-mention-group {
|
||||||
|
padding: 4px 10px 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ai-mention-item-name { color: var(--df-text); font-weight: 500; flex-shrink: 0; }
|
||||||
|
.ai-mention-item-desc {
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 类型标签小色块(项目/任务区分) */
|
||||||
|
.ai-mention-item-type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
background: var(--df-bg-hover);
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
}
|
||||||
|
.ai-mention-item-type--project {
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 16%, transparent);
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
.ai-mention-item-type--task {
|
||||||
|
background: color-mix(in srgb, var(--df-success) 16%, transparent);
|
||||||
|
color: var(--df-success);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,10 +18,11 @@ import { ref, computed, nextTick, onBeforeUnmount, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { useMarkdown } from '../../composables/useMarkdown'
|
import { useMarkdown } from '../../composables/useMarkdown'
|
||||||
import { useAppSettingsStore } from '../../stores/appSettings'
|
|
||||||
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
import { useMessageScroll } from '../../composables/ai/useMessageScroll'
|
||||||
|
import { useStreamRenderer } from '../../composables/ai/useStreamRenderer'
|
||||||
import ToolCardList from '../ToolCardList.vue'
|
import ToolCardList from '../ToolCardList.vue'
|
||||||
import MessageItem from './MessageItem.vue'
|
import MessageItem from './MessageItem.vue'
|
||||||
|
import EmptyState from './EmptyState.vue'
|
||||||
import { formatRelative, formatDate } from '../../utils/time'
|
import { formatRelative, formatDate } from '../../utils/time'
|
||||||
import type { AiMessage } from '../../api/types'
|
import type { AiMessage } from '../../api/types'
|
||||||
|
|
||||||
@@ -43,183 +44,16 @@ const { t } = useI18n()
|
|||||||
// ═══ Markdown 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══
|
// ═══ Markdown 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══
|
||||||
// B-23:渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
|
// B-23:渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
|
||||||
// renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。
|
// renderMd)抽至 composables/useMarkdown(模块级单例),与 TaskDetail/AiChat 共享。
|
||||||
// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)保留在本组件,
|
// 流式核心(splitBlocks/parseBlock/renderStreamingBlocks/scheduleStreamParse)抽至
|
||||||
// 经 getMarked()/getPurify()/mdReady/escapeFallback 复用同一份渲染器实例,行为零变化。
|
// composables/ai/useStreamRenderer(本组件经 opts 注入 saveSelection/restoreSelection/
|
||||||
const {
|
// isFollowingBottom/scrollToBottom 回调),行为零变化。
|
||||||
mdReady,
|
//
|
||||||
loadMarkdown,
|
// useStreamRenderer 的初始化延后到 saveSelection/restoreSelection 定义之后(下方),
|
||||||
renderMd,
|
// 因 composable 的 opts 依赖这两个函数。mdReady/renderMd/escapeFallback/streamingBlocks/
|
||||||
escapeFallback,
|
// scheduleStreamParse/renderStreamingBlocks/clearStreamingState 均由 composable 返回。
|
||||||
getMarked,
|
const { loadMarkdown } = useMarkdown()
|
||||||
getPurify,
|
|
||||||
} = useMarkdown()
|
|
||||||
|
|
||||||
// ═══ AR-1 流式 Markdown 渲染开关(保守方案 B 的可回退开关) ═══
|
// ── UX-2025-20: 空状态示例问题(examplePrompts 已随 EmptyState.vue 提取) ──
|
||||||
// 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)
|
|
||||||
const _blockCache = new Map<string, string>()
|
|
||||||
const BLOCK_CACHE_LIMIT = 300
|
|
||||||
// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
|
|
||||||
// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持(UX-2025-01)
|
|
||||||
const streamingBlocks = ref<StreamBlock[]>([])
|
|
||||||
let rafId: number | null = null
|
|
||||||
let lastStreamText = ''
|
|
||||||
|
|
||||||
/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块
|
|
||||||
/// (跨双换行不切),非代码段按双换行切段。
|
|
||||||
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
|
|
||||||
///
|
|
||||||
/// CR-260615-04:旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则
|
|
||||||
/// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错,
|
|
||||||
/// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏
|
|
||||||
/// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏),
|
|
||||||
/// 段落/标题等其余 token 的 .raw 按 \n\n 切段。lexer 与 parse 用同一份 marked 实例 +
|
|
||||||
/// 同一份围栏规则,切块边界与渲染边界一致。
|
|
||||||
function splitBlocks(text: string): string[] {
|
|
||||||
const marked = getMarked()
|
|
||||||
const blocks: string[] = []
|
|
||||||
// marked 未就绪时 splitBlocks 不应被调用(renderStreamingBlocks 已 mdReady 守卫),
|
|
||||||
// 此处兜底返回原文,不引入正则。
|
|
||||||
if (!marked) return [text]
|
|
||||||
const tokens = marked.lexer(text)
|
|
||||||
for (const tok of tokens) {
|
|
||||||
if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue
|
|
||||||
const raw = (tok as { raw: string }).raw
|
|
||||||
if ((tok as { type: string }).type === 'code') {
|
|
||||||
// 代码围栏 token:整段一块(含行首可选空格 + 3+ 反引号 + info 字符串)
|
|
||||||
if (raw.trim()) blocks.push(raw)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 非代码 token(paragraph/space/heading/list/blockquote/...):
|
|
||||||
// 按 \n\n 切段(段落内单换行不切,inline code 自然保留在段内)
|
|
||||||
for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b)
|
|
||||||
}
|
|
||||||
return blocks.length ? blocks : [text]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
|
|
||||||
function parseBlockNoCache(block: string): string {
|
|
||||||
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。
|
|
||||||
// UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
|
|
||||||
// getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的
|
|
||||||
// renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse()
|
|
||||||
// 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback,
|
|
||||||
// parse 抛错时 try/catch 同样降级,保证流式不中断。
|
|
||||||
const marked = getMarked()
|
|
||||||
const purify = getPurify()
|
|
||||||
if (!marked || !purify) return escapeFallback(block)
|
|
||||||
try {
|
|
||||||
return purify.sanitize(marked.parse(block) as string)
|
|
||||||
} catch {
|
|
||||||
return escapeFallback(block)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 块级 memo parse:已完成块缓存命中,O(末块)
|
|
||||||
function parseBlock(block: string): string {
|
|
||||||
const cached = _blockCache.get(block)
|
|
||||||
if (cached !== undefined) return cached
|
|
||||||
const html = parseBlockNoCache(block)
|
|
||||||
if (_blockCache.size >= BLOCK_CACHE_LIMIT) _blockCache.delete(_blockCache.keys().next().value!)
|
|
||||||
_blockCache.set(block, html)
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
|
|
||||||
/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
|
|
||||||
/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
|
|
||||||
interface StreamBlock {
|
|
||||||
html: string
|
|
||||||
key: string // 已完成块=block文本hash(稳定), 末块='tail-'+递增序号(每次变)
|
|
||||||
}
|
|
||||||
function renderStreamingBlocks(text: string): StreamBlock[] {
|
|
||||||
if (!text) return []
|
|
||||||
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
|
|
||||||
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
|
|
||||||
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
|
|
||||||
return [{ html: escapeFallback(text), key: 'fallback' }]
|
|
||||||
}
|
|
||||||
const blocks = splitBlocks(text)
|
|
||||||
const n = blocks.length
|
|
||||||
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
|
|
||||||
return blocks.map((b, i) => {
|
|
||||||
const isTail = i === n - 1
|
|
||||||
// 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
|
|
||||||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
|
|
||||||
return h >>> 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// rAF 节流:同帧多 delta 仅排队一次,帧内取最新 lastStreamText parse
|
|
||||||
function scheduleStreamParse(text: string) {
|
|
||||||
lastStreamText = text
|
|
||||||
if (rafId !== null) return
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
rafId = null
|
|
||||||
// UX-2025-01: 末块 innerHTML 每帧替换,保存选区
|
|
||||||
saveSelection()
|
|
||||||
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
|
|
||||||
nextTick(() => {
|
|
||||||
restoreSelection()
|
|
||||||
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
|
|
||||||
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
|
|
||||||
// 滚到渲染前高度)致"差一截"。
|
|
||||||
if (isFollowingBottom.value) scrollToBottom()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// template 绑定入口:最后AI消息流式时用 streamingBlocks(分块v-for,已完成块DOM稳定),
|
|
||||||
/// 否则 renderMd(完成/历史,整段string给v-html)
|
|
||||||
function renderContent(msg: AiMessage): string {
|
|
||||||
// 流式:由模板直接读 streamingBlocks(v-for 分块),此处不参与
|
|
||||||
return renderMd(msg.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UX-2025-20: 空状态示例问题 ──
|
|
||||||
// 示例问题卡片(点击自动填入并发送);文案走 i18n key 数组,模板按 key 翻译。
|
|
||||||
const examplePrompts = [
|
|
||||||
'aiChat.examplePrompt1',
|
|
||||||
'aiChat.examplePrompt2',
|
|
||||||
'aiChat.examplePrompt3',
|
|
||||||
'aiChat.examplePrompt4',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const messagesContainer = ref<HTMLDivElement>()
|
const messagesContainer = ref<HTMLDivElement>()
|
||||||
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
|
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
|
||||||
@@ -285,6 +119,24 @@ const {
|
|||||||
onContentChange,
|
onContentChange,
|
||||||
setFollowing,
|
setFollowing,
|
||||||
} = useMessageScroll(messagesContainer)
|
} = useMessageScroll(messagesContainer)
|
||||||
|
|
||||||
|
// ═══ 流式 Markdown 块级 memo 渲染(抽至 composables/ai/useStreamRenderer) ═══
|
||||||
|
// opts 注入 saveSelection/restoreSelection(function 声明,提升可用,定义在下方)、
|
||||||
|
// isFollowingBottom/scrollToBottom(来自上方 useMessageScroll)。
|
||||||
|
const {
|
||||||
|
mdReady,
|
||||||
|
renderMd,
|
||||||
|
streamingBlocks,
|
||||||
|
scheduleStreamParse,
|
||||||
|
clearStreamingState,
|
||||||
|
cancelPendingRaf,
|
||||||
|
} = useStreamRenderer({
|
||||||
|
saveSelection,
|
||||||
|
restoreSelection,
|
||||||
|
isFollowingBottom,
|
||||||
|
scrollToBottom,
|
||||||
|
})
|
||||||
|
|
||||||
watch(() => store.state.messages.length, onContentChange)
|
watch(() => store.state.messages.length, onContentChange)
|
||||||
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
|
// SW-260618-07: currentText → onContentChange 合并到下方 scheduleStreamParse watch(同源 currentText,单 callback 顺序执行)
|
||||||
|
|
||||||
@@ -308,8 +160,7 @@ watch(() => store.state.currentText, (text) => {
|
|||||||
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
|
// 残留上一轮 → 新气泡 isLastAi 命中(:1017)渲染残留分块 → 与上一条回复重叠堆叠。
|
||||||
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
|
// 在 currentText 归零处清,精确覆盖"新轮切换"与"生成结束"两个时机。
|
||||||
if (!text) {
|
if (!text) {
|
||||||
streamingBlocks.value = []
|
clearStreamingState()
|
||||||
lastStreamText = ''
|
|
||||||
}
|
}
|
||||||
onContentChange()
|
onContentChange()
|
||||||
}
|
}
|
||||||
@@ -320,7 +171,9 @@ watch(mdReady, (ready) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
watch(() => store.state.streaming, (s) => {
|
watch(() => store.state.streaming, (s) => {
|
||||||
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
// 原行为:两分支均先取消 pending rAF(streaming 中途切换帧防泄漏);s===true 不清
|
||||||
|
// streamingBlocks(依赖 currentText watch 的 !text 分支按新轮时机清,BUG-260624-01)。
|
||||||
|
cancelPendingRaf()
|
||||||
if (s) {
|
if (s) {
|
||||||
// UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
|
// UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
|
||||||
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
|
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
|
||||||
@@ -332,8 +185,7 @@ watch(() => store.state.streaming, (s) => {
|
|||||||
} else {
|
} else {
|
||||||
// UX-2025-01: 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建
|
// UX-2025-01: 流式→完成切换,模板从 v-for 分块切到 v-html 整段,DOM 重建
|
||||||
saveSelection()
|
saveSelection()
|
||||||
streamingBlocks.value = []
|
clearStreamingState()
|
||||||
lastStreamText = ''
|
|
||||||
nextTick(() => restoreSelection())
|
nextTick(() => restoreSelection())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -724,16 +576,16 @@ watch(() => store.state.activeConversationId, () => {
|
|||||||
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
|
// 切到正在生成的 conv / 分离窗口挂载。比靠 currentText 副作用间接触发更精确,不依赖
|
||||||
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
|
// switchConversation 内部语句顺序(对齐 no-patch-groundwork:状态变更点收敛,非渲染侧补丁)。
|
||||||
if (streamingBlocks.value.length) {
|
if (streamingBlocks.value.length) {
|
||||||
streamingBlocks.value = []
|
clearStreamingState()
|
||||||
lastStreamText = ''
|
} else {
|
||||||
|
cancelPendingRaf()
|
||||||
}
|
}
|
||||||
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
|
// 流式 rAF 清理:组件卸载时若仍有 pending rAF(streaming 中途切走/关窗),取消防泄漏。
|
||||||
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
|
// streaming 翻 false 的 watch 已清 rafId,此为中途卸载兜底。
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
cancelPendingRaf()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
|
// Markdown 预热(父原 loadMarkdown 在 onMounted 调,子组件同样幂等——useMarkdown 单例,
|
||||||
@@ -749,37 +601,13 @@ defineExpose({
|
|||||||
<template>
|
<template>
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
|
<div class="ai-messages" ref="messagesContainer" @scroll="onMessagesScroll(() => collapseAllToolLists(buildActiveToolIds(store.state.messages)))">
|
||||||
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) -->
|
<!-- 空状态 (UX-2025-20: 示例问题卡片 + 无 provider 引导跳 Settings) — 提取至 EmptyState.vue -->
|
||||||
<div class="ai-empty" v-if="store.state.messages.length === 0 && !store.state.streaming">
|
<EmptyState
|
||||||
<template v-if="store.state.providers.length === 0">
|
v-if="store.state.messages.length === 0 && !store.state.streaming"
|
||||||
<div class="ai-empty-icon">
|
:has-provider="store.state.providers.length > 0"
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-warning)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
@send-example-prompt="(i18nKey) => emit('send-example-prompt', i18nKey)"
|
||||||
</div>
|
@go-to-settings="emit('go-to-settings')"
|
||||||
<p class="ai-empty-text">{{ $t('aiChat.emptyNoProvider') }}</p>
|
/>
|
||||||
<p class="ai-empty-sub">{{ $t('aiChat.emptyNoProviderHint') }}</p>
|
|
||||||
<!-- 跳 Settings:嵌入模式用 router.push;分离窗口无 Settings 路由,降级 toast 提示去主窗口 -->
|
|
||||||
<button class="ai-empty-action" @click="emit('go-to-settings')">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
|
||||||
<span>{{ $t('aiChat.goToSettings') }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="ai-empty-icon">
|
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--df-text-dim)" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
|
||||||
</div>
|
|
||||||
<p class="ai-empty-text">{{ $t('aiChat.emptyTitle') }}</p>
|
|
||||||
<p class="ai-empty-sub">{{ $t('aiChat.emptyHint') }}</p>
|
|
||||||
<!-- UX-2025-20: 示例问题卡片(点击自动填入并发送) -->
|
|
||||||
<div class="ai-empty-prompts">
|
|
||||||
<button
|
|
||||||
v-for="(prompt, i) in examplePrompts"
|
|
||||||
:key="i"
|
|
||||||
class="ai-empty-prompt"
|
|
||||||
@click="emit('send-example-prompt', prompt)"
|
|
||||||
>{{ $t(prompt) }}</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
|
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
|
||||||
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
||||||
@@ -830,7 +658,7 @@ defineExpose({
|
|||||||
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) -->
|
<!-- F-15:压缩摘要 system 消息置顶显示(compressed 原文照常展开不折叠,见 messageSegments) -->
|
||||||
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
|
<div v-if="item.msg.role === 'system'" class="ai-msg-system">
|
||||||
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
|
<span class="ai-msg-system-label">{{ $t('aiChat.compressedSummaryLabel') }}</span>
|
||||||
<div class="ai-msg-system-summary ai-md" v-html="renderContent(item.msg)"></div>
|
<div class="ai-msg-system-summary ai-md" v-html="renderMd(item.msg.content)"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 用户消息(提取至 MessageItem.vue) -->
|
<!-- 用户消息(提取至 MessageItem.vue) -->
|
||||||
<MessageItem
|
<MessageItem
|
||||||
@@ -864,7 +692,7 @@ defineExpose({
|
|||||||
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
<span v-if="isViewingGenerating" class="ai-cursor"></span>
|
||||||
</template>
|
</template>
|
||||||
<!-- 完成/历史:整段v-html(不变) -->
|
<!-- 完成/历史:整段v-html(不变) -->
|
||||||
<div v-else v-html="renderContent(item.msg)"></div>
|
<div v-else v-html="renderMd(item.msg.content)"></div>
|
||||||
<button
|
<button
|
||||||
v-if="item.msg.content"
|
v-if="item.msg.content"
|
||||||
class="ai-copy-btn ai-copy-btn--ai"
|
class="ai-copy-btn ai-copy-btn--ai"
|
||||||
@@ -985,73 +813,7 @@ defineExpose({
|
|||||||
.back-to-bottom-enter-from,
|
.back-to-bottom-enter-from,
|
||||||
.back-to-bottom-leave-to { opacity: 0; transform: translateY(8px); }
|
.back-to-bottom-leave-to { opacity: 0; transform: translateY(8px); }
|
||||||
|
|
||||||
/* -- Empty State -- */
|
/* -- Empty State 样式已随 EmptyState.vue 提取 -- */
|
||||||
.ai-empty {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
.ai-empty-icon { margin-bottom: 4px; }
|
|
||||||
.ai-empty-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
}
|
|
||||||
.ai-empty-sub {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
text-align: center;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
/* UX-2025-20: 无 provider 引导 — 跳 Settings 按钮(克制:边框线 + accent 文字,不喧宾) */
|
|
||||||
.ai-empty-action {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border: 0.5px solid var(--df-accent);
|
|
||||||
border-radius: var(--df-radius);
|
|
||||||
background: var(--df-accent-bg);
|
|
||||||
color: var(--df-accent);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.ai-empty-action:hover { filter: brightness(1.08); }
|
|
||||||
/* UX-2025-20 §7.3: 示例问题卡片网格(2 列,轻量;复用 .ai-* 卡片风格) */
|
|
||||||
.ai-empty-prompts {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 6px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.ai-empty-prompt {
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
border-radius: var(--df-radius);
|
|
||||||
background: var(--df-bg-card);
|
|
||||||
color: var(--df-text-secondary);
|
|
||||||
font-size: 11.5px;
|
|
||||||
line-height: 1.4;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.85;
|
|
||||||
transition: all 0.15s var(--df-ease);
|
|
||||||
}
|
|
||||||
.ai-empty-prompt:hover {
|
|
||||||
border-color: var(--df-accent);
|
|
||||||
color: var(--df-text);
|
|
||||||
opacity: 1;
|
|
||||||
background: color-mix(in srgb, var(--df-accent) 8%, var(--df-bg-card));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -- Message Layout -- */
|
/* -- Message Layout -- */
|
||||||
.ai-msg-user {
|
.ai-msg-user {
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
|
|||||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||||
flushCurrentText()
|
flushCurrentText()
|
||||||
state.currentText = ''
|
state.currentText = ''
|
||||||
|
state.completedTools = 0 // 新轮重置工具计数器
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: `ai-${nextMsgId()}` as MessageId,
|
id: `ai-${nextMsgId()}` as MessageId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -445,6 +446,7 @@ function handleToolEvent(event: AiChatEvent): boolean {
|
|||||||
tc.status = 'completed'
|
tc.status = 'completed'
|
||||||
tc.result = event.result
|
tc.result = event.result
|
||||||
}
|
}
|
||||||
|
state.completedTools++ // 进度条计数器
|
||||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||||
clearToolSlowTimer(event.id)
|
clearToolSlowTimer(event.id)
|
||||||
// AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
// AE-2025-06: 工具结束(无论审批通过后执行还是被拒),清审批超时计时器
|
||||||
|
|||||||
220
src/composables/ai/useStreamRenderer.ts
Normal file
220
src/composables/ai/useStreamRenderer.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//! 流式 Markdown 块级 memo 渲染 — 抽自 MessageList.vue(零行为变更)。
|
||||||
|
//!
|
||||||
|
//! 职责:流式 delta 的块级切块 + memo 缓存 + rAF 节流分块渲染。
|
||||||
|
//! - splitBlocks:marked.lexer() 围栏感知切块(代码块整段一块,非代码按双换行切段)
|
||||||
|
//! - parseBlock/parseBlockNoCache:已完成块缓存命中 O(末块),末块每次重 parse
|
||||||
|
//! - renderStreamingBlocks:切块 + memo,返回 { html, key } 数组供模板 v-for v-html
|
||||||
|
//! - scheduleStreamParse:rAF 节流,帧内取最新文本 parse + 选区保持 + 跟随滚底
|
||||||
|
//!
|
||||||
|
//! 与父组件(MessageList)的交互经回调注入:
|
||||||
|
//! - saveSelection/restoreSelection:UX-2025-01 选文字保持(v-html 替换 innerHTML 销毁选区)
|
||||||
|
//! - isFollowingBottom:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部)
|
||||||
|
//! - scrollToBottom:回底操作
|
||||||
|
//!
|
||||||
|
//! 共享:useMarkdown 模块级单例(_marked/_purify/mdReady/escapeFallback/getMarked/getPurify),
|
||||||
|
//! 与 TaskDetail/AiChat/MessageList 同实例,渲染结果零变化。
|
||||||
|
|
||||||
|
import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue'
|
||||||
|
import { useMarkdown } from '../useMarkdown'
|
||||||
|
import { useAppSettingsStore } from '../../stores/appSettings'
|
||||||
|
|
||||||
|
/// 流式渲染返回的单块渲染结果(html + 模板 v-for key)
|
||||||
|
export interface StreamBlock {
|
||||||
|
html: string
|
||||||
|
/** 已完成块=block文本hash(稳定), 末块='tail-'+递增序号(每次变) */
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseStreamRendererOptions {
|
||||||
|
/** UX-2025-01:保存当前文字选区(v-html 替换前调) */
|
||||||
|
saveSelection: () => void
|
||||||
|
/** UX-2025-01:恢复之前保存的选区(DOM 重建后调) */
|
||||||
|
restoreSelection: () => void
|
||||||
|
/** B-260618-24:跟随意图锁存(跟随中则 rAF 回调内滚到真实底部) */
|
||||||
|
isFollowingBottom: Ref<boolean> | ComputedRef<boolean>
|
||||||
|
/** 回到底部操作 */
|
||||||
|
scrollToBottom: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStreamRenderer(opts: UseStreamRendererOptions) {
|
||||||
|
const {
|
||||||
|
mdReady,
|
||||||
|
renderMd,
|
||||||
|
escapeFallback,
|
||||||
|
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)
|
||||||
|
const _blockCache = new Map<string, string>()
|
||||||
|
const BLOCK_CACHE_LIMIT = 300
|
||||||
|
// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
|
||||||
|
// 分块渲染:已完成块 DOM 稳定不重建 → 选文字保持(UX-2025-01)
|
||||||
|
const streamingBlocks = ref<StreamBlock[]>([])
|
||||||
|
let rafId: number | null = null
|
||||||
|
let lastStreamText = ''
|
||||||
|
|
||||||
|
/// 切块:代码围栏(```...``` 或 3+ 反引号 + 行首可选空格 + info 字符串)整体一块
|
||||||
|
/// (跨双换行不切),非代码段按双换行切段。
|
||||||
|
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
|
||||||
|
///
|
||||||
|
/// CR-260615-04:旧实现用正则 /```[^\n]*\n[\s\S]*?(?:```|$)/ 切块,与 marked 围栏规则
|
||||||
|
/// 不一致——不要求行首、固定 3 反引号,导致行中裸三反引号 / 4+ 反引号嵌套围栏切错,
|
||||||
|
/// 前块缓存固化错误 html。改用 marked.lexer()(经 getMarked() 取 marked 实例)做围栏
|
||||||
|
/// 感知切块:lexer 把代码围栏识别为单个 type==='code' token(.raw 含整段围栏),
|
||||||
|
/// 段落/标题等其余 token 的 .raw 按 \n\n 切段。lexer 与 parse 用同一份 marked 实例 +
|
||||||
|
/// 同一份围栏规则,切块边界与渲染边界一致。
|
||||||
|
function splitBlocks(text: string): string[] {
|
||||||
|
const marked = getMarked()
|
||||||
|
const blocks: string[] = []
|
||||||
|
// marked 未就绪时 splitBlocks 不应被调用(renderStreamingBlocks 已 mdReady 守卫),
|
||||||
|
// 此处兜底返回原文,不引入正则。
|
||||||
|
if (!marked) return [text]
|
||||||
|
const tokens = marked.lexer(text)
|
||||||
|
for (const tok of tokens) {
|
||||||
|
if (!tok || typeof (tok as { raw?: string }).raw !== 'string') continue
|
||||||
|
const raw = (tok as { raw: string }).raw
|
||||||
|
if ((tok as { type: string }).type === 'code') {
|
||||||
|
// 代码围栏 token:整段一块(含行首可选空格 + 3+ 反引号 + info 字符串)
|
||||||
|
if (raw.trim()) blocks.push(raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 非代码 token(paragraph/space/heading/list/blockquote/...):
|
||||||
|
// 按 \n\n 切段(段落内单换行不切,inline code 自然保留在段内)
|
||||||
|
for (const b of raw.split(/\n{2,}/)) if (b.trim()) blocks.push(b)
|
||||||
|
}
|
||||||
|
return blocks.length ? blocks : [text]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
|
||||||
|
function parseBlockNoCache(block: string): string {
|
||||||
|
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用。
|
||||||
|
// UX-260617-25:防御性 null check —— renderStreamingBlocks 顶部已有 mdReady/getMarked/
|
||||||
|
// getPurify 守卫,正常路径 marked/purify 必非 null。但 marked.use(highlightCode) 注入的
|
||||||
|
// renderer 在动态 import 后仍可能抛异常(hljs 注册竞态/未知语言)→ 此前直接 marked!.parse()
|
||||||
|
// 会让异常冒泡到 rAF 回调中断流式,表现为生成卡死。这里 null 兜底降级 escapeFallback,
|
||||||
|
// parse 抛错时 try/catch 同样降级,保证流式不中断。
|
||||||
|
const marked = getMarked()
|
||||||
|
const purify = getPurify()
|
||||||
|
if (!marked || !purify) return escapeFallback(block)
|
||||||
|
try {
|
||||||
|
return purify.sanitize(marked.parse(block) as string)
|
||||||
|
} catch {
|
||||||
|
return escapeFallback(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 块级 memo parse:已完成块缓存命中,O(末块)
|
||||||
|
function parseBlock(block: string): string {
|
||||||
|
const cached = _blockCache.get(block)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const html = parseBlockNoCache(block)
|
||||||
|
if (_blockCache.size >= BLOCK_CACHE_LIMIT) _blockCache.delete(_blockCache.keys().next().value!)
|
||||||
|
_blockCache.set(block, html)
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
|
||||||
|
/// 返回 { html, key } 数组:key 用 block 文本 hash(已完成块稳定/末块递增),
|
||||||
|
/// 模板 v-for + v-html 分块渲染,已完成块 DOM 不重建 → 选文字保持。
|
||||||
|
function renderStreamingBlocks(text: string): StreamBlock[] {
|
||||||
|
if (!text) return []
|
||||||
|
// AR-1 流式 MD 开关关 → 回退纯文本短路(escapeFallback 全文转义 + <br>,原行为)
|
||||||
|
// mdReady 未就绪也走纯文本兜底(marked 尚未加载完成)。
|
||||||
|
if (!streamingMdEnabled.value || !mdReady.value || !getMarked() || !getPurify()) {
|
||||||
|
return [{ html: escapeFallback(text), key: 'fallback' }]
|
||||||
|
}
|
||||||
|
const blocks = splitBlocks(text)
|
||||||
|
const n = blocks.length
|
||||||
|
let tailSeq = 0 // 末块递增序号,确保末块 key 每次不同触发更新
|
||||||
|
return blocks.map((b, i) => {
|
||||||
|
const isTail = i === n - 1
|
||||||
|
// 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
|
||||||
|
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
|
||||||
|
return h >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rAF 节流:同帧多 delta 仅排队一次,帧内取最新 lastStreamText parse
|
||||||
|
function scheduleStreamParse(text: string) {
|
||||||
|
lastStreamText = text
|
||||||
|
if (rafId !== null) return
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = null
|
||||||
|
// UX-2025-01: 末块 innerHTML 每帧替换,保存选区
|
||||||
|
opts.saveSelection()
|
||||||
|
streamingBlocks.value = renderStreamingBlocks(lastStreamText)
|
||||||
|
nextTick(() => {
|
||||||
|
opts.restoreSelection()
|
||||||
|
// B-260618-24: rAF 回调内 streamingBlocks 已赋值、DOM 高度就绪,跟随中则滚到真实底部。
|
||||||
|
// 治 onContentChange 的 nextTick(scrollToBottom) 抢跑旧 scrollHeight(微任务先于 rAF,
|
||||||
|
// 滚到渲染前高度)致"差一截"。
|
||||||
|
if (opts.isFollowingBottom.value) opts.scrollToBottom()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理流式渲染状态(streaming 翻 false / 会话切换 / 卸载前调)
|
||||||
|
/// 取消 pending rAF + 清 streamingBlocks + 重置 lastStreamText。
|
||||||
|
function clearStreamingState(): void {
|
||||||
|
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
||||||
|
streamingBlocks.value = []
|
||||||
|
lastStreamText = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅取消 pending rAF(不清 streamingBlocks/lastStreamText)。
|
||||||
|
/// 用于 streaming 翻 true 时只取消上一帧 pending,保留 streamingBlocks(原行为)。
|
||||||
|
function cancelPendingRaf(): void {
|
||||||
|
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mdReady,
|
||||||
|
renderMd,
|
||||||
|
escapeFallback,
|
||||||
|
streamingBlocks,
|
||||||
|
scheduleStreamParse,
|
||||||
|
renderStreamingBlocks,
|
||||||
|
clearStreamingState,
|
||||||
|
cancelPendingRaf,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,6 @@ export default {
|
|||||||
// Note: max round / completed tool count are not surfaced by backend this round;
|
// Note: max round / completed tool count are not surfaced by backend this round;
|
||||||
// template conditionally omits them. Add *WithMax keys once backend exposes them.
|
// template conditionally omits them. Add *WithMax keys once backend exposes them.
|
||||||
agenticProgress: '🔄 Round {round} · ⏳{pending} pending',
|
agenticProgress: '🔄 Round {round} · ⏳{pending} pending',
|
||||||
agenticProgressNoPending: '🔄 Round {round}',
|
agenticProgressNoPending: '🔄 Round {round} · ✅{completed} tools done',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,6 @@ export default {
|
|||||||
// AE-2025-07: Agentic 循环进度条文案
|
// AE-2025-07: Agentic 循环进度条文案
|
||||||
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
|
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
|
||||||
agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',
|
agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',
|
||||||
agenticProgressNoPending: '🔄 循环 {round}',
|
agenticProgressNoPending: '🔄 循环 {round} · ✅{completed}个工具完成',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ const _stateBase: {
|
|||||||
skills: SkillInfo[]
|
skills: SkillInfo[]
|
||||||
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[]
|
queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[]
|
||||||
agentRound: number
|
agentRound: number
|
||||||
|
/// 已完成工具调用数(本轮累计,AiToolCallCompleted 时 +1,AiAgentRound 时重置)
|
||||||
|
completedTools: number
|
||||||
archivedCollapsed: boolean
|
archivedCollapsed: boolean
|
||||||
foldedGroups: Record<string, boolean>
|
foldedGroups: Record<string, boolean>
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
@@ -137,6 +139,7 @@ const _stateBase: {
|
|||||||
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
|
// Agentic 循环当前轮次(0=非agentic/未开始;>0=收到后端 AiAgentRound 事件的 round)
|
||||||
// AE-2025-07:进度条用; AiCompleted/AiError 复位为 0
|
// AE-2025-07:进度条用; AiCompleted/AiError 复位为 0
|
||||||
agentRound: 0,
|
agentRound: 0,
|
||||||
|
completedTools: 0,
|
||||||
// 归档分组折叠态(默认折叠)
|
// 归档分组折叠态(默认折叠)
|
||||||
archivedCollapsed: true,
|
archivedCollapsed: true,
|
||||||
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)
|
// 时间分组折叠态(默认:更早折叠,今天/昨天展开;持久化进 df-ai-ui)
|
||||||
|
|||||||
Reference in New Issue
Block a user