PlantAI-rag-new 首次提交:文献知识工作台

- BFF 薄适配层(FastAPI,8090):QA 代理/SSE 中继/Neo4j 直连/抽取代理/会话持久化
- Vue3 前端:五相位检索流水线、文献来源与证据链、知识图谱、提取任务四 tab
- 四档响应式(桌面/笔记本/平板/手机)、深浅主题
- 81 个 FR 全部实现,测试追溯矩阵 81/81,后端 121 测/前端 156 测
- 真实凭据已脱敏,运行时配置在 backend/.env(不入 git)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-25 20:47:52 +08:00
co-authored by Claude Fable 5
commit 1e94de7464
171 changed files with 26291 additions and 0 deletions
@@ -0,0 +1,393 @@
<script setup>
// chat/AssistantMessage.vue —— 助手消息与块联合渲染(FR-CHAT-003/004/009/010/012)
// 块类型:text(markdown-it + DOMPurify 消毒后 v-html)/ table(渐变表头 + 非「说明」列 mono)/
// code / stream(光标 + shimmer-line,瞬态不落库)/ pipeline(五相位卡)/
// tool(阶段卡)/ sources(内联引用 chip)/ graph(轻量摘要行,完整图在 bench kg tab)
import { computed } from 'vue';
import { T, BENCH_TABS } from '../../i18n/labels.js';
import { sanitizeHtml, NON_MONO_HEADER } from './markdown.js';
import PipelineCard from './PipelineCard.vue';
import ToolCard from './ToolCard.vue';
const props = defineProps({
message: { type: Object, required: true },
reveal: { type: Object, default: null }, // {text}:揭示动画进行中
revealText: { type: String, default: '' },
onJumpToGraph: { type: Function, default: null }, // graph 摘要行 → bench kg tab
onJumpToSource: { type: Function, default: null }, // 引用 chip → bench sources tab
onRetry: { type: Function, default: null },
});
const emit = defineEmits(['toggle-tool', 'skip-reveal', 'retry', 'jump-graph', 'jump-source']);
const isStreaming = computed(() => props.message.status === 'streaming');
// text 块 html 在创建/落库时已渲染为消毒 HTML(markdown.js renderMarkdown);
// 渲染前再消毒一次(纵深防御,FR-CHAT-004:LLM 输出必须消毒)
const renderedText = (html) => sanitizeHtml(html);
/** 引用 chip 编号(按 sources 块顺序) */
const sourceIndex = (block, item) => {
const idx = (block.items || []).indexOf(item);
return idx >= 0 ? idx + 1 : null;
};
const graphSummary = computed(() => {
const b = (props.message.blocks || []).find((x) => x.type === 'graph');
if (!b) return null;
const n = (b.nodes || []).length;
const e = (b.edges || []).length;
const c = (b.chains || []).length;
if (!n && !e) return null;
return { nodes: n, edges: e, chains: c };
});
// 图谱 tab 名称(labels.BENCH_TABS),消息内摘要与跳转按钮共用同一文案
const kgTabLabel = BENCH_TABS.find((t) => t.key === 'kg')?.label || '';
/** 停止/错误后的错误文案(FR-CHAT-011/012:标注「已停止」「已中断」等)
* BUG-R1-05 修复:errText(后端 error.message 或映射文案,store 写入)优先展示——
* 未知错误码/上游信封的 message 都经 errText 透传,而非被「连接中断」吞掉。 */
const errNote = computed(() => {
const m = props.message;
if (m.status !== 'error') return null;
if (m.errText) return m.errText;
switch (m.streamErr) {
case 'timeout': return T.timeoutMsg;
case 'citation': return T.citationError;
case 'stopped': return T.stopped;
case 'interrupted': return T.interrupted;
case 'disconnected': return T.disconnected;
default:
return typeof m.streamErr === 'string' && m.streamErr ? m.streamErr : null;
}
});
const showRetry = computed(() => !!(props.onRetry && props.message.status === 'error'));
</script>
<template>
<div class="msg assistant">
<div class="msg-head">
<span class="agent-avatar">✦</span>
<span class="who">{{ T.assistantName }}</span>
<span class="when">{{ message.timeLabel }}</span>
</div>
<div class="msg-body">
<template v-for="(b, bi) in message.blocks" :key="bi">
<!-- text:markdown-it → DOMPurify → v-html(FR-CHAT-004) -->
<div v-if="b.type === 'text'" class="md-text" v-html="renderedText(b.html)"></div>
<!-- table:md-table 渐变表头 + 非「说明」列 mono -->
<table v-if="b.type === 'table'" class="md-table">
<thead>
<tr><th v-for="(h, hi) in b.headers" :key="hi">{{ h }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, ri) in b.rows" :key="ri">
<td v-for="(cell, ci) in row" :key="ci" :class="{ mono: b.headers[ci] !== NON_MONO_HEADER }">{{ cell }}</td>
</tr>
</tbody>
</table>
<!-- code -->
<div v-if="b.type === 'code'" class="code-block">{{ b.text }}</div>
<!-- stream:瞬态揭示块(光标 + shimmer-line;FR-CHAT-010) -->
<div v-if="b.type === 'stream'" class="stream-wrap">
<span class="stream-text">{{ b.text }}</span><span class="stream-cursor"></span>
<div class="shimmer-line" style="margin-top: 8px"></div>
<button class="skip-btn" type="button" @click="emit('skip-reveal')">{{ T.skipReveal }}</button>
</div>
<!-- pipeline:检索流水线五相位卡 -->
<PipelineCard v-if="b.type === 'pipeline'" :block="b" />
<!-- tool:阶段卡 -->
<ToolCard
v-if="b.type === 'tool'"
:block="b"
:running="isStreaming && b.status === 'running'"
@toggle="emit('toggle-tool', bi)"
/>
<!-- sources:内联引用 chip(FR-CHAT-015) -->
<div v-if="b.type === 'sources' && b.items && b.items.length" class="inline-sources">
<span class="src-label">{{ T.sourcesTitle }}</span>
<button
v-for="(s, si) in b.items"
:key="s.id || si"
class="src-chip"
type="button"
:title="s.title"
@click="emit('jump-source', { index: sourceIndex(b, s), item: s })"
>
[{{ sourceIndex(b, s) }}]
</button>
</div>
<!-- graph:轻量摘要行(完整图渲染在 B3 bench kg tab) -->
<div v-if="b.type === 'graph' && graphSummary" class="graph-summary">
<span class="gs-icon">◈</span>
<span class="gs-text">
{{ kgTabLabel }}
<span class="gs-count">{{ graphSummary.nodes }}</span>
<span class="gs-count">{{ graphSummary.edges }}</span>
</span>
<button class="gs-link" type="button" @click="emit('jump-graph')">{{ kgTabLabel }}</button>
</div>
</template>
<!-- 错误标注 + 重试(FR-CHAT-012;失败必须可见) -->
<div v-if="errNote" class="err-note">
<span class="err-text">{{ errNote }}</span>
<button v-if="showRetry" class="retry-btn" type="button" @click="emit('retry')">{{ T.retry }}</button>
</div>
</div>
</div>
</template>
<style scoped>
/* 兜底样式(B1 base.css 就绪后删减;类名按 UI_SPEC §4.3 / §7) */
.msg {
margin-bottom: 22px;
}
.msg.assistant {
position: relative;
padding-left: 14px;
}
.msg.assistant::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
border-radius: 2px;
background: transparent;
transition: background 0.25s var(--pa-ease);
}
.msg.assistant:hover::before {
background: var(--pa-gradient);
}
.msg-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.agent-avatar {
width: 24px;
height: 24px;
border-radius: 8px;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #fff;
font-weight: 600;
background: var(--pa-gradient);
}
.who {
font-size: 12.5px;
font-weight: 600;
}
.when {
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
.msg-body {
font-size: 14px;
color: var(--pa-ink);
}
.md-text :deep(p) {
margin: 0 0 10px;
}
.md-text :deep(p:last-child) {
margin-bottom: 0;
}
.md-text :deep(strong) {
font-weight: 600;
}
.stream-wrap {
margin: 10px 0 4px;
}
.stream-text {
font-size: 14px;
color: var(--pa-ink);
white-space: pre-wrap;
word-break: break-word;
}
.stream-cursor {
display: inline-block;
width: 8px;
height: 15px;
margin-left: 2px;
background: var(--pa-g-to);
vertical-align: -2px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
.shimmer-line {
position: relative;
height: 3px;
border-radius: 2px;
overflow: hidden;
background: var(--pa-surface-3);
}
.shimmer-line::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 200%;
background: linear-gradient(100deg, transparent 30%, rgba(0, 212, 255, 0.25) 50%, transparent 70%);
will-change: transform;
animation: shimmer 1.8s infinite;
}
.skip-btn {
margin-top: 8px;
padding: 3px 12px;
border-radius: var(--pa-r-pill);
border: 1px solid var(--pa-hairline);
background: var(--pa-surface-2);
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
}
.skip-btn:hover {
color: var(--pa-ink);
}
.inline-sources {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin: 10px 0;
}
.src-label {
font-size: 12px;
color: var(--pa-ink-subtle);
margin-right: 4px;
}
.src-chip {
padding: 2px 8px;
border-radius: var(--pa-r-pill);
background: color-mix(in srgb, var(--pa-g-from) 16%, transparent);
color: var(--pa-g-to);
font-family: var(--pa-font-mono);
font-size: 12px;
cursor: pointer;
}
.src-chip:hover {
box-shadow: 0 0 10px rgba(0, 212, 255, 0.3);
}
.graph-summary {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0;
padding: 8px 12px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.gs-icon {
color: var(--pa-aux);
}
.gs-count {
font-family: var(--pa-font-mono);
font-size: 12px;
color: var(--pa-g-to);
background: color-mix(in srgb, var(--pa-g-from) 16%, transparent);
border-radius: var(--pa-r-xs);
padding: 1px 7px;
margin-left: 6px;
}
.gs-link {
margin-left: auto;
color: var(--pa-g-to);
font-size: 12px;
cursor: pointer;
}
.err-note {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
padding: 8px 12px;
border-radius: var(--pa-r-md);
background: rgba(255, 92, 122, 0.12);
border: 1px solid rgba(255, 92, 122, 0.35);
font-size: 13px;
color: var(--pa-error);
}
.retry-btn {
margin-left: auto;
padding: 4px 12px;
border-radius: var(--pa-r-pill);
background: var(--pa-gradient);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
/* md-table / code-block 兜底(全局类,B1 base.css 定义后此处仅作保障) */
:deep(.md-table) {
width: 100%;
margin: 12px 0;
border-collapse: collapse;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
overflow: hidden;
font-size: 13px;
}
:deep(.md-table thead th) {
text-align: left;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: var(--pa-ink-muted);
background: var(--pa-surface-3);
border-bottom: 2px solid transparent;
border-image: var(--pa-gradient) 1;
}
:deep(.md-table tbody td) {
padding: 8px 12px;
border-bottom: 1px solid var(--pa-hairline);
}
:deep(.md-table tbody tr:last-child td) {
border-bottom: none;
}
:deep(.md-table tbody td.mono) {
font-family: var(--pa-font-mono);
font-size: 12.5px;
}
:deep(.code-block) {
margin: 12px 0;
padding: 11px 14px;
background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-md);
font-family: var(--pa-font-mono);
font-size: 12.5px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
color: var(--pa-ink-muted);
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<script setup>
// ChatPanel.vue —— 中栏对话区(B2 入口组件,替换 PM 占位)
// 欢迎空态(无会话/无消息)+ 消息流 + 作曲器(FR-CHAT-001~015)。
// 跳转衔接:引用 chip → bench 来源 tab(FR-CHAT-015);图谱摘要 → bench kg tab。
import { computed } from 'vue';
import { useChatStore } from '../../stores/chat.js';
import { useHealthStore } from '../../stores/health.js';
import { useBenchStore } from '../../stores/bench.js';
import { T } from '../../i18n/labels.js';
import EmptyState from './EmptyState.vue';
import MessageList from './MessageList.vue';
import Composer from './Composer.vue';
const chat = useChatStore();
const health = useHealthStore();
let bench = null;
try {
bench = useBenchStore();
} catch {
bench = null; // B3 未就绪:跳转静默失效,不影响主流程
}
const isEmpty = computed(() => !chat.messages.length);
// BUG-R1-04 修复:QA 上游不可用(health.qaOk=false)→ 作曲器禁用 + 降级提示(FR-HEALTH-002)。
// 未知态(首次探测未返回,data=null)不视为不可用,避免首屏闪烁禁用。
const composerDisabled = computed(() => health.data != null && !health.qaOk);
const composerNote = computed(() => (composerDisabled.value ? T.qaDown : ''));
/** 发送前守卫:QA 降级时拒绝发送(禁用态双保险,E2E 点击兜底) */
const send = (text) => {
if (composerDisabled.value) return;
chat.send(text);
};
const jumpToSource = (payload) => {
if (!bench) return;
if (typeof bench.setSourceScope === 'function') bench.setSourceScope('current');
bench.setActiveTab('sources');
// setHighlightSource 为 B3 后续动作;缺失时仅切 tab 不报错(集成点,PM 统一)
if (payload?.index && typeof bench.setHighlightSource === 'function') {
bench.setHighlightSource(payload.index);
}
};
const jumpToGraph = () => {
if (bench) bench.setActiveTab('kg');
};
const stop = () => chat.stop();
const retry = () => chat.retry();
const changeMode = (mode) => chat.setMode(mode);
// 切换会话/发送后清空输入框无需处理(Composer 自持 draft,欢迎态切换即重置)
</script>
<template>
<main class="chat-panel">
<template v-if="chat.loadingDetail || chat.loadError || isEmpty">
<!-- 空态/骨架与消息流共用 .chat-scroll > .chat-inner 容器(视觉与原型一致:760px 居中) -->
<div class="chat-scroll">
<div class="chat-inner">
<!-- 详情加载骨架屏(FR-SESSION-004) -->
<div v-if="chat.loadingDetail" class="skeleton-wrap">
<div class="skeleton-msg" v-for="i in 3" :key="i">
<div class="sk-line" :style="{ width: `${68 - i * 9}%` }"></div>
<div class="sk-line short"></div>
</div>
</div>
<!-- 加载失败(NOT_FOUND 回列表 / 其他展示 message,失败必须可见) -->
<div v-else-if="chat.loadError" class="load-error">
<p>{{ chat.loadError.message || T.upstreamError }}</p>
<button class="retry-btn" type="button" @click="chat.loadSession(chat.sessionId)">{{ T.retry }}</button>
</div>
<!-- 欢迎空态(FR-CHAT-001) -->
<EmptyState v-else @pick="send" />
</div>
</div>
</template>
<!-- 消息流 -->
<MessageList
v-else
:messages="chat.messages"
:busy="chat.busy"
:reveal="chat.reveal"
:scroll-seq="chat.scrollSeq"
@retry="retry"
@jump-graph="jumpToGraph"
@jump-source="jumpToSource"
/>
<!-- 作曲器(FR-HEALTH-002:QA 不可用时禁用 + 降级提示,BUG-R1-04) -->
<Composer
:mode="chat.mode"
:busy="chat.busy"
:disabled="composerDisabled"
:health-note="composerNote"
@send="send"
@stop="stop"
@change-mode="changeMode"
/>
</main>
</template>
<style scoped>
.skeleton-wrap {
padding-top: 28px;
}
.skeleton-msg {
margin-bottom: 26px;
}
.sk-line {
height: 14px;
border-radius: 6px;
background: var(--pa-surface-3);
position: relative;
overflow: hidden;
margin-bottom: 10px;
}
.sk-line::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 200%;
background: linear-gradient(100deg, transparent 30%, rgba(0, 212, 255, 0.06) 50%, transparent 70%);
will-change: transform;
animation: shimmer 1.8s infinite;
}
.sk-line.short {
width: 46%;
}
.load-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: var(--pa-error);
font-size: 14px;
}
.retry-btn {
padding: 7px 18px;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<script setup>
// chat/Composer.vue —— 作曲器(FR-CHAT-005/006/007/011,UI_SPEC §4.6)
// 模式 pill 三态(labels.MODES)/ textarea min 46px max 160px 内滚 /
// Enter 换行、⌘/Ctrl+Enter 发送(输入法组合键判定不触发)/
// 发送按钮 busy 时切换为「停止」(FR-CHAT-011)。
import { ref, computed } from 'vue';
import { MODES as MODE_LABELS, T } from '../../i18n/labels.js';
const props = defineProps({
mode: { type: String, default: 'ask' },
busy: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }, // 健康降级(FR-HEALTH-002)→ 置灰
healthNote: { type: String, default: '' },
});
const emit = defineEmits(['send', 'stop', 'change-mode']);
const draft = ref('');
const textarea = ref(null);
const canSend = computed(() => !!draft.value.trim() && !props.busy && !props.disabled);
const onKeydown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
// ⌘/Ctrl+Enter 发送:输入法组合/按键重复不触发
if (e.isComposing || e.repeat) return;
e.preventDefault();
submit();
}
// Enter 单独按下 = 换行(默认行为,不拦截)
};
const submit = () => {
if (!canSend.value) return;
emit('send', draft.value);
draft.value = '';
};
/** 文本超出 max-height 后内部滚动(自动增高由浏览器 scrollHeight 驱动) */
const autoGrow = () => {
const el = textarea.value;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(Math.max(el.scrollHeight, 46), 160)}px`;
};
defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.focus() });
</script>
<template>
<div class="composer-wrap">
<div class="composer">
<div class="mode-pills">
<button
v-for="pm in MODE_LABELS"
:key="pm.key"
type="button"
:class="['mode-pill', { on: mode === pm.key }]"
:disabled="busy || disabled"
@click="emit('change-mode', pm.key)"
>
{{ pm.label }}
</button>
</div>
<textarea
ref="textarea"
v-model="draft"
:placeholder="T.composerPlaceholder"
:disabled="disabled"
@input="autoGrow"
@keydown="onKeydown"
></textarea>
<div class="composer-actions">
<span class="hint">{{ healthNote || T.composerHint }}</span>
<button
v-if="!busy"
class="send-btn"
type="button"
:disabled="!canSend"
@click="submit"
>
<svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
{{ T.send }}
</button>
<button v-else class="send-btn stop-btn" type="button" @click="emit('stop')">
{{ T.stop }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
/* 兜底样式(类名按 UI_SPEC §4.6 / §7;原型逐值复刻) */
.composer-wrap {
padding: 4px 28px 14px;
padding-bottom: max(14px, env(safe-area-inset-bottom));
}
.composer {
max-width: 760px;
margin: 0 auto;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-xl);
padding: 12px;
transition: border-color 0.2s var(--pa-ease), box-shadow 0.25s var(--pa-ease);
}
.composer:focus-within {
border-color: transparent;
background: linear-gradient(var(--pa-surface-1), var(--pa-surface-1)) padding-box,
var(--pa-gradient) border-box;
border: 1px solid transparent;
box-shadow: 0 0 22px rgba(0, 212, 255, 0.16);
}
.mode-pills {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.mode-pill {
padding: 4px 12px;
border-radius: var(--pa-r-pill);
font-size: 12px;
font-weight: 500;
color: var(--pa-ink-muted);
border: 1px solid var(--pa-hairline);
transition: all 0.2s var(--pa-ease);
cursor: pointer;
}
.mode-pill:hover:not(:disabled) {
color: var(--pa-ink);
background: var(--pa-surface-2);
}
.mode-pill:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mode-pill.on {
color: #fff;
background: var(--pa-gradient);
border-color: transparent;
box-shadow: 0 0 12px rgba(0, 212, 255, 0.3);
}
textarea {
width: 100%;
min-height: 46px;
max-height: 160px;
resize: none;
background: transparent;
border: none;
outline: none;
font-size: 14px;
line-height: 1.6;
color: var(--pa-ink);
font-family: var(--pa-font-ui);
display: block;
}
textarea::placeholder {
color: var(--pa-ink-subtle);
}
textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.hint {
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
.send-btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 18px;
border-radius: var(--pa-r-md);
background: var(--pa-gradient);
color: #fff;
font-size: 13px;
font-weight: 600;
transition: box-shadow 0.3s var(--pa-ease), transform 0.15s var(--pa-ease), opacity 0.2s var(--pa-ease);
cursor: pointer;
}
.send-btn:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
transform: translateY(-1px);
}
.send-btn:active:not(:disabled) {
transform: translateY(0);
}
.send-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.stop-btn {
background: var(--pa-error);
}
@media (max-width: 767px) {
textarea {
font-size: 16px; /* 低于 16px 触发 iOS 自动放大(FR-RESP-006) */
}
}
</style>
@@ -0,0 +1,77 @@
<script setup>
// chat/EmptyState.vue —— 欢迎空态(FR-CHAT-001)+ 3 张建议卡(labels.SUGGEST_CARDS)
import { SUGGEST_CARDS, T } from '../../i18n/labels.js';
import SuggestCard from './SuggestCard.vue';
const emit = defineEmits(['pick']);
</script>
<template>
<div class="empty-state">
<div class="hero-dot">✧</div>
<h1>{{ T.sessionEmptyTitle }}</h1>
<p>{{ T.sessionEmptySub }}</p>
<div class="suggest-grid">
<SuggestCard
v-for="card in SUGGEST_CARDS"
:key="card.name"
:card="card"
@pick="emit('pick', $event)"
/>
</div>
<p class="independent-note">{{ T.independentQuestionNote }}</p>
</div>
</template>
<style scoped>
/* B1 未落地 base.css 时的兜底(类名按 UI_SPEC §4.3 / §7) */
.empty-state {
text-align: center;
padding-top: 9vh;
}
.hero-dot {
width: 46px;
height: 46px;
margin: 0 auto 18px;
border-radius: 14px;
background: var(--pa-gradient);
box-shadow: 0 0 30px rgba(0, 212, 255, 0.45), 0 0 70px rgba(94, 106, 210, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
}
h1 {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.4px;
margin-bottom: 10px;
background: linear-gradient(90deg, var(--pa-ink), var(--pa-ink-muted));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
p {
color: var(--pa-ink-muted);
font-size: 14px;
margin-bottom: 30px;
}
.suggest-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
text-align: left;
}
.independent-note {
margin-top: 24px;
margin-bottom: 0;
font-size: 12px;
color: var(--pa-ink-subtle);
}
@media (max-width: 767px) {
.suggest-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,123 @@
<script setup>
// chat/MessageList.vue —— 消息流渲染(FR-CHAT-003/014)
// 自动滚动:新消息/流式更新滚到底;用户手动向上滚动后暂停,
// 显示「回到最新」按钮(FR-CHAT-014);点击或滚回底部恢复。
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { T } from '../../i18n/labels.js';
import UserMessage from './UserMessage.vue';
import AssistantMessage from './AssistantMessage.vue';
const props = defineProps({
messages: { type: Array, required: true },
busy: { type: Boolean, default: false },
reveal: { type: Object, default: null },
scrollSeq: { type: Number, default: 0 }, // chat store 的滚动请求序号
});
const emit = defineEmits(['retry', 'jump-graph', 'jump-source']);
const el = ref(null);
const stickToBottom = ref(true);
const hasMessages = computed(() => props.messages.length > 0);
const onScroll = () => {
const node = el.value;
if (!node) return;
const dist = node.scrollHeight - node.scrollTop - node.clientHeight;
const nearBottom = dist < 60;
if (nearBottom) stickToBottom.value = true;
else stickToBottom.value = false;
};
const scrollToBottom = (smooth = false) => {
const node = el.value;
if (!node) return;
node.scrollTo({ top: node.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
};
const backToLatest = () => {
stickToBottom.value = true;
scrollToBottom(true);
};
watch(
() => [props.messages.length, props.scrollSeq, props.reveal],
() => {
if (stickToBottom.value) scrollToBottom();
},
{ flush: 'post' },
);
onMounted(() => scrollToBottom());
onBeforeUnmount(() => {});
</script>
<template>
<div ref="el" class="chat-scroll" @scroll="onScroll">
<div class="chat-inner">
<template v-for="(m, mi) in messages" :key="m.id ?? mi">
<UserMessage v-if="m.role === 'user'" :message="m" />
<AssistantMessage
v-else
:message="m"
:reveal="reveal"
@retry="emit('retry')"
@jump-graph="emit('jump-graph')"
@jump-source="(payload) => emit('jump-source', payload)"
/>
</template>
</div>
<transition name="back-fade">
<button v-if="hasMessages && !stickToBottom" class="back-latest" type="button" @click="backToLatest">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
{{ T.backToLatest }}
</button>
</transition>
</div>
</template>
<style scoped>
.chat-scroll {
flex: 1;
overflow-y: auto;
min-height: 0;
position: relative;
}
.chat-inner {
max-width: 760px;
margin: 0 auto;
padding: 28px 28px 12px;
}
.back-latest {
position: sticky;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--pa-r-pill);
background: var(--pa-raised);
border: 1px solid var(--pa-hairline-strong);
color: var(--pa-ink-muted);
font-size: 12px;
cursor: pointer;
z-index: 4;
}
.back-latest:hover {
color: var(--pa-ink);
box-shadow: 0 0 14px rgba(0, 212, 255, 0.18);
}
.back-fade-enter-active,
.back-fade-leave-active {
transition: opacity 0.2s var(--pa-ease);
}
.back-fade-enter-from,
.back-fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,204 @@
<script setup>
// chat/PipelineCard.vue —— 检索流水线五相位卡(FR-CHAT-009,UI_SPEC §4.4)
// 相位色只答「哪一步在做什么」(--pa-t-* 令牌),语义色只答「整体成败」,
// 两者不得混用(PRD 关键约束)。running 相位点与字用该相位的 --pa-t-* 色。
import { T, PHASE_COLOR_VAR, STATUS_LABEL } from '../../i18n/labels.js';
defineProps({
block: { type: Object, required: true }, // {type:'pipeline', status, steps:[{name,state,desc,phase}]}
});
const stepStateText = (state) => ({
done: T.stepDone,
running: T.stepRunning,
waiting: T.stepWaiting,
}[state] || '');
</script>
<template>
<div class="pipeline-card">
<div class="pc-head">
<span class="pc-title">{{ T.pipelineTitle }}</span>
<span class="badge" :class="block.status">
<span class="dot"></span>{{ STATUS_LABEL[block.status] || STATUS_LABEL.idle }}
</span>
</div>
<div class="pipeline">
<div v-for="(st, si) in block.steps" :key="si" :class="['p-step', st.state]">
<span class="p-dot" :style="st.state === 'running' ? { background: `var(${PHASE_COLOR_VAR[st.phase]})` } : null">
{{ st.state === 'done' ? '✓' : st.state === 'running' ? '●' : '◌' }}
</span>
<span class="p-name">{{ st.name }}</span>
<span class="p-desc" :title="st.desc">{{ st.desc }}</span>
<span class="p-state" :style="st.state === 'running' ? { color: `var(${PHASE_COLOR_VAR[st.phase]})` } : null">
<span v-if="st.state === 'running'" class="spin"></span>
{{ stepStateText(st.state) }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
/* 兜底样式(类名按 UI_SPEC §4.4 / §7;原型逐值复刻) */
.pipeline-card {
margin: 14px 0;
padding: 14px 16px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
}
.pc-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.pc-title {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.3px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--pa-r-pill);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.5px;
white-space: nowrap;
}
.badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.badge.running {
color: var(--pa-warning);
background: rgba(245, 184, 92, 0.12);
}
.badge.running .dot {
background: var(--pa-warning);
animation: pulse 2s infinite;
}
.badge.done {
color: var(--pa-success);
background: rgba(46, 211, 160, 0.12);
}
.badge.done .dot {
background: var(--pa-success);
}
.badge.error {
color: var(--pa-error);
background: rgba(255, 92, 122, 0.12);
}
.badge.error .dot {
background: var(--pa-error);
}
.badge.idle {
color: var(--pa-ink-subtle);
background: var(--pa-surface-2);
}
.badge.idle .dot {
background: var(--pa-ink-subtle);
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.5);
}
70% {
box-shadow: 0 0 0 8px rgba(0, 212, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 212, 255, 0);
}
}
.pipeline {
display: flex;
flex-direction: column;
}
.p-step {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
position: relative;
}
.p-step:not(:last-child)::before {
content: '';
position: absolute;
left: 7px;
top: 22px;
bottom: -8px;
width: 2px;
background: var(--pa-hairline);
border-radius: 1px;
}
.p-dot {
flex: none;
width: 16px;
height: 16px;
border-radius: 50%;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: #fff;
}
.p-name {
font-size: 13px;
width: 84px;
flex: none;
}
.p-desc {
flex: 1;
font-size: 12px;
color: var(--pa-ink-muted);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.p-state {
flex: none;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 5px;
}
.p-step.done .p-dot {
background: var(--pa-t-done);
}
.p-step.done .p-state {
color: var(--pa-t-done);
}
.p-step.running .p-dot {
animation: pulse 2s infinite;
}
.p-step.waiting .p-dot {
background: var(--pa-surface-3);
border: 1.5px solid var(--pa-hairline-strong);
}
.p-step.waiting .p-state {
color: var(--pa-ink-subtle);
font-weight: 400;
}
.p-state .spin {
width: 11px;
height: 11px;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: currentColor;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,59 @@
<script setup>
// chat/SuggestCard.vue —— 欢迎空态建议卡(FR-CHAT-001)
// hover -2px lift + 渐变描边 + 青色辉光(UI_SPEC §4.3)。
defineProps({
card: { type: Object, required: true }, // {icon, name, desc, text}
});
const emit = defineEmits(['pick']);
</script>
<template>
<button
class="suggest-card"
type="button"
@click="emit('pick', card.text)"
>
<div class="icon">{{ card.icon }}</div>
<div class="name">{{ card.name }}</div>
<div class="desc">{{ card.desc }}</div>
</button>
</template>
<style scoped>
/* 兜底样式(类名按 UI_SPEC §7) */
.suggest-card {
padding: 15px;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
cursor: pointer;
text-align: left;
color: var(--pa-ink);
transition: transform 0.2s var(--pa-ease), border-color 0.2s var(--pa-ease),
box-shadow 0.2s var(--pa-ease);
}
.suggest-card:hover {
transform: translateY(-2px);
border-color: transparent;
background: linear-gradient(var(--pa-surface-1), var(--pa-surface-1)) padding-box,
var(--pa-gradient) border-box;
border: 1px solid transparent;
box-shadow: 0 0 18px rgba(0, 212, 255, 0.15);
}
.icon {
font-size: 16px;
margin-bottom: 6px;
}
.name {
font-size: 13.5px;
font-weight: 600;
margin-bottom: 3px;
color: var(--pa-ink);
}
.desc {
font-size: 12px;
color: var(--pa-ink-muted);
line-height: 1.5;
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<script setup>
// chat/ToolCard.vue —— 阶段卡(原型 tool-card 的 RAG 化;UI_SPEC §4.5)
// 消息内的阶段卡;工作台内的同型卡片由 B3 渲染(共享视觉类名)。
// running:shimmer 扫光层 + 状态点脉冲;展开体 fade-slide。
// 注意:tb-label 三段文案(参数/输出/日志)labels.js 暂无对应键(PM 补充前暂以
// 组件常量承载,见最终汇报「契约偏差」节)。
import { computed } from 'vue';
import { STATUS_LABEL, PHASE_COLOR_VAR } from '../../i18n/labels.js';
const props = defineProps({
block: { type: Object, required: true }, // {icon,name,status,dur,open,params,result,logs,phase}
});
const emit = defineEmits(['toggle']);
const open = computed({
get: () => !!props.block.open,
set: () => emit('toggle'),
});
const iconTint = computed(() => ({
background: `color-mix(in srgb, var(${PHASE_COLOR_VAR[props.block.phase] || '--pa-g-from'}) 16%, transparent)`,
color: `var(${PHASE_COLOR_VAR[props.block.phase] || '--pa-g-to'})`,
}));
// TODO(labels.js):待 PM 增加 T.toolParams/T.toolResult/T.toolLog 后替换
const tParams = '参数';
const tResult = '输出';
const tLog = '日志';
</script>
<template>
<div :class="['tool-card', block.status, { open }]">
<div class="tool-head" @click="emit('toggle')">
<span class="t-ico" :style="iconTint">{{ block.icon }}</span>
<span class="t-name" :title="block.name">{{ block.name }}</span>
<span class="badge" :class="block.status">
<span class="dot"></span>{{ STATUS_LABEL[block.status] || STATUS_LABEL.idle }}
</span>
<span v-if="block.dur" class="t-dur">{{ block.dur }}</span>
<svg class="t-chev" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M9 18l6-6-6-6" />
</svg>
</div>
<div v-if="open" class="tool-body">
<div v-if="block.params" class="tb-label">{{ tParams }}</div>
<div v-if="block.params" class="tb-params">{{ block.params }}</div>
<template v-if="block.result">
<div class="tb-label">{{ tResult }}</div>
<div class="tb-result">{{ block.result }}</div>
</template>
<template v-if="block.logs && block.logs.length">
<div class="tb-label">{{ tLog }}</div>
<div class="tb-log">
<div v-for="(l, li) in block.logs" :key="li" :class="l.cls">{{ l.text }}</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
/* 兜底样式(类名按 UI_SPEC §4.5 / §7;原型逐值复刻) */
.tool-card {
margin: 12px 0;
background: var(--pa-surface-1);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-lg);
overflow: hidden;
}
.tool-card.running {
position: relative;
}
.tool-card.running::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 200%;
pointer-events: none;
background: linear-gradient(100deg, transparent 30%, rgba(0, 212, 255, 0.05) 50%, transparent 70%);
will-change: transform;
animation: shimmer 1.8s infinite;
}
.tool-head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
user-select: none;
}
.t-ico {
width: 22px;
height: 22px;
border-radius: 7px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 11.5px;
}
.t-name {
flex: 1;
font-size: 13.5px;
font-weight: 600;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.t-dur {
font-family: var(--pa-font-mono);
font-size: 11.5px;
color: var(--pa-ink-subtle);
}
.t-chev {
color: var(--pa-ink-subtle);
transition: transform 0.25s var(--pa-ease);
flex: none;
}
.tool-card.open .t-chev {
transform: rotate(90deg);
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--pa-r-pill);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.5px;
white-space: nowrap;
}
.badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.badge.running {
color: var(--pa-warning);
background: rgba(245, 184, 92, 0.12);
}
.badge.running .dot {
background: var(--pa-warning);
animation: pulse 2s infinite;
}
.badge.done {
color: var(--pa-success);
background: rgba(46, 211, 160, 0.12);
}
.badge.done .dot {
background: var(--pa-success);
}
.badge.error {
color: var(--pa-error);
background: rgba(255, 92, 122, 0.12);
}
.badge.error .dot {
background: var(--pa-error);
}
.badge.idle {
color: var(--pa-ink-subtle);
background: var(--pa-surface-2);
}
.badge.idle .dot {
background: var(--pa-ink-subtle);
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.5);
}
70% {
box-shadow: 0 0 0 8px rgba(0, 212, 255, 0);
}
}
.tool-body {
padding: 11px 14px 13px;
border-top: 1px solid var(--pa-hairline);
animation: fade-slide 0.2s var(--pa-ease-out);
}
@keyframes fade-slide {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.tb-label {
font-size: 11px;
letter-spacing: 0.5px;
color: var(--pa-ink-subtle);
margin: 8px 0 5px;
}
.tb-params {
font-family: var(--pa-font-mono);
font-size: 12.5px;
color: var(--pa-g-to);
background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
padding: 7px 10px;
white-space: pre-wrap;
word-break: break-all;
}
.tb-result {
font-size: 13px;
color: var(--pa-ink);
}
.tb-log {
font-family: var(--pa-font-mono);
font-size: 11.5px;
line-height: 1.8;
color: var(--pa-ink-muted);
background: var(--pa-code-bg);
border: 1px solid var(--pa-hairline);
border-radius: var(--pa-r-sm);
padding: 8px 10px;
max-height: 130px;
overflow-y: auto;
}
.tb-log .log-ok {
color: var(--pa-success);
}
.tb-log .log-run {
color: var(--pa-g-to);
}
</style>
@@ -0,0 +1,13 @@
<script setup>
// chat/UserMessage.vue —— 用户消息气泡(FR-CHAT-002)
// 右对齐,最大宽 70%,user-bubble 渐变底 + hairline-strong 边(tokens.css 提供)。
defineProps({
message: { type: Object, required: true },
});
</script>
<template>
<div class="msg user">
<div class="bubble">{{ message.text }}</div>
</div>
</template>
+98
View File
@@ -0,0 +1,98 @@
// chat/markdown.js —— LLM 输出的 Markdown → 消毒 HTML(B2 归属)
// FR-CHAT-004:markdown-it 解析 → DOMPurify 消毒 → v-html。
// 自定义渲染规则挂 `md-table` / `code-block` 类(UI_SPEC §4.3)。
import MarkdownIt from 'markdown-it';
import DOMPurify from 'dompurify';
/** 表头为「说明」的列保持比例字体,其余列 mono(UI_SPEC §4.3) */
export const NON_MONO_HEADER = '说明';
let md = null;
function ensureRenderer() {
if (md) return md;
const inst = new MarkdownIt({
html: false, // LLM 输出先不解析内联 HTML,统一由 DOMPurify 兜底
linkify: true,
breaks: false,
});
inst.renderer.rules.table_open = () => '<table class="md-table">';
inst.renderer.rules.table_close = () => '</table>';
inst.renderer.rules.thead_open = () => '<thead>';
inst.renderer.rules.tbody_open = () => '<tbody>';
inst.renderer.rules.th_open = () => '<th>';
inst.renderer.rules.td_open = () => '<td>';
// 围栏代码块(```)走 fence 规则,缩进代码块走 code_block 规则,两者统一挂 code-block 类
const codeBlock = (tokens, idx) =>
`<div class="code-block">${inst.utils.escapeHtml(tokens[idx].content)}</div>`;
inst.renderer.rules.fence = codeBlock;
inst.renderer.rules.code_block = codeBlock;
// 数据格 mono:表头为「说明」的列除外。
// markdown-it 的 td 行内内容在 td_open 时尚不可用,但 thead 一定先于 tbody 渲染,
// 故在 th_open 时记录当前列表头文本即可(渲染为同步单线程,无并发交错)。
const cellText = (token) => {
let s = '';
for (const t of token.children || []) s += t.content || '';
return s;
};
const prevTh = { value: '' };
const _thOpen = inst.renderer.rules.th_open;
const _tdOpen = inst.renderer.rules.td_open;
inst.renderer.rules.th_open = (tokens, idx, options, env, self) => {
prevTh.value = cellText(tokens[idx]);
return _thOpen(tokens, idx, options, env, self);
};
inst.renderer.rules.td_open = (tokens, idx, options, env, self) => {
const open = _tdOpen(tokens, idx, options, env, self);
return prevTh.value !== NON_MONO_HEADER ? open.replace(/<td/, '<td class="mono"') : open;
};
md = inst;
return md;
}
/** DOMPurify 实例:浏览器/jsdoms 默认导出即实例;工厂形态(Node 裸环境)按需构造 */
function getPurify() {
if (DOMPurify && typeof DOMPurify.sanitize === 'function') return DOMPurify;
if (typeof DOMPurify === 'function' && typeof window !== 'undefined') {
try {
return DOMPurify(window);
} catch {
return null;
}
}
return null;
}
/** markdown 文本 → 消毒后的 HTML(失败或无消毒环境返回转义原文,不抛异常) */
export function renderMarkdown(text) {
const src = String(text == null ? '' : text);
try {
const html = ensureRenderer().render(src);
const purify = getPurify();
return purify ? purify.sanitize(html) : html;
} catch {
const purify = getPurify();
return purify ? purify.sanitize(escapeHtml(src)) : escapeHtml(src);
}
}
/**
* 已渲染 HTML(块联合 text.html:创建时已由 renderMarkdown 生成,
* 或后端落库的消毒 HTML)→ 渲染前再消毒一次(纵深防御:绝不信任持久化内容)。
*/
export function sanitizeHtml(html) {
const purify = getPurify();
if (!purify) return String(html == null ? '' : html);
return purify.sanitize(String(html == null ? '' : html));
}
function escapeHtml(s) {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}