P0/M1: 四部分重构——frontend→ui、backend→sdk 改名 + 引用修复 + 骨架

- git mv 保留历史:ui/(Vue3+Vite)、sdk/(BFF 中间层)
- 路径语义修正:db_path/figures_cache 改相对 sdk/(BFF cwd),
  历史库 sdk/backend/data 迁移至 sdk/data/(会话/图片缓存无损)
- 引用修复 ~15 处:config.py、deploy/*.sh、vitest/playwright 配置、
  单测导入路径、docs、README、gitignore、.env.example
- 骨架:agent/{qa,methodology,legacy-rag,vendor}、knowledge/manifest.yaml、
  tooling/{compile,kb,crypto,assemble,audit,tools,scripts} + README
- 门禁验证:sdk pytest 125 通过、ui vitest 158 通过、
  生产 BFF 脚本重启四上游全绿 + 静态托管 + 会话库无损

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jerry
2026-08-31 12:56:47 +08:00
co-authored by Claude Fable 5
parent 71189567ee
commit 0875bb036c
159 changed files with 222 additions and 174 deletions
+44
View File
@@ -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');
}
+147
View File
@@ -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;
+37
View File
@@ -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 });
}
+57
View File
@@ -0,0 +1,57 @@
// 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.onEvidence] payload 为归一化 search 响应(answer 空;方案 3 两级体验)
* @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, onEvidence, 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,
// 两级体验(方案 3,2026-08-26):BFF 并发 search 的归一化结果,非终态、0 或 1 次
evidence: onEvidence || 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 });
});
}
+46
View File
@@ -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`);
}