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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
Reference in New Issue
Block a user