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,3 @@
|
||||
# frontend/.gitignore(最小版,B1 可扩展)
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>海古纪 PlantAI · 知识工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "plantai-rag-new-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "3.5.13",
|
||||
"pinia": "2.3.0",
|
||||
"markdown-it": "14.1.0",
|
||||
"dompurify": "3.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.4.11",
|
||||
"@vitejs/plugin-vue": "5.2.1",
|
||||
"vitest": "2.1.8",
|
||||
"@vue/test-utils": "2.4.6",
|
||||
"jsdom": "25.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// playwright.config.js —— T(测试 agent)归属。
|
||||
// L5 端到端骨架:三档视口(TEST_PLAN §3),与 vitest(vitest.config.js)完全独立。
|
||||
// 完整用例(按 docs/TEST_CASES.md 的 TC-E2E-*)在后续轮次以 spec 文件补齐到 ../tests/e2e/。
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '../tests/e2e',
|
||||
testMatch: '**/*.spec.js',
|
||||
// 2026-08-25 R2 加固(T 建议采纳):真实 QA 流最长 ~92s 且上游串行排队会放大,
|
||||
// 全局 timeout 提到 480s;各 spec 内对不含真实流的用例仍可用 test.setTimeout 收紧。
|
||||
timeout: 480_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false, // 三项目串行,避免 dev server 端口与 mock 场景互相干扰
|
||||
workers: 1,
|
||||
retries: 0, // 测试轮次纪律:失败即记录,不自动重试掩盖缺陷
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5180',
|
||||
browserName: 'chromium',
|
||||
headless: true, // 远端可改 headed;本地保持无头
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'mobile',
|
||||
use: {
|
||||
viewport: { width: 390, height: 844 }, // iPhone 14
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
deviceScaleFactor: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tablet',
|
||||
use: { viewport: { width: 768, height: 1024 } }, // iPad
|
||||
},
|
||||
{
|
||||
name: 'desktop',
|
||||
use: { viewport: { width: 1440, height: 900 } },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev -- --strictPort',
|
||||
url: 'http://127.0.0.1:5180',
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
// 复用已由 deploy/start-dev.sh 或手动拉起的 dev server(集成/远端场景)
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- App.vue —— B1 归属。挂载三栏 AppShell。 -->
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import AppShell from './components/layout/AppShell.vue';
|
||||
import { useHealthStore } from './stores/health.js';
|
||||
|
||||
// FR-SHELL-007 首屏初始化:并行请求健康与 QA 元信息(失败不阻塞渲染)
|
||||
const health = useHealthStore();
|
||||
onMounted(() => {
|
||||
health.start();
|
||||
health.fetchQaInfo();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
health.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell />
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
// api/extraction.js —— 论文抽取任务(BFF 代理 8000/7900;B3 归属)
|
||||
import { get, post } from './http.js';
|
||||
|
||||
/**
|
||||
* 任务列表。上游无 progress 数值,progress_pct 由 BFF 按 stage 派生:
|
||||
* queued=5 / mineru=35 / llm=75 / completed=100 / failed=null。
|
||||
*/
|
||||
export function listJobs({ limit = 20 } = {}) {
|
||||
return get('/api/extraction/jobs', { limit });
|
||||
}
|
||||
|
||||
/** 任务详情(stats 计数 + plan,plan 仅 completed 有) */
|
||||
export function getJob(jobId) {
|
||||
return get(`/api/extraction/jobs/${encodeURIComponent(jobId)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传论文(multipart)。限制:仅 PDF,单文件 ≤50MB(BFF 校验,前端先行)。
|
||||
* @param {File} file
|
||||
* @returns {Promise<{success:boolean, job_id:string, status_url:string, message:string}>}
|
||||
*/
|
||||
export function uploadPaper(file, { paper_id, family, output_name } = {}) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (paper_id != null && paper_id !== '') form.append('paper_id', paper_id);
|
||||
if (family != null && family !== '') form.append('family', family);
|
||||
if (output_name != null && output_name !== '') form.append('output_name', output_name);
|
||||
return post('/api/extraction/upload', form);
|
||||
}
|
||||
|
||||
/** 运行日志:{text} 透传,rows 为空属正常现象 */
|
||||
export function getRunLog() {
|
||||
return get('/api/extraction/run-log');
|
||||
}
|
||||
|
||||
/** 上游透传 {rows:[]}——rows 为空属正常(上游批次目录未配置),前端显示空态而非错误 */
|
||||
export function getProcessingStatus() {
|
||||
return get('/api/extraction/processing-status');
|
||||
}
|
||||
|
||||
/** 上游透传 {rows:[]} */
|
||||
export function getSummary() {
|
||||
return get('/api/extraction/summary');
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// api/http.js —— B1 归属,B2/B3 复用。fetch 封装 + 统一错误信封解析。
|
||||
// 契约(API.md §1):非 2xx 与降级响应返回 {"ok":false,"error":{"code","message","degraded"}}。
|
||||
// 网络错误与信封错误一律归一为结构化 ApiError。
|
||||
//
|
||||
// 超时策略(BUG-R1-07 修复,API.md 全表读超时:qa chat 300s / qa search 180s /
|
||||
// extraction upload 600s / kg overview 60s / 邻域 30s):
|
||||
// - 默认读超时 600s = API.md 全表最大值。前端兜底永不先于 BFF 超时自断——
|
||||
// 真实超时时由 BFF 返回 TIMEOUT 信封(504),前端展示契约文案「检索超时」,
|
||||
// 而不是自己 fabricate 一个 NETWORK 错误。
|
||||
// - 调用方可传 opts.timeout(毫秒)做更紧的超时(如已知快路径);
|
||||
// timeout: 0 表示不设超时。
|
||||
// - SSE 流式(raw:true)不走读超时——上游最长 240s,由调用方 AbortSignal 控制生命周期。
|
||||
//
|
||||
// 查询参数(BUG-R1-01 修复):get(path, params, opts) 的 params 经 URLSearchParams
|
||||
// 序列化拼到 query string(undefined/null 跳过),匹配 B3 kg.js / extraction.js 的调用形态:
|
||||
// get('/api/kg/entities', { q, limit }) → /api/kg/entities?q=…&limit=20
|
||||
// get('/api/kg/entities/{id}/neighborhood', { depth, limit }) → …/neighborhood?depth=1&limit=50
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, { code = 'NETWORK', status = 0, degraded = false, cause } = {}) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code; // 信封 error.code,网络错误为 'NETWORK'
|
||||
this.status = status; // HTTP 状态码,网络错误为 0
|
||||
this.degraded = degraded; // true = 上游不可用(UI 走降级提示而非错误弹窗)
|
||||
if (cause) this.cause = cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认读超时:API.md 全表最大值(extraction upload 600s)——前端兜底不得先于 BFF 超时 */
|
||||
const DEFAULT_TIMEOUT_MS = 600000;
|
||||
|
||||
function buildQuery(params) {
|
||||
if (!params) return '';
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null) continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const qs = sp.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body, headers = {}, timeout = DEFAULT_TIMEOUT_MS, signal, raw = false, params } = {}) {
|
||||
// GET 的 params 序列化为 query string(BUG-R1-01)
|
||||
let url = path;
|
||||
if (method === 'GET' && params) url += buildQuery(params);
|
||||
|
||||
const opts = { method, headers: { ...headers }, signal };
|
||||
if (body !== undefined && body !== null) {
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
||||
// multipart(FR-EXTRACT-004 上传):不设 Content-Type(浏览器自动带 boundary)
|
||||
opts.body = body;
|
||||
} else {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
const ctrl = new AbortController();
|
||||
// raw(SSE 流式)不设读超时;timeout:0 显式关闭
|
||||
const t = raw || timeout === 0 ? null : setTimeout(() => ctrl.abort(), timeout);
|
||||
res = await fetch(url, {
|
||||
...opts,
|
||||
signal: signal || ctrl.signal,
|
||||
});
|
||||
if (t) clearTimeout(t);
|
||||
} catch (e) {
|
||||
// AbortError 也可能是调用方主动中断流式请求——继续抛原始错误,由调用方处理
|
||||
if (e.name === 'AbortError' && signal && signal.aborted) throw e;
|
||||
throw new ApiError('网络异常,请检查连接', {
|
||||
code: 'NETWORK',
|
||||
degraded: false,
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
|
||||
// raw:true —— 返回原始 Response 供 SSE 流式消费(B2 consumeSseStream);
|
||||
// 非 2xx 仍按统一错误信封抛 ApiError(HTTP 错误在流开始前就被拒绝)
|
||||
if (raw) {
|
||||
if (!res.ok) {
|
||||
let payload = null;
|
||||
if ((res.headers.get('content-type') || '').includes('application/json')) {
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
if (payload && payload.ok === false && payload.error) {
|
||||
throw new ApiError(payload.error.message || '服务错误', {
|
||||
code: payload.error.code || 'INTERNAL',
|
||||
status: res.status,
|
||||
degraded: !!payload.error.degraded,
|
||||
});
|
||||
}
|
||||
throw new ApiError(`请求失败(HTTP ${res.status})`, { code: 'INTERNAL', status: res.status });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
let payload = null;
|
||||
const isJson = (res.headers.get('content-type') || '').includes('application/json');
|
||||
if (isJson) {
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// 统一错误信封(API.md §1)
|
||||
if (payload && payload.ok === false && payload.error) {
|
||||
throw new ApiError(payload.error.message || '服务错误', {
|
||||
code: payload.error.code || 'INTERNAL',
|
||||
status: res.status,
|
||||
degraded: !!payload.error.degraded,
|
||||
});
|
||||
}
|
||||
throw new ApiError(`请求失败(HTTP ${res.status})`, { code: 'INTERNAL', status: res.status });
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
/** params 序列化为 query string(BUG-R1-01);opts 可传 { timeout, signal, raw } */
|
||||
get: (path, params = {}, opts = {}) => request(path, { ...opts, method: 'GET', params }),
|
||||
/** body 为普通对象 → JSON;FormData → multipart。opts 可传 { timeout, signal, raw } */
|
||||
post: (path, body, opts = {}) => request(path, { ...opts, method: 'POST', body }),
|
||||
patch: (path, body, opts = {}) => request(path, { ...opts, method: 'PATCH', body }),
|
||||
del: (path, opts = {}) => request(path, { ...opts, method: 'DELETE' }),
|
||||
/** 原始 fetch(无封装),供 SSE 流式调用(B2 用 ReadableStream 消费 POST /api/qa/chat/stream) */
|
||||
rawFetch: fetch,
|
||||
};
|
||||
|
||||
// 命名导出(B2/B3 api/* 层按此 import;与 api 对象同函数,两种取法等价)
|
||||
export const get = api.get;
|
||||
export const post = api.post;
|
||||
export const patch = api.patch;
|
||||
export const del = api.del;
|
||||
|
||||
// 别名:B2 代码注释中的 HttpError 即 ApiError(统一错误信封,契约 API.md §1)
|
||||
export const HttpError = ApiError;
|
||||
@@ -0,0 +1,37 @@
|
||||
// api/kg.js —— 知识图谱自由浏览(BFF Bolt 直连 Neo4j;B3 归属)
|
||||
// 错误统一走 http.js 信封:所有非 2xx 抛 HttpError{status, code, message, degraded}
|
||||
import { get } from './http.js';
|
||||
|
||||
export function kgStatus() {
|
||||
return get('/api/kg/status');
|
||||
}
|
||||
|
||||
export function kgOverview() {
|
||||
return get('/api/kg/overview');
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体搜索(name_norm 精确/前缀匹配,非模糊全文)。
|
||||
* @param {string} q 查询词
|
||||
* @param {number} [limit=20]
|
||||
* @returns {Promise<{query:string, results:Array<{id,name,type,support_count}>}>}
|
||||
*/
|
||||
export function searchEntities(q, limit = 20) {
|
||||
return get('/api/kg/entities', { q, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体详情(id 为 canonical_id 或名字)。
|
||||
* @returns {Promise<{id,name,type,properties:object}>}
|
||||
*/
|
||||
export function getEntity(id) {
|
||||
return get(`/api/kg/entities/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 邻域展开(depth 1-2,默认 1;limit ≤200)。
|
||||
* @returns {Promise<{center,center_name,depth,nodes:Array,edges:Array}>}
|
||||
*/
|
||||
export function getNeighborhood(id, { depth = 1, limit = 50 } = {}) {
|
||||
return get(`/api/kg/entities/${encodeURIComponent(id)}/neighborhood`, { depth, limit });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// api/qa.js —— QA 问答接口(B2 归属)
|
||||
// 契约:docs/API.md §4。错误统一走 http.js 信封:
|
||||
// 所有非 2xx 抛 HttpError{status, code, message, degraded}。
|
||||
import { post, get } from './http.js';
|
||||
import { consumeSseStream } from '../sse/parser.js';
|
||||
|
||||
/** QA 服务元信息(版本 + 12 段 pipeline 描述 + 配置) */
|
||||
export function info() {
|
||||
return get('/api/qa/info');
|
||||
}
|
||||
|
||||
/** 非流式问答(POST /api/qa/chat),响应为归一化 QA 响应 */
|
||||
export function chat(body) {
|
||||
return post('/api/qa/chat', body);
|
||||
}
|
||||
|
||||
/** 检索模式(POST /api/qa/search),BFF 强制 use_llm:false,只出证据不出答案 */
|
||||
export function search(body) {
|
||||
return post('/api/qa/search', body);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式问答(POST /api/qa/chat/stream)。
|
||||
* 帧事件类型(API.md §4):
|
||||
* progress / complete / error / timeout / raw /(无 event 名 → message)
|
||||
*
|
||||
* @param {object} body 请求体(由 config/modes.js 拼装 + question)
|
||||
* @param {object} [handlers]
|
||||
* @param {(ev: object) => void} [handlers.onProgress]
|
||||
* @param {(ev: object) => void} [handlers.onComplete] payload 为归一化 QA 响应
|
||||
* @param {(ev: object) => void} [handlers.onError] {code,message,retryable}
|
||||
* @param {(ev: object) => void} [handlers.onRaw] raw 仅记录不渲染
|
||||
* @param {(ev: object) => void} [handlers.onUnknown] 未知事件(含无 event 名的帧)
|
||||
* @param {AbortSignal} [signal] 停止/中断(AbortController.signal)
|
||||
* @returns {Promise<void>} 流自然结束 resolve;HTTP 错误抛 HttpError;
|
||||
* abort / 网络中断抛 DOMException(AbortError)/TypeError —— 调用方统一映射文案
|
||||
*/
|
||||
export async function chatStream(body, { onProgress, onComplete, onError, onRaw, onUnknown, signal } = {}) {
|
||||
const noop = () => {};
|
||||
const resp = await post('/api/qa/chat/stream', body, { signal, raw: true });
|
||||
|
||||
const dispatch = {
|
||||
progress: onProgress || noop,
|
||||
complete: onComplete || noop,
|
||||
error: onError || noop,
|
||||
// timeout 帧与 error 帧同等对待;上游 payload 无 code 字段,此处统一补 TIMEOUT(PRD 错误映射)
|
||||
timeout: (ev) => (onError || noop)({ ...ev, code: 'TIMEOUT' }),
|
||||
raw: onRaw || noop,
|
||||
};
|
||||
await consumeSseStream(resp, (ev) => {
|
||||
const fn = dispatch[ev.event] || onUnknown || noop;
|
||||
fn({ ...ev, payload: ev.data });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// api/sessions.js —— 会话与消息 CRUD(B2 归属)
|
||||
// 契约:docs/API.md §5。错误统一走 http.js 信封。
|
||||
import { get, post, patch, del } from './http.js';
|
||||
|
||||
/** GET /api/sessions —— 按 updated_at 倒序 */
|
||||
export function listSessions() {
|
||||
return get('/api/sessions');
|
||||
}
|
||||
|
||||
/** POST /api/sessions —— 创建(可空 title,默认「新会话」),不预置空消息 */
|
||||
export function createSession({ title } = {}) {
|
||||
return post('/api/sessions', title ? { title } : {});
|
||||
}
|
||||
|
||||
/** GET /api/sessions/{id} —— 会话 + messages 详情 */
|
||||
export function getSession(id) {
|
||||
return get(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** PATCH /api/sessions/{id} —— {title} 或 {status} */
|
||||
export function updateSession(id, patchBody) {
|
||||
return patch(`/api/sessions/${encodeURIComponent(id)}`, patchBody);
|
||||
}
|
||||
|
||||
/** DELETE /api/sessions/{id} —— 204,级联删消息 */
|
||||
export function deleteSession(id) {
|
||||
return del(`/api/sessions/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** POST /api/sessions/{id}/messages —— 用户消息 / 助手流式骨架 */
|
||||
export function createMessage(sessionId, body) {
|
||||
return post(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, body);
|
||||
}
|
||||
|
||||
/** PATCH /api/sessions/{id}/messages/{mid} —— 流式检查点(~3s)与终态落库 */
|
||||
export function updateMessage(sessionId, messageId, patchBody) {
|
||||
return patch(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`,
|
||||
patchBody,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /api/sessions/{id}/sources —— 会话级来源聚合(按 paper_id 去重 + count) */
|
||||
export function getSessionSources(id) {
|
||||
return get(`/api/sessions/${encodeURIComponent(id)}/sources`);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<!-- BenchPanel.vue —— 右工作台四 tab(B3 归属)
|
||||
FR-RETRIEVAL/SOURCE/EXTRACT/KG。类名复用 UI_SPEC §7:bench-panel / bench-tabs / bench-tab / bench-body / tab-pane。
|
||||
折叠(右栏 28px 手柄条)由 B1 AppShell 的 .bench-collapsed 控制;本组件提供 `collapsed` props
|
||||
与 setCollapsed 事件对接 B1 把手;折叠(含 <1024 收起态)时暂停提取轮询(FR-EXTRACT-005)。 -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { BENCH_TABS } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import RetrievalPane from './RetrievalPane.vue';
|
||||
import SourcesPane from './SourcesPane.vue';
|
||||
import ExtractionPane from './ExtractionPane.vue';
|
||||
import KgPane from './KgPane.vue';
|
||||
|
||||
const props = defineProps({
|
||||
collapsed: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['collapse']);
|
||||
|
||||
const bench = useBenchStore();
|
||||
const collapsedRef = ref(props.collapsed);
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsedRef.value = !collapsedRef.value;
|
||||
bench.setCollapsed(collapsedRef.value);
|
||||
// 收起态(含 <1024 强制收起)时暂停提取轮询(FR-EXTRACT-005)
|
||||
if (collapsedRef.value) bench.pausePolling();
|
||||
emit('collapse', collapsedRef.value);
|
||||
}
|
||||
|
||||
function selectTab(key) {
|
||||
bench.setActiveTab(key);
|
||||
}
|
||||
|
||||
// 工作台按钮(移动端全屏覆盖层入口)需要的来源数角标(FR-RESP-003)
|
||||
const sourceCount = computed(() => {
|
||||
const s = bench.sources;
|
||||
return s ? s.length : 0;
|
||||
});
|
||||
defineExpose({ sourceCount, activeTab: computed(() => bench.activeTab) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="bench-panel" :class="{ 'collapsed-local': collapsedRef }">
|
||||
<div class="inner">
|
||||
<div class="bench-tabs" role="tablist">
|
||||
<button
|
||||
v-for="tb in BENCH_TABS"
|
||||
:key="tb.key"
|
||||
role="tab"
|
||||
:aria-selected="bench.activeTab === tb.key"
|
||||
:class="['bench-tab', { on: bench.activeTab === tb.key }]"
|
||||
@click="selectTab(tb.key)"
|
||||
>{{ tb.label }}</button>
|
||||
</div>
|
||||
<div class="bench-body">
|
||||
<div v-if="bench.activeTab === 'retrieval'" class="tab-pane"><RetrievalPane /></div>
|
||||
<div v-else-if="bench.activeTab === 'sources'" class="tab-pane"><SourcesPane /></div>
|
||||
<div v-else-if="bench.activeTab === 'extract'" class="tab-pane"><ExtractionPane /></div>
|
||||
<div v-else class="tab-pane"><KgPane /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bench-handle">
|
||||
<button
|
||||
class="bench-handle-btn"
|
||||
type="button"
|
||||
:aria-label="collapsedRef ? BENCH_LABELS.benchExpand : BENCH_LABELS.benchCollapse"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 复用 B1 base.css 的 .bench-* 类;此处仅补 B3 专属细节 */
|
||||
.bench-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--pa-surface-1);
|
||||
border-left: 1px solid var(--pa-hairline);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s var(--pa-ease-out);
|
||||
}
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 380px;
|
||||
transition: opacity 0.2s var(--pa-ease);
|
||||
flex: 1;
|
||||
}
|
||||
.bench-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 10px 14px 0;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.bench-tab {
|
||||
position: relative;
|
||||
padding: 8px 10px 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s var(--pa-ease);
|
||||
}
|
||||
.bench-tab:hover { color: var(--pa-ink); }
|
||||
.bench-tab.on { color: var(--pa-ink); font-weight: 600; }
|
||||
.bench-tab.on::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background: var(--pa-gradient);
|
||||
border-radius: 2px;
|
||||
animation: fade-slide 0.25s var(--pa-ease-out);
|
||||
}
|
||||
.bench-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
min-height: 0;
|
||||
}
|
||||
.tab-pane { animation: fade-slide 0.2s var(--pa-ease-out); }
|
||||
.bench-handle {
|
||||
display: none;
|
||||
width: 28px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.bench-handle-btn {
|
||||
width: 28px;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bench-handle-btn:hover { color: var(--pa-ink); }
|
||||
/* 折叠态由 AppShell 的 .bench-collapsed 控制(B1),此处以 collapsed prop 同步 */
|
||||
.collapsed-local .inner { opacity: 0; pointer-events: none; }
|
||||
.collapsed-local .bench-handle { display: flex; }
|
||||
.collapsed-local .bench-handle svg { transform: rotate(180deg); }
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!-- ChainGroup.vue —— 证据链分组(B3 归属)
|
||||
FR-SOURCE-004:按 chain_id 分组,可展开为逐跳列表:源实体 —[关系]→ 目标实体,
|
||||
每跳附 paper_id/PMID,跳数以徽章显示(labels.chainBadge)。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
chain: { type: Object, required: true },
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
/** 归一化 hop 列表:兼容 {hops:[…]} 与直接数组两种形态(API.md §9) */
|
||||
const hops = computed(() => {
|
||||
const c = props.chain || {};
|
||||
if (Array.isArray(c)) return c;
|
||||
return Array.isArray(c.hops) ? c.hops : [];
|
||||
});
|
||||
|
||||
const hopCount = computed(() => hops.value.length);
|
||||
|
||||
/** 每跳附 paper_id/PMID(真实 hop 字段见 API.md §7 链条容错) */
|
||||
function hopRef(h) {
|
||||
const refs = [];
|
||||
if (h.pmid) refs.push(`PMID:${h.pmid}`);
|
||||
else if (h.paper_id) refs.push(h.paper_id);
|
||||
return refs.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chain-group">
|
||||
<button class="chain-head" type="button" @click="emit('toggle')">
|
||||
<svg class="chain-chev" :class="{ open }" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
<span class="chain-id">{{ chain.chain_id || 'chain' }}</span>
|
||||
<span class="badge chain-badge"><span class="dot"></span>{{ hopCount }} {{ T.chainBadge }}</span>
|
||||
</button>
|
||||
<div v-if="open" class="chain-body">
|
||||
<div v-for="(h, i) in hops" :key="i" class="hop-item">
|
||||
<span class="hop-idx">{{ i + 1 }}</span>
|
||||
<span class="hop-text">
|
||||
<span class="hop-ent">{{ h.source || h.from_name }}</span>
|
||||
<span class="hop-rel">—[{{ h.relation || h.target_relation || '?' }}]→</span>
|
||||
<span class="hop-ent">{{ h.target || h.to_name }}</span>
|
||||
</span>
|
||||
<span v-if="hopRef(h)" class="hop-ref">{{ hopRef(h) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chain-group {
|
||||
margin-bottom: 6px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chain-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--pa-ink);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.chain-head:hover { background: var(--pa-surface-2); }
|
||||
.chain-chev {
|
||||
color: var(--pa-ink-subtle);
|
||||
transition: transform 0.25s var(--pa-ease);
|
||||
flex: none;
|
||||
}
|
||||
.chain-chev.open { transform: rotate(90deg); }
|
||||
.chain-id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.chain-badge {
|
||||
color: var(--pa-ink-subtle);
|
||||
background: var(--pa-surface-3);
|
||||
flex: none;
|
||||
}
|
||||
.chain-badge .dot { display: none; }
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.chain-body {
|
||||
border-top: 1px solid var(--pa-hairline);
|
||||
padding: 6px 10px 8px;
|
||||
animation: fade-slide 0.2s var(--pa-ease-out);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.hop-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hop-idx {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.hop-text { min-width: 0; word-break: break-word; }
|
||||
.hop-ent { color: var(--pa-ink-muted); font-family: var(--pa-font-mono); }
|
||||
.hop-rel { color: var(--pa-g-to); font-family: var(--pa-font-mono); margin: 0 2px; }
|
||||
.hop-ref { color: var(--pa-ink-subtle); font-size: 10.5px; font-family: var(--pa-font-mono); flex: none; }
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
<!-- ExtractionPane.vue —— 工作台 Tab3 提取任务(B3 归属)
|
||||
FR-EXTRACT-001~006:prog-item 列表、详情展开(stats + plan 仅 completed)、run-log mono 滚动贴底、
|
||||
上传(PDF ≤50MB 校验)、5s 轮询(存在 running 时;tab 切走由 BenchPanel 暂停)、离线降级。 -->
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { getRunLog, uploadPaper } from '../../api/extraction.js';
|
||||
import { STATUS_LABEL, T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import ProgressItem from './ProgressItem.vue';
|
||||
|
||||
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const bench = useBenchStore();
|
||||
const openId = ref(null);
|
||||
const fileInput = ref(null);
|
||||
const uploadMsg = ref('');
|
||||
const uploadOk = ref(false);
|
||||
const uploading = ref(false);
|
||||
const runLog = ref('');
|
||||
const logOpen = ref(false);
|
||||
const logError = ref(false);
|
||||
const logBox = ref(null);
|
||||
|
||||
const jobs = computed(() => bench.jobs);
|
||||
const detail = computed(() => (openId.value ? bench.getJobDetail(openId.value) : null));
|
||||
const detailLoading = computed(() => bench.detailLoading);
|
||||
const detailError = computed(() => bench.detailError);
|
||||
|
||||
function toggleJob(job) {
|
||||
if (openId.value === job.job_id) {
|
||||
openId.value = null;
|
||||
return;
|
||||
}
|
||||
openId.value = job.job_id;
|
||||
bench.loadJobDetail(job.job_id).catch(() => {});
|
||||
}
|
||||
|
||||
function statsRows(job) {
|
||||
const s = job?.stats || job || {};
|
||||
return [
|
||||
['stage', s.stage_count],
|
||||
['species', s.species_count],
|
||||
['figure_table', s.figure_table_caption_count],
|
||||
['figure_table_stage', s.figure_table_stage_count],
|
||||
].filter(([, v]) => v != null);
|
||||
}
|
||||
|
||||
async function fetchRunLog() {
|
||||
logError.value = false;
|
||||
try {
|
||||
const data = await getRunLog();
|
||||
runLog.value = data?.text || '';
|
||||
if (logOpen.value) await scrollLogToBottom();
|
||||
} catch {
|
||||
logError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollLogToBottom() {
|
||||
await nextTick();
|
||||
if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight;
|
||||
}
|
||||
|
||||
function toggleLog() {
|
||||
logOpen.value = !logOpen.value;
|
||||
if (logOpen.value && !runLog.value) fetchRunLog();
|
||||
else if (logOpen.value) scrollLogToBottom();
|
||||
}
|
||||
|
||||
// ---- 上传(FR-EXTRACT-004:仅 PDF,单文件 ≤50MB;失败显示明确文案) ----
|
||||
function pickFile() {
|
||||
uploadMsg.value = '';
|
||||
uploadOk.value = false;
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFileChange(e) {
|
||||
uploadMsg.value = '';
|
||||
uploadOk.value = false;
|
||||
const file = e.target.files?.[0];
|
||||
if (e.target) e.target.value = '';
|
||||
if (!file) return;
|
||||
const isPdf = file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '');
|
||||
if (!isPdf) {
|
||||
uploadMsg.value = T.extractUploadLimit;
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
uploadMsg.value = T.extractUploadLimit;
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
await uploadPaper(file);
|
||||
uploadOk.value = true;
|
||||
uploadMsg.value = '';
|
||||
await bench.loadJobs({ force: true }).catch(() => {});
|
||||
if (bench.isPolling()) bench.startPolling();
|
||||
} catch (err) {
|
||||
// 上游 message 已中文化,直接展示(PRD §2.2)
|
||||
uploadMsg.value = (T.extractUploadFail || '上传失败:') + (err?.message || err?.error?.message || String(err));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 轮询生命周期(FR-EXTRACT-005)----
|
||||
onMounted(() => {
|
||||
bench.loadJobs({ force: true })
|
||||
.then(() => {
|
||||
if (!bench.hasActiveJobs) return;
|
||||
if (!bench.isPolling()) bench.startPolling();
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => bench.hasActiveJobs,
|
||||
(has) => {
|
||||
if (has && !bench.isPolling() && !bench.pollPaused && bench.activeTab === 'extract') {
|
||||
bench.startPolling();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function retryLoad() {
|
||||
bench.loadJobs({ force: true }).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="extraction-pane">
|
||||
<!-- 离线降级(FR-EXTRACT-006,仅此 tab 受影响) -->
|
||||
<div v-if="bench.jobsError" class="offline-block">
|
||||
<span class="offline-dot"></span>
|
||||
<span class="offline-text">{{ T.extractionDown }}</span>
|
||||
<button class="retry-btn" @click="retryLoad">{{ T.retry }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 上传入口(FR-EXTRACT-004) -->
|
||||
<div class="upload-row">
|
||||
<button class="upload-btn" :disabled="uploading" @click="pickFile">{{ T.extractUpload }}</button>
|
||||
<span class="upload-limit">{{ T.extractUploadLimit }}</span>
|
||||
<input ref="fileInput" type="file" accept="application/pdf,.pdf" class="hidden-input" @change="onFileChange" />
|
||||
</div>
|
||||
<div v-if="uploading" class="upload-msg">{{ T.loading }}</div>
|
||||
<div v-else-if="uploadMsg" class="upload-msg err">{{ uploadMsg }}</div>
|
||||
<div v-else-if="uploadOk" class="upload-msg ok">{{ '✓' }}</div>
|
||||
|
||||
<!-- 运行日志(FR-EXTRACT-003,mono 滚动贴底) -->
|
||||
<button class="log-toggle" @click="toggleLog">
|
||||
<svg class="log-chev" :class="{ open: logOpen }" width="11" height="11" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
|
||||
<span>{{ BENCH_LABELS.extractRunLog }}</span>
|
||||
</button>
|
||||
<div v-if="logOpen" class="log-wrap">
|
||||
<div v-if="logError" class="offline-text">{{ T.upstreamError }}</div>
|
||||
<div v-else ref="logBox" class="run-log">{{ runLog || T.loading }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<div v-if="!jobs.length && !bench.jobsLoading" class="empty-note">{{ T.extractEmpty }}</div>
|
||||
|
||||
<!-- 任务列表(FR-EXTRACT-001) -->
|
||||
<div v-for="j in jobs" :key="j.job_id" class="job-block">
|
||||
<ProgressItem :job="j" :open="openId === j.job_id" @toggle="toggleJob(j)" />
|
||||
|
||||
<!-- 详情展开(FR-EXTRACT-002) -->
|
||||
<div v-if="openId === j.job_id" class="job-detail fade-slide">
|
||||
<div v-if="detailLoading" class="detail-loading">{{ T.loading }}</div>
|
||||
<div v-else-if="detailError || !detail" class="detail-error">{{ T.upstreamError }}
|
||||
<button class="retry-btn" @click="bench.loadJobDetail(j.job_id).catch(() => {})">{{ T.retry }}</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="detail-row status-row">
|
||||
<span class="dl-label">{{ T.labelGroup }}</span>
|
||||
<span class="badge" :class="detail.status"><span class="dot"></span>{{ STATUS_LABEL[detail.status] || detail.status }}</span>
|
||||
</div>
|
||||
<div v-if="detail.message" class="detail-row">
|
||||
<span class="dl-label">message</span>
|
||||
<span class="dl-value">{{ detail.message }}</span>
|
||||
</div>
|
||||
<div class="detail-stats">
|
||||
<div v-for="[k, v] in statsRows(detail)" :key="k" class="stat-item">
|
||||
<span class="stat-v">{{ v }}</span>
|
||||
<span class="stat-k">{{ k }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- plan 仅 completed 有(FR-EXTRACT-002:未完成置 null 不显示且不报错) -->
|
||||
<template v-if="detail.plan">
|
||||
<div class="dl-label">plan</div>
|
||||
<div class="plan-code">
|
||||
<pre>{{ JSON.stringify(detail.plan, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.extraction-pane { display: flex; flex-direction: column; }
|
||||
.offline-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(255, 92, 122, 0.08);
|
||||
border: 1px solid rgba(255, 92, 122, 0.35);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pa-error); flex: none; }
|
||||
.offline-text { font-size: 12px; color: var(--pa-error); }
|
||||
.retry-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
.retry-btn:hover { color: var(--pa-ink); }
|
||||
.upload-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.upload-btn {
|
||||
padding: 5px 14px;
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--pa-gradient);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.upload-btn:hover { box-shadow: 0 0 24px rgba(0, 212, 255, 0.4); transform: translateY(-1px); }
|
||||
.upload-btn:disabled { opacity: 0.45; cursor: not-allowed; box-shadow: none; transform: none; }
|
||||
.upload-limit { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.hidden-input { display: none; }
|
||||
.upload-msg { font-size: 11.5px; margin: 4px 0 8px; color: var(--pa-ink-subtle); }
|
||||
.upload-msg.err { color: var(--pa-error); word-break: break-word; }
|
||||
.upload-msg.ok { color: var(--pa-success); }
|
||||
.log-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.log-toggle:hover { color: var(--pa-ink); }
|
||||
.log-chev { transition: transform 0.25s var(--pa-ease); }
|
||||
.log-chev.open { transform: rotate(90deg); }
|
||||
.log-wrap { margin-bottom: 10px; }
|
||||
.run-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;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.job-block { margin-bottom: 4px; }
|
||||
.job-detail {
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
padding: 10px 12px;
|
||||
margin: -8px 0 14px;
|
||||
}
|
||||
.fade-slide { animation: fade-slide 0.2s var(--pa-ease-out); }
|
||||
.detail-loading, .detail-error { font-size: 12px; color: var(--pa-ink-subtle); padding: 4px 0; }
|
||||
.detail-error { color: var(--pa-error); display: flex; align-items: center; gap: 8px; }
|
||||
.detail-error .retry-btn { margin-left: 0; }
|
||||
.detail-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; font-size: 12px; }
|
||||
.dl-label { font-size: 11px; letter-spacing: 0.5px; color: var(--pa-ink-subtle); margin: 8px 0 5px; flex: none; }
|
||||
.status-row .dl-label { margin: 0; }
|
||||
.dl-value { color: var(--pa-ink-muted); word-break: break-word; }
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.badge.running, .badge.queued { color: var(--pa-warning); background: rgba(245, 184, 92, 0.12); }
|
||||
.badge.running .dot { animation: pulse 2s infinite; }
|
||||
.badge.completed { color: var(--pa-success); background: rgba(46, 211, 160, 0.12); }
|
||||
.badge.failed { color: var(--pa-error); background: rgba(255, 92, 122, 0.12); }
|
||||
.detail-stats { display: flex; gap: 6px; flex-wrap: wrap; margin: 6px 0; }
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
background: var(--pa-surface-2);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.stat-v { font-family: var(--pa-font-mono); font-size: 12.5px; color: var(--pa-ink); }
|
||||
.stat-k { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.plan-code {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
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: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.plan-code pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<!-- KgEntityDetail.vue —— 实体详情侧滑面板(B3 归属)
|
||||
FR-KG-003:点击节点 → 侧滑详情面板展示实体属性;Neo4j 可用时调 GET /api/kg/entities/{name}
|
||||
拉全量属性;不可用时仅显示消息内已有属性。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { getEntity } from '../../api/kg.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null }, // {id,label,type} 或消息 graph 节点
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
const extra = ref(null); // Neo4j 全量属性
|
||||
|
||||
const localProps = computed(() => {
|
||||
const n = props.node || {};
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(n)) {
|
||||
if (k === 'id' || k === 'label' || k === 'type') continue;
|
||||
if (v == null || v === '') continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const propEntries = computed(() => {
|
||||
const merged = { ...localProps.value, ...(extra.value?.properties || {}) };
|
||||
return Object.entries(merged).filter(([, v]) => v != null && v !== '');
|
||||
});
|
||||
|
||||
const nodeKey = computed(() => (props.node?.id != null ? String(props.node.id) : props.node?.label));
|
||||
|
||||
onMounted(() => {
|
||||
fetchExtra();
|
||||
});
|
||||
watch(nodeKey, () => {
|
||||
extra.value = null;
|
||||
failed.value = false;
|
||||
fetchExtra();
|
||||
});
|
||||
|
||||
async function fetchExtra() {
|
||||
const n = props.node;
|
||||
if (!n) return;
|
||||
const key = n.id != null ? String(n.id) : n.label;
|
||||
if (!key) return;
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
try {
|
||||
const data = await getEntity(key);
|
||||
extra.value = data;
|
||||
} catch {
|
||||
failed.value = true; // Neo4j 不可用:仍显示消息内已有属性(FR-KG-003)
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(v) {
|
||||
if (v && typeof v === 'object') {
|
||||
try { return JSON.stringify(v); } catch { return String(v); }
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-entity-detail">
|
||||
<div class="detail-head">
|
||||
<span class="detail-title">{{ T.kgEntityDetail }}</span>
|
||||
<button class="close-btn" type="button" :aria-label="BENCH_LABELS.close" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<div v-if="node" class="detail-body">
|
||||
<div class="ent-name">{{ node.label || node.id }}</div>
|
||||
<div class="ent-meta">
|
||||
<span class="ent-type">{{ node.type }}</span>
|
||||
<span v-if="node.id && node.id !== node.label" class="ent-id">{{ node.id }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="detail-note">{{ T.loading }}</div>
|
||||
<div class="prop-list">
|
||||
<div v-for="[k, v] in propEntries" :key="k" class="prop-row">
|
||||
<span class="prop-k">{{ k }}</span>
|
||||
<span class="prop-v">{{ fmt(v) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!propEntries.length && !loading" class="detail-note">—</div>
|
||||
</div>
|
||||
<div v-else class="detail-body">
|
||||
<div class="detail-note">{{ T.upstreamError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-entity-detail {
|
||||
position: fixed;
|
||||
top: var(--topbar-h);
|
||||
right: 0;
|
||||
bottom: var(--statusbar-h);
|
||||
width: 300px;
|
||||
max-width: 90vw;
|
||||
background: var(--pa-surface-1);
|
||||
border-left: 1px solid var(--pa-hairline-strong);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slide-in 0.3s var(--pa-ease-out);
|
||||
box-shadow: -8px 0 24px rgba(10, 10, 16, 0.35);
|
||||
}
|
||||
@keyframes slide-in {
|
||||
from { transform: translateX(24px); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
.detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.detail-title { font-size: 12px; font-weight: 600; }
|
||||
.close-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
}
|
||||
.close-btn:hover { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.detail-body { flex: 1; overflow-y: auto; padding: 14px; }
|
||||
.ent-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ent-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||
.ent-type {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
background: var(--pa-surface-3);
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.ent-id { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.detail-note { font-size: 12px; color: var(--pa-ink-subtle); padding: 8px 0; }
|
||||
.prop-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.prop-row { display: flex; gap: 8px; align-items: baseline; font-size: 12px; line-height: 1.5; }
|
||||
.prop-k {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 10.5px;
|
||||
color: var(--pa-g-to);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 1px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.prop-v { color: var(--pa-ink-muted); word-break: break-all; }
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<!-- KgGraphView.vue —— 手写 SVG 力导向图谱渲染器(B3 归属)
|
||||
FR-KG-001/002/006:节点圆点+标签、渐变边+关系名、type→稳定调色板、图例;
|
||||
缩放 0.3–3×、拖拽平移、双击复位、触摸双指缩放/单指拖拽。
|
||||
节点数超过上限时由 KgPane 决定降级列表视图(本组件仍可渲染,由父层把控)。 -->
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { forceLayout, buildPalette } from './graphLayout.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const props = defineProps({
|
||||
nodes: { type: Array, default: () => [] },
|
||||
edges: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 320 },
|
||||
});
|
||||
const emit = defineEmits(['node-click']);
|
||||
|
||||
const viewW = ref(800);
|
||||
const viewH = ref(600);
|
||||
const scale = ref(1);
|
||||
const tx = ref(0);
|
||||
const ty = ref(0);
|
||||
const container = ref(null);
|
||||
|
||||
const layout = computed(() =>
|
||||
forceLayout({ nodes: props.nodes, edges: props.edges }, { width: viewW.value, height: viewH.value }),
|
||||
);
|
||||
const colorOf = computed(() => {
|
||||
const types = (props.nodes || []).map((n) => n?.type || 'unknown');
|
||||
return buildPalette(types);
|
||||
});
|
||||
const renderEdges = computed(() => layout.value.edges);
|
||||
const renderNodes = computed(() => layout.value.nodes);
|
||||
const nodeById = computed(() => {
|
||||
const m = new Map();
|
||||
for (const n of props.nodes || []) m.set(String(n.id), n);
|
||||
return m;
|
||||
});
|
||||
const viewBox = computed(() => `0 0 ${viewW.value} ${viewH.value}`);
|
||||
|
||||
// 图例:type → 颜色(按名称稳定排序)
|
||||
const legendItems = computed(() => {
|
||||
const counts = new Map();
|
||||
for (const n of props.nodes || []) {
|
||||
const t = n.type || 'unknown';
|
||||
counts.set(t, (counts.get(t) || 0) + 1);
|
||||
}
|
||||
return [...counts.keys()].sort().map((t) => ({ type: t, color: colorOf.value.get(t), count: counts.get(t) }));
|
||||
});
|
||||
|
||||
const transform = computed(() => `translate(${tx.value}px, ${ty.value}px) scale(${scale.value})`);
|
||||
|
||||
const clampScale = (v) => Math.min(3, Math.max(0.3, v));
|
||||
|
||||
// ---- 手势(指针事件统一处理鼠标与触摸)----
|
||||
const pointers = new Map();
|
||||
const pan = ref(null); // {x, y, tx, ty}
|
||||
|
||||
function setTransform(ns, nx, ny) {
|
||||
scale.value = clampScale(ns);
|
||||
tx.value = nx;
|
||||
ty.value = ny;
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
container.value?.setPointerCapture?.(e.pointerId);
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size === 2) {
|
||||
// 双指开始:记录初始距离与变换
|
||||
const [p1, p2] = [...pointers.values()];
|
||||
pan.value = {
|
||||
x: (p1.x + p2.x) / 2,
|
||||
y: (p1.y + p2.y) / 2,
|
||||
tx: tx.value,
|
||||
ty: ty.value,
|
||||
dist: Math.hypot(p1.x - p2.x, p1.y - p2.y),
|
||||
scale: scale.value,
|
||||
};
|
||||
} else if (pointers.size === 1) {
|
||||
pan.value = { x: e.clientX, y: e.clientY, tx: tx.value, ty: ty.value, dist: null, scale: scale.value };
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.size >= 2) {
|
||||
// 双指:缩放 + 平移(FR-KG-006)
|
||||
const [p1, p2] = [...pointers.values()];
|
||||
const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
|
||||
if (pan.value && pan.value.dist != null && pan.value.dist > 0) {
|
||||
const ns = clampScale(pan.value.scale * (dist / pan.value.dist));
|
||||
const cx = (p1.x + p2.x) / 2;
|
||||
const cy = (p1.y + p2.y) / 2;
|
||||
const nx = pan.value.tx + (cx - pan.value.x);
|
||||
const ny = pan.value.ty + (cy - pan.value.y);
|
||||
setTransform(ns, nx, ny);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pointers.size === 1 && pan.value) {
|
||||
tx.value = pan.value.tx + (e.clientX - pan.value.x);
|
||||
ty.value = pan.value.ty + (e.clientY - pan.value.y);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size === 1) {
|
||||
const [p] = [...pointers.values()];
|
||||
pan.value = { x: p.x, y: p.y, tx: tx.value, ty: ty.value, dist: null, scale: scale.value };
|
||||
} else if (pointers.size === 0) {
|
||||
pan.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚轮缩放(以鼠标位置为锚点)
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
const rect = container.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
|
||||
const ns = clampScale(scale.value * factor);
|
||||
const px = e.clientX - rect.left;
|
||||
const py = e.clientY - rect.top;
|
||||
const nx = px - ((px - tx.value) * ns) / scale.value;
|
||||
const ny = py - ((py - ty.value) * ns) / scale.value;
|
||||
setTransform(ns, nx, ny);
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
setTransform(1, 0, 0);
|
||||
}
|
||||
|
||||
function onClickNode(n) {
|
||||
emit('node-click', nodeById.value.get(n.id) || { id: n.id, label: n.label, type: n.type });
|
||||
}
|
||||
|
||||
// 挂载时测量容器宽度以设定视口宽
|
||||
let ro = null;
|
||||
onMounted(() => {
|
||||
const el = container.value;
|
||||
if (!el) return;
|
||||
const apply = () => {
|
||||
const w = el.clientWidth || 348;
|
||||
viewW.value = Math.max(320, Math.round(w * 2));
|
||||
viewH.value = Math.max(300, Math.round(props.height * 2));
|
||||
};
|
||||
apply();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
ro = new ResizeObserver(apply);
|
||||
ro.observe(el);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
ro?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-graph">
|
||||
<div
|
||||
ref="container"
|
||||
class="graph-canvas"
|
||||
:style="{ height: height + 'px' }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@wheel.prevent="onWheel"
|
||||
@dblclick="resetView"
|
||||
>
|
||||
<svg class="graph-svg" :viewBox="viewBox" preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id="pa-edge-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5E6AD2" />
|
||||
<stop offset="100%" stop-color="#00D4FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g :style="{ transform: transform, transformOrigin: '0 0' }">
|
||||
<g v-for="(e, i) in renderEdges" :key="'e' + i" class="edge-g">
|
||||
<line
|
||||
:x1="renderNodes.find(n => n.id === e.source)?.x ?? 0"
|
||||
:y1="renderNodes.find(n => n.id === e.source)?.y ?? 0"
|
||||
:x2="renderNodes.find(n => n.id === e.target)?.x ?? 0"
|
||||
:y2="renderNodes.find(n => n.id === e.target)?.y ?? 0"
|
||||
stroke="url(#pa-edge-grad)"
|
||||
stroke-width="1.2"
|
||||
stroke-opacity="0.65"
|
||||
/>
|
||||
<text
|
||||
v-if="e.relation"
|
||||
class="edge-label"
|
||||
:x="((renderNodes.find(n => n.id === e.source)?.x ?? 0) + (renderNodes.find(n => n.id === e.target)?.x ?? 0)) / 2"
|
||||
:y="((renderNodes.find(n => n.id === e.source)?.y ?? 0) + (renderNodes.find(n => n.id === e.target)?.y ?? 0)) / 2"
|
||||
>{{ e.relation }}</text>
|
||||
</g>
|
||||
<g v-for="n in renderNodes" :key="'n' + n.id" class="node-g" @click="onClickNode(n)">
|
||||
<circle :cx="n.x" :cy="n.y" :r="7" :fill="colorOf.get(n.type)" stroke="var(--pa-surface-1)" stroke-width="1.5" />
|
||||
<text :x="n.x" :y="n.y + 3.2" text-anchor="middle" class="node-label">{{ n.label }}</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="graph-tools">
|
||||
<button class="reset-btn" type="button" :title="BENCH_LABELS.resetView" @click="resetView">⟲</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="legendItems.length" class="kg-legend">
|
||||
<span v-for="l in legendItems" :key="l.type" class="lg-item">
|
||||
<span class="lg-dot" :style="{ background: l.color }"></span>
|
||||
<span class="lg-type">{{ l.type }}</span>
|
||||
<span class="lg-count">{{ l.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-graph { display: flex; flex-direction: column; }
|
||||
.graph-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
background: var(--pa-code-bg);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
.graph-canvas:active { cursor: grabbing; }
|
||||
.graph-svg { width: 100%; height: 100%; display: block; }
|
||||
.graph-svg :deep(g) { transition: transform 0.08s linear; }
|
||||
.node-g { cursor: pointer; }
|
||||
.node-g circle { transition: fill 0.15s var(--pa-ease); }
|
||||
.node-g:hover circle { stroke: var(--pa-ink); stroke-width: 2; }
|
||||
.node-label {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
fill: var(--pa-ink);
|
||||
paint-order: stroke;
|
||||
stroke: var(--pa-code-bg);
|
||||
stroke-width: 3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edge-label {
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 9px;
|
||||
fill: var(--pa-ink-subtle);
|
||||
paint-order: stroke;
|
||||
stroke: var(--pa-code-bg);
|
||||
stroke-width: 2.5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.graph-tools { position: absolute; top: 8px; right: 8px; display: flex; gap: 6px; }
|
||||
.reset-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-1);
|
||||
color: var(--pa-ink-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.reset-btn:hover { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.kg-legend { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.lg-item { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--pa-ink-muted); }
|
||||
.lg-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.lg-type { font-family: var(--pa-font-ui); }
|
||||
.lg-count { font-family: var(--pa-font-mono); color: var(--pa-ink-subtle); font-size: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<!-- KgPane.vue —— 工作台 Tab4 知识图谱(B3 归属)
|
||||
FR-KG-001~008:答案图谱渲染(graph 块)、节点着色图例、节点详情侧滑、实体搜索、
|
||||
邻域深度 1/2、离线降级(答案图谱仍可用)、空态、>50 节点(移动端 >30)降级列表视图。 -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { searchEntities, getNeighborhood } from '../../api/kg.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
import KgGraphView from './KgGraphView.vue';
|
||||
import KgEntityDetail from './KgEntityDetail.vue';
|
||||
|
||||
const bench = useBenchStore();
|
||||
|
||||
const query = ref('');
|
||||
const results = ref([]);
|
||||
const searching = ref(false);
|
||||
const searchError = ref(false);
|
||||
const detailNode = ref(null);
|
||||
const graphData = ref(null); // 当前渲染的 {nodes, edges}(答案图谱或邻域结果)
|
||||
const graphSource = ref(null); // 'answer' | 'neighborhood'
|
||||
const listModeForced = ref(false);
|
||||
|
||||
const isMobile = () => (typeof window !== 'undefined' ? window.innerWidth < 768 : false);
|
||||
|
||||
// 答案图谱(永远可用,FR-KG-007)
|
||||
const answerGraph = computed(() => {
|
||||
const g = bench.currentGraph;
|
||||
if (!g) return null;
|
||||
const nodes = Array.isArray(g.nodes) ? g.nodes : [];
|
||||
const edges = Array.isArray(g.edges) ? g.edges : [];
|
||||
return nodes.length ? { nodes, edges } : null;
|
||||
});
|
||||
|
||||
// 节点上限:桌面 50 / 移动端 30(FR-KG-001、FR-RESP-012)
|
||||
const nodeLimit = computed(() => bench.kgNodeLimit || 50);
|
||||
const isDegradedList = computed(() => {
|
||||
if (listModeForced.value) return true;
|
||||
const g = graphData.value;
|
||||
return !!g && g.nodes.length > nodeLimit.value;
|
||||
});
|
||||
|
||||
const depth = ref(1);
|
||||
const neighborhoodMeta = ref(null);
|
||||
|
||||
const itemsWithEdges = computed(() => {
|
||||
const g = graphData.value;
|
||||
if (!g) return [];
|
||||
const edges = g.edges || [];
|
||||
return g.nodes.map((n) => {
|
||||
const related = edges.filter((e) => e.source === n.id || e.target === n.id);
|
||||
return { ...n, edgeCount: related.length, relations: [...new Set(related.map((e) => e.relation || e.label))] };
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (isMobile()) bench.setKgNodeLimit(30);
|
||||
// 答案图谱为空时,若 Neo4j 可用可自由浏览;状态探测放 kg tab 激活时
|
||||
if (bench.kgStatus == null) {
|
||||
bench.checkKgStatus().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 切会话/切消息后重置浏览态(答案图谱跟随变化)
|
||||
watch(answerGraph, (g) => {
|
||||
if (g) {
|
||||
graphData.value = g;
|
||||
graphSource.value = 'answer';
|
||||
listModeForced.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 实体搜索(FR-KG-004,精确/前缀匹配,无全文索引)----
|
||||
async function onSearch() {
|
||||
const q = query.value.trim();
|
||||
if (!q) return;
|
||||
if (!bench.kgReady) {
|
||||
searchError.value = true;
|
||||
return;
|
||||
}
|
||||
searching.value = true;
|
||||
searchError.value = false;
|
||||
try {
|
||||
const data = await searchEntities(q, 20);
|
||||
results.value = data?.results || [];
|
||||
} catch {
|
||||
searchError.value = true;
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 邻域展开(FR-KG-005,depth 1/2 默认 1;两跳约 13s 需提示较慢)----
|
||||
const loadingNeighborhood = ref(false);
|
||||
const neighborhoodError = ref(false);
|
||||
const slowNotice = ref(false);
|
||||
|
||||
async function loadNeighborhood(id, name) {
|
||||
if (!bench.kgReady) {
|
||||
neighborhoodError.value = true;
|
||||
return;
|
||||
}
|
||||
loadingNeighborhood.value = true;
|
||||
neighborhoodError.value = false;
|
||||
slowNotice.value = false;
|
||||
try {
|
||||
const data = await getNeighborhood(id, { depth: depth.value, limit: 50 });
|
||||
graphData.value = { nodes: data.nodes || [], edges: data.edges || [] };
|
||||
graphSource.value = 'neighborhood';
|
||||
neighborhoodMeta.value = data;
|
||||
results.value = [];
|
||||
slowNotice.value = depth.value === 2;
|
||||
} catch {
|
||||
neighborhoodError.value = true;
|
||||
} finally {
|
||||
loadingNeighborhood.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectResult(r) {
|
||||
detailNode.value = null;
|
||||
loadNeighborhood(r.id, r.name);
|
||||
}
|
||||
|
||||
function onNodeClick(n) {
|
||||
detailNode.value = n;
|
||||
}
|
||||
|
||||
function onDepthChange(v) {
|
||||
depth.value = v;
|
||||
const meta = neighborhoodMeta.value;
|
||||
if (meta?.center) loadNeighborhood(meta.center, meta.center_name);
|
||||
}
|
||||
|
||||
// 离线态:Neo4j 不可用 → 搜索与邻域禁用(FR-KG-007)
|
||||
const kgDown = computed(() => !bench.kgReady && bench.kgStatus != null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kg-pane">
|
||||
<!-- 离线降级(FR-KG-007:答案图谱仍可用) -->
|
||||
<div v-if="kgDown" class="offline-block">
|
||||
<span class="offline-dot"></span>
|
||||
<span class="offline-text">{{ T.kgDown }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 答案图谱 / 邻域结果 -->
|
||||
<template v-if="graphData && graphData.nodes.length">
|
||||
<div v-if="isDegradedList" class="degrade-note">
|
||||
{{ BENCH_LABELS.kgDegradedListPrefix }} {{ nodeLimit }}{{ BENCH_LABELS.kgDegradedListSuffix }}
|
||||
</div>
|
||||
<template v-if="isDegradedList">
|
||||
<div class="node-list">
|
||||
<div v-for="n in itemsWithEdges" :key="n.id" class="node-list-item" @click="onNodeClick(n)">
|
||||
<span class="nl-name">{{ n.label }}</span>
|
||||
<span class="nl-meta">
|
||||
<span class="nl-type">{{ n.type }}</span>
|
||||
<span v-if="n.edgeCount" class="nl-edges">{{ n.edgeCount }} {{ BENCH_LABELS.kgRelationUnit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<KgGraphView :nodes="graphData.nodes" :edges="graphData.edges" :height="320" @node-click="onNodeClick" />
|
||||
</template>
|
||||
<div v-if="graphSource === 'neighborhood'" class="nb-meta">
|
||||
<span class="nb-center">{{ neighborhoodMeta?.center_name }}</span>
|
||||
<span class="nb-count">{{ graphData.nodes.length }} {{ BENCH_LABELS.kgNodeUnit }} · {{ (graphData.edges || []).length }} {{ BENCH_LABELS.kgEdgeUnit }}</span>
|
||||
<span v-if="slowNotice" class="nb-slow">{{ BENCH_LABELS.kgDepth2NeighborhoodSlow }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空态(FR-KG-008,不显示空画布) -->
|
||||
<div v-else-if="!loadingNeighborhood" class="empty-note">{{ T.kgEmpty }}</div>
|
||||
|
||||
<!-- 自由浏览区(FR-KG-004/005) -->
|
||||
<div class="kg-browse">
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="query"
|
||||
class="search-box"
|
||||
type="text"
|
||||
:placeholder="T.kgSearchPlaceholder"
|
||||
:disabled="kgDown"
|
||||
@keydown.enter="onSearch"
|
||||
/>
|
||||
<button class="search-btn" :disabled="kgDown || searching" @click="onSearch">
|
||||
{{ searching ? T.loading : BENCH_LABELS.kgSearch }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchError" class="offline-text">{{ T.kgDown }}</div>
|
||||
<div v-if="results.length" class="search-results">
|
||||
<div v-for="r in results" :key="r.id" class="result-item" @click="onSelectResult(r)">
|
||||
<span class="rs-name">{{ r.name }}</span>
|
||||
<span class="rs-meta">{{ r.type }} · {{ r.support_count ?? 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="neighborhoodError" class="offline-text">{{ T.kgDown }}</div>
|
||||
|
||||
<!-- 邻域深度选择(默认 1,FR-KG-005) -->
|
||||
<div v-if="neighborhoodMeta" class="depth-row">
|
||||
<span class="depth-label">{{ T.kgDepth }}</span>
|
||||
<button :class="['chip', { on: depth === 1 }]" @click="onDepthChange(1)">1</button>
|
||||
<button :class="['chip', { on: depth === 2 }]" @click="onDepthChange(2)">2</button>
|
||||
<span class="depth-hint">{{ BENCH_LABELS.kgDepth2SlowHint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实体详情侧滑(FR-KG-003) -->
|
||||
<KgEntityDetail v-if="detailNode" :node="detailNode" @close="detailNode = null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kg-pane { display: flex; flex-direction: column; gap: 10px; }
|
||||
.offline-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 92, 122, 0.08);
|
||||
border: 1px solid rgba(255, 92, 122, 0.35);
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 12px;
|
||||
color: var(--pa-error);
|
||||
}
|
||||
.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pa-error); flex: none; }
|
||||
.offline-text { font-size: 12px; color: var(--pa-error); }
|
||||
.degrade-note { font-size: 11.5px; color: var(--pa-ink-subtle); }
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.node-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.node-list-item:hover { background: var(--pa-surface-2); }
|
||||
.nl-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.nl-meta { display: flex; gap: 6px; align-items: center; flex: none; }
|
||||
.nl-type { font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.nl-edges { font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-muted); }
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.kg-browse { display: flex; flex-direction: column; gap: 8px; }
|
||||
.search-row { display: flex; gap: 6px; }
|
||||
.search-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.search-box:focus { outline: none; border-color: var(--pa-g-to); box-shadow: 0 0 14px rgba(0, 212, 255, 0.18); }
|
||||
.search-box:disabled { opacity: 0.5; }
|
||||
.search-btn {
|
||||
flex: none;
|
||||
padding: 0 14px;
|
||||
height: 34px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: none;
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.search-results {
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.result-item:hover { background: var(--pa-surface-2); }
|
||||
.rs-name {
|
||||
font-family: var(--pa-font-mono);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rs-meta { flex: none; font-family: var(--pa-font-mono); font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.depth-row { display: flex; align-items: center; gap: 6px; }
|
||||
.depth-label { font-size: 11.5px; color: var(--pa-ink-subtle); margin-right: 2px; }
|
||||
.chip {
|
||||
width: 26px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.chip:hover { color: var(--pa-ink); }
|
||||
.chip.on { color: #fff; background: var(--pa-g-from); border-color: transparent; }
|
||||
.depth-hint { font-size: 10.5px; color: var(--pa-ink-subtle); }
|
||||
.nb-meta { display: flex; gap: 8px; align-items: baseline; font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.nb-center { font-family: var(--pa-font-mono); color: var(--pa-ink-muted); }
|
||||
.nb-count { font-family: var(--pa-font-mono); }
|
||||
.nb-slow { color: var(--pa-warning); }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<!-- ProgressItem.vue —— 提取任务进度条(B3 归属)
|
||||
FR-EXTRACT-001:prog-item 视觉——任务名 + mono 百分比 + 6px 轨道 + 渐变填充 + sweep 扫光;
|
||||
100% 转 success 停扫光;failed 显示错误态不显示进度条。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { STATUS_LABEL } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
job: { type: Object, required: true },
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const pct = computed(() => props.job?.progress_pct);
|
||||
const isFailed = computed(() => props.job?.status === 'failed');
|
||||
const isFull = computed(() => pct.value != null && pct.value >= 100);
|
||||
|
||||
function toggle() {
|
||||
emit('toggle');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prog-item" :class="{ failed: isFailed, clickable: true }">
|
||||
<div class="pi-head" role="button" tabindex="0" @click="toggle" @keydown.enter="toggle" @keydown.space.prevent="toggle">
|
||||
<span class="pi-name" :title="job.title">{{ job.title }}</span>
|
||||
<span v-if="isFailed" class="pi-pct err">{{ STATUS_LABEL.error }}</span>
|
||||
<span v-else-if="pct != null" class="pi-pct" :class="{ ok: isFull }">{{ pct }}%</span>
|
||||
</div>
|
||||
<div v-if="!isFailed && pct != null" class="prog-track">
|
||||
<div class="prog-fill" :class="{ full: isFull }" :style="{ width: Math.min(100, Math.max(0, pct)) + '%' }"></div>
|
||||
</div>
|
||||
<div v-if="isFailed && job.message" class="pi-errmsg">{{ job.message }}</div>
|
||||
<div v-if="job.message && !isFailed" class="pi-msg">{{ job.message }}</div>
|
||||
<div v-if="open" class="pi-chev-row">
|
||||
<svg class="pi-chev open" width="12" height="12" 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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prog-item { margin-bottom: 16px; }
|
||||
.prog-item.failed { padding: 8px 10px; border: 1px solid var(--pa-hairline); border-radius: var(--pa-r-md); background: var(--pa-surface-1); }
|
||||
.pi-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pi-head:focus-visible { outline: 2px solid rgba(94, 106, 210, 0.5); outline-offset: 1px; }
|
||||
.pi-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pi-pct { font-family: var(--pa-font-mono); font-size: 12px; color: var(--pa-ink-muted); flex: none; }
|
||||
.pi-pct.ok { color: var(--pa-success); }
|
||||
.pi-pct.err { color: var(--pa-error); font-weight: 600; }
|
||||
.prog-track { height: 6px; border-radius: 3px; background: var(--pa-surface-3); overflow: hidden; position: relative; }
|
||||
.prog-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--pa-gradient);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: width 0.5s var(--pa-ease);
|
||||
}
|
||||
.prog-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 200%;
|
||||
background: linear-gradient(100deg, transparent 20%, rgba(255, 255, 255, 0.45) 50%, transparent 80%);
|
||||
will-change: transform;
|
||||
animation: sweep 2.4s infinite;
|
||||
}
|
||||
.prog-fill.full { background: var(--pa-success); }
|
||||
.prog-fill.full::after { animation: none; }
|
||||
.pi-msg { font-size: 11.5px; color: var(--pa-ink-subtle); margin-top: 5px; }
|
||||
.pi-errmsg {
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-error);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<!-- RetrievalPane.vue —— 工作台 Tab1 检索过程(B3 归属)
|
||||
FR-RETRIEVAL-001~006:阶段卡列表(每 SSE progress 事件一张)、相位筛选 chips、
|
||||
retrieval_plan 信息块、运行中 shimmer、空态。 -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { getActivePinia } from 'pinia';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { TOOL_FILTERS, PHASE_LABEL, PHASE_COLOR_VAR, PHASE_DESC, T } from '../../i18n/labels.js';
|
||||
import { BENCH_LABELS } from './benchLabels.js';
|
||||
|
||||
const bench = useBenchStore();
|
||||
|
||||
const phaseIcons = { planning: '◇', retrieval: '⌕', evidence: '⚖', generation: '✎', done: '✓' };
|
||||
|
||||
/** 流是否已终态(done/error/停止)——终态后所有阶段卡必须收口为 done,
|
||||
* 否则非 done 相位的卡片会永远停留在「运行中」+ shimmer(2026-08-25 修复)。
|
||||
* 只认 completed 事件收口是不够的:上游 completed 只影响它自己那张卡。 */
|
||||
const streamFinished = computed(() => {
|
||||
const msg = bench.currentAssistantMessage;
|
||||
if (!msg) return false;
|
||||
try {
|
||||
const chat = getActivePinia()?._s.get('chat');
|
||||
return !!chat && !chat.busy && msg.status !== 'streaming';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 每张卡的状态:流已终态 → 全部 done;否则仅完成相位是 done,其余 running(FR-RETRIEVAL-004)
|
||||
const cardStates = computed(() => {
|
||||
const byStage = new Map();
|
||||
const order = [];
|
||||
for (const e of bench.stageEvents) {
|
||||
if (!byStage.has(e.stage)) {
|
||||
byStage.set(e.stage, { count: 0, last: e, first: e });
|
||||
order.push(e.stage);
|
||||
}
|
||||
const g = byStage.get(e.stage);
|
||||
g.count += 1;
|
||||
g.last = e;
|
||||
}
|
||||
const isDone = (g) => streamFinished.value || g.last.phase === 'done';
|
||||
return order.map((stage) => {
|
||||
const g = byStage.get(stage);
|
||||
return { stage, group: g, status: isDone(g) ? 'done' : 'running' };
|
||||
});
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const f = bench.phaseFilter;
|
||||
return f === 'all'
|
||||
? cardStates.value
|
||||
: cardStates.value.filter((c) => c.group.last.phase === f);
|
||||
});
|
||||
|
||||
const plan = computed(() => {
|
||||
const msg = bench.currentAssistantMessage;
|
||||
if (!msg) return null;
|
||||
return msg.retrieval_plan || msg.raw?.retrieval_plan || null;
|
||||
});
|
||||
|
||||
// 展开状态本地管理:运行中的卡默认展开,完成后自动折叠(FR-RETRIEVAL-003)
|
||||
const open = ref({});
|
||||
function isOpen(stage) {
|
||||
if (stage in open.value) return open.value[stage];
|
||||
const c = cardStates.value.find((x) => x.stage === stage);
|
||||
return c ? c.status === 'running' : false;
|
||||
}
|
||||
function toggle(stage) {
|
||||
open.value = { ...open.value, [stage]: !isOpen(stage) };
|
||||
}
|
||||
|
||||
// 耗时 mono(mm:ss 或 s,未完成带 +)
|
||||
function fmtDur(ms) {
|
||||
if (!ms || !isFinite(ms) || ms <= 0) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
const base = m > 0 ? `${m}m${String(r).padStart(2, '0')}s` : `${r}s`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function detailLines(details) {
|
||||
if (!details) return [];
|
||||
return Object.entries(details).map(([k, v]) => {
|
||||
let val = v;
|
||||
if (Array.isArray(v)) val = v.join(', ');
|
||||
else if (v && typeof v === 'object') {
|
||||
try { val = JSON.stringify(v); } catch { val = String(v); }
|
||||
}
|
||||
return `${k}: ${val}`;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="retrieval-pane">
|
||||
<!-- 检索计划信息块(FR-RETRIEVAL-005,无则整块不显示) -->
|
||||
<div v-if="plan" class="preview-block plan-block">
|
||||
<div class="pb-title">{{ T.retrievalPlanTitle }}</div>
|
||||
<div class="plan-rows">
|
||||
<div v-for="(v, k) in plan" :key="k" class="plan-row">
|
||||
<span class="plan-k">{{ k }}</span>
|
||||
<span class="plan-v">{{ Array.isArray(v) ? v.join('、') : String(v) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相位筛选 chips(FR-RETRIEVAL-002) -->
|
||||
<div class="filter-chips">
|
||||
<button
|
||||
v-for="f in TOOL_FILTERS"
|
||||
:key="f.key"
|
||||
:class="['chip', { on: bench.phaseFilter === f.key }]"
|
||||
@click="bench.setPhaseFilter(f.key)"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态(FR-RETRIEVAL-006,不显示假卡片) -->
|
||||
<div v-if="!filtered.length" class="empty-note">{{ T.retrievalEmpty }}</div>
|
||||
|
||||
<!-- 阶段卡(FR-RETRIEVAL-001) -->
|
||||
<div
|
||||
v-for="c in filtered"
|
||||
:key="c.stage"
|
||||
:class="['tool-card', c.status, { open: isOpen(c.stage) }]"
|
||||
>
|
||||
<div class="tool-head" @click="toggle(c.stage)">
|
||||
<span class="t-ico" :style="{ color: `var(${PHASE_COLOR_VAR[c.group.last.phase]})` }">
|
||||
{{ phaseIcons[c.group.last.phase] || '•' }}
|
||||
</span>
|
||||
<span class="t-name" :title="c.group.last.label">{{ c.group.last.label }}</span>
|
||||
<span class="badge" :class="c.status"><span class="dot"></span>{{ c.status === 'running' ? T.stepRunning : T.stepDone }}</span>
|
||||
<span v-if="c.group.last.ts" class="t-dur">{{ fmtDur(c.group.last.ts - c.group.first.ts) }}</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="isOpen(c.stage)" class="tool-body">
|
||||
<div class="tb-label">{{ BENCH_LABELS.stageLabel }}</div>
|
||||
<div class="tb-phase">
|
||||
<span class="phase-dot" :style="{ background: `var(${PHASE_COLOR_VAR[c.group.last.phase]})` }"></span>
|
||||
{{ PHASE_LABEL[c.group.last.phase] || c.group.last.phase }} · {{ PHASE_DESC[c.group.last.phase] || '' }}
|
||||
</div>
|
||||
<template v-if="detailLines(c.group.last.details).length">
|
||||
<div class="tb-label">{{ BENCH_LABELS.paramsLabel }}</div>
|
||||
<div class="tb-params"><div v-for="l in detailLines(c.group.last.details)" :key="l">{{ l }}</div></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.retrieval-pane { display: flex; flex-direction: column; }
|
||||
.plan-block { margin-bottom: 12px; }
|
||||
.plan-rows { display: flex; flex-direction: column; gap: 5px; }
|
||||
.plan-row { display: flex; gap: 8px; font-size: 12px; line-height: 1.5; }
|
||||
.plan-k {
|
||||
flex: none;
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--pa-g-to);
|
||||
background: var(--pa-code-bg);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-sm);
|
||||
padding: 1px 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.plan-v { color: var(--pa-ink-muted); min-width: 0; word-break: break-word; }
|
||||
.filter-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.chip {
|
||||
padding: 3px 11px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.chip:hover { color: var(--pa-ink); }
|
||||
.chip.on { color: #fff; background: var(--pa-g-from); border-color: transparent; }
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.tool-card {
|
||||
margin: 0 0 10px;
|
||||
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: 8px 11px;
|
||||
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;
|
||||
background: color-mix(in srgb, var(--pa-g-from) 16%, transparent);
|
||||
color: var(--pa-g-to);
|
||||
}
|
||||
.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); }
|
||||
.tool-body {
|
||||
padding: 0 11px 10px;
|
||||
border-top: 1px solid var(--pa-hairline);
|
||||
padding-top: 11px;
|
||||
animation: fade-slide 0.2s var(--pa-ease-out);
|
||||
}
|
||||
.tb-label { font-size: 11px; letter-spacing: 0.5px; color: var(--pa-ink-subtle); margin: 8px 0 5px; }
|
||||
.tb-phase { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--pa-ink-muted); }
|
||||
.phase-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.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;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
/* 徽章(复用 B1 .badge;此处补 bench 内联兜底) */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
flex: none;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.badge.running { color: var(--pa-warning); background: rgba(245, 184, 92, 0.12); }
|
||||
.badge.running .dot { animation: pulse 2s infinite; }
|
||||
.badge.done { color: var(--pa-success); background: rgba(46, 211, 160, 0.12); }
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- SourceRow.vue —— 文献来源单条(B3 归属)
|
||||
FR-SOURCE-001/002/003:mono 列表视觉、字段缺失不渲染占位、外链优先级 url>doi>pmid>pmcid。 -->
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: Object, required: true },
|
||||
index: { type: Number, default: 0 },
|
||||
count: { type: Number, default: null }, // 会话级聚合的出现次数
|
||||
highlighted: { type: Boolean, default: false }, // FR-CHAT-015:引用 chip 定位高亮
|
||||
});
|
||||
|
||||
const expanded = ref(false);
|
||||
const rootEl = ref(null);
|
||||
|
||||
// 高亮激活时滚动到可视区(FR-CHAT-015 滚动定位)
|
||||
watch(
|
||||
() => props.highlighted,
|
||||
(on) => {
|
||||
if (on && rootEl.value) {
|
||||
rootEl.value.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** 外链优先级:url > doi > pmid > pmcid;均无 → null(不显示链接图标,FR-SOURCE-003) */
|
||||
const linkHref = computed(() => {
|
||||
const s = props.source || {};
|
||||
if (s.url) return s.url;
|
||||
if (s.doi) return `https://doi.org/${s.doi}`;
|
||||
if (s.pmid) return `https://pubmed.ncbi.nlm.nih.gov/${s.pmid}/`;
|
||||
if (s.pmcid) return `https://www.ncbi.nlm.nih.gov/pmc/articles/${s.pmcid}/`;
|
||||
return null;
|
||||
});
|
||||
|
||||
const metaParts = computed(() => {
|
||||
const s = props.source || {};
|
||||
const parts = [];
|
||||
if (s.pmid) parts.push(`PMID:${s.pmid}`);
|
||||
if (s.doi) parts.push(`DOI:${s.doi}`);
|
||||
if (s.score != null) parts.push(`${T.score} ${Number(s.score).toFixed(2)}`);
|
||||
if (props.count != null && props.count > 1) parts.push(`×${props.count}`);
|
||||
return parts;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="ft-item source-row" :class="{ 'hl-active': highlighted }">
|
||||
<span class="ft-ico idx">{{ index + 1 }}</span>
|
||||
<div class="src-main">
|
||||
<div class="src-line1">
|
||||
<span class="ft-name" :title="source.title || ''">{{ source.title }}</span>
|
||||
</div>
|
||||
<div class="ft-size src-meta">{{ metaParts.join(' · ') }}</div>
|
||||
<div v-if="source.snippet" class="src-snippet" :class="{ clamp: !expanded }" @click="expanded = !expanded">
|
||||
{{ source.snippet }}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="linkHref"
|
||||
class="ft-dl"
|
||||
:href="linkHref"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
:title="linkHref"
|
||||
@click.stop
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14L21 3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-row {
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
font-family: var(--pa-font-mono);
|
||||
font-size: 12.5px;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.source-row:hover { background: var(--pa-surface-2); }
|
||||
.source-row.hl-active {
|
||||
background: color-mix(in srgb, var(--pa-g-to) 14%, transparent);
|
||||
border-radius: var(--pa-r-sm);
|
||||
animation: hl-fade 2s var(--pa-ease-out) forwards;
|
||||
}
|
||||
@keyframes hl-fade {
|
||||
0% { background: color-mix(in srgb, var(--pa-g-to) 26%, transparent); }
|
||||
100% { background: color-mix(in srgb, var(--pa-g-to) 6%, transparent); }
|
||||
}
|
||||
.ft-item { display: flex; align-items: flex-start; gap: 8px; }
|
||||
.ft-ico { flex: none; width: 16px; text-align: center; }
|
||||
.idx { color: var(--pa-ink-subtle); font-size: 11px; line-height: 1.7; }
|
||||
.src-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.src-line1 { display: flex; align-items: baseline; min-width: 0; }
|
||||
.ft-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--pa-ink);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.src-meta { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.ft-size { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.src-snippet {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--pa-ink-muted);
|
||||
font-family: var(--pa-font-ui);
|
||||
cursor: pointer;
|
||||
word-break: break-word;
|
||||
}
|
||||
.src-snippet.clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ft-dl {
|
||||
flex: none;
|
||||
color: var(--pa-ink-subtle);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-top: 2px;
|
||||
transition: opacity 0.15s var(--pa-ease), color 0.15s var(--pa-ease);
|
||||
}
|
||||
.ft-dl svg { width: 13px; height: 13px; }
|
||||
.ft-dl:hover { color: var(--pa-g-to); }
|
||||
/* 移动端触摸:链接图标常驻(FR-RESP-007 由 media (hover:none) 兜底,此处对齐) */
|
||||
@media (hover: none) {
|
||||
.ft-dl { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!-- SourcesPane.vue —— 工作台 Tab2 文献来源(B3 归属)
|
||||
FR-SOURCE-001~006:mono 列表、字段缺失不渲染占位、外链优先级 url>doi>pmid>pmcid、
|
||||
证据链 ChainGroup 逐跳展开、本次/会话切换、空态。 -->
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
import { T } from '../../i18n/labels.js';
|
||||
import SourceRow from './SourceRow.vue';
|
||||
import ChainGroup from './ChainGroup.vue';
|
||||
|
||||
const bench = useBenchStore();
|
||||
const chains = ref({}); // chain_id → open
|
||||
|
||||
// 会话级聚合按需拉取(仅当用户切到「本会话全部」且尚无缓存时)
|
||||
watch(
|
||||
() => [bench.sourceScope, bench.currentAssistantMessage],
|
||||
([scope]) => {
|
||||
if (scope === 'session' && !bench.sessionSources && bench._activeSessionId()) {
|
||||
bench.loadSessionSources().catch(() => {});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const items = computed(() => bench.sources);
|
||||
const chainList = computed(() => {
|
||||
const msg = bench.currentAssistantMessage;
|
||||
if (!msg) return [];
|
||||
const blk = (msg.blocks || []).find((b) => b?.type === 'graph');
|
||||
return Array.isArray(blk?.chains) ? blk.chains : [];
|
||||
});
|
||||
const hasChains = computed(() => chainList.value.length > 0);
|
||||
|
||||
function toggleChain(id) {
|
||||
chains.value = { ...chains.value, [id]: !chains.value[id] };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sources-pane">
|
||||
<!-- 本次回答 / 本会话全部(FR-SOURCE-005) -->
|
||||
<div class="scope-switch">
|
||||
<button
|
||||
:class="['scope-btn', { on: bench.sourceScope === 'current' }]"
|
||||
@click="bench.setSourceScope('current')"
|
||||
>{{ T.sessionSources }}</button>
|
||||
<button
|
||||
:class="['scope-btn', { on: bench.sourceScope === 'session' }]"
|
||||
@click="bench.setSourceScope('session')"
|
||||
>{{ T.sessionAllSources }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 会话级加载失败提示(失败必须可见,PRD §2.2) -->
|
||||
<div v-if="bench.sourceScope === 'session' && bench.sourcesError" class="scope-error">
|
||||
{{ T.upstreamError }}
|
||||
<button class="retry-btn" @click="bench.loadSessionSources({ force: true }).catch(() => {})">{{ T.retry }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态(FR-SOURCE-006,不显示空列表框) -->
|
||||
<div v-if="!items || !items.length" class="empty-note">{{ T.sourceEmpty }}</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 证据链(FR-SOURCE-004,按 chain_id 分组) -->
|
||||
<div v-if="hasChains" class="chain-section">
|
||||
<div class="bench-sec-title">{{ T.streamEvents }}</div>
|
||||
<ChainGroup
|
||||
v-for="c in chainList"
|
||||
:key="c.chain_id || JSON.stringify(c)"
|
||||
:chain="c"
|
||||
:open="!!chains[c.chain_id]"
|
||||
@toggle="toggleChain(c.chain_id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- mono 来源列表(FR-SOURCE-001/002;FR-CHAT-015 高亮由 highlightSource 驱动) -->
|
||||
<div class="bench-sec-title">{{ T.sourcesTitle }} · {{ items.length }}</div>
|
||||
<div class="source-list">
|
||||
<SourceRow
|
||||
v-for="(it, i) in items"
|
||||
:key="it.src.id || `${i}-${it.src.paper_id}`"
|
||||
:index="i"
|
||||
:source="it.src"
|
||||
:count="it.label"
|
||||
:highlighted="bench.highlightSource != null && bench.highlightSource.index === i && bench.highlightSource.until > Date.now()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sources-pane { display: flex; flex-direction: column; }
|
||||
.scope-switch {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-pill);
|
||||
padding: 2px;
|
||||
width: fit-content;
|
||||
}
|
||||
.scope-btn {
|
||||
padding: 3px 12px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s var(--pa-ease);
|
||||
}
|
||||
.scope-btn.on { color: #fff; background: var(--pa-g-from); }
|
||||
.scope-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--pa-error);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.retry-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px;
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.retry-btn:hover { color: var(--pa-ink); }
|
||||
.empty-note {
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12px;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.chain-section { margin-bottom: 4px; }
|
||||
.bench-sec-title { font-size: 11px; letter-spacing: 0.6px; color: var(--pa-ink-subtle); margin: 10px 0 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
// bench/benchLabels.js —— B3 本地文案补充(已并入 labels.js,2026-08-25 PM 集成)
|
||||
// 本文件现为薄别名:所有键已合并进 src/i18n/labels.js 的 T 表(唯一事实源)。
|
||||
// 组件可继续 import { BENCH_LABELS },后续清理时可改为直接引用 T。
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
const KEYS = [
|
||||
'extractRunLog', 'kgSearch', 'kgDepth2SlowHint', 'kgDepth2NeighborhoodSlow',
|
||||
'kgDegradedListPrefix', 'kgDegradedListSuffix', 'kgNodeUnit', 'kgEdgeUnit',
|
||||
'kgRelationUnit', 'benchExpand', 'benchCollapse', 'close', 'resetView',
|
||||
'stageLabel', 'paramsLabel',
|
||||
];
|
||||
|
||||
export const BENCH_LABELS = Object.fromEntries(KEYS.map((k) => [k, T[k]]));
|
||||
@@ -0,0 +1,188 @@
|
||||
// bench/graphLayout.js —— 手写 SVG 力导向布局(无第三方图库;B3 归属)
|
||||
// DESIGN.md 禁 CDN,图谱由纯手写算法完成:圆形种子初始化 → 有限迭代斥力/弹簧 → 包围盒归一化。
|
||||
// 输入/输出均为与 Vue 无关的纯数据,便于单元测试。
|
||||
|
||||
/**
|
||||
* 节点类型 → 调色板稳定映射(FR-KG-002:品牌色 + 辅助紫 + 五相位色)。
|
||||
* 未知类型归入 'unknown'。注意顺序固定:保证同一 type 会话内颜色稳定。
|
||||
*/
|
||||
const PALETTE = [
|
||||
'#5E6AD2', // 品牌 g-from
|
||||
'#00D4FF', // 品牌 g-to
|
||||
'#A78BFA', // 辅助紫
|
||||
'#C0A8DD', // 相位 规划
|
||||
'#9FBBE0', // 相位 检索
|
||||
'#9FC9A2', // 相位 证据
|
||||
'#DFA88F', // 相位 生成
|
||||
'#C08532', // 相位 完成
|
||||
];
|
||||
|
||||
/**
|
||||
* 对一组 type 做稳定配色(FR-KG-002)。
|
||||
* 调用方可预传入稳定基数 base=1,避免不同图之间同一 type 撞不同颜色。
|
||||
*/
|
||||
export function buildPalette(types, base = 1) {
|
||||
const seen = new Set();
|
||||
const sorted = [...types].sort();
|
||||
for (const t of sorted) if (t) seen.add(t);
|
||||
const map = new Map();
|
||||
let i = 0;
|
||||
for (const t of [...seen, 'unknown']) {
|
||||
if (!map.has(t)) map.set(t, PALETTE[(base + i) % PALETTE.length]);
|
||||
i += 1;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 稳定索引(字符串哈希),供确定着色 */
|
||||
export function hashString(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 力导向布局主入口。
|
||||
* @param {{nodes:Array<{id,label?,type?}>, edges:Array<{source,target,relation?}>}} graph
|
||||
* @param {{width?:number, height?:number, seed?:number, iterations?:number}} opts
|
||||
* @returns {Array<{id,label,type,x,y,vx,vy}>}
|
||||
*/
|
||||
export function forceLayout(graph, { width = 800, height = 600, seed = 0, iterations = 180 } = {}) {
|
||||
const nodes = (graph?.nodes || [])
|
||||
.filter((n) => n && n.id != null)
|
||||
.map((n) => ({
|
||||
id: String(n.id),
|
||||
label: n.label != null ? String(n.label) : String(n.id),
|
||||
type: n.type || 'unknown',
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
}));
|
||||
const ids = new Set(nodes.map((n) => n.id));
|
||||
const edges = (graph?.edges || [])
|
||||
.filter((e) => e && ids.has(String(e.source)) && ids.has(String(e.target)))
|
||||
.map((e) => ({ source: String(e.source), target: String(e.target), relation: e.relation || e.label || '' }));
|
||||
|
||||
if (!nodes.length) return { nodes: [], edges: [], width, height };
|
||||
|
||||
const n = nodes.length;
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
|
||||
// 圆形种子初始化(确定性,不依赖 Math.random 顺序)
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const ang = (i / n) * Math.PI * 2 + seed * 0.13;
|
||||
nodes[i].x = cx + Math.cos(ang) * 120;
|
||||
nodes[i].y = cy + Math.sin(ang) * 120;
|
||||
nodes[i].vx = 0;
|
||||
nodes[i].vy = 0;
|
||||
}
|
||||
|
||||
// 邻接表(用于跳过相邻节点间的斥力,避免边把邻居完全推开)
|
||||
const adj = new Map();
|
||||
for (const nd of nodes) adj.set(nd.id, new Set());
|
||||
for (const e of edges) {
|
||||
adj.get(e.source)?.add(e.target);
|
||||
adj.get(e.target)?.add(e.source);
|
||||
}
|
||||
|
||||
const k = Math.sqrt((width * height) / Math.max(n, 1)) * 0.9;
|
||||
const iters = Math.max(60, Math.min(iterations, 300));
|
||||
|
||||
for (let it = 0; it < iters; it += 1) {
|
||||
const cooling = 1 - it / iters; // 温度线性退火
|
||||
|
||||
// 斥力(库仑):相邻节点跳过,减少抖动
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
for (let j = i + 1; j < n; j += 1) {
|
||||
const a = nodes[i];
|
||||
const b = nodes[j];
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 < 1) {
|
||||
dx = (hashString(a.id + b.id) % 100) - 50;
|
||||
dy = (hashString(b.id + a.id) % 100) - 50;
|
||||
d2 = dx * dx + dy * dy + 1;
|
||||
}
|
||||
if (adj.get(a.id).has(b.id)) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const force = (k * k) / d2 * 1.2;
|
||||
const fx = (dx / d) * force;
|
||||
const fy = (dy / d) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
}
|
||||
|
||||
// 弹簧(胡克):边相连节点保持理想距离
|
||||
for (const e of edges) {
|
||||
const a = nodes.find((nd) => nd.id === e.source);
|
||||
const b = nodes.find((nd) => nd.id === e.target);
|
||||
if (!a || !b) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let d = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const target = k * 0.7;
|
||||
const force = (d - target) * 0.04;
|
||||
const fx = (dx / d) * force;
|
||||
const fy = (dy / d) * force;
|
||||
a.vx += fx;
|
||||
a.vy += fy;
|
||||
b.vx -= fx;
|
||||
b.vy -= fy;
|
||||
}
|
||||
|
||||
// 中心引力
|
||||
for (const nd of nodes) {
|
||||
nd.vx += (cx - nd.x) * 0.004;
|
||||
nd.vy += (cy - nd.y) * 0.004;
|
||||
}
|
||||
|
||||
// 积分 + 阻尼 + 限速
|
||||
for (const nd of nodes) {
|
||||
nd.vx *= 0.55;
|
||||
nd.vy *= 0.55;
|
||||
const sp = Math.hypot(nd.vx, nd.vy);
|
||||
const maxV = k * 0.3;
|
||||
if (sp > maxV) {
|
||||
nd.vx = (nd.vx / sp) * maxV;
|
||||
nd.vy = (nd.vy / sp) * maxV;
|
||||
}
|
||||
nd.x += nd.vx * cooling + nd.vx * 0.4;
|
||||
nd.y += nd.vy * cooling + nd.vy * 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
// 包围盒归一化:平移到 (0,0),缩放适配画布,留 40px 标签边距
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const nd of nodes) {
|
||||
minX = Math.min(minX, nd.x);
|
||||
minY = Math.min(minY, nd.y);
|
||||
maxX = Math.max(maxX, nd.x);
|
||||
maxY = Math.max(maxY, nd.y);
|
||||
}
|
||||
const bw = Math.max(maxX - minX, 1);
|
||||
const bh = Math.max(maxY - minY, 1);
|
||||
const pad = 40;
|
||||
const sx = (width - pad * 2) / bw;
|
||||
const sy = (height - pad * 2) / bh;
|
||||
const s = Math.min(sx, sy, 1.6);
|
||||
const offX = (width - bw * s) / 2;
|
||||
const offY = (height - bh * s) / 2;
|
||||
for (const nd of nodes) {
|
||||
nd.x = (nd.x - minX) * s + offX;
|
||||
nd.y = (nd.y - minY) * s + offY;
|
||||
}
|
||||
|
||||
return { nodes, edges, width, height };
|
||||
}
|
||||
|
||||
/** 每边最多渲染的关系名标签数(防 hub 拥挤) */
|
||||
export const MAX_EDGE_LABELS = 12;
|
||||
@@ -0,0 +1,69 @@
|
||||
// kgPaneCompat.js —— 测试适配层(B3 单测使用,不含生产逻辑)
|
||||
// 作用:把 KgPane 组件的关键判定逻辑抽取为纯函数,
|
||||
// 供 Vitest 直接验证(组件内逻辑与本文件保持同源复制,测试聚焦判定规则本身)。
|
||||
// 组件内实现是权威,本文件仅作测试探针;若两者不一致,以组件为准并修正本文件。
|
||||
|
||||
export const NODE_LIMIT_DESKTOP = 50;
|
||||
export const NODE_LIMIT_MOBILE = 30;
|
||||
|
||||
export const LINK_PRIORITY = ['url', 'doi', 'pmid', 'pmcid'];
|
||||
|
||||
/**
|
||||
* 外链优先级(FR-SOURCE-003):url > doi > pmid > pmcid;均无 → null(不显示链接图标)。
|
||||
*/
|
||||
export function resolveSourceLink(s) {
|
||||
if (!s) return null;
|
||||
if (s.url) return s.url;
|
||||
if (s.doi) return `https://doi.org/${s.doi}`;
|
||||
if (s.pmid) return `https://pubmed.ncbi.nlm.nih.gov/${s.pmid}/`;
|
||||
if (s.pmcid) return `https://www.ncbi.nlm.nih.gov/pmc/articles/${s.pmcid}/`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* progress_pct 派生(FR-EXTRACT-001,BFF 已派生,前端兜底同规则):
|
||||
* queued=5 / mineru=35 / llm=75 / completed=100 / failed=null。
|
||||
*/
|
||||
export function deriveProgressPct(job) {
|
||||
if (!job) return null;
|
||||
if (job.progress_pct != null) return job.progress_pct;
|
||||
switch (job.stage) {
|
||||
case 'queued': return 5;
|
||||
case 'mineru': return 35;
|
||||
case 'llm': return 75;
|
||||
case 'completed': return 100;
|
||||
case 'failed': return null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要轮询(FR-EXTRACT-005):存在 running/queued 任务 → true;
|
||||
* 全部终态(completed/failed)→ false。切走 tab 由 store 层暂停。
|
||||
*/
|
||||
export function shouldPoll(jobs) {
|
||||
return (jobs || []).some((j) => j.status === 'running' || j.status === 'queued');
|
||||
}
|
||||
|
||||
/** 图谱节点降级判定(FR-KG-001):节点数超过上限(桌面 50 / 移动端 30)→ 列表视图 */
|
||||
export function isDegradedGraph(nodeCount, limit = NODE_LIMIT_DESKTOP) {
|
||||
return nodeCount > limit;
|
||||
}
|
||||
|
||||
/** 离线态判定(FR-KG-007):kgReady=false 且状态已探测过 → 搜索/邻域禁用,答案图谱仍可用 */
|
||||
export function isKgOffline(kgReady, kgStatus) {
|
||||
return !kgReady && kgStatus != null;
|
||||
}
|
||||
|
||||
/** 会话来源按 paper_id 去重并累计出现次数(FR-SOURCE-005) */
|
||||
export function aggregateSessionSources(sources) {
|
||||
if (!Array.isArray(sources)) return [];
|
||||
const seen = new Map();
|
||||
for (const it of sources) {
|
||||
const key = it.paper_id || it.id || JSON.stringify(it);
|
||||
const prev = seen.get(key);
|
||||
if (prev) prev.count += it.count ?? 1;
|
||||
else seen.set(key, { ...it, count: it.count ?? 1 });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
@@ -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("'", ''');
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<!-- AppShell.vue —— B1 归属。三栏外壳(FR-SHELL-001,UI_SPEC §3)。
|
||||
三栏 grid:260px minmax(0,1fr) 380px;顶栏/底栏贯通。
|
||||
折叠态持久化到 localStorage(pa-side-collapsed / pa-bench-collapsed,FR-SHELL-004/005)。
|
||||
集成 SessionPanel/ChatPanel/BenchPanel 三个入口占位(B2/B3 后续替换其内部实现,本文件不改它们)。
|
||||
响应式四档(FR-RESP-001..004):
|
||||
<1024px → .drawers-mode:左右栏脱离网格,成为抽屉(Drawer 内容经 Teleport 落到 body)
|
||||
<768px → .mobile:底栏隐藏、工作台为全屏覆盖层(顶部 tab 条 + 关闭按钮)
|
||||
FR-RESP-010:监听 visualViewport 键盘弹出,压缩消息区高度保持贴底。 -->
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import AuroraBg from './AuroraBg.vue';
|
||||
import TopBar from './TopBar.vue';
|
||||
import StatusBar from './StatusBar.vue';
|
||||
import Drawer from './Drawer.vue';
|
||||
import SessionPanel from '../session/SessionPanel.vue';
|
||||
import ChatPanel from '../chat/ChatPanel.vue';
|
||||
import BenchPanel from '../bench/BenchPanel.vue';
|
||||
import { BENCH_TABS } from '../../i18n/labels.js';
|
||||
import { useSessionsStore } from '../../stores/sessions.js';
|
||||
import { useBenchStore } from '../../stores/bench.js';
|
||||
|
||||
/* ---------- 折叠状态(FR-SHELL-004/005:localStorage 持久化) ---------- */
|
||||
const SIDE_KEY = 'pa-side-collapsed';
|
||||
const BENCH_KEY = 'pa-bench-collapsed';
|
||||
|
||||
const sideCollapsed = ref(localStorage.getItem(SIDE_KEY) === '1');
|
||||
const benchCollapsed = ref(localStorage.getItem(BENCH_KEY) === '1');
|
||||
|
||||
function toggleSide() {
|
||||
sideCollapsed.value = !sideCollapsed.value;
|
||||
localStorage.setItem(SIDE_KEY, sideCollapsed.value ? '1' : '0');
|
||||
}
|
||||
function toggleBench() {
|
||||
benchCollapsed.value = !benchCollapsed.value;
|
||||
localStorage.setItem(BENCH_KEY, benchCollapsed.value ? '1' : '0');
|
||||
}
|
||||
|
||||
/* ---------- 响应式档位(FR-RESP-001..004) ---------- */
|
||||
const viewportW = ref(typeof window !== 'undefined' ? window.innerWidth : 1280);
|
||||
const tier = computed(() => {
|
||||
if (viewportW.value >= 1280) return 'desktop';
|
||||
if (viewportW.value >= 1024) return 'laptop';
|
||||
if (viewportW.value >= 768) return 'tablet';
|
||||
return 'mobile';
|
||||
});
|
||||
const drawersMode = computed(() => tier.value === 'tablet' || tier.value === 'mobile');
|
||||
|
||||
// FR-RESP-002:首次进入 laptop 档且用户从未设置过偏好时,自动折叠左栏;
|
||||
// 用户显式展开(写入 '0')后不再干预。
|
||||
watch(tier, (t) => {
|
||||
if (t === 'laptop' && localStorage.getItem(SIDE_KEY) === null) {
|
||||
sideCollapsed.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function onResize() {
|
||||
viewportW.value = window.innerWidth;
|
||||
}
|
||||
|
||||
/* ---------- 抽屉与覆盖层状态 ---------- */
|
||||
const sideOpen = ref(false);
|
||||
const benchOpen = ref(false);
|
||||
// 移动端工作台全屏覆盖层(FR-RESP-004)
|
||||
const benchOverlayOpen = ref(false);
|
||||
|
||||
function openSide() {
|
||||
sideOpen.value = true;
|
||||
}
|
||||
function openBench() {
|
||||
// 2026-08-25 PM 修复:mobile 档应开全屏覆盖层而非右抽屉(FR-RESP-004)
|
||||
if (tier.value === 'mobile') {
|
||||
benchOverlayOpen.value = true;
|
||||
} else {
|
||||
benchOpen.value = true;
|
||||
}
|
||||
}
|
||||
function closeSide() {
|
||||
sideOpen.value = false;
|
||||
}
|
||||
function closeBench() {
|
||||
benchOpen.value = false;
|
||||
}
|
||||
function closeAllBench() {
|
||||
benchOpen.value = false;
|
||||
benchOverlayOpen.value = false;
|
||||
}
|
||||
function closeBenchOverlay() {
|
||||
benchOverlayOpen.value = false;
|
||||
}
|
||||
|
||||
/* ---------- 顶栏数据(B2 sessions store 驱动;空值守卫回退) ---------- */
|
||||
const sessions = useSessionsStore();
|
||||
const bench = useBenchStore();
|
||||
const title = computed(() => sessions.activeSession?.title || null);
|
||||
const status = computed(() => sessions.activeSession?.status || null);
|
||||
const menuAvailable = computed(() => drawersMode.value);
|
||||
const benchAvailable = computed(() => drawersMode.value);
|
||||
|
||||
/* ---------- 折叠把手的 SVG ---------- */
|
||||
const chevronLeft =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>';
|
||||
const chevronRight =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>';
|
||||
const iconClose =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>';
|
||||
|
||||
/* ---------- FR-RESP-010 虚拟键盘适配 ---------- */
|
||||
const keyboardOpen = ref(false);
|
||||
let vv = null;
|
||||
function onVV() {
|
||||
if (!window.visualViewport) return;
|
||||
const kbdOpen = window.innerHeight - window.visualViewport.height > 140;
|
||||
if (kbdOpen !== keyboardOpen.value) {
|
||||
keyboardOpen.value = kbdOpen;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize);
|
||||
if (window.visualViewport) {
|
||||
vv = window.visualViewport;
|
||||
vv.addEventListener('resize', onVV);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
if (vv) vv.removeEventListener('resize', onVV);
|
||||
});
|
||||
|
||||
/* ---------- 抽屉打开时给 <html> 加类(锁背景滚动钩子) ---------- */
|
||||
const anyOpen = computed(() => sideOpen.value || benchOpen.value || benchOverlayOpen.value);
|
||||
watch(anyOpen, (open) => {
|
||||
const root = document.documentElement;
|
||||
if (open) root.classList.add('drawer-open');
|
||||
else root.classList.remove('drawer-open');
|
||||
});
|
||||
|
||||
/* ---------- 壳层 class ---------- */
|
||||
const shellClass = computed(() => ({
|
||||
'side-collapsed': sideCollapsed.value && !drawersMode.value,
|
||||
'bench-collapsed': benchCollapsed.value && !drawersMode.value,
|
||||
'drawers-mode': drawersMode.value,
|
||||
mobile: tier.value === 'mobile',
|
||||
'keyboard-open': keyboardOpen.value,
|
||||
'has-tools': drawersMode.value,
|
||||
}));
|
||||
/* ---------- 覆盖层 tab 条(tab 状态由 bench store 接管,FR-RESP-004 全屏工作台) ---------- */
|
||||
const overlayActiveTab = computed(() => bench.activeTab);
|
||||
function overlaySelectTab(tab) {
|
||||
bench.setActiveTab(tab);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :class="shellClass">
|
||||
<AuroraBg />
|
||||
|
||||
<TopBar
|
||||
:title="title"
|
||||
:status="status"
|
||||
:tier="tier"
|
||||
:menu-available="menuAvailable"
|
||||
:bench-available="benchAvailable"
|
||||
:bench-overlay-open="benchOverlayOpen"
|
||||
@menu="openSide"
|
||||
@bench="openBench"
|
||||
@close-bench="closeAllBench"
|
||||
/>
|
||||
|
||||
<!-- 左栏(≥1024px 固定;<1024px 抽屉形态) -->
|
||||
<div v-if="!drawersMode" class="aside-host">
|
||||
<SessionPanel />
|
||||
</div>
|
||||
|
||||
<!-- 左折叠把手(FR-SHELL-004;仅桌面档显示) -->
|
||||
<button
|
||||
v-if="!drawersMode"
|
||||
class="icon-btn side-handle-btn"
|
||||
:class="{ collapsed: sideCollapsed }"
|
||||
type="button"
|
||||
:title="sideCollapsed ? '展开会话栏' : '折叠会话栏'"
|
||||
:aria-label="sideCollapsed ? '展开会话栏' : '折叠会话栏'"
|
||||
@click="toggleSide"
|
||||
>
|
||||
<span v-html="sideCollapsed ? chevronRight : chevronLeft"></span>
|
||||
</button>
|
||||
|
||||
<!-- 中栏 -->
|
||||
<ChatPanel />
|
||||
|
||||
<!-- 右栏(≥1024px 固定;<1024px 抽屉形态) -->
|
||||
<div v-if="!drawersMode" class="bench-wrap">
|
||||
<BenchPanel />
|
||||
<!-- 折叠手柄条(FR-SHELL-005:折叠 ≠ 隐藏,28px 手柄保留可见) -->
|
||||
<button class="bench-handle" type="button" title="展开工作台" aria-label="展开工作台" @click="toggleBench">
|
||||
<span v-html="chevronLeft"></span>
|
||||
</button>
|
||||
<!-- 展开态的折叠入口 -->
|
||||
<button
|
||||
class="icon-btn bench-collapse-btn"
|
||||
type="button"
|
||||
title="折叠工作台"
|
||||
aria-label="折叠工作台"
|
||||
@click="toggleBench"
|
||||
>
|
||||
<span v-html="chevronRight"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 底栏(<768px 隐藏) -->
|
||||
<StatusBar />
|
||||
|
||||
<!-- <1024px 抽屉:左 = 会话抽屉,右 = 工作台抽屉 -->
|
||||
<template v-if="drawersMode">
|
||||
<Drawer :open="sideOpen" side="left" width="260px" title="会话列表" @close="closeSide">
|
||||
<SessionPanel />
|
||||
</Drawer>
|
||||
<Drawer :open="benchOpen" side="right" width="300px" title="工作台" @close="closeBench">
|
||||
<BenchPanel />
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<!-- <768px 全屏工作台覆盖层(FR-RESP-004:顶部 tab 条 + 关闭按钮) -->
|
||||
<Teleport to="body">
|
||||
<div v-if="benchOverlayOpen && tier === 'mobile'" class="bench-overlay">
|
||||
<div class="bench-overlay-tabs">
|
||||
<div class="bench-tabs">
|
||||
<button
|
||||
v-for="t in BENCH_TABS"
|
||||
:key="t.key"
|
||||
class="bench-tab"
|
||||
:class="{ on: t.key === overlayActiveTab }"
|
||||
type="button"
|
||||
@click="overlaySelectTab(t.key)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn bench-close-btn"
|
||||
type="button"
|
||||
title="关闭工作台"
|
||||
aria-label="关闭工作台"
|
||||
@click="closeBenchOverlay"
|
||||
>
|
||||
<span v-html="iconClose"></span>
|
||||
</button>
|
||||
</div>
|
||||
<BenchPanel />
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!-- AuroraBg.vue —— B1 归属。极光背景层(UI_SPEC §3 / FR-SHELL-006)。
|
||||
z-index:0 固定层、pointer-events:none;两光斑 aurora-drift + 48px 点阵。
|
||||
<768px 时 light 光斑 opacity:0(FR-RESP-012)由 responsive.css 关闭。 -->
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="aurora-bg" aria-hidden="true">
|
||||
<div class="blob a"></div>
|
||||
<div class="blob b"></div>
|
||||
<div class="grid-dots"></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,153 @@
|
||||
<!-- Drawer.vue —— B1 归属,B2/B3 复用。通用抽屉(UI_SPEC §5 / §7)。
|
||||
props side/width/open;scrim 点击关闭;Esc 关闭;
|
||||
打开时焦点圈定在抽屉内(Tab 循环),关闭后归还焦点。
|
||||
B2 用法示例:
|
||||
<Drawer :open="ui.sideOpen" side="left" :width="260" @close="ui.sideOpen = false">
|
||||
<SessionPanel />
|
||||
</Drawer>
|
||||
B3 用法示例:
|
||||
<Drawer :open="ui.benchOpen" side="right" :width="300" @close="ui.benchOpen = false">
|
||||
<BenchPanel />
|
||||
</Drawer> -->
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
/** 是否打开(打开/关闭均有 300ms ease-out 过渡) */
|
||||
open: { type: Boolean, default: false },
|
||||
/** left | right | top */
|
||||
side: { type: String, default: 'left' },
|
||||
/** 抽屉宽度(CSS 宽度值),受 UI_SPEC max-width:300px 约束 */
|
||||
width: { type: String, default: '260px' },
|
||||
/** 无障碍标签 */
|
||||
title: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const root = ref(null);
|
||||
let previouslyFocused = null;
|
||||
let isOpen = false;
|
||||
|
||||
function close() {
|
||||
if (props.open) emit('close');
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!isOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
// 焦点圈定:Tab 循环限制在抽屉内
|
||||
const el = root.value;
|
||||
if (!el) return;
|
||||
const items = el.querySelectorAll(
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && (active === first || !el.contains(active))) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && (active === last || !el.contains(active))) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
isOpen = open;
|
||||
if (open) {
|
||||
previouslyFocused = document.activeElement;
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
// 待抽屉滑入后再聚焦首个可聚焦元素(否则 focus 会提前滚页)
|
||||
requestAnimationFrame(() => {
|
||||
const el = root.value;
|
||||
if (!el) return;
|
||||
const first = el.querySelector(
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (first) first.focus();
|
||||
});
|
||||
} else {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
if (previouslyFocused && previouslyFocused.focus) {
|
||||
try {
|
||||
previouslyFocused.focus();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
previouslyFocused = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="drawer-scrim-fade">
|
||||
<div v-if="open" class="drawer-scrim drawer-scrim-el" @click="close" aria-hidden="true"></div>
|
||||
</Transition>
|
||||
<aside
|
||||
ref="root"
|
||||
class="drawer drawer-el"
|
||||
:class="[side, { open }]"
|
||||
:style="{ width }"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title || undefined"
|
||||
>
|
||||
<div class="drawer-body">
|
||||
<slot />
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-el {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 62;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
background: var(--pa-surface-1);
|
||||
transition: transform .3s var(--pa-ease-out);
|
||||
/* UI_SPEC §5:宽 300px max */
|
||||
max-width: 300px;
|
||||
}
|
||||
.drawer-el.left { left: 0; border-right: 1px solid var(--pa-hairline); transform: translateX(-100%); }
|
||||
.drawer-el.left.open { transform: translateX(0); }
|
||||
.drawer-el.right { right: 0; border-left: 1px solid var(--pa-hairline); transform: translateX(100%); }
|
||||
.drawer-el.right.open { transform: translateX(0); }
|
||||
.drawer-el.top {
|
||||
top: 0; left: 0; right: 0; max-width: none;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.drawer-el.top.open { transform: translateY(0); }
|
||||
.drawer-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
|
||||
.drawer-scrim-el {
|
||||
position: fixed; inset: 0; z-index: 61;
|
||||
background: var(--pa-scrim);
|
||||
}
|
||||
.drawer-scrim-fade-enter-active { animation: fade-in .2s var(--pa-ease); }
|
||||
.drawer-scrim-fade-leave-active { opacity: 0; transition: opacity .2s var(--pa-ease); }
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!-- StatusBar.vue —— B1 归属。底部状态栏(FR-SHELL-003 / UI_SPEC §4.8)。
|
||||
左:4 个 agent 状态点(规划/检索/证据/生成,与流水线相位联动——chat store 驱动,空值守卫)。
|
||||
右:PlantAI RAG v0.1 + QA 版本(mono,来自 /api/qa/info)+ 会话数(mono)+ 上游健康标记。
|
||||
<768px 时整条 display:none(responsive.css,聚合状态点接管顶栏)。
|
||||
B2 并行开发中——useChatStore 动态导入;数据契约:
|
||||
chat.currentMessage.blocks[].type==='pipeline' 的 steps[]({state, phase}) -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { VERSION, PHASE_LABEL, PHASE_COLOR_VAR, T } from '../../i18n/labels.js';
|
||||
import { useHealthStore } from '../../stores/health.js';
|
||||
|
||||
const health = useHealthStore();
|
||||
|
||||
/* ---------- chat store(B2 并行开发中,空值守卫) ---------- */
|
||||
const chatStore = ref(null);
|
||||
const agentStates = computed(() => {
|
||||
try {
|
||||
const chat = chatStore.value;
|
||||
if (!chat) return null;
|
||||
const msg = chat.currentMessage;
|
||||
const pipe = (msg?.blocks || []).find((b) => b?.type === 'pipeline');
|
||||
const steps = pipe?.steps || [];
|
||||
const out = {};
|
||||
for (const key of ['planning', 'retrieval', 'evidence', 'generation']) {
|
||||
const st = steps.find((s) => s?.phase === key);
|
||||
out[key] = st?.state ?? '';
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const sessionCount = computed(() => {
|
||||
try {
|
||||
const n = chatStore.value?.sessionCount;
|
||||
return typeof n === 'number' ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const m = await import('../../stores/chat.js');
|
||||
chatStore.value = m.useChatStore();
|
||||
} catch {
|
||||
chatStore.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 四个相位点的渲染数据 ---------- */
|
||||
const phaseKeys = ['planning', 'retrieval', 'evidence', 'generation'];
|
||||
const phases = computed(() =>
|
||||
phaseKeys.map((key) => {
|
||||
const st = agentStates.value ? agentStates.value[key] : '';
|
||||
return {
|
||||
key,
|
||||
label: PHASE_LABEL[key],
|
||||
state: st === 'running' || st === 'done' || st === 'error' ? st : '',
|
||||
colorVar: PHASE_COLOR_VAR[key],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/* ---------- 右侧信息(FR-SHELL-003 验收项) ---------- */
|
||||
const qaVersion = computed(() => health.qaVersion);
|
||||
const count = computed(() => {
|
||||
if (sessionCount.value != null) return sessionCount.value;
|
||||
if (health.sessionCount != null) return health.sessionCount;
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="status-bar">
|
||||
<div class="agent-dots">
|
||||
<span v-for="p in phases" :key="p.key" class="agent-dot-item" :class="`ph-${p.key}`">
|
||||
<span class="ad" :class="p.state" :style="{ '--pa-phase-color': `var(${p.colorVar})` }"></span>{{ p.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-right">
|
||||
<span>{{ VERSION }}</span>
|
||||
<span v-if="qaVersion" class="mono">{{ qaVersion }}</span>
|
||||
<span v-if="count != null" class="mono">{{ count }} {{ T.railSession }}</span>
|
||||
<!-- 上游健康标记(静默降级:红点/绿点,不弹窗) -->
|
||||
<span class="health-dots" title="上游服务状态">
|
||||
<span class="hd" :class="health.unknown ? 'unknown' : health.qaOk ? 'ok' : 'err'"></span>
|
||||
<span class="hd" :class="health.unknown ? 'unknown' : health.neo4jOk ? 'ok' : 'err'"></span>
|
||||
<span class="hd" :class="health.unknown ? 'unknown' : health.extractionOk ? 'ok' : 'err'"></span>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 相位色只在 running 时落到点上(默认 running 为执行蓝,此处按相位覆盖) */
|
||||
.agent-dot-item .ad.running {
|
||||
background: var(--pa-phase-color, var(--pa-t-execution));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<!-- TopBar.vue —— B1 归属。顶栏(FR-SHELL-002)。
|
||||
Logo(渐变点 + 渐变字)→ 居中标题 + 状态徽章 → 主题切换(日月 SVG rotate 180°)。
|
||||
<1024px:汉堡按钮 + 工作台按钮(带来源数角标,角标由 bench store 驱动)。
|
||||
<768px:聚合状态点(四个 agent 点合一,chat store 驱动)。
|
||||
B2/B3 并行开发中——useChatStore / useBenchStore 动态导入并做空值守卫,缺失时全部优雅回退。
|
||||
数据契约(B2/B3 真实 store 形态):
|
||||
chat.busy → 是否有进行中的流
|
||||
chat.currentMessage.blocks[].type==='pipeline' 的 steps[](state/phase)
|
||||
chat.lastResult.ok === false → 最近一次失败(聚合点 error)
|
||||
bench.currentSources → 来源数组(工作台按钮角标数) -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { APP_NAME, STATUS_LABEL } from '../../i18n/labels.js';
|
||||
import { useThemeStore } from '../../stores/theme.js';
|
||||
|
||||
const props = defineProps({
|
||||
/** 当前会话标题(无会话为 null → 显示「就绪」徽章) */
|
||||
title: { type: String, default: null },
|
||||
/** idle | running | done | error | null(null → 就绪态) */
|
||||
status: { type: String, default: null },
|
||||
/** 媒体档位由 AppShell 下发:'desktop' | 'laptop' | 'tablet' | 'mobile' */
|
||||
tier: { type: String, default: 'desktop' },
|
||||
/** 汉堡按钮可用与否 */
|
||||
menuAvailable: { type: Boolean, default: false },
|
||||
/** 工作台按钮可用与否 */
|
||||
benchAvailable: { type: Boolean, default: false },
|
||||
/** 移动端工作台全屏覆盖层打开状态 */
|
||||
benchOverlayOpen: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['menu', 'bench', 'close-bench']);
|
||||
|
||||
/* ---------- 主题 ---------- */
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
/* ---------- B2/B3 store 数据(动态导入 + 空值守卫) ---------- */
|
||||
const chatStore = ref(null);
|
||||
const benchStore = ref(null);
|
||||
|
||||
function useChat() {
|
||||
try {
|
||||
return chatStore.value ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function useBench() {
|
||||
try {
|
||||
return benchStore.value ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 工作台按钮角标:当前助手消息的来源数(bench.currentSources) */
|
||||
const sourceCount = computed(() => {
|
||||
try {
|
||||
const bench = useBench();
|
||||
const items = bench?.currentSources;
|
||||
return Array.isArray(items) ? items.length : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/** 聚合状态点(移动端顶栏):busy → running(带当前相位);最近失败 → error */
|
||||
const aggregate = computed(() => {
|
||||
try {
|
||||
const chat = useChat();
|
||||
if (!chat) return null;
|
||||
if (chat.busy === true) {
|
||||
const phase = runningPhaseOf(chat);
|
||||
return { phase, running: true, error: false };
|
||||
}
|
||||
if (chat.lastResult && chat.lastResult.ok === false) {
|
||||
return { phase: null, running: false, error: true };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const aggregateClass = computed(() => {
|
||||
const a = aggregate.value;
|
||||
if (!a) return '';
|
||||
if (a.error) return 'error';
|
||||
if (a.running) return 'running';
|
||||
return 'done';
|
||||
});
|
||||
|
||||
/** 从当前助手消息的 pipeline 块取正在运行相位的 key */
|
||||
function runningPhaseOf(chat) {
|
||||
try {
|
||||
const msg = chat.currentMessage;
|
||||
const pipe = (msg?.blocks || []).find((b) => b?.type === 'pipeline');
|
||||
const running = (pipe?.steps || []).find((s) => s?.state === 'running');
|
||||
if (running && running.phase && running.phase !== 'done') return running.phase;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const m = await import('../../stores/chat.js');
|
||||
chatStore.value = m.useChatStore();
|
||||
} catch {
|
||||
chatStore.value = null;
|
||||
}
|
||||
try {
|
||||
const m = await import('../../stores/bench.js');
|
||||
benchStore.value = m.useBenchStore();
|
||||
} catch {
|
||||
benchStore.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 徽章(FR-SHELL-002:无会话时显示「就绪」徽章;状态文案走 labels) ---------- */
|
||||
const badgeStatus = computed(() => (props.status ? props.status : 'idle'));
|
||||
const badgeText = computed(() =>
|
||||
props.status ? STATUS_LABEL[props.status] || props.status : STATUS_LABEL.ready
|
||||
);
|
||||
|
||||
/* ---------- 事件 ---------- */
|
||||
function onMenu() {
|
||||
emit('menu');
|
||||
}
|
||||
function onBench() {
|
||||
emit('bench');
|
||||
}
|
||||
function onToggleTheme() {
|
||||
themeStore.toggle();
|
||||
}
|
||||
|
||||
/* ---------- 图标 ---------- */
|
||||
const iconSun = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
||||
const iconMoon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></svg>';
|
||||
const iconMenu = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 12h18M3 6h18M3 18h18"/></svg>';
|
||||
const iconBench = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 5h12M6 12h12M6 19h12"/></svg>';
|
||||
const iconClose = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="top-bar">
|
||||
<!-- 汉堡(<1024px) -->
|
||||
<button
|
||||
v-if="menuAvailable && tier !== 'desktop' && tier !== 'laptop'"
|
||||
class="icon-btn hamburger-btn"
|
||||
type="button"
|
||||
aria-label="打开会话列表"
|
||||
title="会话列表"
|
||||
@click="onMenu"
|
||||
>
|
||||
<span v-html="iconMenu"></span>
|
||||
</button>
|
||||
|
||||
<!-- Logo(渐变点 + 渐变字;mobile 隐藏文字仅留点) -->
|
||||
<div class="logo">
|
||||
<span class="logo-dot"></span>
|
||||
<span class="logo-text">{{ APP_NAME }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 居中:标题 + 状态徽章 -->
|
||||
<div class="top-title">
|
||||
<span class="t">{{ title || APP_NAME }}</span>
|
||||
<span class="badge" :class="badgeStatus">
|
||||
<span class="dot"></span>{{ badgeText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 移动端聚合状态点(chat store 驱动) -->
|
||||
<span
|
||||
v-if="tier === 'mobile' && aggregate"
|
||||
class="status-dot aggregate-dot"
|
||||
:class="aggregateClass"
|
||||
aria-label="聚合任务状态"
|
||||
></span>
|
||||
|
||||
<!-- 工作台按钮(<1024px;mobile 打开全屏覆盖层时变为关闭按钮) -->
|
||||
<button
|
||||
v-if="benchAvailable && tier !== 'desktop' && tier !== 'laptop'"
|
||||
class="icon-btn bench-btn"
|
||||
type="button"
|
||||
:aria-label="benchOverlayOpen ? '关闭工作台' : '打开工作台'"
|
||||
:title="benchOverlayOpen ? '关闭工作台' : '工作台'"
|
||||
@click="benchOverlayOpen ? $emit('close-bench') : onBench()"
|
||||
>
|
||||
<span v-html="benchOverlayOpen ? iconClose : iconBench"></span>
|
||||
<span v-if="!benchOverlayOpen && sourceCount != null" class="bench-btn-count">{{ sourceCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 主题切换(日月 SVG + rotate 180° 400ms,FR-THEME-002) -->
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
:title="themeStore.theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'"
|
||||
:aria-label="themeStore.theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'"
|
||||
@click="onToggleTheme"
|
||||
>
|
||||
<span class="theme-toggle-anim" :class="{ flipped: themeStore.theme === 'dark' }">
|
||||
<span v-html="themeStore.theme === 'dark' ? iconSun : iconMoon"></span>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
// session/SearchBox.vue —— 会话搜索框(FR-SESSION-005,UI_SPEC §4.2)
|
||||
// focus-within 渐变描边(double-bg 技巧)+ 青色辉光。
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="M21 21l-4.3-4.3" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
:placeholder="T.searchPlaceholder"
|
||||
@input="emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 兜底样式(类名按 UI_SPEC §4.2 / §7) */
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
background: var(--pa-surface-2);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
transition: border-color 0.2s var(--pa-ease), box-shadow 0.2s var(--pa-ease);
|
||||
}
|
||||
.search-box:focus-within {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(var(--pa-surface-2), var(--pa-surface-2)) padding-box,
|
||||
var(--pa-gradient) border-box;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 0 14px rgba(0, 212, 255, 0.18);
|
||||
}
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--pa-ink-subtle);
|
||||
flex: none;
|
||||
}
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<script setup>
|
||||
// session/SessionItem.vue —— 会话条目(FR-SESSION-002/006/007/009,UI_SPEC §4.2)
|
||||
// 状态点四态(running 脉冲、done/error 实心、idle 空心;形状+颜色+动静态三重编码)/
|
||||
// 活动态渐变条 + 8% 底 + 22% 描边 / 双击进入重命名(Enter 提交、Esc 取消、空标题不提交)/
|
||||
// 删除入口(BUG-R1-03 修复):hover 出现删除按钮 + 右键唤出删除确认,
|
||||
// 点击/右键均 stop 冒泡(不触发条目选择)。移动端触控目标 ≥44px(FR-RESP-008)。
|
||||
import { ref, nextTick } from 'vue';
|
||||
import { T, STATUS_LABEL } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
session: { type: Object, required: true },
|
||||
active: { type: Boolean, default: false },
|
||||
metaText: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'rename', 'delete']);
|
||||
|
||||
const editing = ref(false);
|
||||
const draft = ref('');
|
||||
const inputEl = ref(null);
|
||||
|
||||
const statusText = (s) => STATUS_LABEL[s] || STATUS_LABEL.idle;
|
||||
|
||||
const startEdit = () => {
|
||||
draft.value = props.session.title || '';
|
||||
editing.value = true;
|
||||
nextTick(() => inputEl.value && inputEl.value.focus());
|
||||
};
|
||||
|
||||
const commit = () => {
|
||||
const t = draft.value.trim();
|
||||
editing.value = false;
|
||||
if (t && t !== props.session.title) emit('rename', { id: props.session.id, title: t });
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
editing.value = false;
|
||||
};
|
||||
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === 'Escape') {
|
||||
cancel();
|
||||
}
|
||||
};
|
||||
|
||||
/** 删除入口:右键或删除按钮 → 二次确认(FR-SESSION-007) */
|
||||
const askDelete = () => {
|
||||
if (editing.value) return;
|
||||
emit('delete', props.session);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="session-item"
|
||||
:class="{ active }"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
@click="editing || emit('select', session.id)"
|
||||
@dblclick="startEdit"
|
||||
@contextmenu.prevent="askDelete"
|
||||
@keydown.enter="!editing && emit('select', session.id)"
|
||||
@keydown.f2.prevent="startEdit"
|
||||
>
|
||||
<span class="status-dot" :class="session.status" :title="statusText(session.status)"></span>
|
||||
<div class="txt">
|
||||
<input
|
||||
v-if="editing"
|
||||
ref="inputEl"
|
||||
v-model="draft"
|
||||
class="rename-input"
|
||||
:placeholder="T.renamePlaceholder"
|
||||
@keydown="onKey"
|
||||
@blur="cancel"
|
||||
@click.stop
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="title" :title="session.title">{{ session.title }}</div>
|
||||
<div class="meta">{{ metaText }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
class="item-del"
|
||||
type="button"
|
||||
:aria-label="T.confirmDeleteTitle"
|
||||
:title="T.confirmDeleteTitle"
|
||||
@click.stop="askDelete"
|
||||
@keydown.enter.stop="askDelete"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 兜底样式(类名按 UI_SPEC §4.2 / §7;原型逐值复刻) */
|
||||
.session-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s var(--pa-ease);
|
||||
}
|
||||
.session-item:hover {
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.session-item.active {
|
||||
background: color-mix(in srgb, var(--pa-g-to) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--pa-g-to) 22%, transparent);
|
||||
}
|
||||
.session-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 7px;
|
||||
bottom: 7px;
|
||||
width: 2px;
|
||||
background: var(--pa-gradient);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.status-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.running {
|
||||
background: var(--pa-g-to);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
.status-dot.done {
|
||||
background: var(--pa-success);
|
||||
}
|
||||
.status-dot.error {
|
||||
background: var(--pa-error);
|
||||
}
|
||||
.status-dot.idle {
|
||||
border: 1.5px solid 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);
|
||||
}
|
||||
}
|
||||
.txt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--pa-ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: var(--pa-ink-subtle);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rename-input {
|
||||
width: 100%;
|
||||
background: var(--pa-surface-2);
|
||||
border: 1px solid var(--pa-g-to);
|
||||
border-radius: var(--pa-r-sm);
|
||||
color: var(--pa-ink);
|
||||
font-size: 13px;
|
||||
padding: 3px 6px;
|
||||
outline: none;
|
||||
}
|
||||
/* 删除入口:hover 出现;触摸设备(无 hover)常驻可见(FR-RESP-007);目标 ≥44px(FR-RESP-008) */
|
||||
.item-del {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--pa-r-sm);
|
||||
color: var(--pa-ink-subtle);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s var(--pa-ease), background 0.15s var(--pa-ease), color 0.15s var(--pa-ease);
|
||||
}
|
||||
.item-del svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.item-del:hover {
|
||||
color: var(--pa-error);
|
||||
background: rgba(255, 92, 122, 0.12);
|
||||
}
|
||||
.session-item:hover .item-del,
|
||||
.session-item:focus-within .item-del {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
.item-del {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.item-del {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.item-del svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup>
|
||||
// SessionPanel.vue —— 左栏会话面板(B2 入口组件,替换 PM 占位)
|
||||
// 新建按钮 / 搜索框 / 分组列表(今天/昨天/更早)/ 状态点四态 / 活动渐变条 /
|
||||
// 重命名双击 / 删除二次确认 / 空搜索提示(FR-SESSION-001~009)。
|
||||
// 注意:左抽屉形态由 B1 布局层负责;本组件只渲染面板内容(session-panel 根元素)。
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useSessionsStore } from '../../stores/sessions.js';
|
||||
import { useChatStore } from '../../stores/chat.js';
|
||||
import { T, SESSION_GROUPS } from '../../i18n/labels.js';
|
||||
import SearchBox from './SearchBox.vue';
|
||||
import SessionItem from './SessionItem.vue';
|
||||
import SessionRail from './SessionRail.vue';
|
||||
|
||||
const sessions = useSessionsStore();
|
||||
const chat = useChatStore();
|
||||
|
||||
const confirmDelete = computed(() => sessions._confirmDelete || null);
|
||||
|
||||
onMounted(() => {
|
||||
sessions.load().catch(() => {
|
||||
/* 列表加载失败 → error 状态展示空态/离线态(失败必须可见,PRD §2.2) */
|
||||
});
|
||||
});
|
||||
|
||||
/** 元信息行:消息数 + 相对时间(今天 HH:mm / 昨天 / 日期) */
|
||||
const metaText = (s) => {
|
||||
const parts = [];
|
||||
const n = s.message_count ?? 0;
|
||||
parts.push(String(n));
|
||||
const t = s.updated_at ? new Date(s.updated_at) : null;
|
||||
if (t && !Number.isNaN(t.getTime())) {
|
||||
const now = new Date();
|
||||
const sameDay = (a, b) =>
|
||||
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
const y = new Date(now.getTime() - 86400000);
|
||||
if (sameDay(t, now)) {
|
||||
parts.push(`${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`);
|
||||
} else if (sameDay(t, y)) {
|
||||
parts.push(SESSION_GROUPS[1]);
|
||||
} else {
|
||||
parts.push(`${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
return parts.join(' · ');
|
||||
};
|
||||
|
||||
const select = (id) => {
|
||||
if (sessions.activeId === id) return;
|
||||
chat.selectSession(id).catch((err) => {
|
||||
if (err && err.status === 404) {
|
||||
// NOT_FOUND → 回列表(API.md §1)
|
||||
sessions.remove(id).catch(() => {});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createNew = async () => {
|
||||
try {
|
||||
const sess = await sessions.create();
|
||||
await chat.selectSession(sess.id);
|
||||
} catch {
|
||||
/* 失败必须可见:creating 失败时按钮恢复,列表 error 态已体现 */
|
||||
}
|
||||
};
|
||||
|
||||
/** 重命名(FR-SESSION-006):PATCH 后列表与顶栏(同 store)同步更新 */
|
||||
const rename = ({ id, title }) => {
|
||||
sessions.rename(id, title).catch(() => {});
|
||||
};
|
||||
|
||||
/** 删除(FR-SESSION-007):二次确认;删当前会话 → 切最新一个;列表空 → 欢迎空态 */
|
||||
const askDelete = (s) => {
|
||||
sessions._confirmDelete = { id: s.id, title: s.title };
|
||||
};
|
||||
const cancelDelete = () => {
|
||||
sessions._confirmDelete = null;
|
||||
};
|
||||
const doDelete = async () => {
|
||||
const target = sessions._confirmDelete;
|
||||
sessions._confirmDelete = null;
|
||||
if (!target) return;
|
||||
try {
|
||||
await sessions.remove(target.id);
|
||||
if (sessions.activeId) {
|
||||
await chat.selectSession(sessions.activeId);
|
||||
} else {
|
||||
chat.sessionId = null;
|
||||
chat.messages = [];
|
||||
}
|
||||
} catch {
|
||||
/* 删除失败:列表未变,错误可重试 */
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="session-panel">
|
||||
<div class="inner">
|
||||
<div class="pad">
|
||||
<button class="new-task-btn" type="button" :disabled="sessions.creating" @click="createNew">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
{{ T.newTask }}
|
||||
</button>
|
||||
<SearchBox v-model="sessions.searchQuery" />
|
||||
</div>
|
||||
|
||||
<div class="session-list">
|
||||
<!-- 加载骨架 -->
|
||||
<div v-if="sessions.loading" class="list-skeleton">
|
||||
<div v-for="i in 4" :key="i" class="sk-item"></div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败(离线态,失败必须可见) -->
|
||||
<div v-else-if="sessions.error && !sessions.items.length" class="empty-search">
|
||||
{{ T.upstreamError }}
|
||||
</div>
|
||||
|
||||
<!-- 分组列表 -->
|
||||
<template v-else>
|
||||
<div v-for="g in sessions.groups" :key="g.label" class="session-group">
|
||||
<div class="group-label">{{ g.label }}</div>
|
||||
<SessionItem
|
||||
v-for="s in g.items"
|
||||
:key="s.id"
|
||||
:session="s"
|
||||
:active="sessions.activeId === s.id"
|
||||
:meta-text="metaText(s)"
|
||||
@select="select"
|
||||
@rename="rename"
|
||||
@delete="askDelete"
|
||||
/>
|
||||
</div>
|
||||
<!-- 空列表引导(FR-SESSION-001) -->
|
||||
<div v-if="sessions.empty" class="empty-search">{{ T.sessionEmptyTitle }}</div>
|
||||
<!-- 无匹配(FR-SESSION-005) -->
|
||||
<div v-if="sessions.noMatch" class="empty-search">{{ T.noMatch }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 折叠后的图标轨(FR-SHELL-004 的 B2 侧内容) -->
|
||||
<SessionRail
|
||||
:active="true"
|
||||
@open="$emit('open-rail')"
|
||||
@goto-kg="$emit('goto-kg')"
|
||||
@goto-settings="$emit('goto-settings')"
|
||||
/>
|
||||
|
||||
<!-- 删除二次确认(FR-SESSION-007)
|
||||
Teleport 到 body:SessionPanel 在平板档渲染于 B1 的 Drawer 内,
|
||||
.drawer-el 的 transform 会使内部 position:fixed 相对该祖先定位(而非视口),
|
||||
且确认框 320px 宽会溢出 260px 抽屉——必须脱离抽屉层级。 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="confirmDelete" class="confirm-mask">
|
||||
<div class="confirm-box">
|
||||
<div class="cb-title">{{ T.confirmDeleteTitle }}</div>
|
||||
<div class="cb-body">{{ T.confirmDeleteBody }}</div>
|
||||
<div class="cb-actions">
|
||||
<button class="cb-btn ghost" type="button" @click="cancelDelete">{{ T.cancel }}</button>
|
||||
<button class="cb-btn danger" type="button" @click="doDelete">{{ T.confirm }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 容器样式由 B1 base.css 承担(.session-panel / .aside-host > .session-panel,
|
||||
面板在 aside-host 内渲染,不重复定义避免双边框冲突)。
|
||||
此处仅保留组件内部结构兜底(B1 base.css 已就绪,两处同值不冲突)与确认弹层。 */
|
||||
.session-panel {
|
||||
position: relative; /* 删除确认弹层定位基准 */
|
||||
}
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: 260px;
|
||||
transition: opacity 0.2s var(--pa-ease);
|
||||
}
|
||||
.pad {
|
||||
padding: 14px 14px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.new-task-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
height: 40px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-gradient);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight:600;
|
||||
box-shadow: 0 0 0 rgba(0, 212, 255, 0);
|
||||
transition: box-shadow 0.3s var(--pa-ease), transform 0.2s var(--pa-ease);
|
||||
cursor: pointer;
|
||||
}
|
||||
.new-task-btn:hover:not(:disabled) {
|
||||
box-shadow: 0 0 24px rgba(0, 212, 255, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.new-task-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.new-task-btn svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
.session-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.group-label {
|
||||
padding: 10px 6px 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.empty-search {
|
||||
padding: 24px 10px;
|
||||
text-align: center;
|
||||
color: var(--pa-ink-subtle);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.list-skeleton .sk-item {
|
||||
height: 40px;
|
||||
border-radius: var(--pa-r-md);
|
||||
background: var(--pa-surface-3);
|
||||
margin-bottom: 6px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-skeleton .sk-item::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;
|
||||
}
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 70; /* 高于 Drawer(62):确认框必须盖住抽屉与 scrim */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--pa-scrim);
|
||||
}
|
||||
.confirm-box {
|
||||
width: 320px;
|
||||
padding: 20px;
|
||||
border-radius: var(--pa-r-lg);
|
||||
background: var(--pa-raised);
|
||||
border: 1px solid var(--pa-hairline-strong);
|
||||
}
|
||||
.cb-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cb-body {
|
||||
font-size: 13px;
|
||||
color: var(--pa-ink-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.cb-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.cb-btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: var(--pa-r-md);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cb-btn.ghost {
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.cb-btn.danger {
|
||||
background: var(--pa-error);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
// session/SessionRail.vue —— 左栏折叠后的 64px 图标轨(FR-SHELL-004 的 B2 侧)
|
||||
// 会话/图谱/设置三个 rail-item;会话项当前,带左侧渐变竖条。
|
||||
import { T } from '../../i18n/labels.js';
|
||||
|
||||
defineProps({
|
||||
active: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open', 'goto-kg', 'goto-settings']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="collapsed-rail">
|
||||
<button class="rail-item" :class="{ active }" :title="T.railSession" type="button" @click="emit('open')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="rail-item" :title="T.railKg" type="button" @click="emit('goto-kg')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="rail-item" :title="T.railSettings" type="button" @click="emit('goto-settings')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1-1.55 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.55-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h0a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.55h0a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v0a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.55 1z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 兜底样式(类名按 UI_SPEC §3 / §7) */
|
||||
.collapsed-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
.rail-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--pa-r-md);
|
||||
color: var(--pa-ink-muted);
|
||||
position: relative;
|
||||
transition: background 0.2s var(--pa-ease), color 0.2s var(--pa-ease);
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-item:hover {
|
||||
background: var(--pa-surface-2);
|
||||
color: var(--pa-ink);
|
||||
}
|
||||
.rail-item svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
.rail-item.active {
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-surface-2);
|
||||
}
|
||||
.rail-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: var(--pa-gradient);
|
||||
border-radius: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- EmptyNotice.vue —— B1 归属,B2/B3 复用。
|
||||
空态/离线提示(PRD §2.4 数据不伪造:空结果渲染为空态,禁止渲染成假内容)。 -->
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
});
|
||||
defineEmits(['action']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-notice">
|
||||
<div v-if="icon" class="empty-notice-icon" aria-hidden="true">{{ icon }}</div>
|
||||
<div v-if="title" class="empty-notice-title">{{ title }}</div>
|
||||
<div v-if="desc" class="empty-notice-desc">{{ desc }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!-- IconButton.vue —— B1 归属,B2/B3 复用。
|
||||
30×30 图标按钮(UI_SPEC §4.1 .icon-btn)。默认渲染传入的 SVG 字符串;
|
||||
也可用 #icon 插槽传任意节点(含 SVG 组件)。 -->
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** 无障碍标签(title 与 aria-label) */
|
||||
label: { type: String, default: '' },
|
||||
/** 按钮内联 SVG 字符串(与 #icon 插槽二选一,插槽优先) */
|
||||
icon: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
active: { type: Boolean, default: false },
|
||||
});
|
||||
defineEmits(['click']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:title="label"
|
||||
:aria-label="label || undefined"
|
||||
:aria-pressed="active ? 'true' : undefined"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<slot name="icon">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- icon 为受控常量 SVG,非用户输入 -->
|
||||
<span v-if="icon" v-html="icon"></span>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- SkeletonCard.vue —— B1 归属,B2/B3 复用。
|
||||
骨架屏卡(DESIGN.md §6:surface-3 底 + shimmer 层),用于列表/详情加载占位。 -->
|
||||
<script setup>
|
||||
defineProps({
|
||||
width: { type: String, default: '100%' },
|
||||
height: { type: String, default: '48px' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skeleton-card" :style="{ width, height }"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- StatusBadge.vue —— B1 归属,B2/B3 复用。
|
||||
状态徽章 pill(UI_SPEC §4.1 / PRD §2.1):四态三编码(颜色 + 图标 + 文字)。
|
||||
状态由 .badge.{status} 视觉承载(base.css 原样搬运自原型);文案走 labels。 -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { STATUS_LABEL } from '../../i18n/labels.js';
|
||||
|
||||
const props = defineProps({
|
||||
/** idle | running | done | error(PRD §2.1 会话状态四态) */
|
||||
status: { type: String, required: true },
|
||||
/** 文案覆盖(默认取 STATUS_LABEL;后端 error.message 直显可传入) */
|
||||
label: { type: String, default: null },
|
||||
});
|
||||
|
||||
const text = computed(() => {
|
||||
if (props.label != null) return props.label;
|
||||
return STATUS_LABEL[props.status] || props.status;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="status">
|
||||
<span class="dot"></span>{{ text }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- StatusDot.vue —— B1 归属,B2/B3 复用。
|
||||
8px 会话状态点(UI_SPEC §4.2):running 脉冲 / done·error 实心 / idle 空心。 -->
|
||||
<script setup>
|
||||
defineProps({
|
||||
/** idle | running | done | error(PRD §2.1) */
|
||||
status: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-dot" :class="status"></span>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
// config/modes.js —— 作曲器三种模式的请求参数集(PM 独占,只读)
|
||||
// 对应 PRD FR-CHAT-006。B2 发请求时按此拼装 body,不得增删字段。
|
||||
|
||||
const BASE_PARAMS = {
|
||||
debug: false,
|
||||
chain_top_k: 3,
|
||||
chain_hops: 2,
|
||||
chain_max_edges: 12,
|
||||
chain_max_nodes: 10,
|
||||
native_chain_enabled: true,
|
||||
native_chain_max_hops: 3,
|
||||
native_chain_top_k: 5,
|
||||
};
|
||||
|
||||
export const MODES = {
|
||||
ask: {
|
||||
key: 'ask',
|
||||
endpoint: '/api/qa/chat/stream',
|
||||
params: { ...BASE_PARAMS, use_llm: true },
|
||||
},
|
||||
plan: {
|
||||
key: 'plan',
|
||||
endpoint: '/api/qa/chat/stream',
|
||||
// 方案模式:深证据链,与 ask 的区别在于更强的链式检索
|
||||
params: {
|
||||
...BASE_PARAMS,
|
||||
use_llm: true,
|
||||
native_chain_enabled: true,
|
||||
native_chain_max_hops: 3,
|
||||
native_chain_top_k: 5,
|
||||
},
|
||||
},
|
||||
search: {
|
||||
key: 'search',
|
||||
endpoint: '/api/qa/search',
|
||||
params: { ...BASE_PARAMS, use_llm: false },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
// i18n/labels.js —— 全局中文文案与映射表(PM 独占,只读)
|
||||
// 所有组件文案从本文件引用,组件内不写死中文字符串(后端返回的 error.message 除外)
|
||||
|
||||
export const APP_NAME = '海古纪 PlantAI';
|
||||
export const APP_TITLE = '知识工作台';
|
||||
export const VERSION = 'PlantAI RAG v0.1';
|
||||
|
||||
export const STATUS_LABEL = {
|
||||
running: '运行中',
|
||||
done: '已完成',
|
||||
error: '失败',
|
||||
idle: '排队中',
|
||||
ready: '就绪',
|
||||
};
|
||||
|
||||
export const PHASE_LABEL = {
|
||||
planning: '规划',
|
||||
retrieval: '检索',
|
||||
evidence: '证据',
|
||||
generation: '生成',
|
||||
done: '完成',
|
||||
};
|
||||
|
||||
export const PHASE_COLOR_VAR = {
|
||||
planning: '--pa-t-planning',
|
||||
retrieval: '--pa-t-execution',
|
||||
evidence: '--pa-t-knowledge',
|
||||
generation: '--pa-t-validation',
|
||||
done: '--pa-t-done',
|
||||
};
|
||||
|
||||
export const PHASE_DESC = {
|
||||
planning: '意图理解与实体识别',
|
||||
retrieval: '多路检索与子问题召回',
|
||||
evidence: '证据筛选与交叉验证',
|
||||
generation: '答案生成与引用锚定',
|
||||
done: '检索完成',
|
||||
};
|
||||
|
||||
// SSE stage → 相位映射(与 BFF sse_relay 保持一致;BFF 已算 phase,此表仅作校验与调试兜底)
|
||||
export const STAGE_PHASE = {
|
||||
understanding_started: 'planning',
|
||||
entities_identified: 'planning',
|
||||
related_search_started: 'planning',
|
||||
concepts_found: 'retrieval',
|
||||
subquestion_retrieved: 'retrieval',
|
||||
agent_review_started: 'retrieval',
|
||||
subquestion_retry_started: 'retrieval',
|
||||
evidence_accepted: 'evidence',
|
||||
evidence_review_completed: 'evidence',
|
||||
answer_generation_started: 'generation',
|
||||
completed: 'done',
|
||||
};
|
||||
|
||||
export const BENCH_TABS = [
|
||||
{ key: 'retrieval', label: '检索过程' },
|
||||
{ key: 'sources', label: '文献来源' },
|
||||
{ key: 'extract', label: '提取任务' },
|
||||
{ key: 'kg', label: '知识图谱' },
|
||||
];
|
||||
|
||||
export const TOOL_FILTERS = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'planning', label: '规划' },
|
||||
{ key: 'retrieval', label: '检索' },
|
||||
{ key: 'evidence', label: '证据' },
|
||||
{ key: 'generation', label: '生成' },
|
||||
];
|
||||
|
||||
export const MODES = [
|
||||
{ key: 'ask', label: '问答' },
|
||||
{ key: 'plan', label: '方案' },
|
||||
{ key: 'search', label: '检索' },
|
||||
];
|
||||
|
||||
export const SESSION_GROUPS = ['今天', '昨天', '更早'];
|
||||
|
||||
export const SUGGEST_CARDS = [
|
||||
{
|
||||
icon: '🧬',
|
||||
name: '基因家族功能',
|
||||
desc: '查询 SPL / MADS-box / WRKY 等家族的功能与调控网络',
|
||||
text: '水稻SPL基因家族的功能研究有哪些关键进展?',
|
||||
},
|
||||
{
|
||||
icon: '🌳',
|
||||
name: '家族鉴定与进化',
|
||||
desc: '某物种中某家族的成员数量与系统发育关系',
|
||||
text: '拟南芥HD-ZIP转录因子家族的成员组成与进化关系?',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
name: '表达与证据链',
|
||||
desc: '基因在不同组织中的表达模式与文献证据',
|
||||
text: 'OsSPL14 在水稻穗部的表达模式及其调控网络证据?',
|
||||
},
|
||||
];
|
||||
|
||||
export const T = {
|
||||
newTask: '新建会话',
|
||||
searchPlaceholder: '搜索会话…',
|
||||
noMatch: '无匹配会话',
|
||||
railSession: '会话',
|
||||
railKg: '知识图谱',
|
||||
railSettings: '设置',
|
||||
composerPlaceholder: '输入你的问题,例如:水稻SPL基因家族的功能研究有哪些关键进展?',
|
||||
composerHint: 'Enter 换行 · ⌘/Ctrl + Enter 发送',
|
||||
send: '发送',
|
||||
stop: '停止',
|
||||
skipReveal: '跳过',
|
||||
backToLatest: '回到最新',
|
||||
assistantName: 'PlantAI 智能体',
|
||||
pipelineTitle: '⚙ 检索流水线',
|
||||
stepDone: '已完成',
|
||||
stepRunning: '运行中',
|
||||
stepWaiting: '等待',
|
||||
interrupted: '已中断',
|
||||
stopped: '已停止',
|
||||
retry: '重试',
|
||||
disconnected: '连接中断,请检查网络后重试',
|
||||
timeoutMsg: '检索超时,请重试',
|
||||
citationError: '引用校验失败,请重试',
|
||||
qaDown: '知识问答服务当前不可用,请稍后重试',
|
||||
kgDown: '知识图谱服务离线,实体搜索与邻域浏览暂不可用',
|
||||
extractionDown: '提取任务服务离线',
|
||||
sourcesTitle: '文献来源',
|
||||
sourceEmpty: '本次检索未命中文献',
|
||||
retrievalEmpty: '发起提问后,这里会实时展示检索过程',
|
||||
retrievalPlanTitle: '检索计划',
|
||||
pipelineInfoTitle: '检索流水线(QA v1.1.73)',
|
||||
extractEmpty: '暂无抽取任务',
|
||||
extractUpload: '上传论文 PDF',
|
||||
extractUploadLimit: '仅支持 PDF,单文件 ≤50MB',
|
||||
extractUploadFail: '上传失败:',
|
||||
kgEmpty: '本次检索未产生图谱关系,可使用下方搜索浏览知识库',
|
||||
kgSearchPlaceholder: '搜索实体名,如 OsSPL14',
|
||||
chainBadge: '跳',
|
||||
sessionEmptyTitle: '海古纪 PlantAI 知识工作台',
|
||||
sessionEmptySub: '基于文献知识库的基因家族问答 —— 每一次提问都有证据锚定',
|
||||
independentQuestionNote: '每次提问独立检索,不携带上文语境',
|
||||
confirmDeleteTitle: '删除会话',
|
||||
confirmDeleteBody: '删除后会话与全部消息不可恢复,确认删除?',
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
renamePlaceholder: '会话标题',
|
||||
loading: '加载中…',
|
||||
streamEvents: '阶段事件',
|
||||
sourceCount: '来源',
|
||||
score: '分数',
|
||||
sessionSources: '本次回答',
|
||||
sessionAllSources: '本会话全部',
|
||||
labelGroup: '分组',
|
||||
kgNeighborhood: '邻域',
|
||||
kgDepth: '深度',
|
||||
kgEntityDetail: '实体详情',
|
||||
upstreamError: '服务暂时不可用',
|
||||
// —— 工作台文案(原 B3 benchLabels.js 合并,2026-08-25)——
|
||||
extractRunLog: '运行日志',
|
||||
kgSearch: '搜索',
|
||||
kgDepth2SlowHint: '(深度 2 较慢,约 13s)',
|
||||
kgDepth2NeighborhoodSlow: '(深度 2 检索较慢)',
|
||||
kgDegradedListPrefix: '节点数超过',
|
||||
kgDegradedListSuffix: ',已降级为列表视图(仍可搜索实体浏览邻域)',
|
||||
kgNodeUnit: '节点',
|
||||
kgEdgeUnit: '边',
|
||||
kgRelationUnit: '关系',
|
||||
benchExpand: '展开工作台',
|
||||
benchCollapse: '折叠工作台',
|
||||
close: '关闭',
|
||||
resetView: '复位视图',
|
||||
stageLabel: '阶段',
|
||||
paramsLabel: '参数',
|
||||
// —— B2 补充(2026-08-25 PM 集成)——
|
||||
toolParams: '参数',
|
||||
toolResult: '输出',
|
||||
toolLog: '日志',
|
||||
graphSummary: '知识图谱',
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// main.js —— B1 归属。应用入口。
|
||||
// 主题初始化必须在 createApp 之前同步执行(UI_SPEC §6,防闪白)。
|
||||
// index.html 中 <html data-theme="dark"> 硬编码兜底;此处按 localStorage 覆盖。
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import './styles/tokens.css';
|
||||
import './styles/base.css';
|
||||
import './styles/animations.css';
|
||||
import './styles/responsive.css';
|
||||
import { applyTheme } from './stores/theme.js';
|
||||
|
||||
/* ---------- 防闪白:挂载前同步应用主题(FR-SHELL-007 初始化顺序) ---------- */
|
||||
const t = localStorage.getItem('pa-theme');
|
||||
if (t === 'light' || t === 'dark') applyTheme(t);
|
||||
|
||||
createApp(App).use(createPinia()).mount('#app');
|
||||
@@ -0,0 +1,127 @@
|
||||
// sse/parser.js —— SSE 流解析(B2 归属)
|
||||
// 消费方式:fetch + ReadableStream(PRD FR-CHAT-008:请求带 JSON body 的 POST,
|
||||
// 禁止 EventSource)。本模块处理:
|
||||
// - `event:` / `data:` 行(data 多行按 \n 合并,SSE 规范)
|
||||
// - `:` 注释帧(15s keepalive ping)→ 忽略
|
||||
// - CRLF / LF 混合换行
|
||||
// - 无 event 名(默认 event = 'message')
|
||||
// - 跨 chunk 截断(含多字节 UTF-8 字符)
|
||||
// 输出类型化事件对象 { event, data, dataStr },data 为 JSON 解析结果(失败时保留原字符串)。
|
||||
|
||||
/**
|
||||
* 解析一个完整帧文本(不含帧分隔符)。
|
||||
* @param {string} text 帧文本(可能含 CRLF)
|
||||
* @returns {{event: string, data: *, dataStr: string} | null}
|
||||
* 注释帧 / 无 data 的帧返回 null。
|
||||
*/
|
||||
export function parseFrame(text) {
|
||||
const lines = String(text).split(/\r?\n/);
|
||||
let event = null;
|
||||
const dataLines = [];
|
||||
let hasData = false;
|
||||
for (const line of lines) {
|
||||
if (line === '' || line.startsWith(':')) continue; // 空行 / 注释(keepalive)
|
||||
let name = line;
|
||||
let value = '';
|
||||
const colon = line.indexOf(':');
|
||||
if (colon !== -1) {
|
||||
name = line.slice(0, colon);
|
||||
value = line.slice(colon + 1).replace(/^ /, ''); // 冒号后仅去掉一个前导空格
|
||||
}
|
||||
if (name === 'event') event = value;
|
||||
else if (name === 'data') {
|
||||
dataLines.push(value);
|
||||
hasData = true;
|
||||
}
|
||||
// 其余字段(id/retry)与 BFF 契约无关,忽略
|
||||
}
|
||||
if (!hasData) return null;
|
||||
const dataStr = dataLines.join('\n');
|
||||
let data = dataStr;
|
||||
try {
|
||||
data = JSON.parse(dataStr);
|
||||
} catch {
|
||||
/* 非 JSON data(异常上游)保留原字符串 */
|
||||
}
|
||||
return { event: event || 'message', data, dataStr };
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量解析器:feed() 喂入 chunk,end() 结束。
|
||||
* 内部用 TextDecoder({stream:true}) 处理多字节字符跨 chunk 截断。
|
||||
*/
|
||||
export class SseParser {
|
||||
constructor({ onEvent } = {}) {
|
||||
this.onEvent = onEvent || (() => {});
|
||||
this.decoder = new TextDecoder('utf-8');
|
||||
this.buffer = '';
|
||||
}
|
||||
|
||||
feed(chunk) {
|
||||
// 真实 fetch 流给 Uint8Array;字符串(测试/手工调用)也接受,统一按 UTF-8 处理
|
||||
let bytes = chunk;
|
||||
if (typeof chunk === 'string') bytes = new TextEncoder().encode(chunk);
|
||||
else if (Array.isArray(chunk)) bytes = new Uint8Array(chunk);
|
||||
this.buffer += this.decoder.decode(bytes, { stream: true });
|
||||
this._drain();
|
||||
}
|
||||
|
||||
end() {
|
||||
this.buffer += this.decoder.decode();
|
||||
this._drain();
|
||||
const rest = this.buffer;
|
||||
this.buffer = '';
|
||||
if (rest.trim()) {
|
||||
const ev = parseFrame(rest);
|
||||
if (ev) this.onEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
_drain() {
|
||||
for (;;) {
|
||||
const idx = this._separatorIndex();
|
||||
if (idx < 0) return;
|
||||
const sepLen = this.buffer[idx] === '\r' ? 2 : 1; // '\r\n\r\n' 或 '\n\n'
|
||||
const frame = this.buffer.slice(0, idx);
|
||||
this.buffer = this.buffer.slice(idx + sepLen);
|
||||
const ev = parseFrame(frame);
|
||||
if (ev) this.onEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
_separatorIndex() {
|
||||
const a = this.buffer.indexOf('\n\n');
|
||||
const b = this.buffer.indexOf('\r\n\r\n');
|
||||
if (a < 0) return b;
|
||||
if (b < 0) return a;
|
||||
return Math.min(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费一个 SSE response(fetch 产物),逐帧回调 onEvent。
|
||||
* @param {{body: ReadableStream}} response fetch response
|
||||
* @param {(ev: {event: string, data: *, dataStr: string}) => void} onEvent
|
||||
* @returns {Promise<void>} 流自然结束 resolve;读取异常(含 AbortError)原样抛出
|
||||
*/
|
||||
export async function consumeSseStream(response, onEvent) {
|
||||
if (!response || !response.body || typeof response.body.getReader !== 'function') {
|
||||
throw new Error('SSE: response body is not a readable stream');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const parser = new SseParser({ onEvent });
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
parser.feed(value);
|
||||
}
|
||||
parser.end();
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* 读取中断(abort)时 reader 可能已锁定,忽略 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// stores/bench.js —— 右工作台四 tab 状态与数据装配(B3 归属)
|
||||
//
|
||||
// 数据来源:
|
||||
// - retrieval 阶段卡:B2 chat store 暴露 `currentEvents`(阶段事件数组,形状见 README 约定,
|
||||
// 若该 getter 不存在则回退到 bench 自持的本地事件队列,由 SSE 侧经 `recordStageEvent()` 补充)
|
||||
// - sources:当前助手消息 `sources` 块 + `GET /api/sessions/{id}/sources`(本次/本会话切换)
|
||||
// - extraction:任务列表 + 详情 + 5s 轮询(存在 running 时;tab 切走暂停)
|
||||
// - kg:当前消息 `graph` 块(答案图谱,永远可用)+ `/api/kg/*` 自由浏览(Neo4j 可用时)
|
||||
import { defineStore, getActivePinia } from 'pinia';
|
||||
import { listJobs, getJob } from '../api/extraction.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
// http.js 由 B1 归属(契约:统一错误信封)。为容忍 B1 交付时序(并行开发期间该文件
|
||||
// 可能尚未就绪),这里用「可注入 + glob 兜底」的方式取得 get 函数:
|
||||
// - 生产集成:import.meta.glob 在构建期把 http.js 编入映射,运行时正常取到;
|
||||
// - 单测:调用 __setHttpGet(fn) 注入 mock,完全不触碰 http.js。
|
||||
let _httpGet = null;
|
||||
export function __setHttpGet(fn) {
|
||||
_httpGet = fn;
|
||||
}
|
||||
const _apiModules = import.meta.glob('../api/*.js');
|
||||
async function httpGet(url, params) {
|
||||
if (_httpGet) return _httpGet(url, params);
|
||||
const mod = await _apiModules['../api/http.js']?.();
|
||||
if (!mod || typeof mod.get !== 'function') {
|
||||
throw new Error('api/http.js 未就绪(B1 交付后可用)');
|
||||
}
|
||||
return mod.get(url, params);
|
||||
}
|
||||
|
||||
/** 把 B2 的事件形状与本地队列形状统一为阶段卡记录 */
|
||||
export function normalizeStageEvent(ev) {
|
||||
if (!ev || typeof ev !== 'object') return null;
|
||||
return {
|
||||
id: ev.id || `${ev.stage ?? 'stage'}-${ev.at ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
stage: ev.stage ?? '',
|
||||
phase: ev.phase ?? 'retrieval',
|
||||
label: ev.label || ev.stage || '',
|
||||
details: ev.details ?? {},
|
||||
at: ev.at ?? null,
|
||||
ts: ev.ts ?? (ev.at ? Date.parse(ev.at) : Date.now()),
|
||||
};
|
||||
}
|
||||
|
||||
export const useBenchStore = defineStore('bench', {
|
||||
state: () => ({
|
||||
// ---- 工作台 UI ----
|
||||
activeTab: 'retrieval',
|
||||
collapsed: false,
|
||||
// ---- 检索过程 ----
|
||||
phaseFilter: 'all',
|
||||
localStageEvents: [],
|
||||
// ---- 文献来源 ----
|
||||
sourceScope: 'current', // 'current' 本次回答 | 'session' 本会话全部
|
||||
sessionSources: null, // GET /api/sessions/{id}/sources 聚合结果
|
||||
sourcesLoading: false,
|
||||
sourcesError: false,
|
||||
highlightSource: null, // FR-CHAT-015:引用 chip → 来源条目高亮({index, until}),2s 后自动清除
|
||||
// ---- 提取任务 ----
|
||||
jobs: [],
|
||||
jobsCount: 0,
|
||||
jobsLoading: false,
|
||||
jobsError: false,
|
||||
jobDetail: {},
|
||||
detailLoading: false,
|
||||
detailError: false,
|
||||
pollTimer: null,
|
||||
pollPaused: false,
|
||||
// ---- 知识图谱 ----
|
||||
kgStatus: null, // { ok, active, schema, ... }
|
||||
kgReady: false, // Neo4j 是否可用(false → 离线降级)
|
||||
kgError: false,
|
||||
kgNodeLimit: 50, // 移动端由 KgPane 下调为 30
|
||||
// ---- 统一状态机 ----
|
||||
refreshSeq: 0, // 每轮「置空 → 加载」自增,用于丢弃过期响应
|
||||
needRefresh: {}, // pane → true:下次激活时重新装配(离线重试等场景)
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// 阶段卡数据源:优先 B2 chat store;未就绪时用本地队列兜底(见与 B2 的接口约定)
|
||||
stageEvents() {
|
||||
try {
|
||||
const chat = getActivePinia()?._s.get('chat');
|
||||
const ce = chat?.currentEvents;
|
||||
if (Array.isArray(ce) && ce.length) return ce.map(normalizeStageEvent).filter(Boolean);
|
||||
} catch {
|
||||
/* B2 未就绪:回退本地队列 */
|
||||
}
|
||||
return this.localStageEvents;
|
||||
},
|
||||
filteredStageEvents() {
|
||||
const f = this.phaseFilter;
|
||||
return f === 'all' ? this.stageEvents : this.stageEvents.filter((e) => e.phase === f);
|
||||
},
|
||||
/** 是否有运行中的提问(有则卡片默认展开/高亮) */
|
||||
hasRunningStage() {
|
||||
return this.stageEvents.some((e) => e.phase !== 'done');
|
||||
},
|
||||
/** 当前助手消息的 sources 块(无 → null) */
|
||||
currentSources() {
|
||||
const msg = this.currentAssistantMessage;
|
||||
if (!msg) return null;
|
||||
const blk = (msg.blocks || []).find((b) => b?.type === 'sources');
|
||||
return Array.isArray(blk?.items) ? blk.items : null;
|
||||
},
|
||||
/** 当前助手消息的 graph 块(答案图谱) */
|
||||
currentGraph() {
|
||||
const msg = this.currentAssistantMessage;
|
||||
if (!msg) return null;
|
||||
const blk = (msg.blocks || []).find((b) => b?.type === 'graph');
|
||||
return blk || null;
|
||||
},
|
||||
/** 当前助手消息(B2 chat store 不可用时返回 null) */
|
||||
currentAssistantMessage() {
|
||||
try {
|
||||
const chat = getActivePinia()?._s.get('chat');
|
||||
const cur = chat?.currentMessage;
|
||||
if (cur && cur.role === 'assistant') return cur;
|
||||
const last = chat?.messages?.slice(-1)[0];
|
||||
return last && last.role === 'assistant' ? last : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** 按 paper_id 去重并累计出现次数的会话级来源 */
|
||||
sessionSourceItems() {
|
||||
const s = this.sessionSources;
|
||||
if (!s || !Array.isArray(s.sources)) return [];
|
||||
const seen = new Map();
|
||||
for (const it of s.sources) {
|
||||
const key = it.paper_id || it.id || JSON.stringify(it);
|
||||
const prev = seen.get(key);
|
||||
if (prev) prev.count += it.count ?? 1;
|
||||
else seen.set(key, { ...it, count: it.count ?? 1 });
|
||||
}
|
||||
return [...seen.values()];
|
||||
},
|
||||
/** 当前来源列表(含来源标签) */
|
||||
sources() {
|
||||
if (this.sourceScope === 'current') {
|
||||
const items = this.currentSources;
|
||||
return items ? items.map((s) => ({ src: s, label: null })) : null;
|
||||
}
|
||||
if (!this.sessionSources) return null;
|
||||
return this.sessionSourceItems.map((s) => ({ src: s, label: s.count }));
|
||||
},
|
||||
/** 存在 running/queued 任务 → 需要轮询(failed/completed 为终态) */
|
||||
hasActiveJobs() {
|
||||
return this.jobs.some((j) => j.status === 'running' || j.status === 'queued');
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
// ---------- 基础 UI ----------
|
||||
setActiveTab(tab) {
|
||||
if (tab === this.activeTab) return;
|
||||
const prev = this.activeTab;
|
||||
this.activeTab = tab;
|
||||
if (prev === 'extract') this.pausePolling();
|
||||
if (tab === 'extract') {
|
||||
this.pollPaused = false; // 回到 extract 恢复轮询资格(存在 running 时由 pane 重启)
|
||||
if (this.needRefresh.extract) {
|
||||
this.needRefresh.extract = false;
|
||||
this.loadJobs({ force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
setCollapsed(v) {
|
||||
this.collapsed = !!v;
|
||||
},
|
||||
setPhaseFilter(key) {
|
||||
this.phaseFilter = key;
|
||||
},
|
||||
setSourceScope(scope) {
|
||||
this.sourceScope = scope;
|
||||
},
|
||||
// FR-CHAT-015:引用 chip 点击 → 切到来源 tab 并高亮对应条目 2s(PM 集成补丁,B2 依赖)
|
||||
setHighlightSource(index) {
|
||||
this.highlightSource = { index, until: Date.now() + 2000 };
|
||||
if (this._hlTimer) clearTimeout(this._hlTimer);
|
||||
this._hlTimer = setTimeout(() => {
|
||||
if (this.highlightSource && this.highlightSource.until <= Date.now()) {
|
||||
this.highlightSource = null;
|
||||
}
|
||||
}, 2200);
|
||||
},
|
||||
setKgNodeLimit(n) {
|
||||
this.kgNodeLimit = n;
|
||||
},
|
||||
|
||||
// ---------- 检索过程 ----------
|
||||
/** 本地事件队列兜底入口(B2 就绪前由外部 SSE 订阅方调用) */
|
||||
recordStageEvent(ev) {
|
||||
const norm = normalizeStageEvent(ev);
|
||||
if (norm) this.localStageEvents.push(norm);
|
||||
},
|
||||
clearStageEvents() {
|
||||
this.localStageEvents = [];
|
||||
},
|
||||
|
||||
// ---------- 文献来源 ----------
|
||||
/**
|
||||
* 拉取本会话全部来源聚合。返回 null 表示无活动会话(不需要请求)。
|
||||
* degraded/离线时保留已有数据并标记错误(失败必须可见,见 PRD §2.2)。
|
||||
*/
|
||||
async loadSessionSources({ force = false } = {}) {
|
||||
if (!force && this.sessionSources) return this.sessionSources;
|
||||
const sid = this._activeSessionId();
|
||||
if (!sid) return null;
|
||||
const seq = ++this.refreshSeq;
|
||||
this.sourcesLoading = true;
|
||||
this.sourcesError = false;
|
||||
try {
|
||||
const data = await httpGet(`/api/sessions/${sid}/sources`);
|
||||
if (seq === this.refreshSeq) this.sessionSources = data;
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (seq === this.refreshSeq) this.sourcesError = true;
|
||||
throw err;
|
||||
} finally {
|
||||
if (seq === this.refreshSeq) this.sourcesLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ---------- 提取任务 ----------
|
||||
async loadJobs({ force = false } = {}) {
|
||||
if (!force && this.jobs.length) return this.jobs;
|
||||
const seq = ++this.refreshSeq;
|
||||
this.jobsLoading = true;
|
||||
this.jobsError = false;
|
||||
try {
|
||||
const data = await listJobs({ limit: 20 });
|
||||
if (seq !== this.refreshSeq) return data.jobs;
|
||||
this.jobs = data.jobs || [];
|
||||
this.jobsCount = data.count ?? this.jobs.length;
|
||||
} catch (err) {
|
||||
if (seq === this.refreshSeq) this.jobsError = true;
|
||||
throw err;
|
||||
} finally {
|
||||
if (seq === this.refreshSeq) this.jobsLoading = false;
|
||||
}
|
||||
return this.jobs;
|
||||
},
|
||||
async loadJobDetail(jobId) {
|
||||
if (this.jobDetail[jobId]?.pending) return;
|
||||
this.detailLoading = true;
|
||||
this.detailError = false;
|
||||
try {
|
||||
const data = await getJob(jobId);
|
||||
this.jobDetail[jobId] = { data, at: Date.now() };
|
||||
} catch (err) {
|
||||
this.jobDetail[jobId] = { data: null, error: true, at: Date.now() };
|
||||
this.detailError = true;
|
||||
throw err;
|
||||
} finally {
|
||||
this.detailLoading = false;
|
||||
}
|
||||
},
|
||||
getJobDetail(jobId) {
|
||||
return this.jobDetail[jobId]?.data || null;
|
||||
},
|
||||
// ---- 轮询(fake-timer 可测:isPolling() 可注入;setTimeout/clearTimeout 注入便于测试替换) ----
|
||||
schedulePoll(timeoutFn, clearFn, delay = POLL_INTERVAL_MS) {
|
||||
if (this.pollTimer != null) clearFn(this.pollTimer);
|
||||
this.pollTimer = timeoutFn(() => this._tickPoll(timeoutFn, clearFn, delay), delay);
|
||||
},
|
||||
_tickPoll(timeoutFn, clearFn, delay) {
|
||||
this.pollTimer = null;
|
||||
if (this.pollPaused || this.activeTab !== 'extract') return;
|
||||
const active = this.jobs.some((j) => j.status === 'running' || j.status === 'queued');
|
||||
if (!active) return;
|
||||
this.loadJobs({ force: true })
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.pollPaused || this.activeTab !== 'extract') return;
|
||||
const stillActive = this.jobs.some((j) => j.status === 'running' || j.status === 'queued');
|
||||
if (stillActive) {
|
||||
this.pollTimer = timeoutFn(() => this._tickPoll(timeoutFn, clearFn, delay), delay);
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 启动轮询:仅在「extract tab 激活 + 存在进行中任务 + 未暂停」时运行。
|
||||
* @param {{timeoutFn?:Function, clearFn?:Function, delay?:number}} opts 测试注入点
|
||||
*/
|
||||
startPolling({ timeoutFn = setTimeout, clearFn = clearTimeout, delay = POLL_INTERVAL_MS } = {}) {
|
||||
if (this.pollPaused) return false;
|
||||
const active = this.jobs.some((j) => j.status === 'running' || j.status === 'queued');
|
||||
if (!active || this.activeTab !== 'extract') return false;
|
||||
this.schedulePoll(timeoutFn, clearFn, delay);
|
||||
return true;
|
||||
},
|
||||
/** 暂停轮询(tab 切走时调用),不销毁 jobs 数据 */
|
||||
pausePolling() {
|
||||
this.pollPaused = true;
|
||||
this.stopPolling();
|
||||
},
|
||||
/** 完全停止并清定时器 */
|
||||
stopPolling(clearFn = clearTimeout) {
|
||||
if (this.pollTimer != null) {
|
||||
clearFn(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
},
|
||||
isPolling() {
|
||||
return this.pollTimer != null;
|
||||
},
|
||||
|
||||
// ---------- 知识图谱 ----------
|
||||
/**
|
||||
* 探测 Neo4j 可用性(GET /api/kg/status)。
|
||||
* 503 degraded → kgReady=false(离线降级:答案图谱仍可用,FR-KG-007)。
|
||||
*/
|
||||
async checkKgStatus({ force = false } = {}) {
|
||||
if (!force && this.kgStatus) return this.kgStatus;
|
||||
const seq = ++this.refreshSeq;
|
||||
this.kgError = false;
|
||||
try {
|
||||
const kgApi = await import('../api/kg.js');
|
||||
const data = await kgApi.kgStatus();
|
||||
if (seq !== this.refreshSeq) return data;
|
||||
this.kgStatus = data;
|
||||
this.kgReady = !!data?.ok;
|
||||
this.kgError = !this.kgReady;
|
||||
return data;
|
||||
} catch (err) {
|
||||
if (seq === this.refreshSeq) {
|
||||
this.kgReady = false;
|
||||
this.kgError = true;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
/** 在 kgStatus 不可信/过期时,由 KgPane 直接调 kg API 的结果回写 */
|
||||
setKgAvailable(ok) {
|
||||
this.kgReady = !!ok;
|
||||
},
|
||||
|
||||
// ---------- 统一 ----------
|
||||
/** 从历史消息恢复(B2 加载历史会话后调用,可选) */
|
||||
hydrate() {
|
||||
this.needRefresh = {};
|
||||
},
|
||||
resetSessionData() {
|
||||
this.localStageEvents = [];
|
||||
this.sessionSources = null;
|
||||
this.sourcesLoading = false;
|
||||
this.sourcesError = false;
|
||||
this.jobDetail = {};
|
||||
this.detailLoading = false;
|
||||
this.detailError = false;
|
||||
this.stopPolling();
|
||||
},
|
||||
_activeSessionId() {
|
||||
try {
|
||||
const sessions = getActivePinia()?._s.get('sessions');
|
||||
return sessions?.activeId || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,810 @@
|
||||
// stores/chat.js —— 对话状态机(B2 归属,核心)
|
||||
// 承载 FR-CHAT-008/009/010/011/012/013:
|
||||
// send(text):用户消息落库 → 会话 running → 助手骨架(pipeline 五相位 waiting)
|
||||
// → fetch 流(SSE)→ 逐帧消费 → 每 ~3s 检查点 PATCH → 终态落库
|
||||
// stop():AbortController 中断(FR-CHAT-011),保留已收到的块,标「已停止」
|
||||
// retry():同消息位置替换(FR-CHAT-012),不新增用户消息
|
||||
// loadSession():刷新后从 detail 恢复;interrupted 消息置 error 标「已中断」
|
||||
// 揭示动画:complete 后 30ms/字符 typewriter,>2000 字符总时长 ≤8s,带跳过
|
||||
// 错误映射:TIMEOUT / INVALID_CITATION / UPSTREAM_DOWN / 连接中断 → labels.js 文案
|
||||
import { defineStore } from 'pinia';
|
||||
import { createMessage, updateMessage } from '../api/sessions.js';
|
||||
import { chatStream, search } from '../api/qa.js';
|
||||
import { useSessionsStore } from './sessions.js';
|
||||
import { PHASE_LABEL, PHASE_DESC, T } from '../i18n/labels.js';
|
||||
import { MODES } from '../config/modes.js';
|
||||
import { renderMarkdown } from '../components/chat/markdown.js';
|
||||
|
||||
const CHECKPOINT_MS = 3000;
|
||||
const REVEAL_STEP_MS = 30;
|
||||
const REVEAL_MAX_MS = 8000;
|
||||
|
||||
/** 流水线五相位(规划/检索/证据/生成/完成);完成相位不进底栏 agent 点 */
|
||||
export const PIPELINE_PHASES = ['planning', 'retrieval', 'evidence', 'generation', 'done'];
|
||||
|
||||
/** 底栏四个 agent 点(UI_SPEC §4.8:规划/检索/证据/生成) */
|
||||
export const AGENT_DOT_PHASES = ['planning', 'retrieval', 'evidence', 'generation'];
|
||||
|
||||
/** 新建一条骨架消息(FR-CHAT-008 步骤 3):pipeline 块五相位全 waiting */
|
||||
export function newSkeletonMessage() {
|
||||
return {
|
||||
id: null, // POST /messages 后回填
|
||||
role: 'assistant',
|
||||
kind: 'qa',
|
||||
status: 'streaming',
|
||||
streamErr: null, // 错误标注:null | 'timeout' | 'interrupted' | 'stopped' | 'disconnected' | 'citation' | {message}
|
||||
interrupted: false,
|
||||
blocks: [{
|
||||
type: 'pipeline',
|
||||
status: 'running',
|
||||
steps: PIPELINE_PHASES.map((phase) => ({
|
||||
name: PHASE_LABEL[phase],
|
||||
state: 'waiting',
|
||||
desc: PHASE_DESC[phase],
|
||||
phase,
|
||||
})),
|
||||
}],
|
||||
raw: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 把归一化 QA 响应(complete 帧 payload)装配成块联合(不含 pipeline;FR-CHAT-013)
|
||||
* BUG-R1-06 修复:上游 answer 是 markdown 字符串(PRD FR-CHAT-004),装配时必须经
|
||||
* renderMarkdown(markdown-it + DOMPurify)转 HTML 后再生成 text 块——此前直接以原文
|
||||
* 落块,导致答案以纯文本渲染。 */
|
||||
export function qaToBlocks(payload) {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const blocks = [];
|
||||
if (payload.answer) blocks.push({ type: 'text', html: renderMarkdown(payload.answer) });
|
||||
if (Array.isArray(payload.sources) && payload.sources.length) {
|
||||
blocks.push({ type: 'sources', items: payload.sources, source_count: payload.source_count ?? payload.sources.length });
|
||||
}
|
||||
if (payload.graph && (payload.graph.nodes?.length || payload.graph.edges?.length)) {
|
||||
blocks.push({ type: 'graph', nodes: payload.graph.nodes || [], edges: payload.graph.edges || [], chains: payload.graph.chains || payload.chains || [] });
|
||||
}
|
||||
if (payload.chains?.length && !payload.graph) {
|
||||
blocks.push({ type: 'graph', nodes: [], edges: [], chains: payload.chains });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export const useChatStore = defineStore('chat', {
|
||||
state: () => ({
|
||||
sessionId: null, // 当前会话 id(与 sessions.activeId 对应)
|
||||
messages: [], // 当前会话消息(本地运行时形态)
|
||||
busy: false, // 有进行中的流(作曲器禁用发送、按钮变停止)
|
||||
mode: 'ask', // ask | plan | search
|
||||
loadingDetail: false, // 会话详情加载中(骨架屏)
|
||||
loadError: null, // 详情加载失败 {status, code, message} | null
|
||||
historyLoading: false,
|
||||
reveal: null, // 揭示动画状态 { text } | null(有值即揭示中)
|
||||
runningMsgId: null, // 正在流式消费的助手消息 id(本地索引)
|
||||
activeAbort: null, // 当前流的 AbortController
|
||||
abortAnn: null, // 本轮 abort 的标注意图('interrupted' | null;流 catch 据此定标注)
|
||||
checkpointTimer: null,
|
||||
lastCheckpointAt: 0,
|
||||
completedOnce: false, // 本轮是否已收到 complete(其后 progress 仅进阶段卡,不动相位)
|
||||
currentMessage: null, // B3 约定:当前助手消息(含流式中)
|
||||
currentEvents: [], // B3 约定:本轮 progress 事件(阶段卡数据源)
|
||||
historyEvents: [], // 历史消息的 progress 事件(恢复后供 bench 使用)
|
||||
lastResult: null, // 最近一次终态(ok/error + payload),供组件/测试观测
|
||||
scrollSeq: 0, // 消息区滚动请求序号(「回到最新」按钮复用)
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/** 揭示动画期间当前消息是否处在流式状态 */
|
||||
revealing() {
|
||||
return !!this.reveal;
|
||||
},
|
||||
/** 阶段卡数据源(B3 bench store 消费) */
|
||||
stageEvents() {
|
||||
return this.currentEvents;
|
||||
},
|
||||
/** 会话总数(B1 StatusBar 消费;sessions store 不可用时回退 null) */
|
||||
sessionCount() {
|
||||
try {
|
||||
return useSessionsStore().items.length;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** 是否有中断/错误的可重试消息 */
|
||||
lastRetryable() {
|
||||
return this.lastResult && this.lastResult.ok === false;
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setMode(mode) {
|
||||
if (mode in MODES) this.mode = mode;
|
||||
},
|
||||
|
||||
/** 切换会话:中断进行中的流并把原会话置 error 标「已中断」(FR-SESSION-004)
|
||||
* 同步 sessions.activeId(BUG-R1-02):活动高亮(FR-SESSION-009)与顶栏标题/徽章
|
||||
* (FR-SHELL-002)都依赖 activeSession,此前只设 this.sessionId 导致 .active 永不出现。 */
|
||||
async selectSession(id) {
|
||||
if (this.sessionId === id) return;
|
||||
await this.abort('interrupted');
|
||||
const sessions = useSessionsStore();
|
||||
this.sessionId = id;
|
||||
sessions.activeId = id; // ← 活动会话单一事实源
|
||||
this.messages = [];
|
||||
this.busy = false;
|
||||
this.reveal = null;
|
||||
this.currentMessage = null;
|
||||
this.currentEvents = [];
|
||||
this.historyEvents = [];
|
||||
this.loadError = null;
|
||||
this.lastResult = null;
|
||||
this.completedOnce = false;
|
||||
this.runningMsgId = null;
|
||||
if (this.checkpointTimer != null) {
|
||||
clearInterval(this.checkpointTimer);
|
||||
this.checkpointTimer = null;
|
||||
}
|
||||
await resetBenchData();
|
||||
if (id) await this.loadSession(id);
|
||||
},
|
||||
|
||||
/** 从 GET /api/sessions/{id} 恢复全部块(FR-CHAT-013) */
|
||||
async loadSession(id) {
|
||||
this.loadingDetail = true;
|
||||
this.loadError = null;
|
||||
try {
|
||||
const sessions = useSessionsStore();
|
||||
const data = await sessions.fetchDetail(id);
|
||||
this.hydrateDetail(data);
|
||||
} catch (err) {
|
||||
this.loadError = { status: err.status ?? 0, code: err.code || 'UNKNOWN', message: err.message || '' };
|
||||
this.messages = [];
|
||||
throw err;
|
||||
} finally {
|
||||
this.loadingDetail = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** 把后端 detail 转为本地运行时消息(user 无 blocks,用 text 渲染) */
|
||||
hydrateDetail(data) {
|
||||
this.messages = (data.messages || []).map((m) => {
|
||||
const msg = { ...m };
|
||||
if (msg.role === 'user') {
|
||||
msg.blocks = [{ type: 'text', html: msg.text || '' }];
|
||||
} else {
|
||||
const blks = Array.isArray(msg.blocks) ? msg.blocks.map((b) => ({ ...b })) : [];
|
||||
msg.blocks = blks.filter((b) => b.type !== 'stream'); // stream 瞬态不落库(防御)
|
||||
// 中断恢复(FR-CHAT-008 刷新中途 / FR-CHAT-013):'interrupted'/'streaming'(检查点落库的
|
||||
// 半成品)以及无标注的 error 一律置 error 并标「已中断」
|
||||
if (msg.status === 'interrupted' || msg.status === 'streaming' ||
|
||||
(msg.status === 'error' && !msg.streamErr)) {
|
||||
msg.interrupted = true;
|
||||
msg.status = 'error';
|
||||
msg.streamErr = 'interrupted';
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
this.currentEvents = [];
|
||||
this.historyEvents = [];
|
||||
this.lastResult = null;
|
||||
this.currentMessage = this.messages.length
|
||||
? this.messages[this.messages.length - 1]
|
||||
: null;
|
||||
this.scrollSeq++;
|
||||
hydrateBench();
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// send 全流程(FR-CHAT-008 时序 1–7)
|
||||
// ---------------------------------------------------------------
|
||||
async send(text, { mode = this.mode } = {}) {
|
||||
const q = String(text == null ? '' : text).trim();
|
||||
if (!q || this.busy) return;
|
||||
const sessions = useSessionsStore();
|
||||
if (!this.sessionId) {
|
||||
try {
|
||||
const sess = await sessions.create();
|
||||
this.sessionId = sess.id;
|
||||
} catch {
|
||||
return; // 创建失败:列表页可见错误(失败必须可见)
|
||||
}
|
||||
}
|
||||
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
|
||||
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
|
||||
}
|
||||
const sessionId = this.sessionId;
|
||||
this.busy = true;
|
||||
this.mode = mode;
|
||||
this.completedOnce = false;
|
||||
this.lastResult = null;
|
||||
this.currentEvents = [];
|
||||
this.historyEvents = [];
|
||||
|
||||
// 1. 用户消息立即入列 + 落库(mode 记在消息上供 retry 复用,FR-CHAT-012「相同模式」)
|
||||
const userMsgRaw = { id: null, role: 'user', kind: 'qa', status: 'done', text: q, mode, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
this.messages.push(userMsgRaw);
|
||||
// 关键(2026-08-25 PM 修复):闭包必须持响应式代理而非 raw 对象,
|
||||
// raw 突变不触发 Vue 渲染(详见 CHANGELOG 的响应式断链缺陷)
|
||||
const userMsg = this.messages[this.messages.length - 1];
|
||||
let userId = null;
|
||||
try {
|
||||
const saved = await createMessage(sessionId, { role: 'user', text: q });
|
||||
userId = saved.id;
|
||||
userMsg.id = userId;
|
||||
} catch (err) {
|
||||
// 用户消息落库失败:按失败可见原则,终止本轮并给出可重试错误
|
||||
userMsg.persistError = err;
|
||||
this._finish({ ok: false, error: err, message: userMsg, silentAbort: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 会话 running
|
||||
sessions.patchLocal(sessionId, { status: 'running' });
|
||||
try {
|
||||
await updateSessionQuiet(sessionId, { status: 'running' });
|
||||
} catch { /* 状态 PATCH 失败不阻断(终态会再同步) */ }
|
||||
|
||||
// 3. 助手骨架(pipeline 五相位 waiting)+ 落库
|
||||
const aMsgRaw = newSkeletonMessage();
|
||||
this.messages.push(aMsgRaw);
|
||||
// 关键(2026-08-25 PM 修复):见上,闭包必须持代理,SSE 相位突变才触发渲染
|
||||
const aMsg = this.messages[this.messages.length - 1];
|
||||
this.currentMessage = aMsg;
|
||||
this.runningMsgId = null;
|
||||
try {
|
||||
const skeleton = await createMessage(sessionId, {
|
||||
role: 'assistant', kind: 'qa', status: 'streaming',
|
||||
blocks: [{ type: 'pipeline', status: 'running', steps: aMsg.blocks[0].steps.map((st) => ({ ...st })) }],
|
||||
});
|
||||
aMsg.id = skeleton.id;
|
||||
this.runningMsgId = skeleton.id;
|
||||
} catch {
|
||||
aMsg.id = null; // 骨架落库失败:终态用 replace 语义重建(防御,正常 mock/BFF 不会发生)
|
||||
}
|
||||
|
||||
// 4–5. 打开流并按模式消费
|
||||
const modeCfg = MODES[mode] || MODES.ask;
|
||||
this._setupCheckpoint();
|
||||
if (modeCfg.endpoint === '/api/qa/search') {
|
||||
await this._runSearch(q, modeCfg, aMsg, sessionId, userId);
|
||||
} else {
|
||||
await this._runStream(q, modeCfg, aMsg, sessionId, userId);
|
||||
}
|
||||
},
|
||||
|
||||
async _runStream(q, modeCfg, aMsg, sessionId, userId) {
|
||||
const controller = new AbortController();
|
||||
this.activeAbort = controller;
|
||||
const startAt = Date.now();
|
||||
let ended = false;
|
||||
|
||||
const endTask = () => {
|
||||
ended = true;
|
||||
if (this.activeAbort === controller) this.activeAbort = null;
|
||||
this._stopCheckpoint();
|
||||
};
|
||||
const updateLocal = (fn) => {
|
||||
if (this.messages.includes(aMsg)) fn();
|
||||
};
|
||||
|
||||
try {
|
||||
await chatStream(
|
||||
{ question: q, ...modeCfg.params },
|
||||
{
|
||||
signal: controller.signal,
|
||||
onProgress: (ev) => {
|
||||
if (ended) return;
|
||||
const p = ev.payload || {};
|
||||
const phase = this._normalizePhase(p.phase);
|
||||
const label = p.label || p.stage || '';
|
||||
updateLocal(() => {
|
||||
if (!this.completedOnce && phase !== 'done') this._advancePipeline(aMsg, phase, label);
|
||||
if (phase === 'done') {
|
||||
// 上游 completed 事件:当前相位定格 done(此时 complete 尚未到达)
|
||||
const steps = aMsg.blocks[0].steps;
|
||||
const running = steps.find((s) => s.state === 'running');
|
||||
if (running) running.state = 'done';
|
||||
else steps.forEach((s) => { s.state = 'done'; });
|
||||
}
|
||||
});
|
||||
this.currentEvents.push({ ...p, at: p.at || new Date().toISOString() });
|
||||
},
|
||||
onComplete: async (ev) => {
|
||||
if (ended) return;
|
||||
// complete 帧之后流即关闭:同步收口,避免「流自然结束」误判为连接中断
|
||||
endTask();
|
||||
const payload = ev.payload || {};
|
||||
const blocks = qaToBlocks(payload);
|
||||
updateLocal(() => {
|
||||
// text 块先不放:由揭示动画以 stream 块渐进显示,终了替换为最终 text
|
||||
aMsg.blocks = [
|
||||
aMsg.blocks[0],
|
||||
...blocks.filter((b) => b.type !== 'text'),
|
||||
];
|
||||
aMsg.raw = payload.raw ?? payload;
|
||||
const pipe = aMsg.blocks[0];
|
||||
pipe.status = 'done';
|
||||
pipe.steps.forEach((s) => { s.state = 'done'; });
|
||||
});
|
||||
this.completedOnce = true;
|
||||
// 揭示动画(FR-CHAT-010):stream 块瞬态、不落库
|
||||
if (payload.answer) {
|
||||
await this.revealText(aMsg, payload.answer);
|
||||
} else {
|
||||
updateLocal(() => { aMsg.status = 'done'; });
|
||||
}
|
||||
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
|
||||
// 2026-08-25 健壮性加固:收尾链(落库+_finish)必须无条件执行。
|
||||
// 此前 _persistFinal 抛异常会使 _finish 永不运行 → busy 卡死、
|
||||
// 会话状态停留在 running,刷新后被误判「已中断」。
|
||||
try {
|
||||
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
|
||||
} catch (persistErr) {
|
||||
// 落库失败不掩盖成功事实:本地消息保持 done,仅记录
|
||||
console.warn('persistFinal failed after complete:', persistErr);
|
||||
}
|
||||
this._finish({
|
||||
ok: true,
|
||||
payload,
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
dur,
|
||||
blocks: blocks.length,
|
||||
});
|
||||
},
|
||||
onError: (ev) => {
|
||||
if (ended) return;
|
||||
const p = ev.payload || {};
|
||||
endTask();
|
||||
const err = { code: p.code || 'STREAM_ERROR', message: p.message || '', retryable: !!p.retryable };
|
||||
const map = this._errorMarker(err.code);
|
||||
if (map.text) err.message = err.message || map.text;
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = map.ann;
|
||||
// 后端/映射文案显式落 errText:ann='message'(未知码)时由 errNote 优先展示
|
||||
aMsg.errText = err.message;
|
||||
const pipe = aMsg.blocks[0];
|
||||
pipe.status = 'error';
|
||||
const running = pipe.steps.find((s) => s.state === 'running');
|
||||
if (running) running.state = 'done';
|
||||
});
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finish({ ok: false, error: err, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
|
||||
},
|
||||
onRaw: () => { /* raw 帧仅记录:payload 已由 raw 帧携带,此处不追加 UI 状态 */ },
|
||||
onUnknown: () => { /* 无 event 名的帧:忽略(keepalive 等) */ },
|
||||
},
|
||||
);
|
||||
if (!ended) {
|
||||
// 流在无终态帧的情况下自然关闭 → 按中断处理(FR-CHAT-008 网络中断)
|
||||
endTask();
|
||||
const err = { code: 'CONNECTION_CLOSED', message: T.disconnected };
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = 'disconnected';
|
||||
const pipe = aMsg.blocks[0];
|
||||
pipe.status = 'error';
|
||||
});
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finish({ ok: false, error: err, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
|
||||
}
|
||||
} catch (err) {
|
||||
if (ended) return; // 已被某个帧处理
|
||||
endTask();
|
||||
const isAbort = err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED');
|
||||
// BUG-R1-05 修复:ApiError(BFF 错误信封,code=UPSTREAM_ERROR/UPSTREAM_DOWN/…)
|
||||
// 不是网络中断——优先透传 code 与后端 message(PRD §2.2:后端 error.message 直接展示)。
|
||||
// 只有无 code 的原始网络错误(fetch reject TypeError)才标「连接中断」。
|
||||
if (!isAbort && err && typeof err.code === 'string' && err.code !== 'NETWORK') {
|
||||
const marker = this._errorMarker(err.code);
|
||||
const text = err.message || marker.text || T.upstreamError;
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = marker.ann;
|
||||
aMsg.errText = text;
|
||||
const pipe = aMsg.blocks[0];
|
||||
pipe.status = 'error';
|
||||
const running = pipe.steps.find((s) => s.state === 'running');
|
||||
if (running) running.state = 'done';
|
||||
});
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finish({
|
||||
ok: false,
|
||||
error: { code: err.code, message: text, retryable: true },
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 中断意图优先:切会话 →「已中断」;用户停止 →「已停止」;其余 →「连接中断」
|
||||
const interrupted = isAbort && this.abortAnn === 'interrupted';
|
||||
const marker = interrupted ? 'interrupted' : isAbort ? 'stopped' : 'disconnected';
|
||||
const message = marker === 'interrupted' ? T.interrupted : isAbort ? T.stopped : T.disconnected;
|
||||
updateLocal(() => {
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = marker;
|
||||
if (interrupted) aMsg.interrupted = true;
|
||||
const pipe = aMsg.blocks[0];
|
||||
pipe.status = 'error';
|
||||
const running = pipe.steps.find((s) => s.state === 'running');
|
||||
if (running) running.state = 'done';
|
||||
});
|
||||
// 中断时保留已收到的块(FR-CHAT-011);落库写 error 但保留 blocks
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finish({
|
||||
ok: false,
|
||||
error: { code: isAbort ? 'ABORTED' : (err.code || 'NETWORK'), message, retryable: !isAbort },
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async _runSearch(q, modeCfg, aMsg, sessionId, userId) {
|
||||
const startAt = Date.now();
|
||||
try {
|
||||
const payload = await search({ question: q, ...modeCfg.params });
|
||||
const blocks = qaToBlocks(payload);
|
||||
aMsg.blocks = [aMsg.blocks[0], ...blocks];
|
||||
aMsg.raw = payload.raw ?? payload;
|
||||
const pipe = aMsg.blocks[0];
|
||||
pipe.status = 'done';
|
||||
pipe.steps.forEach((s) => { s.state = 'done'; });
|
||||
aMsg.status = 'done';
|
||||
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
|
||||
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
|
||||
this._finish({
|
||||
ok: true,
|
||||
payload,
|
||||
message: aMsg,
|
||||
sessionId,
|
||||
userMessageId: userId,
|
||||
assistantMessageId: aMsg.id,
|
||||
dur,
|
||||
blocks: blocks.length,
|
||||
});
|
||||
} catch (err) {
|
||||
const marker = this._errorMarker(err.code);
|
||||
const text = err.message || marker.text || T.upstreamError;
|
||||
aMsg.status = 'error';
|
||||
aMsg.streamErr = marker.ann;
|
||||
aMsg.errText = text;
|
||||
const pipe = aMsg.blocks[0];
|
||||
pipe.status = 'error';
|
||||
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
this._finish({ ok: false, error: { code: err.code || 'NETWORK', message: text, retryable: true }, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 揭示动画(FR-CHAT-010)
|
||||
// ---------------------------------------------------------------
|
||||
async revealText(aMsg, fullText) {
|
||||
// 无动画环境(测试 jsdom 无 matchMedia / 用户关闭动效):直接定格最终 text
|
||||
const reduced =
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function' ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduced) {
|
||||
this._finalizeReveal(aMsg, fullText);
|
||||
return;
|
||||
}
|
||||
if (!this.messages.includes(aMsg)) return;
|
||||
const total = fullText.length;
|
||||
if (total === 0) {
|
||||
aMsg.status = 'done';
|
||||
return;
|
||||
}
|
||||
// >2000 字符加速:总时长上限 8s(FR-CHAT-010)
|
||||
const step = total > 2000
|
||||
? Math.ceil(total / Math.floor(REVEAL_MAX_MS / REVEAL_STEP_MS))
|
||||
: REVEAL_STEP_MS;
|
||||
const revealId = (aMsg.revealId || 0) + 1;
|
||||
aMsg.revealId = revealId;
|
||||
aMsg.blocks.push({ type: 'stream', text: '' });
|
||||
this.reveal = { text: fullText };
|
||||
await new Promise((resolve) => {
|
||||
let n = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (!this.messages.includes(aMsg) || aMsg.revealId !== revealId) {
|
||||
clearInterval(timer);
|
||||
if (this.reveal) this.reveal = null;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
n = Math.min(total, n + step);
|
||||
const sb = aMsg.blocks[aMsg.blocks.length - 1];
|
||||
if (sb && sb.type === 'stream') sb.text = fullText.slice(0, n);
|
||||
if (n >= total) {
|
||||
clearInterval(timer);
|
||||
this._finalizeReveal(aMsg, fullText);
|
||||
resolve();
|
||||
}
|
||||
}, REVEAL_STEP_MS);
|
||||
});
|
||||
},
|
||||
|
||||
/** 揭示收尾:stream 块替换为最终 text 块(FR-CHAT-013:stream 瞬态不落库)
|
||||
* 揭示动画按字符回放的是 markdown 原文,终态 text 块必须与 qaToBlocks 一致
|
||||
* 走 renderMarkdown 转 HTML(BUG-R1-06:装配处已渲染,此处保持一致) */
|
||||
_finalizeReveal(aMsg, fullText) {
|
||||
// 使进行中的揭示定时器下一 tick 自愈退出(skipReveal 路径)
|
||||
aMsg.revealId = (aMsg.revealId || 0) + 1;
|
||||
const i = aMsg.blocks.findIndex((b) => b.type === 'stream');
|
||||
if (i >= 0) aMsg.blocks.splice(i, 1);
|
||||
if (!aMsg.blocks.some((b) => b.type === 'text')) {
|
||||
aMsg.blocks.push({ type: 'text', html: renderMarkdown(fullText) });
|
||||
}
|
||||
aMsg.status = 'done';
|
||||
this.reveal = null;
|
||||
this.scrollSeq++;
|
||||
},
|
||||
|
||||
/** 「跳过」:立即显示全文 */
|
||||
skipReveal() {
|
||||
const target = this.messages.find((m) => m.revealId && m.blocks.some((b) => b.type === 'stream'));
|
||||
if (target && this.reveal) this._finalizeReveal(target, this.reveal.text);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 停止 / 中断(FR-CHAT-011)
|
||||
// ---------------------------------------------------------------
|
||||
async stop() {
|
||||
if (!this.busy) return;
|
||||
const ctrl = this.activeAbort;
|
||||
if (ctrl) {
|
||||
try {
|
||||
ctrl.abort();
|
||||
} catch { /* 已中止 */ }
|
||||
this.activeAbort = null;
|
||||
}
|
||||
this._stopCheckpoint();
|
||||
},
|
||||
|
||||
/** 切换会话等场景的中断:原会话标「已中断」(FR-SESSION-004) */
|
||||
async abort(ann = 'interrupted') {
|
||||
const ctrl = this.activeAbort;
|
||||
if (ctrl) {
|
||||
this.abortAnn = ann;
|
||||
ctrl.abort();
|
||||
this.activeAbort = null;
|
||||
}
|
||||
this._stopCheckpoint();
|
||||
if (ann === 'interrupted' && this.sessionId) {
|
||||
await this._markInterrupted();
|
||||
}
|
||||
this.abortAnn = null;
|
||||
},
|
||||
|
||||
async _markInterrupted() {
|
||||
const sessions = useSessionsStore();
|
||||
// 流 catch 可能先于本方法把消息置 error('stopped');只要带 pipeline 块即视为本轮助手消息
|
||||
const aMsg = [...this.messages].reverse().find(
|
||||
(m) => m.role === 'assistant' && m.blocks.some((b) => b.type === 'pipeline'),
|
||||
);
|
||||
if (aMsg) {
|
||||
aMsg.status = 'error';
|
||||
aMsg.interrupted = true;
|
||||
aMsg.streamErr = 'interrupted';
|
||||
const pipe = aMsg.blocks.find((b) => b.type === 'pipeline');
|
||||
if (pipe) pipe.status = 'error';
|
||||
}
|
||||
if (this.sessionId) {
|
||||
sessions.patchLocal(this.sessionId, { status: 'error' });
|
||||
updateSessionQuiet(this.sessionId, { status: 'error' }).catch(() => {});
|
||||
if (aMsg && aMsg.id) {
|
||||
await this._persistFinal(aMsg, this.sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
|
||||
}
|
||||
}
|
||||
this.busy = false;
|
||||
this.reveal = null;
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 重试(FR-CHAT-012):同消息位置替换,不新增用户消息
|
||||
// ---------------------------------------------------------------
|
||||
async retry() {
|
||||
if (this.busy) return;
|
||||
const idx = this.messages.length - 1;
|
||||
const aMsg = this.messages[idx];
|
||||
const user = this.messages[idx - 1];
|
||||
if (!aMsg || aMsg.role !== 'assistant' || !user || user.role !== 'user') return;
|
||||
const q = user.text || '';
|
||||
this.messages.splice(idx, 1);
|
||||
this.currentEvents = [];
|
||||
this.historyEvents = [];
|
||||
this.lastResult = null;
|
||||
this.busy = true;
|
||||
this.completedOnce = false;
|
||||
const sessionId = this.sessionId;
|
||||
const sessions = useSessionsStore();
|
||||
|
||||
const aNewRaw = newSkeletonMessage();
|
||||
this.messages.push(aNewRaw);
|
||||
// 关键(2026-08-25 PM 修复):持代理而非 raw
|
||||
const aNew = this.messages[this.messages.length - 1];
|
||||
this.currentMessage = aNew;
|
||||
this.runningMsgId = null;
|
||||
try {
|
||||
const skeleton = await createMessage(sessionId, {
|
||||
role: 'assistant', kind: 'qa', status: 'streaming',
|
||||
blocks: [{ type: 'pipeline', status: 'running', steps: aNew.blocks[0].steps.map((st) => ({ ...st })) }],
|
||||
});
|
||||
aNew.id = skeleton.id;
|
||||
this.runningMsgId = skeleton.id;
|
||||
} catch { /* 同 send:骨架失败不阻断 */ }
|
||||
|
||||
const modeCfg = MODES[user.mode || this.mode] || MODES.ask;
|
||||
if (user.mode) this.mode = user.mode; // 恢复原提问模式(FR-CHAT-012 相同模式重试)
|
||||
this._setupCheckpoint();
|
||||
if (modeCfg.endpoint === '/api/qa/search') {
|
||||
await this._runSearch(q, modeCfg, aNew, sessionId, user.id);
|
||||
} else {
|
||||
await this._runStream(q, modeCfg, aNew, sessionId, user.id);
|
||||
}
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 内部:持久化 / 相位推进 / 错误映射
|
||||
// ---------------------------------------------------------------
|
||||
/** 检查点节奏:骨架落库后每 ~3s PATCH 一次(FR-CHAT-008 步骤 6) */
|
||||
_setupCheckpoint() {
|
||||
this._stopCheckpoint();
|
||||
this.lastCheckpointAt = Date.now();
|
||||
this.checkpointTimer = setInterval(() => {
|
||||
const aMsg = this.messages.find((m) => m.role === 'assistant' && m.status === 'streaming');
|
||||
if (!aMsg || !aMsg.id) return;
|
||||
if (!this._changedSinceCheckpoint(aMsg)) return;
|
||||
updateMessage(this.sessionId, aMsg.id, {
|
||||
status: 'streaming',
|
||||
blocks: this._persistableBlocks(aMsg),
|
||||
raw: aMsg.raw ?? null,
|
||||
}).then((saved) => {
|
||||
const sessions = useSessionsStore();
|
||||
sessions.touch(this.sessionId, saved.updated_at || new Date().toISOString());
|
||||
this.lastCheckpointAt = Date.now();
|
||||
}).catch(() => { /* 检查点失败由下个检查点或终态弥补 */ });
|
||||
}, CHECKPOINT_MS);
|
||||
},
|
||||
|
||||
_changedSinceCheckpoint(aMsg) {
|
||||
if (this.completedOnce) return true;
|
||||
const pipe = aMsg.blocks.find((b) => b.type === 'pipeline');
|
||||
if (!pipe) return false;
|
||||
return pipe.steps.some((s) => s.state !== 'waiting');
|
||||
},
|
||||
|
||||
_stopCheckpoint() {
|
||||
if (this.checkpointTimer != null) {
|
||||
clearInterval(this.checkpointTimer);
|
||||
this.checkpointTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** 落库块:pipeline + 真实内容块;stream 块瞬态不落库 */
|
||||
_persistableBlocks(aMsg) {
|
||||
const out = [];
|
||||
const pipe = aMsg.blocks.find((b) => b.type === 'pipeline');
|
||||
if (pipe) out.push({ ...pipe, steps: pipe.steps.map((s) => ({ ...s })) });
|
||||
for (const b of aMsg.blocks) {
|
||||
if (b.type === 'pipeline' || b.type === 'stream') continue;
|
||||
out.push({ ...b });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async _persistFinal(aMsg, sessionId, status, { raw } = {}) {
|
||||
if (aMsg.id) {
|
||||
await updateMessage(sessionId, aMsg.id, {
|
||||
status,
|
||||
blocks: this._persistableBlocks(aMsg),
|
||||
raw: raw ?? aMsg.raw ?? null,
|
||||
});
|
||||
} else {
|
||||
await createMessage(sessionId, {
|
||||
role: 'assistant', kind: 'qa', status,
|
||||
blocks: this._persistableBlocks(aMsg),
|
||||
raw: raw ?? aMsg.raw ?? null,
|
||||
});
|
||||
}
|
||||
const sessions = useSessionsStore();
|
||||
sessions.touch(sessionId, new Date().toISOString());
|
||||
},
|
||||
|
||||
_advancePipeline(aMsg, phase, label) {
|
||||
const steps = aMsg.blocks[0].steps;
|
||||
for (const s of steps) {
|
||||
if (s.phase === phase) {
|
||||
if (s.state === 'waiting') s.state = 'running';
|
||||
if (label) s.desc = label;
|
||||
} else if (['planning', 'retrieval', 'evidence', 'generation'].includes(s.phase) && s.state === 'running') {
|
||||
s.state = 'done';
|
||||
}
|
||||
}
|
||||
if (phase === 'done') steps.forEach((s) => { s.state = 'done'; });
|
||||
this.scrollSeq++;
|
||||
},
|
||||
|
||||
/** 相位兜底校验:BFF 已算 phase,此处仅校验合法性(labels.STAGE_PHASE 亦可用) */
|
||||
_normalizePhase(phase) {
|
||||
return PIPELINE_PHASES.includes(phase) ? phase : 'retrieval';
|
||||
},
|
||||
|
||||
/** 错误码 → 标注与文案(labels.js;后端 error.message 直接展示) */
|
||||
_errorMarker(code) {
|
||||
switch (code) {
|
||||
case 'TIMEOUT':
|
||||
return { ann: 'timeout', text: T.timeoutMsg };
|
||||
case 'INVALID_CITATION':
|
||||
return { ann: 'citation', text: T.citationError };
|
||||
case 'UPSTREAM_DOWN':
|
||||
return { ann: 'message', text: T.qaDown };
|
||||
case 'CONNECTION_CLOSED':
|
||||
return { ann: 'disconnected', text: T.disconnected };
|
||||
case 'NETWORK':
|
||||
return { ann: 'disconnected', text: T.disconnected };
|
||||
default:
|
||||
return { ann: 'message', text: T.upstreamError };
|
||||
}
|
||||
},
|
||||
|
||||
/** 本轮终态统一收尾:状态同步 + 可观测结果 + 标题自动生成 */
|
||||
_finish({ ok, error, message, payload, sessionId, userMessageId, assistantMessageId, dur, blocks, silentAbort }) {
|
||||
const sessions = useSessionsStore();
|
||||
if (sessionId) {
|
||||
const status = ok ? 'done' : 'error';
|
||||
sessions.patchLocal(sessionId, { status });
|
||||
if (!silentAbort) {
|
||||
updateSessionQuiet(sessionId, { status }).catch(() => {});
|
||||
}
|
||||
}
|
||||
// 标题自动生成(FR-SESSION-008):本轮提问后 PATCH 前 30 字符
|
||||
const user = [...this.messages].reverse().find((m) => m.role === 'user' && (m.text || '').trim());
|
||||
if (sessionId && user) sessions.ensureTitle(sessionId, user.text).catch(() => {});
|
||||
this.busy = false;
|
||||
this.lastResult = { ok, error: error || null, payload: payload || null, message: message || null, sessionId, userMessageId, assistantMessageId, dur, blocks };
|
||||
this.scrollSeq++;
|
||||
},
|
||||
|
||||
/** 刷新滚动请求(聊天区 watch 消费;0 亦可比较,故用自增序号) */
|
||||
requestScroll() {
|
||||
this.scrollSeq++;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** 会话状态 PATCH(不抛出;失败由终态同步弥补) */
|
||||
async function updateSessionQuiet(sessionId, body) {
|
||||
const { updateSession } = await import('../api/sessions.js');
|
||||
try {
|
||||
await updateSession(sessionId, body);
|
||||
} catch {
|
||||
/* 静默:状态同步失败不阻断主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换会话时清空工作台会话数据(B3 bench store 契约:resetSessionData) */
|
||||
async function resetBenchData() {
|
||||
try {
|
||||
// 动态 import 避免与 B3 的循环依赖;B3/B1 未就绪(http.js 缺失)时静默跳过
|
||||
const { useBenchStore } = await import('../stores/bench.js');
|
||||
useBenchStore().resetSessionData();
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
/** 历史消息装配完成后通知工作台(B3 契约:hydrate) */
|
||||
function hydrateBench() {
|
||||
try {
|
||||
import('../stores/bench.js').then(({ useBenchStore }) => {
|
||||
useBenchStore().hydrate();
|
||||
}).catch(() => {});
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// stores/health.js —— B1 归属。上游健康轮询与降级状态(FR-HEALTH-001/002/003/004)。
|
||||
// 数据源:GET /api/health(30s 轮询)+ GET /api/qa/info(QA 版本号)。
|
||||
// 上游状态驱动各 store 的降级消费:导出 getter qaOk / neo4jOk / extractionOk。
|
||||
import { defineStore } from 'pinia';
|
||||
import { api } from '../api/http.js';
|
||||
|
||||
const HEALTH_INTERVAL = 30000;
|
||||
|
||||
export const useHealthStore = defineStore('health', {
|
||||
state: () => ({
|
||||
/** 最近一次成功轮询的 health 数据(失败时保留旧值,供降级展示) */
|
||||
data: null,
|
||||
/** 探活错误 */
|
||||
error: null,
|
||||
/** 轮询是否在运行 */
|
||||
polling: false,
|
||||
/** 最近成功轮询的 ISO 时间 */
|
||||
lastOkAt: null,
|
||||
/** /api/qa/info 结果 */
|
||||
qaInfo: null,
|
||||
/** 首次探测尚未返回(未知态:显示空心点而非错误) */
|
||||
unknown: true,
|
||||
}),
|
||||
getters: {
|
||||
bffOk: (s) => !!s.data && !s.error,
|
||||
/** QA 上游可用(FR-HEALTH-002:不可用→作曲器禁用) */
|
||||
qaOk: (s) => !!s.data && s.data.upstreams?.qa?.ok === true,
|
||||
/** Neo4j 可用(FR-HEALTH-003:不可用→图谱搜索/邻域禁用) */
|
||||
neo4jOk: (s) => !!s.data && s.data.upstreams?.neo4j?.ok === true,
|
||||
/** 抽取上游可用(FR-HEALTH-004:8000 或 7900 任一在线) */
|
||||
extractionOk: (s) => !!s.data && (s.data.upstreams?.rag?.ok === true || s.data.upstreams?.ragAlt?.ok === true),
|
||||
/** BFF 整体状态(status:"ok" 即未降级) */
|
||||
bffHealthy: (s) => !!s.data && s.data.status === 'ok',
|
||||
qaVersion: (s) => s.qaInfo?.version || null,
|
||||
/** 会话数(status bar 展示) */
|
||||
sessionCount: (s) => (s.data && typeof s.data.db?.sessions === 'number' ? s.data.db.sessions : null),
|
||||
},
|
||||
actions: {
|
||||
async _fetch() {
|
||||
try {
|
||||
const data = await api.get('/api/health');
|
||||
this.data = data;
|
||||
this.error = null;
|
||||
this.unknown = false;
|
||||
this.lastOkAt = new Date().toISOString();
|
||||
} catch (e) {
|
||||
this.error = e;
|
||||
this.unknown = false;
|
||||
// 失败不弹窗,仅标记;旧 data 保留供降级展示
|
||||
}
|
||||
},
|
||||
/** 启动轮询:先取一次,此后每 30s 一次;页面 hidden 时跳过 */
|
||||
start() {
|
||||
if (this.polling) return;
|
||||
this.polling = true;
|
||||
this._fetch();
|
||||
this._timer = setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.hidden) return;
|
||||
this._fetch();
|
||||
}, HEALTH_INTERVAL);
|
||||
},
|
||||
/** 停止轮询(组件卸载时调用) */
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
this.polling = false;
|
||||
},
|
||||
/** 拉取 QA 版本号(FR-SHELL-003:版本来自 /api/qa/info;失败不阻塞渲染) */
|
||||
async fetchQaInfo() {
|
||||
try {
|
||||
this.qaInfo = await api.get('/api/qa/info');
|
||||
} catch (e) {
|
||||
this.qaInfo = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
// stores/sessions.js —— 会话列表状态(B2 归属)
|
||||
// FR-SESSION-001~009:列表/分组(今天/昨天/更早)/搜索过滤/活动会话/
|
||||
// CRUD/标题自动生成(meta.title_manual 不再覆盖)/状态机四态。
|
||||
import { defineStore } from 'pinia';
|
||||
import {
|
||||
listSessions,
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
} from '../api/sessions.js';
|
||||
import { SESSION_GROUPS, T } from '../i18n/labels.js';
|
||||
|
||||
/**
|
||||
* 会话分组键:由 updated_at(ISO 8601 UTC)映射到「今天/昨天/更早」。
|
||||
* 分界使用调用时刻的本地日期(PRD FR-SESSION-001:跨零点后分组正确迁移,
|
||||
* 客户端本地日界即用户所见的分组日界)。
|
||||
* 导出便于单测与组件复用。
|
||||
*/
|
||||
export function groupKeyOf(updatedAt, now = new Date()) {
|
||||
if (!updatedAt) return 'older';
|
||||
const t = new Date(updatedAt);
|
||||
if (Number.isNaN(t.getTime())) return 'older';
|
||||
const dayStart = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const delta = dayStart(now).getTime() - dayStart(t).getTime();
|
||||
const DAY = 86400000;
|
||||
if (delta <= 0) return 'today';
|
||||
if (delta <= DAY) return 'yesterday';
|
||||
return 'older';
|
||||
}
|
||||
|
||||
/** 自动标题:问题前 30 字符,超出加省略号;空白回退「新会话」 */
|
||||
export function deriveTitle(text) {
|
||||
const s = String(text == null ? '' : text).trim().slice(0, 30);
|
||||
return s.length ? s : T.newTask;
|
||||
}
|
||||
|
||||
export const useSessionsStore = defineStore('sessions', {
|
||||
state: () => ({
|
||||
items: [], // 会话数组(按 updated_at 倒序)
|
||||
loading: false, // 首次列表加载中(骨架屏)
|
||||
loaded: false, // 是否已完成过一次加载(区分空列表与未加载)
|
||||
error: false, // 列表加载失败 → 空态/离线态(禁止无限 loading)
|
||||
activeId: null, // 活动会话 id(注意:详情数据在 chat store)
|
||||
searchQuery: '',
|
||||
creating: false,
|
||||
_confirmDelete: null, // 删除二次确认暂存 {id,title}(组件层 UI 态挂 store,便于测试)
|
||||
}),
|
||||
|
||||
getters: {
|
||||
active(state) {
|
||||
return state.items.find((s) => s.id === state.activeId) || null;
|
||||
},
|
||||
|
||||
/** B1 AppShell/TopBar 消费名(与 active 同值) */
|
||||
activeSession() {
|
||||
return this.active;
|
||||
},
|
||||
|
||||
/** 搜索过滤(不区分大小写子串匹配 title,前端过滤不发请求) */
|
||||
filtered(state) {
|
||||
const q = state.searchQuery.trim().toLowerCase();
|
||||
if (!q) return state.items;
|
||||
return state.items.filter((s) => (s.title || '').toLowerCase().includes(q));
|
||||
},
|
||||
|
||||
/** 三组分组:[{label:'今天', items:[...]}, ...],空组不输出 */
|
||||
groups(state) {
|
||||
const buckets = [{ key: 'today', label: SESSION_GROUPS[0], items: [] },
|
||||
{ key: 'yesterday', label: SESSION_GROUPS[1], items: [] },
|
||||
{ key: 'older', label: SESSION_GROUPS[2], items: [] }];
|
||||
const byKey = { today: 0, yesterday: 1, older: 2 };
|
||||
for (const s of state.filtered) {
|
||||
const k = groupKeyOf(s.updated_at);
|
||||
buckets[byKey[k] ?? 2].items.push(s);
|
||||
}
|
||||
return buckets.filter((g) => g.items.length > 0);
|
||||
},
|
||||
|
||||
matchedCount(state) {
|
||||
return this.filtered.length;
|
||||
},
|
||||
|
||||
/** 有搜索词但无匹配 → 显示「无匹配会话」 */
|
||||
noMatch(state) {
|
||||
return !!state.searchQuery.trim() && this.matchedCount === 0;
|
||||
},
|
||||
|
||||
/** 已加载且无会话 → 空列表引导 */
|
||||
empty(state) {
|
||||
return state.loaded && state.items.length === 0;
|
||||
},
|
||||
|
||||
/** 本会话是否运行中(左栏状态点 + 顶栏徽章用) */
|
||||
activeRunning() {
|
||||
const a = this.active;
|
||||
return !!(a && a.status === 'running');
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/** 首屏列表加载;失败置 error(失败必须可见,PRD §2.2) */
|
||||
async load() {
|
||||
this.loading = true;
|
||||
this.error = false;
|
||||
try {
|
||||
const data = await listSessions();
|
||||
this.items = Array.isArray(data) ? data : [];
|
||||
this.loaded = true;
|
||||
} catch (err) {
|
||||
this.error = true;
|
||||
this.loaded = true;
|
||||
throw err;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** 创建新会话并切为活动会话(FR-SESSION-003:连点两次产生两个独立会话) */
|
||||
async create() {
|
||||
this.creating = true;
|
||||
try {
|
||||
const sess = await createSession({});
|
||||
this.items.unshift(sess);
|
||||
this.activeId = sess.id;
|
||||
this.searchQuery = '';
|
||||
return sess;
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** 重命名(FR-SESSION-006;空标题不允许提交,调用方校验) */
|
||||
async rename(id, title) {
|
||||
const sess = await updateSession(id, { title });
|
||||
const item = this.items.find((s) => s.id === id);
|
||||
if (item) {
|
||||
item.title = sess.title;
|
||||
item.meta = { ...item.meta, ...(sess.meta || {}), title_manual: true };
|
||||
}
|
||||
return sess;
|
||||
},
|
||||
|
||||
/** 删除(FR-SESSION-007;二次确认在组件层。调用方负责处理「删的是当前会话」的切换) */
|
||||
async remove(id) {
|
||||
await deleteSession(id);
|
||||
this.items = this.items.filter((s) => s.id !== id);
|
||||
if (this.activeId === id) {
|
||||
this.activeId = this.items.length ? this.items[0].id : null;
|
||||
}
|
||||
},
|
||||
|
||||
/** 本地同步状态(send/中断/恢复等场景由 chat store 落库后调用) */
|
||||
patchLocal(id, patch) {
|
||||
const item = this.items.find((s) => s.id === id);
|
||||
if (item) Object.assign(item, patch);
|
||||
},
|
||||
|
||||
/** 更新会话的 updated_at 并冒泡排序(检查点/终态落库后) */
|
||||
touch(id, updatedAt) {
|
||||
const item = this.items.find((s) => s.id === id);
|
||||
if (!item) return;
|
||||
item.updated_at = updatedAt;
|
||||
this.items.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||
},
|
||||
|
||||
/** 标题自动生成(FR-SESSION-008):首问后 PATCH 前 30 字符;title_manual 不覆盖 */
|
||||
async ensureTitle(id, text) {
|
||||
const item = this.items.find((s) => s.id === id);
|
||||
if (!item) return;
|
||||
if (item.meta && item.meta.title_manual) return;
|
||||
const want = deriveTitle(text);
|
||||
if ((item.title || '') === want) return;
|
||||
try {
|
||||
const sess = await updateSession(id, { title: want });
|
||||
const it = this.items.find((s) => s.id === id);
|
||||
if (it) it.title = sess.title;
|
||||
} catch {
|
||||
/* 标题落库失败不阻断问答主流程;下次首问会再尝试 */
|
||||
}
|
||||
},
|
||||
|
||||
/** 加载会话详情(供恢复用);失败抛 404 等错误(调用方决定回列表) */
|
||||
async fetchDetail(id) {
|
||||
return getSession(id);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// stores/theme.js —— B1 归属。主题管理(FR-THEME-002)。
|
||||
// 只管理运行时切换与持久化;首屏初始化(防闪白)在 main.js 中于 createApp 之前同步执行(UI_SPEC §6)。
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const STORAGE_KEY = 'pa-theme';
|
||||
|
||||
/** 当前生效主题(读取 documentElement.dataset.theme,恒有值) */
|
||||
export function currentTheme() {
|
||||
return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
/** 应用主题:同步 dataset + localStorage(main.js 挂载前也用它) */
|
||||
export function applyTheme(theme) {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme', {
|
||||
state: () => ({
|
||||
theme: typeof document !== 'undefined' ? currentTheme() : 'dark',
|
||||
}),
|
||||
actions: {
|
||||
/** 切换深浅主题并持久化 */
|
||||
toggle() {
|
||||
this.theme = this.theme === 'dark' ? 'light' : 'dark';
|
||||
applyTheme(this.theme);
|
||||
},
|
||||
set(theme) {
|
||||
this.theme = theme === 'light' ? 'light' : 'dark';
|
||||
applyTheme(this.theme);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/* animations.css —— B1 归属。UI_SPEC §2 动画规格全量。
|
||||
悬停态(hover lift)由 base.css 包 @media (hover: hover) 呈现;
|
||||
本文件追加 prefers-reduced-motion 关闭规则。 */
|
||||
|
||||
/* 脉冲:running 状态点/徽章。
|
||||
注意:box-shadow 动画会在小圆点上逐帧重绘,但圆点面积小、成本远低于
|
||||
大卡片扫光,保留原实现(shimmer/sweep 才是卡顿主因)。 */
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(0,212,255,.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); }
|
||||
}
|
||||
|
||||
/* 扫光:运行中卡片 / 骨架屏 / 流式进度线。
|
||||
2026-08-25 性能修复:background-position 每帧触发 paint(软渲染下输入卡顿主因),
|
||||
改为 transform 平移(纯合成,GPU 加速)。
|
||||
配套伪元素形态:position:absolute; top/bottom:0; left:0; width:200%;
|
||||
background: linear-gradient(...); will-change:transform; overflow 由宿主隐藏。
|
||||
位移数学:宽度 200% 时,光带中心在元素 50% = 容器 100%(右缘),
|
||||
translateX(-50%) 后移到容器 0%(左缘)——两端均不可见,循环无缝,方向与原版一致。 */
|
||||
@keyframes shimmer {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* 流式光标 */
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* 旋转:running 相位 spinner */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 进度条高光带(方向同 shimmer;性能修复同上) */
|
||||
@keyframes sweep {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* tab 切换 / 工具卡展开体入场 */
|
||||
@keyframes fade-slide {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* 新消息入场 */
|
||||
@keyframes msg-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* 背景光斑漂移(blob.b 延迟 -8s 在 base.css .blob.b 上声明) */
|
||||
@keyframes aurora-drift {
|
||||
from { transform: translate(0,0) scale(1); }
|
||||
to { transform: translate(-40px, 30px) scale(1.08); }
|
||||
}
|
||||
|
||||
/* scrim 淡入(抽屉/覆盖层) */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 主题图标切换 rotate 180°(400ms)——挂在 .theme-toggle-anim 上 */
|
||||
.theme-toggle-anim {
|
||||
transition: transform .4s var(--pa-ease);
|
||||
}
|
||||
.theme-toggle-anim.flipped { transform: rotate(180deg); }
|
||||
|
||||
/* 面板折叠 .3s ease-out:app-shell 的 grid-template-columns 过渡在 base.css .app-shell 上声明 */
|
||||
|
||||
/* ---------- 动效降级(FR-THEME-004):关闭全部 animation/transition ----------
|
||||
状态仍由静态符号表达(✓/●/◌/实心/空心),不依赖运动。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
/* base.css —— B1 归属。reset + 全局类名(UI_SPEC §4/§7 复用约定)。
|
||||
CSS 逐字搬运自权威原型 plantai-agent-ui-prototype.html(仅按契约做以下转换):
|
||||
1. 辉光 alpha 一律 calc(<原值> * var(--pa-glow-opacity))——深色(=1)与原型逐像素一致,浅色按 FR-THEME-003 衰减;
|
||||
2. 悬停态一律包 @media (hover: hover),触摸端在 @media (hover: none) 给常驻替代态(FR-RESP-007);
|
||||
3. tab 文案(工具调用/产物文件/任务进度/预览)留在 labels,此处只搬 CSS。 */
|
||||
|
||||
/* ---------- 基础 ---------- */
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: var(--pa-font-ui);
|
||||
font-size: 14px; line-height: 1.6;
|
||||
color: var(--pa-ink);
|
||||
background: var(--pa-canvas);
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--pa-hairline-strong); border-radius: 3px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
|
||||
input, textarea { font-family: inherit; color: inherit; }
|
||||
.mono { font-family: var(--pa-font-mono); font-size: 12.5px; }
|
||||
html.drawer-open { overflow: hidden; }
|
||||
:focus-visible { outline: 2px solid rgba(94,106,210,.5); outline-offset: 1px; border-radius: var(--pa-r-xs); }
|
||||
|
||||
/* ---------- Aurora 背景层(UI_SPEC §3,FR-SHELL-006) ---------- */
|
||||
.aurora-bg {
|
||||
position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||
}
|
||||
.aurora-bg .blob {
|
||||
position: absolute; border-radius: 50%;
|
||||
/* 2026-08-25 性能修复:去掉 blur(120px) 滤镜——对滤镜元素做逐帧动画会在
|
||||
软渲染环境每帧重算高斯模糊,导致输入卡顿;径向渐变本身已平滑衰减,
|
||||
视觉差异极小。加 will-change 让漂移动画走纯合成层。 */
|
||||
opacity: var(--pa-aurora-opacity);
|
||||
will-change: transform;
|
||||
animation: aurora-drift 16s ease-in-out infinite alternate;
|
||||
}
|
||||
.aurora-bg .blob.a { width: 560px; height: 560px; top: -220px; right: -120px;
|
||||
background: radial-gradient(circle, rgba(94,106,210,.20), transparent 70%); }
|
||||
.aurora-bg .blob.b { width: 620px; height: 620px; bottom: -260px; left: -140px;
|
||||
background: radial-gradient(circle, rgba(0,212,255,.16), transparent 70%);
|
||||
animation-delay: -8s; }
|
||||
.aurora-bg .grid-dots {
|
||||
position: absolute; inset: 0;
|
||||
background-image: radial-gradient(var(--pa-grid-dot) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 0%, transparent 75%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 90% 70% at 50% 0%, black 0%, transparent 75%);
|
||||
}
|
||||
|
||||
/* ---------- 应用外壳:三栏网格(UI_SPEC §3,FR-SHELL-001) ---------- */
|
||||
.app-shell {
|
||||
position: relative; z-index: 1;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0,1fr) 380px;
|
||||
grid-template-rows: var(--topbar-h) minmax(0,1fr) var(--statusbar-h);
|
||||
grid-template-areas: "top top top" "side chat bench" "status status status";
|
||||
transition: grid-template-columns .3s var(--pa-ease-out);
|
||||
}
|
||||
.app-shell.side-collapsed { grid-template-columns: 64px minmax(0,1fr) 380px; }
|
||||
.app-shell.bench-collapsed { grid-template-columns: 260px minmax(0,1fr) 28px; }
|
||||
.app-shell.side-collapsed.bench-collapsed { grid-template-columns: 64px minmax(0,1fr) 28px; }
|
||||
|
||||
/* ---------- 顶栏(UI_SPEC §4.1,FR-SHELL-002) ---------- */
|
||||
.top-bar {
|
||||
grid-area: top;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 16px;
|
||||
background: color-mix(in srgb, var(--pa-canvas) 88%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
z-index: 5;
|
||||
}
|
||||
.logo { display: flex; align-items: center; gap: 8px; }
|
||||
.logo .logo-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--pa-gradient);
|
||||
box-shadow: 0 0 10px rgba(0,212,255, calc(.7 * var(--pa-glow-opacity))),
|
||||
0 0 22px rgba(94,106,210, calc(.5 * var(--pa-glow-opacity)));
|
||||
}
|
||||
.logo .logo-text {
|
||||
font-size: 15px; font-weight: 700; letter-spacing: .2px;
|
||||
background: linear-gradient(90deg, var(--pa-g-from), var(--pa-g-to));
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-title { flex: 1; display: flex; align-items: center; gap: 10px; min-width: 0; justify-content: center; }
|
||||
.top-title .t { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 30px; height: 30px; border-radius: var(--pa-r-md);
|
||||
color: var(--pa-ink-muted);
|
||||
transition: color .2s var(--pa-ease), background .2s var(--pa-ease), transform .4s var(--pa-ease);
|
||||
}
|
||||
.icon-btn svg { width: 16px; height: 16px; }
|
||||
.icon-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
/* 状态徽章(pill,三编码:颜色+图标+文字,UI_SPEC §4.1 / PRD §2.1) */
|
||||
.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: .5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge .dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.badge.running { color: var(--pa-warning); background: rgba(245,184,92,.12); }
|
||||
.badge.running .dot { background: var(--pa-warning); animation: pulse 2s infinite; }
|
||||
.badge.done { color: var(--pa-success); background: rgba(46,211,160,.12); }
|
||||
.badge.done .dot { background: var(--pa-success); }
|
||||
.badge.error { color: var(--pa-error); background: rgba(255,92,122,.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); }
|
||||
|
||||
/* 顶栏 B1 新增件:汉堡 / 工作台按钮(角标)/ 聚合状态点(由 .top-bar.has-tools 打开,<1024px 档位显示) */
|
||||
.bench-btn { position: relative; }
|
||||
.top-bar .hamburger-btn,
|
||||
.top-bar .bench-btn,
|
||||
.top-bar .aggregate-dot { display: none; }
|
||||
.top-bar.has-tools .hamburger-btn,
|
||||
.top-bar.has-tools .bench-btn,
|
||||
.top-bar.has-tools .aggregate-dot { display: inline-flex; }
|
||||
/* 聚合状态点:仅手机档显示(响应式档位在 responsive.css 进一步控制) */
|
||||
.top-bar .aggregate-dot { margin: 0 2px; }
|
||||
.bench-btn .bench-btn-count {
|
||||
position: absolute; top: -2px; right: -4px;
|
||||
min-width: 14px; height: 14px; padding: 0 3px;
|
||||
border-radius: 7px;
|
||||
background: var(--pa-gradient); color: #fff;
|
||||
font-family: var(--pa-font-mono); font-size: 9.5px; font-weight: 600;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 8px rgba(0,212,255, calc(.3 * var(--pa-glow-opacity)));
|
||||
}
|
||||
|
||||
/* ---------- 左栏:会话列表(UI_SPEC §4.2) ---------- */
|
||||
/* B1 外壳包裹层(grid 子元素;SessionPanel 填满其内) */
|
||||
.aside-host {
|
||||
grid-area: side;
|
||||
display: flex; flex-direction: column;
|
||||
min-width: 0; overflow: hidden;
|
||||
}
|
||||
.aside-host > .session-panel { flex: 1; width: 100%; height: 100%; border-right: none; }
|
||||
.session-panel {
|
||||
grid-area: side;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--pa-surface-1);
|
||||
border-right: 1px solid var(--pa-hairline);
|
||||
min-width: 0; overflow: hidden;
|
||||
transition: all .3s var(--pa-ease-out);
|
||||
}
|
||||
.session-panel .inner { display: flex; flex-direction: column; min-height: 0; width: 260px; transition: opacity .2s var(--pa-ease); }
|
||||
.side-collapsed .session-panel .inner { opacity: 0; pointer-events: none; }
|
||||
.session-panel .collapsed-rail {
|
||||
display: none; flex-direction: column; align-items: center; gap: 14px; padding-top: 14px;
|
||||
}
|
||||
.side-collapsed .session-panel .collapsed-rail { display: flex; }
|
||||
.rail-item {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 34px; height: 34px; border-radius: var(--pa-r-md);
|
||||
color: var(--pa-ink-muted); position: relative;
|
||||
transition: background .2s var(--pa-ease), color .2s var(--pa-ease);
|
||||
}
|
||||
.rail-item svg { width: 17px; height: 17px; }
|
||||
.rail-item.active { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.rail-item.active::before {
|
||||
content: ""; position: absolute; left: -8px; top: 8px; bottom: 8px; width: 2px;
|
||||
background: var(--pa-gradient); border-radius: 2px;
|
||||
}
|
||||
|
||||
.session-panel .pad { padding: 14px 14px 10px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.new-task-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||
height: 40px; border-radius: var(--pa-r-md);
|
||||
background: var(--pa-gradient);
|
||||
color: #fff; font-size: 14px; font-weight: 600;
|
||||
box-shadow: 0 0 0 rgba(0,212,255,0);
|
||||
transition: box-shadow .3s var(--pa-ease), transform .2s var(--pa-ease);
|
||||
}
|
||||
.new-task-btn:active { transform: translateY(0); }
|
||||
.new-task-btn svg { width: 15px; height: 15px; }
|
||||
.search-box {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
height: 34px; padding: 0 10px;
|
||||
background: var(--pa-surface-2);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-md);
|
||||
transition: border-color .2s var(--pa-ease), box-shadow .2s var(--pa-ease);
|
||||
}
|
||||
.search-box:focus-within {
|
||||
border-color: transparent;
|
||||
background:
|
||||
linear-gradient(var(--pa-surface-2), var(--pa-surface-2)) padding-box,
|
||||
var(--pa-gradient) border-box;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 0 14px rgba(0,212,255, calc(.18 * var(--pa-glow-opacity)));
|
||||
}
|
||||
.search-box svg { width: 14px; height: 14px; color: var(--pa-ink-subtle); flex: none; }
|
||||
.search-box input { flex: 1; min-width: 0; background: none; border: none; outline: none; font-size: 13px; }
|
||||
.search-box input::placeholder { color: var(--pa-ink-subtle); }
|
||||
|
||||
.session-list { flex: 1; overflow-y: auto; padding: 0 10px 10px; }
|
||||
.session-group { margin-bottom: 8px; }
|
||||
.group-label {
|
||||
padding: 10px 6px 5px;
|
||||
font-size: 11px; font-weight: 500; letter-spacing: .6px;
|
||||
color: var(--pa-ink-subtle);
|
||||
}
|
||||
.session-item {
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: var(--pa-r-md);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background .15s var(--pa-ease);
|
||||
}
|
||||
.session-item.active {
|
||||
background: color-mix(in srgb, var(--pa-g-to) 8%, transparent);
|
||||
border-color: color-mix(in srgb, var(--pa-g-to) 22%, transparent);
|
||||
}
|
||||
.session-item.active::before {
|
||||
content: ""; position: absolute; left: -10px; top: 7px; bottom: 7px; width: 2px;
|
||||
background: var(--pa-gradient); border-radius: 2px;
|
||||
}
|
||||
.session-item .txt { flex: 1; min-width: 0; }
|
||||
.session-item .txt .title { font-size: 13px; font-weight: 500; color: var(--pa-ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.session-item .txt .meta { font-size: 11px; color: var(--pa-ink-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.status-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; }
|
||||
.status-dot.running { background: var(--pa-g-to); animation: pulse 2s infinite; }
|
||||
.status-dot.done { background: var(--pa-success); }
|
||||
.status-dot.error { background: var(--pa-error); }
|
||||
.status-dot.idle { border: 1.5px solid var(--pa-ink-subtle); }
|
||||
.empty-search { padding: 24px 10px; text-align: center; color: var(--pa-ink-subtle); font-size: 12.5px; }
|
||||
|
||||
/* ---------- 中栏:对话区(UI_SPEC §4.3) ---------- */
|
||||
.chat-panel {
|
||||
grid-area: chat;
|
||||
display: flex; flex-direction: column;
|
||||
min-width: 0; min-height: 0;
|
||||
}
|
||||
.chat-scroll { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.chat-inner { max-width: 760px; margin: 0 auto; padding: 28px 28px 12px; }
|
||||
|
||||
/* 欢迎空态 */
|
||||
.empty-state { text-align: center; padding-top: 9vh; }
|
||||
.empty-state .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, calc(.45 * var(--pa-glow-opacity))),
|
||||
0 0 70px rgba(94,106,210, calc(.3 * var(--pa-glow-opacity)));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; color: #fff;
|
||||
}
|
||||
.empty-state h1 {
|
||||
font-size: 28px; font-weight: 600; letter-spacing: -.4px; margin-bottom: 10px;
|
||||
background: linear-gradient(90deg, var(--pa-ink), var(--pa-ink-muted));
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
}
|
||||
.empty-state 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; }
|
||||
.suggest-card {
|
||||
padding: 15px;
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
cursor: pointer;
|
||||
transition: transform .2s var(--pa-ease), border-color .2s var(--pa-ease), box-shadow .2s var(--pa-ease);
|
||||
}
|
||||
.suggest-card .icon { font-size: 16px; margin-bottom: 6px; }
|
||||
.suggest-card .name { font-size: 13.5px; font-weight: 600; margin-bottom: 3px; }
|
||||
.suggest-card .desc { font-size: 12px; color: var(--pa-ink-muted); line-height: 1.5; }
|
||||
|
||||
/* 消息 */
|
||||
.msg { margin-bottom: 22px; animation: msg-in .3s var(--pa-ease-out); }
|
||||
.msg.user { display: flex; justify-content: flex-end; }
|
||||
.msg.user .bubble {
|
||||
max-width: 70%;
|
||||
padding: 10px 15px;
|
||||
background: var(--pa-user-bubble);
|
||||
border: 1px solid var(--pa-user-bubble-border);
|
||||
border-radius: var(--pa-r-lg);
|
||||
font-size: 14px;
|
||||
}
|
||||
.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 .25s var(--pa-ease);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.msg-head .who { font-size: 12.5px; font-weight: 600; }
|
||||
.msg-head .when { font-size: 11.5px; color: var(--pa-ink-subtle); }
|
||||
.msg-body { font-size: 14px; color: var(--pa-ink); }
|
||||
.msg-body p { margin: 0 0 10px; }
|
||||
.msg-body p:last-child { margin-bottom: 0; }
|
||||
.msg-body strong { font-weight: 600; }
|
||||
|
||||
/* Markdown 表格(渐变表头下划线 + mono 数据,UI_SPEC §4.3 / FR-RESP-011 横滚容器) */
|
||||
.md-table-wrap { display: block; position: relative; }
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.md-table tbody td { padding: 8px 12px; border-bottom: 1px solid var(--pa-hairline); }
|
||||
.md-table tbody tr:last-child td { border-bottom: none; }
|
||||
.md-table tbody td.mono { font-family: var(--pa-font-mono); font-size: 12.5px; }
|
||||
|
||||
/* 代码块 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ---------- 流水线卡(UI_SPEC §4.4,FR-CHAT-009) ---------- */
|
||||
.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);
|
||||
}
|
||||
.pipeline-card .pc-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pipeline-card .pc-title { font-size: 12.5px; font-weight: 600; letter-spacing: .3px; }
|
||||
.pipeline { display: flex; flex-direction: column; gap: 0; }
|
||||
.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-step .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-step .p-name { font-size: 13px; width: 84px; flex: none; }
|
||||
.p-step .p-desc { flex: 1; font-size: 12px; color: var(--pa-ink-muted); min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.p-step .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 { background: var(--pa-t-execution); animation: pulse 2s infinite; }
|
||||
.p-step.running .p-state { color: var(--pa-t-execution); }
|
||||
.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-step .p-state .spin { width: 11px; height: 11px; border-radius: 50%;
|
||||
border: 2px solid transparent; border-top-color: currentColor; animation: spin 1s linear infinite; }
|
||||
/* RAG 相位色修饰(UI_SPEC §4.4 注:running 点与字用当前相位 --pa-t-* 色)。
|
||||
B2 在 p-step 上加 ph-<phase> 类即启用。 */
|
||||
.p-step.running.ph-planning .p-dot { background: var(--pa-t-planning); }
|
||||
.p-step.running.ph-planning .p-state { color: var(--pa-t-planning); }
|
||||
.p-step.running.ph-retrieval .p-dot { background: var(--pa-t-execution); }
|
||||
.p-step.running.ph-retrieval .p-state { color: var(--pa-t-execution); }
|
||||
.p-step.running.ph-evidence .p-dot { background: var(--pa-t-knowledge); }
|
||||
.p-step.running.ph-evidence .p-state { color: var(--pa-t-knowledge); }
|
||||
.p-step.running.ph-generation .p-dot { background: var(--pa-t-validation); }
|
||||
.p-step.running.ph-generation .p-state { color: var(--pa-t-validation); }
|
||||
|
||||
/* ---------- 工具调用卡(UI_SPEC §4.5,FR-RETRIEVAL) ---------- */
|
||||
.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, calc(.05 * var(--pa-glow-opacity))) 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;
|
||||
}
|
||||
.tool-head .t-ico { width: 22px; height: 22px; border-radius: 7px; flex: none;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 11.5px;
|
||||
background: color-mix(in srgb, var(--pa-g-from) 16%, transparent);
|
||||
color: var(--pa-g-to);
|
||||
}
|
||||
.tool-head .t-name { flex: 1; font-size: 13.5px; font-weight: 600; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tool-head .t-dur { font-family: var(--pa-font-mono); font-size: 11.5px; color: var(--pa-ink-subtle); }
|
||||
.tool-head .t-chev { color: var(--pa-ink-subtle); transition: transform .25s var(--pa-ease); }
|
||||
.tool-card.open .tool-head .t-chev { transform: rotate(90deg); }
|
||||
.tool-body { padding: 0 14px 13px; border-top: 1px solid var(--pa-hairline); padding-top: 11px;
|
||||
animation: fade-slide .2s var(--pa-ease-out); }
|
||||
.tool-body .tb-label { font-size: 11px; letter-spacing: .5px; color: var(--pa-ink-subtle); margin: 8px 0 5px; }
|
||||
.tool-body .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;
|
||||
}
|
||||
.tool-body .tb-result { font-size: 13px; color: var(--pa-ink); }
|
||||
.tool-body .tb-result .mono { color: var(--pa-success); }
|
||||
.tool-body .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;
|
||||
}
|
||||
.tool-body .tb-log .log-ok { color: var(--pa-success); }
|
||||
.tool-body .tb-log .log-run { color: var(--pa-g-to); }
|
||||
|
||||
/* ---------- 流式文字(UI_SPEC §4.3,FR-CHAT-010 揭示动画) ---------- */
|
||||
.stream-wrap { margin: 10px 0 4px; }
|
||||
.stream-text { font-size: 14px; color: var(--pa-ink); }
|
||||
.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;
|
||||
}
|
||||
.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, calc(.25 * var(--pa-glow-opacity))) 50%, transparent 70%);
|
||||
will-change: transform;
|
||||
animation: shimmer 1.8s infinite;
|
||||
}
|
||||
|
||||
/* ---------- 作曲器(UI_SPEC §4.6,FR-CHAT-005) ---------- */
|
||||
.composer-wrap { padding: 4px 28px 14px; }
|
||||
.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 .2s var(--pa-ease), box-shadow .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, calc(.16 * var(--pa-glow-opacity)));
|
||||
}
|
||||
.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 .2s var(--pa-ease);
|
||||
}
|
||||
.mode-pill.on {
|
||||
color: #fff;
|
||||
background: var(--pa-gradient);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 12px rgba(0,212,255, calc(.3 * var(--pa-glow-opacity)));
|
||||
}
|
||||
.composer 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);
|
||||
}
|
||||
.composer textarea::placeholder { color: var(--pa-ink-subtle); }
|
||||
.composer-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
|
||||
.composer-actions .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 .3s var(--pa-ease), transform .15s var(--pa-ease), opacity .2s var(--pa-ease);
|
||||
}
|
||||
.send-btn:active { transform: translateY(0); }
|
||||
.send-btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; transform: none; }
|
||||
.send-btn svg { width: 13px; height: 13px; }
|
||||
|
||||
/* ---------- 右栏:工作台(UI_SPEC §4.7) ---------- */
|
||||
/* B1 外壳包裹层:grid 子元素 + 折叠手柄宿主;BenchPanel(B3)作为其内容 */
|
||||
.bench-wrap {
|
||||
position: relative;
|
||||
grid-area: bench;
|
||||
display: flex; flex-direction: column;
|
||||
min-width: 0; overflow: hidden;
|
||||
transition: transform .3s var(--pa-ease-out);
|
||||
}
|
||||
.bench-wrap > .bench-panel { flex: 1 1 auto; min-width: 0; min-height: 0; }
|
||||
.bench-panel {
|
||||
grid-area: bench;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--pa-surface-1);
|
||||
border-left: 1px solid var(--pa-hairline);
|
||||
min-width: 0; overflow: hidden;
|
||||
transition: all .3s var(--pa-ease-out);
|
||||
}
|
||||
.bench-panel .inner { display: flex; flex-direction: column; min-height: 0; width: 380px; transition: opacity .2s var(--pa-ease); }
|
||||
.bench-collapsed .bench-panel .inner { opacity: 0; pointer-events: none; }
|
||||
.bench-collapsed .bench-wrap > .bench-panel { opacity: 0; pointer-events: none; }
|
||||
.bench-collapsed .bench-wrap .bench-handle { display: flex; }
|
||||
.bench-handle {
|
||||
display: none; width: 28px; flex-direction: column; align-items: center; padding-top: 10px;
|
||||
color: var(--pa-ink-subtle); cursor: pointer;
|
||||
}
|
||||
.bench-handle svg { width: 15px; height: 15px; }
|
||||
.bench-collapsed .bench-wrap .bench-handle svg { transform: rotate(180deg); }
|
||||
/* 展开态折叠入口:工作台右上角小按钮(FR-SHELL-005 的折叠触发;折叠后由手柄负责展开) */
|
||||
.bench-collapse-btn {
|
||||
position: absolute; top: 10px; right: 8px; z-index: 6;
|
||||
background: var(--pa-surface-1);
|
||||
}
|
||||
.bench-collapsed .bench-wrap .bench-collapse-btn { display: none; }
|
||||
/* 移动端全屏覆盖层的关闭按钮(默认隐藏,仅手机端打开工作台时显示) */
|
||||
.bench-close-btn {
|
||||
position: absolute; top: 8px; right: 8px; z-index: 63;
|
||||
display: none;
|
||||
background: var(--pa-surface-1);
|
||||
}
|
||||
|
||||
.bench-tabs {
|
||||
display: flex; gap: 2px;
|
||||
padding: 10px 14px 0;
|
||||
border-bottom: 1px solid var(--pa-hairline);
|
||||
}
|
||||
.bench-tab {
|
||||
position: relative;
|
||||
padding: 8px 10px 9px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--pa-ink-muted);
|
||||
transition: color .2s var(--pa-ease);
|
||||
}
|
||||
.bench-tab.on { color: var(--pa-ink); font-weight: 600; }
|
||||
.bench-tab.on::after {
|
||||
content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px;
|
||||
background: var(--pa-gradient); border-radius: 2px;
|
||||
animation: fade-slide .25s var(--pa-ease-out);
|
||||
}
|
||||
.bench-body { flex: 1; overflow-y: auto; padding: 14px; }
|
||||
.tab-pane { animation: fade-slide .2s var(--pa-ease-out); }
|
||||
.bench-sec-title { font-size: 11px; letter-spacing: .6px; color: var(--pa-ink-subtle); margin: 10px 0 8px; }
|
||||
|
||||
/* Tab1 工具调用:筛选 chips */
|
||||
.filter-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.chip {
|
||||
padding: 3px 11px; border-radius: var(--pa-r-pill);
|
||||
font-size: 11.5px; color: var(--pa-ink-muted);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
transition: all .18s var(--pa-ease);
|
||||
}
|
||||
.chip.on { color: #fff; background: var(--pa-g-from); border-color: transparent; }
|
||||
.bench-panel .tool-card { margin: 0 0 10px; }
|
||||
.bench-panel .tool-head { padding: 8px 11px; }
|
||||
.bench-panel .tool-body { padding: 0 11px 10px; }
|
||||
|
||||
/* Tab2 产物文件:mono 文件树 */
|
||||
.file-tree { font-family: var(--pa-font-mono); font-size: 12.5px; }
|
||||
.ft-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 8px; border-radius: var(--pa-r-sm);
|
||||
transition: background .15s var(--pa-ease);
|
||||
}
|
||||
.ft-item .ft-ico { flex: none; width: 16px; text-align: center; }
|
||||
.ft-item.dir .ft-name { color: var(--pa-g-to); font-weight: 600; }
|
||||
.ft-item .ft-name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ft-item .ft-size { font-size: 11px; color: var(--pa-ink-subtle); }
|
||||
.ft-dl { opacity: 0; color: var(--pa-ink-subtle); transition: opacity .15s var(--pa-ease); display: inline-flex; }
|
||||
.ft-dl svg { width: 13px; height: 13px; }
|
||||
|
||||
/* Tab3 任务进度:渐变进度条 + 扫光 */
|
||||
.prog-item { margin-bottom: 16px; }
|
||||
.prog-item .pi-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
|
||||
.prog-item .pi-name { font-size: 13px; font-weight: 600; }
|
||||
.prog-item .pi-pct { font-family: var(--pa-font-mono); font-size: 12px; color: var(--pa-ink-muted); }
|
||||
.prog-item .pi-pct.ok { color: var(--pa-success); }
|
||||
.prog-track { height: 6px; border-radius: 3px; background: var(--pa-surface-3); overflow: hidden; position: relative; }
|
||||
.prog-fill { height: 100%; border-radius: 3px; background: var(--pa-gradient);
|
||||
position: relative; overflow: hidden;
|
||||
transition: width .5s var(--pa-ease); }
|
||||
.prog-fill::after {
|
||||
content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 200%;
|
||||
background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,.45) 50%, transparent 80%);
|
||||
will-change: transform;
|
||||
animation: sweep 2.4s infinite;
|
||||
}
|
||||
.prog-fill.full { background: var(--pa-success); }
|
||||
.prog-fill.full::after { animation: none; }
|
||||
|
||||
/* Tab4 预览:SVG 树 + 热图 + Motif 色块 */
|
||||
.preview-block {
|
||||
background: var(--pa-surface-1);
|
||||
border: 1px solid var(--pa-hairline);
|
||||
border-radius: var(--pa-r-lg);
|
||||
padding: 12px; margin-bottom: 14px;
|
||||
}
|
||||
.preview-block .pb-title { font-size: 12px; font-weight: 600; margin-bottom: 8px; }
|
||||
.tree-svg { width: 100%; height: auto; display: block; }
|
||||
.heatmap { display: grid; grid-template-columns: repeat(8, 1fr); gap: 3px; }
|
||||
.heatmap .cell { aspect-ratio: 1.4; border-radius: 2px; }
|
||||
.motif-track { display: flex; gap: 3px; height: 22px; margin-bottom: 6px; }
|
||||
.motif-track .seg { border-radius: 3px; }
|
||||
.motif-track .seg.m1 { background: var(--pa-t-knowledge); }
|
||||
.motif-track .seg.m2 { background: var(--pa-t-execution); }
|
||||
.motif-track .seg.m3 { background: var(--pa-t-planning); }
|
||||
.motif-track .seg.m4 { background: var(--pa-t-validation); }
|
||||
.motif-legend { display: flex; gap: 12px; font-size: 11px; color: var(--pa-ink-muted); flex-wrap: wrap; }
|
||||
.motif-legend span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.motif-legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
|
||||
|
||||
/* ---------- 底部状态栏(UI_SPEC §4.8,FR-SHELL-003) ---------- */
|
||||
.status-bar {
|
||||
grid-area: status;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 14px;
|
||||
background: var(--pa-surface-1);
|
||||
border-top: 1px solid var(--pa-hairline);
|
||||
font-size: 11.5px; color: var(--pa-ink-subtle);
|
||||
z-index: 5;
|
||||
}
|
||||
.agent-dots { display: flex; align-items: center; gap: 10px; }
|
||||
.agent-dot-item { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.agent-dot-item .ad {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--pa-surface-3); border: 1.5px solid var(--pa-hairline-strong);
|
||||
transition: all .3s var(--pa-ease);
|
||||
}
|
||||
.agent-dot-item .ad.running { background: var(--pa-t-execution); border-color: transparent; animation: pulse 2s infinite; }
|
||||
.agent-dot-item .ad.done { background: var(--pa-success); border-color: transparent; }
|
||||
.agent-dot-item .ad.error { background: var(--pa-error); border-color: transparent; }
|
||||
/* RAG 相位色修饰(UI_SPEC §4.8:.running = 该相位 --pa-t-* 色) */
|
||||
.agent-dot-item.ph-planning .ad.running { background: var(--pa-t-planning); }
|
||||
.agent-dot-item.ph-retrieval .ad.running { background: var(--pa-t-execution); }
|
||||
.agent-dot-item.ph-evidence .ad.running { background: var(--pa-t-knowledge); }
|
||||
.agent-dot-item.ph-generation .ad.running { background: var(--pa-t-validation); }
|
||||
.status-right { display: flex; gap: 12px; align-items: center; }
|
||||
.status-right .mono { font-size: 11px; }
|
||||
/* 上游健康标记(FR-SHELL-003 验收项) */
|
||||
.health-dots { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.health-dots .hd { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.health-dots .hd.ok { background: var(--pa-success); }
|
||||
.health-dots .hd.err { background: var(--pa-error); }
|
||||
.health-dots .hd.unknown { background: var(--pa-surface-3); border: 1.5px solid var(--pa-hairline-strong); }
|
||||
|
||||
/* ---------- 折叠把手(UI_SPEC §3,FR-SHELL-004) ---------- */
|
||||
.side-handle-btn {
|
||||
position: absolute; left: 0; bottom: 40px; z-index: 7;
|
||||
width: 26px; height: 26px;
|
||||
border: 1px solid var(--pa-hairline);
|
||||
background: var(--pa-surface-1);
|
||||
border-radius: var(--pa-r-md);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: var(--pa-ink-muted);
|
||||
}
|
||||
.side-handle-btn svg { width: 13px; height: 13px; transition: transform .3s var(--pa-ease-out); }
|
||||
.side-handle-btn.collapsed svg { transform: rotate(180deg); }
|
||||
|
||||
/* ---------- 通用抽屉(UI_SPEC §5 / §7:drawer / drawer-scrim) ---------- */
|
||||
.drawer-scrim {
|
||||
position: fixed; inset: 0; z-index: 61;
|
||||
background: var(--pa-scrim);
|
||||
animation: fade-in .2s var(--pa-ease);
|
||||
}
|
||||
.drawer {
|
||||
position: fixed; top: 0; bottom: 0; z-index: 62;
|
||||
display: flex; flex-direction: column;
|
||||
max-width: 300px; min-width: 0; overflow: hidden;
|
||||
background: var(--pa-surface-1);
|
||||
transition: transform .3s var(--pa-ease-out);
|
||||
}
|
||||
.drawer.left { left: 0; border-right: 1px solid var(--pa-hairline); }
|
||||
.drawer.right { right: 0; border-left: 1px solid var(--pa-hairline); }
|
||||
/* 抽屉内面板自适应(Drawer Teleport 到 body,不受 shell 类约束) */
|
||||
.drawer .session-panel, .drawer .bench-panel {
|
||||
width: 100%; height: 100%;
|
||||
border-left: none; border-right: none;
|
||||
}
|
||||
.drawer .session-panel .inner, .drawer .bench-panel .inner { width: 100%; }
|
||||
.drawer .bench-panel .bench-handle { display: none; }
|
||||
|
||||
/* ---------- 通用 UI:骨架卡 / 空态 / spinner 工具类 ---------- */
|
||||
.skeleton-card {
|
||||
position: relative; overflow: hidden;
|
||||
background: var(--pa-surface-3);
|
||||
border-radius: var(--pa-r-md);
|
||||
}
|
||||
.skeleton-card::after {
|
||||
content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 200%;
|
||||
background: linear-gradient(100deg, transparent 30%, rgba(0,212,255, calc(.05 * var(--pa-glow-opacity))) 50%, transparent 70%);
|
||||
will-change: transform;
|
||||
animation: shimmer 1.8s infinite;
|
||||
}
|
||||
.empty-notice { padding: 24px 10px; text-align: center; color: var(--pa-ink-subtle); font-size: 12.5px; }
|
||||
.empty-notice .empty-notice-icon { font-size: 22px; margin-bottom: 6px; }
|
||||
.empty-notice .empty-notice-title { font-size: 13px; color: var(--pa-ink-muted); margin-bottom: 2px; }
|
||||
.empty-notice .empty-notice-desc { font-size: 12px; }
|
||||
.spin-ico {
|
||||
display: inline-block; width: 11px; height: 11px; border-radius: 50%;
|
||||
border: 2px solid transparent; border-top-color: currentColor;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
悬停态 —— 一律包 @media (hover: hover)(UI_SPEC §2 / §5)。
|
||||
触摸端替代态见文件尾 @media (hover: none)。
|
||||
============================================================ */
|
||||
@media (hover: hover) {
|
||||
.icon-btn:hover:not(:disabled) { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.rail-item:hover { background: var(--pa-surface-2); color: var(--pa-ink); }
|
||||
.new-task-btn:hover {
|
||||
box-shadow: 0 0 24px rgba(0,212,255, calc(.4 * var(--pa-glow-opacity)));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.session-item:hover { background: var(--pa-surface-2); }
|
||||
.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, calc(.15 * var(--pa-glow-opacity)));
|
||||
}
|
||||
.msg.assistant:hover::before { background: var(--pa-gradient); }
|
||||
.mode-pill:hover { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.send-btn:hover:not(:disabled) {
|
||||
box-shadow: 0 0 24px rgba(0,212,255, calc(.4 * var(--pa-glow-opacity)));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.bench-tab:hover { color: var(--pa-ink); }
|
||||
.chip:hover { color: var(--pa-ink); }
|
||||
.ft-item:hover { background: var(--pa-surface-2); }
|
||||
.ft-item:hover .ft-dl { opacity: 1; }
|
||||
.ft-dl:hover { color: var(--pa-g-to); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
触摸端常驻替代态(FR-RESP-007):不依赖悬停也能触达功能
|
||||
============================================================ */
|
||||
@media (hover: none) {
|
||||
.msg.assistant::before { background: var(--pa-gradient); }
|
||||
.ft-dl { opacity: 1; }
|
||||
.session-item:active { background: var(--pa-surface-2); }
|
||||
.rail-item:active { background: var(--pa-surface-2); color: var(--pa-ink); }
|
||||
.icon-btn:active:not(:disabled) { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
.chip:active { color: var(--pa-ink); }
|
||||
.mode-pill:active { color: var(--pa-ink); background: var(--pa-surface-2); }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/* responsive.css —— B1 归属。UI_SPEC §5 四档断点 + 移动端专项(PRD FR-RESP-*)。
|
||||
三栏网格本身由 base.css 的 .app-shell 声明,本文件按档位覆盖。
|
||||
联动类约定:<1024px 时 AppShell 自动加 .drawers-mode(左右栏脱离网格、成为抽屉宿主);
|
||||
<768px 加 .mobile(底栏隐藏、aurora 关闭、聚合状态点接管)。 */
|
||||
|
||||
/* ============================================================
|
||||
Desktop ≥1280px:无覆盖(完整三栏 260/1fr/380)
|
||||
============================================================ */
|
||||
|
||||
/* ============================================================
|
||||
Laptop 1024–1279px(FR-RESP-002):
|
||||
左栏自动折叠 64px(首次进入无偏好时由 AppShell 置折叠);
|
||||
右栏收窄 340px。折叠把手保留——用户显式展开时尊重其选择。
|
||||
============================================================ */
|
||||
@media (max-width: 1279px) and (min-width: 1024px) {
|
||||
.app-shell:not(.side-collapsed):not(.bench-collapsed) { grid-template-columns: 260px minmax(0,1fr) 340px; }
|
||||
.app-shell.side-collapsed:not(.bench-collapsed) { grid-template-columns: 64px minmax(0,1fr) 340px; }
|
||||
.app-shell:not(.side-collapsed).bench-collapsed { grid-template-columns: 260px minmax(0,1fr) 28px; }
|
||||
.app-shell.side-collapsed.bench-collapsed { grid-template-columns: 64px minmax(0,1fr) 28px; }
|
||||
.bench-panel .inner { width: 340px; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Tablet 768–1023px(FR-RESP-003):
|
||||
对话单栏铺满;左栏变左抽屉、右栏变右抽屉(host 在 .drawers-mode 下);
|
||||
顶栏出现汉堡 + 工作台按钮(带来源数角标);底栏保留。
|
||||
============================================================ */
|
||||
@media (max-width: 1023px) and (min-width: 768px) {
|
||||
.app-shell.drawers-mode {
|
||||
grid-template-columns: minmax(0,1fr);
|
||||
grid-template-rows: var(--topbar-h) minmax(0,1fr) var(--statusbar-h);
|
||||
grid-template-areas: "top" "chat" "status";
|
||||
}
|
||||
.app-shell.drawers-mode .session-panel { display: none; }
|
||||
.app-shell.drawers-mode .bench-wrap { display: none; }
|
||||
.app-shell.drawers-mode .side-handle-btn { display: none; }
|
||||
.app-shell.drawers-mode .status-bar { grid-area: status; }
|
||||
.app-shell.drawers-mode .top-bar { grid-area: top; }
|
||||
.app-shell.drawers-mode .chat-panel { grid-area: chat; }
|
||||
|
||||
/* 2026-08-25 PM 修复:has-tools 挂在 .app-shell 上(AppShell shellClass),
|
||||
此处选择器必须从 .app-shell 出发;mobile 档另有自己的规则故 :not(.mobile) */
|
||||
.app-shell.drawers-mode:not(.mobile) .top-bar .hamburger-btn { display: inline-flex; }
|
||||
.app-shell.drawers-mode:not(.mobile) .top-bar .bench-btn { display: inline-flex; }
|
||||
.app-shell.drawers-mode:not(.mobile) .top-bar .aggregate-dot { display: none; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Mobile <768px(FR-RESP-004):
|
||||
顶栏 = 汉堡 + 标题 + 工作台按钮;单栏对话铺满;
|
||||
工作台为全屏覆盖层(顶部 tab 条 + 关闭按钮);底栏隐藏;
|
||||
顶栏显示聚合状态点;aurora 关闭(FR-RESP-012)。
|
||||
============================================================ */
|
||||
@media (max-width: 767px) {
|
||||
.app-shell.drawers-mode.mobile {
|
||||
grid-template-columns: minmax(0,1fr);
|
||||
grid-template-rows: var(--topbar-h) minmax(0,1fr);
|
||||
grid-template-areas: "top" "chat";
|
||||
}
|
||||
.app-shell.drawers-mode.mobile .status-bar { display: none; }
|
||||
.app-shell.drawers-mode.mobile .session-panel { display: none; }
|
||||
.app-shell.drawers-mode.mobile .bench-wrap { display: none; }
|
||||
.app-shell.drawers-mode.mobile .side-handle-btn { display: none; }
|
||||
|
||||
.app-shell.drawers-mode.mobile .top-bar .hamburger-btn { display: inline-flex; }
|
||||
.app-shell.drawers-mode.mobile .top-bar .bench-btn { display: inline-flex; }
|
||||
.app-shell.drawers-mode.mobile .top-bar .aggregate-dot { display: inline-flex; }
|
||||
.app-shell.drawers-mode.mobile .top-bar .logo-text { display: none; }
|
||||
.app-shell.drawers-mode.mobile .top-title .badge { display: none; }
|
||||
/* 2026-08-25 PM:覆盖层内 BenchPanel 自带 tab 条与外层重复,隐藏内部那套
|
||||
(外层含关闭按钮且已接 bench store,保持单一 tab 头部) */
|
||||
.bench-overlay .bench-panel .bench-tabs { display: none; }
|
||||
|
||||
/* 全屏工作台覆盖层 */
|
||||
.bench-overlay {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--pa-canvas);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
.bench-overlay .bench-overlay-tabs { padding: 10px 14px 0; border-bottom: 1px solid var(--pa-hairline); position: relative; }
|
||||
.bench-overlay .bench-tabs { border-bottom: none; }
|
||||
.bench-overlay .bench-panel { flex: 1 1 auto; min-height: 0; border-left: none; background: var(--pa-canvas); }
|
||||
.bench-overlay .bench-panel .inner { width: 100%; }
|
||||
.bench-overlay .bench-close-btn { display: inline-flex; }
|
||||
.bench-overlay .bench-body { padding-bottom: env(safe-area-inset-bottom); }
|
||||
|
||||
/* 聚合状态点(四个 agent 点合一的替代品) */
|
||||
.aggregate-dot { margin: 0 4px; }
|
||||
|
||||
/* 极光光斑关闭 */
|
||||
.aurora-bg .blob { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
移动端专项(PRD §12.2,全部四档生效处已按需限定)
|
||||
============================================================ */
|
||||
|
||||
/* FR-RESP-009 视口高度:100dvh + 100vh 回退(先声明回退链,
|
||||
后文的 safe-area 规则在其上做 calc 覆盖) */
|
||||
.app-shell { height: 100vh; }
|
||||
@supports (height: 100dvh) {
|
||||
.app-shell { height: 100dvh; }
|
||||
}
|
||||
.bench-overlay, .drawer-host, .drawer { height: 100vh; }
|
||||
@supports (height: 100dvh) {
|
||||
.bench-overlay, .drawer-host, .drawer { height: 100dvh; }
|
||||
}
|
||||
|
||||
/* FR-RESP-005 安全区 */
|
||||
.top-bar { padding-top: env(safe-area-inset-top); }
|
||||
.app-shell { height: calc(100dvh - env(safe-area-inset-top)); }
|
||||
@supports not (height: 100dvh) {
|
||||
.app-shell { height: 100vh; }
|
||||
}
|
||||
.composer-wrap { padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
|
||||
.status-bar { padding-bottom: env(safe-area-inset-bottom); }
|
||||
.session-panel .session-list { padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.bench-body { padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
|
||||
|
||||
/* FR-RESP-006 iOS 输入放大规避:<768px 时 textarea 16px */
|
||||
@media (max-width: 767px) {
|
||||
.composer textarea { font-size: 16px; }
|
||||
}
|
||||
|
||||
/* FR-RESP-008 触控目标 44px(iOS HIG) */
|
||||
@media (max-width: 767px) {
|
||||
.session-item, .bench-tab, .mode-pill, .icon-btn { min-height: 44px; }
|
||||
.bench-overlay .bench-tab { min-height: 44px; }
|
||||
}
|
||||
|
||||
/* FR-RESP-010 虚拟键盘适配:visualViewport 由 AppShell 监听(.keyboard-open 类) */
|
||||
.app-shell.keyboard-open .chat-scroll { max-height: none; }
|
||||
|
||||
/* FR-RESP-011 表格横向滚动容器 + 边缘渐隐(<768px,不压缩字号) */
|
||||
@media (max-width: 767px) {
|
||||
.md-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.md-table-wrap::after {
|
||||
content: ""; position: absolute; top: 0; right: 0; bottom: 0; width: 28px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, transparent, var(--pa-surface-1));
|
||||
}
|
||||
.md-table { min-width: max-content; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* tokens.css —— B1 归属。设计令牌唯一来源:docs/UI_SPEC.md §1。
|
||||
值一字不差;实现以本文件为准,权威视觉原型仅作参考。 */
|
||||
|
||||
/* ---------- 1.1 深色主题(默认) ---------- */
|
||||
:root[data-theme="dark"] {
|
||||
--pa-canvas: #0A0A10; --pa-surface-1: #111119; --pa-surface-2: #161622;
|
||||
--pa-surface-3: #1C1C2A; --pa-raised: #202031; --pa-hairline: #23232F;
|
||||
--pa-hairline-strong: #33334A; --pa-ink: #F2F3F8; --pa-ink-muted: #A9ADC4;
|
||||
--pa-ink-subtle: #6B6F8B; --pa-code-bg: #0D0D16;
|
||||
--pa-user-bubble: linear-gradient(135deg, #2A2A3E, #1C1C2A);
|
||||
--pa-user-bubble-border: #33334A;
|
||||
--pa-glow-opacity: 1; --pa-aurora-opacity: 1;
|
||||
--pa-grid-dot: rgba(255,255,255,.035);
|
||||
--pa-scrim: rgba(10,10,16,.6);
|
||||
}
|
||||
|
||||
/* ---------- 1.2 浅色主题 ---------- */
|
||||
:root[data-theme="light"] {
|
||||
--pa-canvas: #F6F8FC; --pa-surface-1: #FFFFFF; --pa-surface-2: #F0F3FA;
|
||||
--pa-surface-3: #E9EDF7; --pa-raised: #FFFFFF; --pa-hairline: #E2E7F2;
|
||||
--pa-hairline-strong: #C9D2E8; --pa-ink: #16182A; --pa-ink-muted: #4C5268;
|
||||
--pa-ink-subtle: #9AA1B8; --pa-code-bg: #F1F4FB;
|
||||
--pa-user-bubble: linear-gradient(135deg, #E8ECFF, #F4FBFF);
|
||||
--pa-user-bubble-border: #C9D2E8;
|
||||
--pa-glow-opacity: .35; --pa-aurora-opacity: .08;
|
||||
--pa-grid-dot: rgba(22,24,42,.05);
|
||||
--pa-scrim: rgba(246,248,252,.6);
|
||||
}
|
||||
|
||||
/* ---------- 1.3 双主题公共 ---------- */
|
||||
:root {
|
||||
/* 品牌 */
|
||||
--pa-g-from: #5E6AD2; --pa-g-to: #00D4FF; --pa-aux: #A78BFA;
|
||||
--pa-gradient: linear-gradient(135deg, var(--pa-g-from), var(--pa-g-to));
|
||||
/* 语义色(只表状态,双主题通用) */
|
||||
--pa-success: #2ED3A0; --pa-warning: #F5B85C; --pa-error: #FF5C7A; --pa-info: #5E9BFF;
|
||||
/* 相位五色(只用于流水线卡/相位徽章/底栏状态点) */
|
||||
--pa-t-planning: #C0A8DD; --pa-t-execution: #9FBBE0; --pa-t-knowledge: #9FC9A2;
|
||||
--pa-t-validation: #DFA88F; --pa-t-done: #C08532;
|
||||
/* 字体 */
|
||||
--pa-font-ui: 'SF Pro Display','SF Pro Text','PingFang SC','Helvetica Neue','Microsoft YaHei','Noto Sans SC',system-ui,sans-serif;
|
||||
--pa-font-mono: 'SF Mono',Menlo,'JetBrains Mono',Consolas,'Liberation Mono',monospace;
|
||||
/* 圆角 */
|
||||
--pa-r-xs: 4px; --pa-r-sm: 6px; --pa-r-md: 8px; --pa-r-lg: 12px; --pa-r-xl: 16px; --pa-r-pill: 9999px;
|
||||
/* 缓动 */
|
||||
--pa-ease: cubic-bezier(.4,0,.2,1);
|
||||
--pa-ease-out: cubic-bezier(.16,1,.3,1);
|
||||
--pa-ease-spring: cubic-bezier(.34,1.56,.64,1);
|
||||
/* 壳层尺寸 */
|
||||
--topbar-h: 48px; --statusbar-h: 28px;
|
||||
}
|
||||
|
||||
/* ---------- UI_SPEC §1.4 合法硬编码值之一:热图 8 级色板(本项目图谱热力用途) ---------- */
|
||||
:root {
|
||||
--pa-heat-0: #0D0D16; --pa-heat-1: #1E2A5E; --pa-heat-2: #2B4AA8; --pa-heat-3: #3B5FD0;
|
||||
--pa-heat-4: #5E6AD2; --pa-heat-5: #00A3E8; --pa-heat-6: #00D4FF; --pa-heat-7: #8DF0FF;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// vite.config.js —— B1 归属,后续由 B1 扩展。此处为 M0 最小可用配置。
|
||||
// 默认代理指向 mock server (8099);集成阶段由 PM 改为真实 BFF (8090)。
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const bffTarget = process.env.VITE_BFF_TARGET || 'http://127.0.0.1:8099';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5180,
|
||||
// 2026-08-25 PM 加固:测试覆盖率文件写进 coverage/ 会触发 Vite 文件监听页面重载,
|
||||
// 干扰 E2E 与浏览器访问(R2 期间 198 次重载)。忽略该目录与测试产物。
|
||||
watch: {
|
||||
ignored: ['**/coverage/**', '**/test-results/**', '**/playwright-report/**'],
|
||||
},
|
||||
proxy: {
|
||||
'/api': { target: bffTarget, changeOrigin: false },
|
||||
},
|
||||
},
|
||||
build: { outDir: 'dist', target: 'es2020' },
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// vitest.config.js —— B1 归属。单测配置(jsdom + @vue/test-utils)。
|
||||
// 测试文件位于项目根 tests/frontend/unit(frontend/ 之外),依赖解析自 frontend/node_modules:
|
||||
// - server.fs.allow 放行上级目录
|
||||
// - include 用绝对路径(与运行 cwd 无关)
|
||||
// - 测试文件中的裸导入(pinia/vue/@vue/test-utils)以 alias 指到 frontend/node_modules 的 ESM 入口
|
||||
// 运行方式(任选):
|
||||
// cd frontend && npx vitest run tests/frontend/unit/b1
|
||||
// 或项目根:npx --prefix frontend vitest run --config frontend/vitest.config.js tests/frontend/unit/b1
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const nodeModules = fileURLToPath(new URL('./node_modules', import.meta.url));
|
||||
const testsDir = fileURLToPath(new URL('../tests/frontend/unit', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
// 独立依赖缓存目录:避免与 dev server(node_modules/.vite)争用 optimizeDeps 锁
|
||||
cacheDir: 'node_modules/.vite-vitest',
|
||||
resolve: {
|
||||
alias: {
|
||||
// 测试文件在 frontend/ 外,裸包名解析不到 frontend/node_modules
|
||||
pinia: `${nodeModules}/pinia/dist/pinia.mjs`,
|
||||
vue: `${nodeModules}/vue/index.mjs`,
|
||||
'@vue/test-utils': `${nodeModules}/@vue/test-utils/dist/vue-test-utils.esm-bundler.mjs`,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// 测试文件位于项目根 tests/(frontend/ 之外),放行上级目录
|
||||
fs: { allow: ['..'] },
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
// 测试目录在项目根 tests/frontend/unit(B1/B2/B3 各自子树)
|
||||
include: [`${testsDir}/**/*.{test,spec}.js`],
|
||||
globals: false,
|
||||
setupFiles: [`${testsDir}/b1/setup.js`],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user