feat(graphrag): 新增多轮检索流式问答与用户管理界面

- 新增多轮 GraphRAG 检索功能,支持流式进度输出(SSE)
- 新增用户管理界面,可查看所有用户图谱统计并快速导航
- 新增多 Agent 任务拆解与执行服务,支持复杂任务协作处理
- 改进 embedding 和 rerank 服务的容错机制,支持备用模型和端点
- 更新前端样式遵循 Acmetone 设计规范,优化视觉一致性
- 新增流式分析接口,支持并行处理和专家评审选项
This commit is contained in:
KOSHM-Pig
2026-03-24 12:04:28 +08:00
parent adabd63769
commit 1f087599c5
16 changed files with 4955 additions and 452 deletions

View File

@@ -0,0 +1,53 @@
---
name: acmetone-design-specs
description: Acmetone项目专属设计规范颜色、字体、特色UI组件。在创建新页面、组件或修改样式时务必调用此技能以保证UI风格一致和最小化改动。
---
# Acmetone 设计规范 (Design Specifications)
在为 Acmetone 项目开发新功能、新增页面或修改 UI 组件时,请务必遵循以下设计规范,确保“基于最小改动”并“尽量在原基础上复用”。
## 1. 颜色规范 (Color Palette)
- **基础背景**:主要为纯白 (`#ffffff`),特殊页面(如 AirplanMode使用深灰 (`#333333`)。
- **文本颜色**
- 主文本:黑色 (`#000000`) 或 `#111111`
- 次要/辅助文本:各级灰色(`#555555`, `#666666`, `#888888`
- **边框与装饰线**:浅灰色(`#cccccc`, `#dddddd`
## 2. 字体与排版 (Typography)
- **基础字体**:系统默认无衬线字体栈 `var(--font-family)` (`-apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC" ...`)。
- **特效/高亮字体**`DotGothic16`(点阵字体),常用于超大标题或特殊高亮,自带复古科技感。
- **典型字号层级**
- 超大标题 (Hero Title)`72px`
- 模块标题 (Section Title)`48px` / `36px`
- 正文描述 (Body Text)`16px` / `14px`
- 小标签/附加信息 (Labels)`13px` / `12px`(配合 `letter-spacing: 1px``1.5px`
## 3. 特色 UI 元素 (Signature UI Elements)
- **科技感边角 (Tech Brackets / Corner Crosses)**
- 项目显著特征。在按钮 (`.btn-container`)、图标包裹盒 (`.ms-icon-box`) 边缘使用四个直角边框进行装饰。
- DOM 结构参考:包含四个 `<span class="corner top-left"></span>``<span class="corner top-right"></span>` 等元素。
- 交互Hover 时伴随边角向外扩散的动效 (`transform: translate(...)`)。
- **按钮体系**
- `light` 模式:透明背景 + 黑字 (`.tech-btn.light`)
- `dark` 模式:黑底白字 + 柔和底阴影 (`box-shadow: 0 15px 35px rgba(0,0,0,0.15)`) (`.tech-btn.dark`)
- **高亮十字星 (Highlight Crosses)**:使用 `.highlight` 配合伪元素或 `.cross-tl`, `.cross-br` 实现角落的十字线装饰。
## 4. 布局与视觉动效 (Layout & Effects)
- **环境光效 (Ambient Glow)**:页面顶部常驻半透明的径向渐变光效 (`.ambient-glow`)。
- **层级管理 (Z-Index)**
- 底层粒子画布 `#particleCanvas``z-index: 1``pointer-events: none`
- 顶层导航:`z-index: 100`
- **微交互动效**
- 图标上下缓动悬浮 (`@keyframes floatIcon`)。
- 按钮内箭头的水平位移动效 (`.btn-container:hover .btn-arrow { transform: translateX(4px); }`)。
## 5. 开发原则
- **复用优先**:优先使用 `style.css` 中已有的类名(如 `.btn-container`, `.ms-icon-box`, `.flex-center`),避免重复写样式。
- **Tailwind 结合**:项目已配置 Tailwind CSS 4.x基础布局可使用 Tailwind 类名,但特色组件(如带边角的按钮)请复用自定义 CSS 类以保持一致性。
## 6. 核心资源参考 (Core Resources)
为了确保设计规范的严格执行,本 Skill 附带了项目核心的样式与配置资源。在需要深入了解具体样式实现或 CSS 变量时,请参考以下文件:
- **全局样式与特色组件**`resources/style.css` (包含了所有自定义的 UI 组件如 `.corner`, `.btn-container` 等的具体实现)
- **Tailwind 配置**`resources/tailwind.config.js`
- **视觉展示网站**`website/index.html` (包含完整设计系统视觉指南与交互演示)

View File

@@ -17,18 +17,97 @@ const sendServiceResult = async (reply, action) => {
/**
* GraphRAG 控制器:负责请求转发与响应封装。
*/
export const createGraphRagController = (service) => ({
export const createGraphRagController = (service, multiAgentService) => ({
health: async (_request, reply) => reply.send({ ok: true }),
ready: async (_request, reply) => sendServiceResult(reply, () => service.ready()),
bootstrap: async (_request, reply) => sendServiceResult(reply, () => service.bootstrap()),
listUsers: async (request, reply) => sendServiceResult(reply, () => service.listUsers(request.query.limit || 200)),
getGraphStats: async (request, reply) => sendServiceResult(reply, () => service.getGraphStats(request.query.userId || 'default')),
ingest: async (request, reply) => sendServiceResult(reply, () => service.ingest(request.body)),
queryTimeline: async (request, reply) =>
sendServiceResult(reply, () => service.queryTimeline(request.body)),
queryGraphRag: async (request, reply) =>
sendServiceResult(reply, () => service.queryGraphRag(request.body)),
queryGraphRagMultiRound: async (request, reply) =>
sendServiceResult(reply, () => service.queryGraphRagMultiRound(request.body)),
queryGraphRagMultiRoundStream: async (request, reply) => {
reply.hijack();
const raw = reply.raw;
const requestOrigin = request.headers.origin;
raw.setHeader('Access-Control-Allow-Origin', requestOrigin || '*');
raw.setHeader('Vary', 'Origin');
raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
raw.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
raw.setHeader('Cache-Control', 'no-cache, no-transform');
raw.setHeader('Connection', 'keep-alive');
raw.setHeader('X-Accel-Buffering', 'no');
raw.flushHeaders?.();
const push = (event, data) => {
raw.write(`event: ${event}\n`);
raw.write(`data: ${JSON.stringify(data)}\n\n`);
};
try {
const result = await service.queryGraphRagMultiRound(request.body, {
onProgress: (event) => push('progress', event)
});
push('done', result);
} catch (error) {
push('error', {
ok: false,
statusCode: Number(error?.statusCode) || 500,
message: error?.message || 'internal error'
});
} finally {
raw.end();
}
},
analyzeAndIngest: async (request, reply) =>
sendServiceResult(reply, () => service.incrementalUpdate(request.body.text, request.body.userId || 'default')),
reindexUserVectors: async (request, reply) =>
sendServiceResult(reply, () => service.reindexUserVectors({
userId: request.body.userId || 'default',
limit: request.body.limit
})),
analyzeAndIngestStream: async (request, reply) => {
reply.hijack();
const raw = reply.raw;
const requestOrigin = request.headers.origin;
raw.setHeader('Access-Control-Allow-Origin', requestOrigin || '*');
raw.setHeader('Vary', 'Origin');
raw.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
raw.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
raw.setHeader('Cache-Control', 'no-cache, no-transform');
raw.setHeader('Connection', 'keep-alive');
raw.setHeader('X-Accel-Buffering', 'no');
raw.flushHeaders?.();
const push = (event, data) => {
raw.write(`event: ${event}\n`);
raw.write(`data: ${JSON.stringify(data)}\n\n`);
};
try {
push('progress', { stage: 'start' });
const result = await service.incrementalUpdate(
request.body.text,
request.body.userId || 'default',
{
parallelism: request.body.parallelism,
expertReview: request.body.expertReview,
onProgress: (event) => push('progress', event)
}
);
push('done', result);
} catch (error) {
push('error', {
ok: false,
statusCode: Number(error?.statusCode) || 500,
message: error?.message || 'internal error'
});
} finally {
raw.end();
}
},
queryHistory: async (request, reply) =>
sendServiceResult(reply, () => service.queryRelationshipHistory(
request.body.userId || 'default',
@@ -36,5 +115,9 @@ export const createGraphRagController = (service) => ({
request.body.limit || 20
)),
getAdvice: async (request, reply) =>
sendServiceResult(reply, () => service.getRelationshipAdvice(request.body.userId || 'default'))
sendServiceResult(reply, () => service.getRelationshipAdvice(request.body.userId || 'default')),
decomposeMultiAgentTask: async (request, reply) =>
sendServiceResult(reply, () => multiAgentService.decomposeTask(request.body)),
executeMultiAgentTask: async (request, reply) =>
sendServiceResult(reply, () => multiAgentService.executeTaskWorkflow(request.body))
});

View File

@@ -48,6 +48,24 @@ export const registerGraphRagRoutes = async (app, controller) => {
* description: 初始化成功
*/
app.post("/bootstrap", controller.bootstrap);
/**
* @openapi
* /users:
* get:
* tags:
* - GraphRAG
* summary: 获取用户管理列表(含各用户图谱统计)
* parameters:
* - in: query
* name: limit
* schema:
* type: integer
* required: false
* responses:
* 200:
* description: 用户列表
*/
app.get("/users", controller.listUsers);
/**
* @openapi
* /graph/stats:
@@ -244,7 +262,97 @@ export const registerGraphRagRoutes = async (app, controller) => {
* description: 参数错误
*/
app.post("/query/graphrag", controller.queryGraphRag);
/**
* @openapi
* /query/graphrag/multi:
* post:
* tags:
* - GraphRAG
* summary: 多轮 GraphRAG 检索 + 关系整理 + 终审判别
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* query_text:
* type: string
* top_k:
* type: integer
* timeline_limit:
* type: integer
* max_rounds:
* type: integer
* responses:
* 200:
* description: 查询成功
* 400:
* description: 参数错误
*/
app.post("/query/graphrag/multi", controller.queryGraphRagMultiRound);
/**
* @openapi
* /query/graphrag/multi/stream:
* post:
* tags:
* - GraphRAG
* summary: 多轮 GraphRAG 流式过程输出SSE
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* userId:
* type: string
* query_text:
* type: string
* top_k:
* type: integer
* timeline_limit:
* type: integer
* max_rounds:
* type: integer
* responses:
* 200:
* description: 流式输出 progress/done/error 事件
*/
app.post("/query/graphrag/multi/stream", controller.queryGraphRagMultiRoundStream);
app.post("/analyze", controller.analyzeAndIngest);
app.post("/vectors/reindex", controller.reindexUserVectors);
/**
* @openapi
* /analyze/stream:
* post:
* tags:
* - GraphRAG
* summary: 流式分析并增量入图SSE
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* text:
* type: string
* userId:
* type: string
* parallelism:
* type: integer
* expertReview:
* type: boolean
* responses:
* 200:
* description: 流式输出 progress/done/error 事件
* 400:
* description: 参数错误
*/
app.post("/analyze/stream", controller.analyzeAndIngestStream);
/**
* @openapi
@@ -294,4 +402,70 @@ export const registerGraphRagRoutes = async (app, controller) => {
* description: 建议生成成功
*/
app.post("/query/advice", controller.getAdvice);
/**
* @openapi
* /multi-agent/decompose:
* post:
* tags:
* - GraphRAG
* summary: 使用多 Agent 方式拆解复杂任务
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - task
* properties:
* task:
* type: string
* context:
* type: string
* constraints:
* type: array
* items:
* type: string
* max_agents:
* type: integer
* responses:
* 200:
* description: 拆解成功
* 400:
* description: 参数错误
*/
app.post("/multi-agent/decompose", controller.decomposeMultiAgentTask);
/**
* @openapi
* /multi-agent/execute:
* post:
* tags:
* - GraphRAG
* summary: 执行多 Agent 协作流程(自动拆解并串行执行)
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* task:
* type: string
* context:
* type: string
* constraints:
* type: array
* items:
* type: string
* max_agents:
* type: integer
* plan:
* type: object
* responses:
* 200:
* description: 执行成功
* 400:
* description: 参数错误
*/
app.post("/multi-agent/execute", controller.executeMultiAgentTask);
};

View File

@@ -5,7 +5,7 @@ import swaggerUi from "@fastify/swagger-ui";
import { env } from "./config/env.js";
import { createClients } from "./config/clients.js";
import { createSwaggerSpec } from "./config/swagger.js";
import { EmbeddingService, RerankService, GraphRagService, LLMService } from "./services/index.js";
import { EmbeddingService, RerankService, GraphRagService, LLMService, MultiAgentService } from "./services/index.js";
import { createGraphRagController } from "./controllers/index.js";
import { registerRoutes } from "./routes/index.js";
@@ -42,7 +42,11 @@ export const createServer = async () => {
llmService,
env
});
const controller = createGraphRagController(service);
const multiAgentService = new MultiAgentService({
llmService,
logger: app.log
});
const controller = createGraphRagController(service, multiAgentService);
await registerRoutes(app, controller, env);

View File

@@ -10,6 +10,10 @@ export class EmbeddingService {
this.apiKey = env.EMBEDDING_API_KEY ?? "";
this.model = env.EMBEDDING_MODEL ?? "";
this.dimension = env.EMBEDDING_DIM;
this.fallbackModels = ["text-embedding-v3", "text-embedding-v2"];
this.endpoint = this.baseUrl.endsWith("/v1")
? `${this.baseUrl}/embeddings`
: `${this.baseUrl}/v1/embeddings`;
}
isEnabled() {
@@ -26,28 +30,41 @@ export class EmbeddingService {
throw createHttpError(400, "embedding 输入文本不能为空");
}
const response = await fetch(`${this.baseUrl}/v1/embeddings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`
},
body: JSON.stringify({
model: this.model,
input: cleaned
})
});
const candidates = [this.model, ...this.fallbackModels]
.map((item) => String(item || "").trim())
.filter((item, index, arr) => item && arr.indexOf(item) === index);
let lastErrorText = "";
for (const model of candidates) {
const response = await fetch(this.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`
},
body: JSON.stringify({
model,
input: cleaned
})
});
if (!response.ok) {
const errorText = await response.text();
throw createHttpError(response.status, `embedding 请求失败: ${errorText}`);
}
if (!response.ok) {
const errorText = await response.text();
lastErrorText = errorText || lastErrorText;
const maybeModelIssue = (response.status === 400 || response.status === 404) && /model_not_supported|unsupported model/i.test(errorText || "");
if (maybeModelIssue) continue;
throw createHttpError(response.status, `embedding 请求失败: ${errorText}`);
}
const data = await response.json();
const vector = data?.data?.[0]?.embedding;
if (!Array.isArray(vector) || vector.length !== this.dimension) {
throw createHttpError(400, `embedding 维度异常,期望 ${this.dimension}`);
const data = await response.json();
const vector = data?.data?.[0]?.embedding;
if (!Array.isArray(vector) || vector.length !== this.dimension) {
throw createHttpError(400, `embedding 维度异常,期望 ${this.dimension}`);
}
if (model !== this.model) {
this.model = model;
}
return vector;
}
return vector;
throw createHttpError(400, `embedding 请求失败: ${lastErrorText}`);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,4 @@ export { EmbeddingService } from "./embedding.service.js";
export { RerankService } from "./rerank.service.js";
export { GraphRagService } from "./graphrag.service.js";
export { LLMService } from "./llm.service.js";
export { MultiAgentService } from "./multiagent.service.js";

View File

@@ -4,6 +4,41 @@ const createHttpError = (statusCode, message) => {
return error;
};
const estimateTokens = (text) => {
if (!text) return 0;
const str = String(text);
return Math.ceil(str.length / 2);
};
const estimateMessageTokens = (messages = []) => {
return messages.reduce((sum, message) => {
return sum + estimateTokens(message?.content || "") + 6;
}, 0);
};
const parseJsonObject = (content) => {
if (!content || typeof content !== "string") {
throw new Error("empty content");
}
const direct = content.trim();
try {
return JSON.parse(direct);
} catch {}
const codeBlockMatch = direct.match(/```json\s*([\s\S]*?)```/i) || direct.match(/```\s*([\s\S]*?)```/i);
if (codeBlockMatch?.[1]) {
try {
return JSON.parse(codeBlockMatch[1].trim());
} catch {}
}
const start = direct.indexOf("{");
const end = direct.lastIndexOf("}");
if (start >= 0 && end > start) {
const candidate = direct.slice(start, end + 1);
return JSON.parse(candidate);
}
throw new Error("json not found");
};
/**
* 文本预处理
*/
@@ -44,12 +79,179 @@ const splitTextIntoChunks = (text, chunkSize = 500, overlap = 50) => {
}
chunks.push(text.slice(start, chunkEnd).trim());
start = chunkEnd - overlap;
if (chunkEnd >= text.length) {
break;
}
const nextStart = chunkEnd - overlap;
start = nextStart > start ? nextStart : chunkEnd;
}
return chunks;
};
const ensureAnalysisShape = (parsed = {}) => {
if (!Array.isArray(parsed.persons)) parsed.persons = [];
if (!Array.isArray(parsed.organizations)) parsed.organizations = [];
if (!Array.isArray(parsed.events)) parsed.events = [];
if (!Array.isArray(parsed.topics)) parsed.topics = [];
if (!Array.isArray(parsed.relations)) parsed.relations = [];
return parsed;
};
const mergeByKey = (items, keyBuilder) => {
const map = new Map();
for (const item of items || []) {
const key = keyBuilder(item);
if (!key) continue;
if (!map.has(key)) {
map.set(key, item);
continue;
}
const oldValue = map.get(key) || {};
map.set(key, { ...oldValue, ...item });
}
return [...map.values()];
};
const mergeAnalyses = (analyses = []) => {
const normalized = analyses.map((a) => ensureAnalysisShape(a || {}));
const persons = mergeByKey(
normalized.flatMap((a) => a.persons || []),
(item) => item?.id || item?.name
);
const organizations = mergeByKey(
normalized.flatMap((a) => a.organizations || []),
(item) => item?.id || item?.name
);
const events = mergeByKey(
normalized.flatMap((a) => a.events || []),
(item) => item?.id || `${item?.type || ""}|${item?.summary || ""}|${item?.occurred_at || ""}`
);
const topics = mergeByKey(
normalized.flatMap((a) => a.topics || []),
(item) => item?.id || item?.name
);
const relations = mergeByKey(
normalized.flatMap((a) => a.relations || []),
(item) => `${item?.source || ""}|${item?.target || ""}|${item?.type || ""}|${item?.summary || ""}`
);
return { persons, organizations, events, topics, relations };
};
const runWithConcurrency = async (items, concurrency, worker) => {
const list = Array.isArray(items) ? items : [];
const results = new Array(list.length);
const maxConcurrency = Math.min(Math.max(Number(concurrency) || 1, 1), Math.max(list.length, 1));
let cursor = 0;
const workers = Array.from({ length: maxConcurrency }, async () => {
while (true) {
const index = cursor;
cursor += 1;
if (index >= list.length) {
break;
}
results[index] = await worker(list[index], index);
}
});
await Promise.all(workers);
return results;
};
const normalizeTextForMatch = (value) => String(value || "").toLowerCase().trim();
const compactSummaryText = (value, maxLen = 80) => {
const raw = String(value || "").trim();
if (!raw) return "";
const parts = raw
.split(/[|;。]/)
.map((p) => p.trim())
.filter(Boolean);
const seen = new Set();
const uniqueParts = [];
for (const part of parts) {
const key = normalizeTextForMatch(part);
if (!key || seen.has(key)) continue;
seen.add(key);
uniqueParts.push(part);
if (uniqueParts.length >= 2) break;
}
const merged = (uniqueParts.length > 0 ? uniqueParts.join("") : raw).trim();
return merged.length > maxLen ? `${merged.slice(0, maxLen - 1)}` : merged;
};
const extractMatchTokens = (text) => {
const raw = String(text || "");
const zh = raw.match(/[\u4e00-\u9fa5]{2,}/g) || [];
const en = raw.match(/[a-zA-Z][a-zA-Z0-9_-]{2,}/g) || [];
const set = new Set();
for (const token of [...zh, ...en]) {
const clean = normalizeTextForMatch(token);
if (!clean || clean.length < 2) continue;
set.add(clean);
if (set.size >= 24) break;
}
return [...set];
};
const buildExistingContext = (text, existingEntities = {}) => {
const persons = Array.isArray(existingEntities?.persons) ? existingEntities.persons : [];
const organizations = Array.isArray(existingEntities?.organizations) ? existingEntities.organizations : [];
if (persons.length === 0 && organizations.length === 0) return "";
const rawInput = String(text || "");
const normalizedInput = normalizeTextForMatch(rawInput);
const tokens = extractMatchTokens(rawInput);
const rankEntities = (list, type) => {
const dedup = new Map();
for (const item of list || []) {
const name = String(item?.name || "").trim();
if (!name) continue;
const dedupKey = `${type}:${normalizeTextForMatch(name)}`;
const summary = compactSummaryText(item?.summary || "");
const id = String(item?.id || "").trim();
const nameNorm = normalizeTextForMatch(name);
const coreHit = id === "user" || id === "partner" || /__user$|__partner$/i.test(id);
const nameHit = nameNorm && normalizedInput.includes(nameNorm);
let tokenHitCount = 0;
if (summary) {
const summaryNorm = normalizeTextForMatch(summary);
for (const t of tokens) {
if (summaryNorm.includes(t)) tokenHitCount += 1;
if (tokenHitCount >= 3) break;
}
}
const score = (coreHit ? 100 : 0) + (nameHit ? 60 : 0) + tokenHitCount * 5;
const current = dedup.get(dedupKey);
const next = { id, name, summary, score, nameHit, coreHit };
if (!current || next.score > current.score) dedup.set(dedupKey, next);
}
return [...dedup.values()].sort((a, b) => b.score - a.score || a.name.length - b.name.length);
};
const personTop = rankEntities(persons, "person").slice(0, 12);
const orgTop = rankEntities(organizations, "organization").slice(0, 8);
const matchedPersons = personTop.filter((p) => p.nameHit || p.coreHit).length;
const matchedOrgs = orgTop.filter((o) => o.nameHit || o.coreHit).length;
return `
## 已有实体列表(命中优先 + 精简)
**若文本提到下列人物/组织,请复用 ID不要新建同名实体。**
输入命中:人物 ${matchedPersons}/${personTop.length},组织 ${matchedOrgs}/${orgTop.length}
已有的人物:
${personTop.map((p) => `- ID: "${p.id}", 名字:"${p.name}", 摘要:${p.summary || "无"}`).join('\n')}
已有的组织:
${orgTop.map((o) => `- ID: "${o.id}", 名字:"${o.name}", 摘要:${o.summary || "无"}`).join('\n')}
代词解析:
- "我" 优先映射 user
- "女朋友/男朋友/对象/她/他" 优先映射 partner若上下文一致
- 出现同名实体时必须复用以上 ID`;
};
export class LLMService {
constructor(env) {
this.baseUrl = (env.LLM_BASE_URL ?? "").replace(/\/+$/, "");
@@ -66,6 +268,9 @@ export class LLMService {
throw createHttpError(400, "LLM 服务未配置,请提供 LLM_BASE_URL/LLM_API_KEY/LLM_MODEL_NAME");
}
const promptTokensEstimated = estimateMessageTokens(messages);
console.log(`[TOKEN] model=${this.model} prompt_tokens_estimated=${promptTokensEstimated} max_tokens=4096`);
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: "POST",
headers: {
@@ -86,38 +291,17 @@ export class LLMService {
}
const data = await response.json();
const usage = data?.usage || {};
const promptTokens = usage.prompt_tokens;
const completionTokens = usage.completion_tokens;
const totalTokens = usage.total_tokens;
console.log(
`[TOKEN] model=${this.model} prompt_tokens_actual=${promptTokens ?? "n/a"} completion_tokens=${completionTokens ?? "n/a"} total_tokens=${totalTokens ?? "n/a"}`
);
return data;
}
/**
* 分析文本并提取详细的实体和关系MiroFish 风格)
* @param {string} text - 用户输入的文本
* @param {object} existingEntities - 现有实体列表(用于识别是否已存在)
*/
async analyzeText(text, existingEntities = {}) {
if (!text?.trim()) {
throw createHttpError(400, "分析文本不能为空");
}
const existingContext = existingEntities.persons?.length > 0 || existingEntities.organizations?.length > 0
? `
## 已有实体列表(极其重要!)
**如果文本中提到的人/组织已存在于下方列表中,必须复用相同的 ID不要创建新实体**
已有的人物:
${(existingEntities.persons || []).map(p => `- ID: "${p.id}", 名字:"${p.name}", 描述:${p.summary}`).join('\n')}
已有的组织:
${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${o.name}", 描述:${o.summary}`).join('\n')}
**代词解析指南**:
- "我女朋友" = 已有实体中的"女朋友"(如果有)
- "她" = 根据上下文推断指代哪个女性角色
- "他" = 根据上下文推断指代哪个男性角色
- "丽丽" = 如果已有实体中有"丽丽",复用 ID`
: '';
async _analyzeTextSinglePass(text, existingContext) {
const systemPrompt = `你是一个恋爱关系知识图谱构建专家。从用户输入的文本中提取实体和关系,用于后续的恋爱决策建议。
## 核心原则
@@ -125,6 +309,7 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
2. **其他人物**:朋友、闺蜜、同事等只需要记录基本信息(名字、与用户的关系)
3. **事件细节**:记录争吵、约会、礼物、重要对话等影响关系的事件
4. **情感线索**:提取情绪变化、态度、期望等软性信息
5. **学校组织**:出现大学/学院/学校名称时,必须抽取为 organizations 节点
## 输出格式(严格 JSON
{
@@ -133,7 +318,15 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
"id": "p1",
"name": "人物名称",
"summary": "人物描述",
"role": "用户 | 恋爱对象 | 朋友 | 家人 | 同事 | 其他"
"role": "用户 | 恋爱对象 | 朋友 | 家人 | 同事 | 其他",
"gender": "male | female | unknown"
}
],
"organizations": [
{
"id": "o1",
"name": "组织名称(公司/学校/大学)",
"summary": "组织描述"
}
],
"events": [
@@ -175,6 +368,10 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
4. **实体去重**:如果文本中提到的人已存在于"已有实体"列表中,**复用相同的 ID**
5. **时间标准化**occurred_at 使用 ISO 格式
6. **情感标注**emotional_tone 标注事件的情感倾向positive/neutral/negative
7. 数量控制persons 最多 8 个organizations 最多 8 个events 最多 8 个topics 最多 8 个relations 最多 16 个
8. 长度控制persons.summary 不超过 100 字events.summary 不超过 120 字relations.summary 不超过 80 字
9. 人物性别person.gender 只允许 male/female/unknown无法确认时填 unknown
10. 若文本出现“某人毕业于/就读于/来自某大学”,需创建该大学 organization并补充关系如 STUDIED_AT/ALUMNUS_OF
只返回 JSON不要有其他文字。`;
@@ -182,10 +379,9 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
{ role: "system", content: systemPrompt },
{ role: "user", content: `${existingContext}\n\n## 待分析文本\n${text}` }
];
console.log("[DEBUG] LLM request messages:", JSON.stringify(messages));
const result = await this.chat(messages, 0.3);
const content = result?.choices?.[0]?.message?.content;
console.log("[DEBUG] LLM raw response:", content);
if (!content) {
@@ -194,18 +390,270 @@ ${(existingEntities.organizations || []).map(o => `- ID: "${o.id}", 名字:"${
let parsed;
try {
const jsonMatch = content.match(/\{[\s\S]*\}/);
parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : JSON.parse(content);
parsed = parseJsonObject(content);
} catch (e) {
throw createHttpError(500, `LLM 返回格式错误:${content.substring(0, 200)}`);
const repaired = await this.chat([
{ role: "system", content: "你是 JSON 修复器。你会把不完整或格式错误的 JSON 修复为合法 JSON。只输出 JSON不要输出其他内容。" },
{
role: "user",
content: `请把下面内容修复为合法 JSON必须包含 persons/organizations/events/topics/relations 五个数组字段,缺失就补空数组:\n\n${content}`
}
], 0.1);
const repairedContent = repaired?.choices?.[0]?.message?.content;
try {
parsed = parseJsonObject(repairedContent);
} catch {
throw createHttpError(500, `LLM 返回格式错误:${content.substring(0, 200)}`);
}
}
return ensureAnalysisShape(parsed);
}
async _expertReviewMergedAnalysis(merged, existingContext) {
const systemPrompt = `你是知识图谱质检专家。你会对已合并的抽取结果进行去重、补全与标准化,只返回 JSON。
输出格式:
{
"persons": [],
"organizations": [],
"events": [],
"topics": [],
"relations": []
}
规则:
1) 保持与输入语义一致,不凭空捏造
2) persons 最多 12organizations 最多 12events 最多 12topics 最多 10relations 最多 24
3) summary 保持简洁
4) 必须返回五个数组字段`;
const reviewed = await this.chat([
{ role: "system", content: systemPrompt },
{
role: "user",
content: `${existingContext}\n\n请审查并标准化下面的合并结果:\n${JSON.stringify(ensureAnalysisShape(merged), null, 2)}`
}
], 0.2);
const reviewedContent = reviewed?.choices?.[0]?.message?.content;
return ensureAnalysisShape(parseJsonObject(reviewedContent || ""));
}
/**
* 分析文本并提取详细的实体和关系MiroFish 风格)
* @param {string} text - 用户输入的文本
* @param {object} existingEntities - 现有实体列表(用于识别是否已存在)
*/
async analyzeText(text, existingEntities = {}, options = {}) {
if (!text?.trim()) {
throw createHttpError(400, "分析文本不能为空");
}
return parsed;
const normalizedText = preprocessText(text);
const existingContext = buildExistingContext(normalizedText, existingEntities);
const onProgress = typeof options?.onProgress === "function" ? options.onProgress : () => {};
const parallelism = Math.min(Math.max(Number(options?.parallelism || 3), 1), 6);
const expertReviewEnabled = options?.expertReview !== false;
const estimatedInputTokens = estimateTokens(normalizedText) + estimateTokens(existingContext);
const shouldChunk = normalizedText.length > 1600 || estimatedInputTokens > 2200;
if (!shouldChunk) {
onProgress({ stage: "single_pass_start", estimated_tokens: estimatedInputTokens });
return await this._analyzeTextSinglePass(normalizedText, existingContext);
}
const chunks = splitTextIntoChunks(normalizedText, 1100, 120).filter(Boolean);
console.log(`[TOKEN] long_text_detected=1 chunks=${chunks.length} estimated_input_tokens=${estimatedInputTokens}`);
onProgress({
stage: "chunking",
chunk_count: chunks.length,
estimated_tokens: estimatedInputTokens,
parallelism
});
const analyses = await runWithConcurrency(chunks, parallelism, async (chunk, index) => {
const chunkTokens = estimateTokens(chunk);
onProgress({
stage: "chunk_start",
chunk_index: index + 1,
chunk_count: chunks.length,
chunk_tokens_estimated: chunkTokens
});
console.log(`[TOKEN] chunk_index=${index + 1}/${chunks.length} chunk_tokens_estimated=${chunkTokens}`);
try {
const chunkAnalysis = await this._analyzeTextSinglePass(chunk, existingContext);
onProgress({
stage: "chunk_done",
chunk_index: index + 1,
chunk_count: chunks.length,
persons: chunkAnalysis.persons.length,
organizations: chunkAnalysis.organizations.length,
events: chunkAnalysis.events.length,
topics: chunkAnalysis.topics.length,
relations: chunkAnalysis.relations.length
});
return chunkAnalysis;
} catch (error) {
onProgress({
stage: "chunk_failed",
chunk_index: index + 1,
chunk_count: chunks.length,
error: error?.message || "chunk_analyze_failed"
});
return { persons: [], events: [], topics: [], relations: [] };
}
});
const merged = mergeAnalyses(analyses);
onProgress({
stage: "merge_done",
persons: merged.persons.length,
organizations: merged.organizations.length,
events: merged.events.length,
topics: merged.topics.length,
relations: merged.relations.length
});
if (!expertReviewEnabled || chunks.length < 2) {
return merged;
}
onProgress({ stage: "expert_review_start" });
try {
const reviewed = await this._expertReviewMergedAnalysis(merged, existingContext);
onProgress({
stage: "expert_review_done",
persons: reviewed.persons.length,
organizations: reviewed.organizations.length,
events: reviewed.events.length,
topics: reviewed.topics.length,
relations: reviewed.relations.length
});
return reviewed;
} catch (error) {
onProgress({
stage: "expert_review_failed",
error: error?.message || "expert_review_failed"
});
return merged;
}
}
async adjudicateEventCorrections(text, recentEvents = [], options = {}) {
const rawText = typeof text === "string" ? text.trim() : "";
if (!rawText) return { decisions: [] };
const maxEvents = Math.min(Math.max(Number(options?.maxEvents || 12), 1), 20);
const minConfidence = Math.min(Math.max(Number(options?.minConfidence ?? 0.72), 0), 1);
const compactEvents = (Array.isArray(recentEvents) ? recentEvents : [])
.slice(0, maxEvents)
.map((e) => ({
id: e.id,
type: e.type || "general",
summary: String(e.summary || "").slice(0, 120),
occurred_at: e.occurred_at || null,
importance: e.importance ?? 5
}))
.filter((e) => e.id);
if (compactEvents.length === 0) return { decisions: [] };
const systemPrompt = `你是“事件纠错裁决器”,按低成本多角色流程工作:抽取员->校验员->裁决员。
只返回 JSON
{
"decisions": [
{
"event_id": "事件ID",
"action": "keep | invalidate | update",
"confidence": 0.0,
"reason": "一句话",
"new_summary": "仅 action=update 时可填",
"new_type": "仅 action=update 时可填",
"new_importance": 1-10
}
]
}
规则:
1) 仅在用户明确表达“误会、说错、澄清、撤回、并非、不是”等纠错语义时,才给 invalidate/update。
2) 不要猜测;证据不足就 keep。
3) event_id 必须来自输入列表。
4) confidence < ${minConfidence} 的决策不要输出。`;
const result = await this.chat([
{ role: "system", content: systemPrompt },
{
role: "user",
content: `用户新输入:\n${rawText}\n\n候选历史事件:\n${JSON.stringify(compactEvents, null, 2)}`
}
], 0.1);
const content = result?.choices?.[0]?.message?.content || "";
let parsed;
try {
parsed = parseJsonObject(content);
} catch {
parsed = { decisions: [] };
}
const validActions = new Set(["keep", "invalidate", "update"]);
const allowedIds = new Set(compactEvents.map((e) => e.id));
const decisions = (Array.isArray(parsed?.decisions) ? parsed.decisions : [])
.filter((d) => d && allowedIds.has(d.event_id) && validActions.has(String(d.action || "").trim()))
.map((d) => ({
event_id: d.event_id,
action: String(d.action).trim(),
confidence: Math.min(Math.max(Number(d.confidence ?? 0), 0), 1),
reason: String(d.reason || "").slice(0, 120),
new_summary: d.new_summary ? String(d.new_summary).slice(0, 160) : "",
new_type: d.new_type ? String(d.new_type).slice(0, 40) : "",
new_importance: Number.isFinite(Number(d.new_importance)) ? Math.min(10, Math.max(1, Number(d.new_importance))) : null
}))
.filter((d) => d.confidence >= minConfidence && d.action !== "keep");
return { decisions };
}
async detectImplicitCorrectionIntent(text, recentEvents = [], options = {}) {
const rawText = typeof text === "string" ? text.trim() : "";
if (!rawText) return { trigger: false, confidence: 0, reason: "empty_text" };
const maxEvents = Math.min(Math.max(Number(options?.maxEvents || 6), 1), 12);
const minConfidence = Math.min(Math.max(Number(options?.minConfidence ?? 0.78), 0), 1);
const compactEvents = (Array.isArray(recentEvents) ? recentEvents : [])
.slice(0, maxEvents)
.map((e) => ({
id: e.id,
type: e.type || "general",
summary: String(e.summary || "").slice(0, 80)
}))
.filter((e) => e.id);
if (compactEvents.length === 0) return { trigger: false, confidence: 0, reason: "no_events" };
const result = await this.chat([
{
role: "system",
content: `你是“纠错触发判定器”。判断用户新输入是否在表达“对历史事件的反转/否定/修正”。只返回 JSON
{"trigger":true|false,"confidence":0.0,"reason":"一句话"}
规则:
1) 仅当明确出现与历史事件相反的事实时 trigger=true
2) 普通新增信息、情绪表达、泛化观点都 trigger=false
3) 不要猜测,保守判断`
},
{
role: "user",
content: `用户新输入:\n${rawText}\n\n最近事件:\n${JSON.stringify(compactEvents, null, 2)}`
}
], 0.1);
const content = result?.choices?.[0]?.message?.content || "";
let parsed;
try {
parsed = parseJsonObject(content);
} catch {
parsed = { trigger: false, confidence: 0, reason: "parse_failed" };
}
const confidence = Math.min(Math.max(Number(parsed?.confidence ?? 0), 0), 1);
const trigger = Boolean(parsed?.trigger) && confidence >= minConfidence;
return {
trigger,
confidence,
reason: String(parsed?.reason || "").slice(0, 120)
};
}
isEmptyAnalysis(data) {
return !data
|| (!Array.isArray(data.persons) || data.persons.length === 0)
&& (!Array.isArray(data.organizations) || data.organizations.length === 0)
&& (!Array.isArray(data.events) || data.events.length === 0)
&& (!Array.isArray(data.topics) || data.topics.length === 0)
&& (!Array.isArray(data.relations) || data.relations.length === 0);

View File

@@ -0,0 +1,362 @@
const createHttpError = (statusCode, message) => {
const error = new Error(message);
error.statusCode = statusCode;
return error;
};
const parseJsonObject = (content) => {
if (!content || typeof content !== "string") {
throw new Error("empty content");
}
const direct = content.trim();
try {
return JSON.parse(direct);
} catch {}
const codeBlockMatch = direct.match(/```json\s*([\s\S]*?)```/i) || direct.match(/```\s*([\s\S]*?)```/i);
if (codeBlockMatch?.[1]) {
try {
return JSON.parse(codeBlockMatch[1].trim());
} catch {}
}
const start = direct.indexOf("{");
const end = direct.lastIndexOf("}");
if (start >= 0 && end > start) {
return JSON.parse(direct.slice(start, end + 1));
}
throw new Error("json not found");
};
export class MultiAgentService {
constructor({ llmService, logger }) {
this.llmService = llmService;
this.logger = logger;
}
_fallbackPlan(task, context, maxAgents) {
const n = Math.min(Math.max(Number(maxAgents || 4), 2), 8);
const baseAgents = [
{
id: "planner",
name: "任务规划Agent",
role: "planner",
goal: "澄清目标、边界与验收标准",
input: "原始任务、约束、上下文",
output: "结构化目标与执行计划"
},
{
id: "analyst",
name: "证据分析Agent",
role: "analyst",
goal: "识别事实、风险与依赖",
input: "规划结果与上下文信息",
output: "关键证据、风险与优先级"
},
{
id: "executor",
name: "执行Agent",
role: "executor",
goal: "将计划落地为可执行动作",
input: "分析结论与执行约束",
output: "执行步骤与回滚策略"
},
{
id: "reviewer",
name: "评审Agent",
role: "reviewer",
goal: "检查质量、合规与可交付性",
input: "执行结果与验收标准",
output: "评审结论与修正建议"
}
];
const agents = baseAgents.slice(0, n);
const workflow = agents.slice(1).map((agent, index) => ({
from: agents[index].id,
to: agent.id,
handoff: "传递结构化结果"
}));
return {
ok: true,
mode: "fallback",
plan_title: "多Agent任务拆解",
task,
context: context || "",
agents,
workflow,
milestones: [
"完成目标澄清",
"完成风险评估",
"完成执行方案",
"完成质量评审"
],
risks: [
"上下文不足导致拆解偏差",
"依赖未显式声明导致计划延误",
"执行与验收标准不一致"
]
};
}
_normalizePlan(plan, maxAgents) {
const agents = Array.isArray(plan?.agents) ? plan.agents.slice(0, maxAgents) : [];
const agentIds = new Set(agents.map((a) => a?.id).filter(Boolean));
const workflow = (Array.isArray(plan?.workflow) ? plan.workflow : [])
.filter((w) => agentIds.has(w?.from) && agentIds.has(w?.to));
return {
ok: true,
mode: plan?.mode || "llm",
plan_title: plan?.plan_title || "多Agent任务拆解",
task: plan?.task || "",
context: plan?.context || "",
agents,
workflow,
milestones: Array.isArray(plan?.milestones) ? plan.milestones : [],
risks: Array.isArray(plan?.risks) ? plan.risks : []
};
}
async executeTaskWorkflow(payload = {}) {
const task = String(payload.task || "").trim();
const context = String(payload.context || "").trim();
const constraints = Array.isArray(payload.constraints) ? payload.constraints : [];
const maxAgents = Math.min(Math.max(Number(payload.max_agents || 4), 2), 8);
const inputPlan = payload.plan && typeof payload.plan === "object" ? payload.plan : null;
if (!task && !inputPlan) {
throw createHttpError(400, "task 不能为空");
}
this.logger?.info?.({
event: "multi_agent_execute_start",
has_input_plan: Boolean(inputPlan),
task_length: task.length,
context_length: context.length,
constraints_count: constraints.length,
max_agents: maxAgents
});
const plan = inputPlan
? this._normalizePlan({
...inputPlan,
task: inputPlan.task || task,
context: inputPlan.context || context
}, maxAgents)
: await this.decomposeTask({ task, context, constraints, max_agents: maxAgents });
if (!Array.isArray(plan.agents) || plan.agents.length < 2) {
throw createHttpError(400, "plan.agents 至少需要 2 个");
}
if (!this.llmService?.isEnabled?.()) {
const steps = plan.agents.map((agent, index) => ({
index: index + 1,
agent_id: agent.id,
agent_name: agent.name,
role: agent.role,
result: `${agent.name}已完成占位执行请在启用LLM后获得真实执行结果`,
deliverables: [agent.output || "结构化输出"],
risks: [],
handoff_to_next: index < plan.agents.length - 1 ? plan.agents[index + 1].id : null
}));
this.logger?.warn?.({
event: "multi_agent_execute_fallback",
reason: "llm_not_enabled",
step_count: steps.length
});
return {
ok: true,
mode: "fallback",
task: plan.task,
plan,
steps,
final_summary: "已按回退模式完成流程编排,当前结果为占位执行"
};
}
const steps = [];
for (let i = 0; i < plan.agents.length; i += 1) {
const agent = plan.agents[i];
const previousOutputs = steps.map((s) => ({
agent_id: s.agent_id,
result: s.result,
deliverables: s.deliverables
}));
const handoffFromWorkflow = plan.workflow.find((w) => w.from === agent.id)?.handoff || "";
const systemPrompt = `你是${agent.name},角色是${agent.role}。你需要对任务执行本角色工作并返回JSON不要输出任何额外文字。
输出格式:
{
"result":"本角色结论",
"deliverables":["交付物1","交付物2"],
"risks":["风险1","风险2"],
"next_handoff":"给下个Agent的交接信息"
}`;
const userPrompt = JSON.stringify({
task: plan.task,
context: plan.context,
constraints,
agent,
handoff_from_workflow: handoffFromWorkflow,
previous_outputs: previousOutputs
});
let stepResult;
try {
const response = await this.llmService.chat([
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
], 0.2);
const content = response?.choices?.[0]?.message?.content || "";
const parsed = parseJsonObject(content);
stepResult = {
index: i + 1,
agent_id: agent.id,
agent_name: agent.name,
role: agent.role,
result: parsed.result || "",
deliverables: Array.isArray(parsed.deliverables) ? parsed.deliverables : [],
risks: Array.isArray(parsed.risks) ? parsed.risks : [],
next_handoff: parsed.next_handoff || ""
};
} catch (error) {
this.logger?.warn?.({
event: "multi_agent_execute_step_failed",
agent_id: agent.id,
message: error?.message || "unknown_error"
});
stepResult = {
index: i + 1,
agent_id: agent.id,
agent_name: agent.name,
role: agent.role,
result: "本步骤执行失败,已跳过",
deliverables: [],
risks: ["LLM返回异常或解析失败"],
next_handoff: ""
};
}
steps.push(stepResult);
}
const finalSummary = steps.map((s) => `[${s.agent_name}] ${s.result}`).join(" | ");
this.logger?.info?.({
event: "multi_agent_execute_success",
step_count: steps.length,
failed_step_count: steps.filter((s) => s.result === "本步骤执行失败,已跳过").length
});
return {
ok: true,
mode: "llm",
task: plan.task,
plan,
steps,
final_summary: finalSummary
};
}
async decomposeTask(payload = {}) {
const task = String(payload.task || payload.query || "").trim();
const context = String(payload.context || "").trim();
const constraints = Array.isArray(payload.constraints) ? payload.constraints : [];
const maxAgents = Math.min(Math.max(Number(payload.max_agents || 4), 2), 8);
if (!task) {
throw createHttpError(400, "task 不能为空");
}
this.logger?.info?.({
event: "multi_agent_decompose_start",
task_length: task.length,
context_length: context.length,
constraints_count: constraints.length,
max_agents: maxAgents
});
if (!this.llmService?.isEnabled?.()) {
const fallback = this._fallbackPlan(task, context, maxAgents);
this.logger?.warn?.({
event: "multi_agent_decompose_fallback",
reason: "llm_not_enabled",
agent_count: fallback.agents.length
});
return fallback;
}
const systemPrompt = `你是多Agent编排专家。请把任务拆解为多个可协作智能体仅返回JSON。
输出格式:
{
"plan_title":"字符串",
"agents":[
{"id":"a1","name":"名称","role":"planner|analyst|executor|reviewer|researcher","goal":"目标","input":"输入","output":"输出"}
],
"workflow":[
{"from":"a1","to":"a2","handoff":"交接内容"}
],
"milestones":["里程碑1","里程碑2"],
"risks":["风险1","风险2"]
}
规则:
1) agents 数量为 2-${maxAgents}
2) id 唯一workflow 中引用必须有效
3) 结果要可执行、可验收
4) 不要输出任何解释文字`;
const userPrompt = JSON.stringify({
task,
context,
constraints
});
let parsed;
try {
const response = await this.llmService.chat([
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }
], 0.2);
const content = response?.choices?.[0]?.message?.content || "";
parsed = parseJsonObject(content);
} catch (error) {
this.logger?.warn?.({
event: "multi_agent_decompose_parse_failed",
message: error?.message || "unknown_error"
});
return this._fallbackPlan(task, context, maxAgents);
}
const agents = Array.isArray(parsed.agents) ? parsed.agents.slice(0, maxAgents) : [];
const idSet = new Set(agents.map((a) => a?.id).filter(Boolean));
const workflow = (Array.isArray(parsed.workflow) ? parsed.workflow : [])
.filter((w) => idSet.has(w?.from) && idSet.has(w?.to));
const result = {
ok: true,
mode: "llm",
plan_title: parsed.plan_title || "多Agent任务拆解",
task,
context,
agents,
workflow,
milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
risks: Array.isArray(parsed.risks) ? parsed.risks : []
};
if (result.agents.length < 2) {
const fallback = this._fallbackPlan(task, context, maxAgents);
this.logger?.warn?.({
event: "multi_agent_decompose_invalid_output",
reason: "agent_count_lt_2"
});
return fallback;
}
this.logger?.info?.({
event: "multi_agent_decompose_success",
mode: result.mode,
agent_count: result.agents.length,
workflow_count: result.workflow.length,
milestone_count: result.milestones.length,
risk_count: result.risks.length
});
return result;
}
}

View File

@@ -9,10 +9,30 @@ export class RerankService {
this.baseUrl = (env.RERANK_BASE_URL ?? "").replace(/\/+$/, "");
this.apiKey = env.RERANK_API_KEY ?? "";
this.model = env.RERANK_MODEL ?? "";
this.disabledReason = "";
const candidates = [];
if (this.baseUrl.endsWith("/v1")) {
candidates.push(`${this.baseUrl}/rerank`);
} else if (this.baseUrl) {
candidates.push(`${this.baseUrl}/v1/rerank`, `${this.baseUrl}/rerank`);
}
if (this.baseUrl.includes("dashscope.aliyuncs.com")) {
candidates.push("https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank");
}
this.endpointCandidates = [...new Set(candidates)];
}
isEnabled() {
return Boolean(this.baseUrl && this.apiKey && this.model);
return Boolean(this.baseUrl && this.apiKey && this.model) && !this.disabledReason;
}
getRuntimeInfo() {
return {
configured: Boolean(this.baseUrl && this.apiKey && this.model),
enabled: this.isEnabled(),
model: this.model || null,
disabled_reason: this.disabledReason || null
};
}
/**
@@ -38,47 +58,63 @@ export class RerankService {
const texts = chunks.map(c => c.text);
try {
// 假设使用类似 OpenAI 或通用的 rerank 接口格式
// 实际使用时需根据具体第三方模型的 API 调整参数和路径
const response = await fetch(`${this.baseUrl}/v1/rerank`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`
},
body: JSON.stringify({
model: this.model,
query: cleanedQuery,
texts: texts
})
});
if (!response.ok) {
const errorText = await response.text();
let response = null;
let errorText = "";
let usedEndpoint = "";
for (const endpoint of this.endpointCandidates) {
usedEndpoint = endpoint;
const isDashScopeTextRerank = endpoint.includes("/services/rerank/text-rerank/text-rerank");
const body = isDashScopeTextRerank
? {
model: this.model,
input: {
query: cleanedQuery,
documents: texts
},
parameters: {
return_documents: false,
top_n: texts.length
}
}
: {
model: this.model,
query: cleanedQuery,
texts
};
response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`
},
body: JSON.stringify(body)
});
if (response.ok) break;
errorText = await response.text();
if (response.status === 404) continue;
throw createHttpError(response.status, `rerank 请求失败: ${errorText}`);
}
if (!response?.ok) {
this.disabledReason = `endpoint_not_supported:${usedEndpoint || "unknown"}`;
return chunks;
}
const data = await response.json();
// data.results 格式通常为: [{ index: 0, relevance_score: 0.9 }, ...]
const results = data?.results;
const results = Array.isArray(data?.results) ? data.results : data?.output?.results;
if (!Array.isArray(results)) {
throw createHttpError(500, "rerank 返回格式异常");
}
// 根据重排结果重新排序 chunks
const rerankedChunks = results
this.disabledReason = "";
return results
.sort((a, b) => b.relevance_score - a.relevance_score)
.map(r => ({
...chunks[r.index],
relevance_score: r.relevance_score
}));
return rerankedChunks;
} catch (error) {
// 重排失败时,为了不阻断流程,可以选择直接返回原结果并记录日志,或者抛出错误
console.error("Rerank error:", error);
return chunks;
} catch {
return chunks;
}
}
}

View File

@@ -12,32 +12,49 @@
box-sizing: border-box;
}
html,
body,
#app {
font-family: 'JetBrains Mono', 'Space Grotesk', 'Noto Sans SC', monospace;
width: 100%;
min-height: 100%;
}
#app {
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #000000;
color: #111111;
background-color: #ffffff;
}
body {
background:
radial-gradient(circle at 12% -10%, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0) 40%),
radial-gradient(circle at 92% -20%, rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0) 45%),
#ffffff;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 9px;
height: 9px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
background: #f4f4f4;
border-left: 1px solid #e6e6e6;
}
::-webkit-scrollbar-thumb {
background: #000000;
background: #222222;
border: 2px solid #f4f4f4;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: #333333;
background: #000000;
}
button {
font-family: inherit;
}
</style>
</style>

View File

@@ -0,0 +1,855 @@
<template>
<div class="qa-wrap">
<div class="ambient-glow"></div>
<div class="panel-card">
<div class="panel-head">
<h3>图谱问答</h3>
<span class="user-tag">userId: {{ userId }}</span>
</div>
<div class="qa-opts">
<label>
轮次
<input v-model.number="maxRounds" type="number" min="2" max="5" class="opt-num" />
</label>
<label>
每轮 TopK
<input v-model.number="topK" type="number" min="2" max="30" class="opt-num" />
</label>
<label>
检索模式
<select v-model="retrievalMode" class="opt-select">
<option value="hybrid">混合检索</option>
<option value="vector">向量检索</option>
<option value="graph">图谱检索</option>
</select>
</label>
</div>
<div class="meta-tip">
<span>重排序模型{{ rerankModelText }}</span>
<span>当前检索{{ retrievalModeText }}</span>
</div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
<div class="chat-card">
<h4>AI 对话</h4>
<div ref="chatListRef" class="chat-list">
<div v-for="item in chatMessages" :key="item.id" class="chat-item" :class="item.role">
<p class="chat-role">{{ item.role === 'user' ? '你' : 'AI' }}</p>
<details v-if="item.role === 'assistant' && hasThinkingData" class="thinking-block" :open="item.pending">
<summary>深度思考 / 工具调用</summary>
<p v-for="line in thinkingLines" :key="line.key" class="thinking-line">{{ line.text }}</p>
</details>
<p class="chat-text">{{ item.pending ? '思考中...' : item.text }}</p>
</div>
<p v-if="!chatMessages.length" class="empty">等待提问</p>
</div>
<div class="chat-compose">
<input
v-model.trim="question"
class="qa-input"
placeholder="输入问题,例如:我和李文旗现在关系怎么样?"
:disabled="loading"
@keyup.enter="runQuery"
/>
<button class="btn-container tech-btn dark" :disabled="!question || loading" @click="runQuery">
<span class="btn-label">{{ loading ? '处理中...' : '发送' }}</span>
<span class="btn-arrow"></span>
<span class="corner top-left"></span>
<span class="corner top-right"></span>
<span class="corner bottom-left"></span>
<span class="corner bottom-right"></span>
</button>
</div>
</div>
<div class="console-card">
<h4>Console</h4>
<div class="console-shell">
<div class="console-head">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<span class="console-title">graphrag-console</span>
</div>
<div ref="consoleBodyRef" class="console-body">
<p v-for="line in consoleLines" :key="line.key" class="console-line" :class="line.status">
<span class="console-prefix">[{{ line.statusLabel }}]</span>
<span class="console-text">{{ line.text }}</span>
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps({
userId: {
type: String,
default: 'default'
}
})
const question = ref('')
const loading = ref(false)
const errorMsg = ref('')
const maxRounds = ref(3)
const topK = ref(8)
const retrievalMode = ref('hybrid')
const resultMeta = ref(null)
const chatMessages = ref([])
const rounds = ref([])
const queryPlan = ref([])
const graphProbe = ref({
keywordCount: 0,
entityCount: 0,
neighborCount: 0,
eventCount: 0,
entities: []
})
const consoleLines = ref([])
const lineSeed = ref(0)
const chatListRef = ref(null)
const consoleBodyRef = ref(null)
const rerankModelText = computed(() => {
const rerank = resultMeta.value?.rerank
if (!rerank?.configured) return '未配置'
if (!rerank?.enabled) return `${rerank.model || '-'}(不可用)`
return rerank.model || '-'
})
const retrievalModeText = computed(() => resultMeta.value?.retrieval_mode_requested || retrievalMode.value)
const hasThinkingData = computed(
() => queryPlan.value.length > 0 || rounds.value.length > 0 || graphProbe.value.entityCount > 0 || graphProbe.value.keywordCount > 0
)
const thinkingLines = computed(() => {
const lines = []
if (queryPlan.value.length) {
lines.push(...queryPlan.value.map((q, idx) => ({ key: `plan_${idx}`, text: `问题拆解 #${idx + 1}${q}` })))
}
if (graphProbe.value.keywordCount || graphProbe.value.entityCount) {
lines.push({
key: 'probe_summary',
text: `图谱探查:关键词 ${graphProbe.value.keywordCount} · 实体 ${graphProbe.value.entityCount} · 邻居 ${graphProbe.value.neighborCount} · 事件 ${graphProbe.value.eventCount}`
})
lines.push(
...graphProbe.value.entities.map((item) => ({
key: `probe_${item.id}`,
text: `命中实体:${item.name}(${item.type}) · 邻居 ${item.neighborCount} · 事件 ${item.eventCount}`
}))
)
}
if (rounds.value.length) {
lines.push(
...rounds.value.flatMap((r, idx) => [
{
key: `round_${idx}`,
text: `${r.round} 轮检索:${r.subQuery || '-'} · 模式 ${r.retrievalMode || '-'} · 片段 ${r.chunkCount || 0} · 时间线 ${r.timelineCount || 0}`
},
...(r.answer ? [{ key: `round_answer_${idx}`, text: `阶段结论:${r.answer}` }] : [])
])
)
}
return lines
})
const stageLabel = {
pipeline_start: '任务初始化',
graph_probe: '图谱探查',
query_plan: '问题拆解',
knowledge_retrieval: '知识检索',
relation_organize: '关系整理',
evidence_synthesis: '证据汇总',
final_review: '终审判别'
}
const stageStatusText = (status) => {
if (status === 'running') return '执行中'
if (status === 'done') return '已完成'
if (status === 'error') return '失败'
return '等待中'
}
const pushConsole = (status, text) => {
lineSeed.value += 1
consoleLines.value = [...consoleLines.value, { key: lineSeed.value, status, statusLabel: stageStatusText(status), text }]
}
const scrollToBottom = (targetRef) => {
const el = targetRef?.value
if (!el) return
el.scrollTop = el.scrollHeight
}
watch(
() => chatMessages.value.length,
async () => {
await nextTick()
scrollToBottom(chatListRef)
}
)
watch(
() => consoleLines.value.length,
async () => {
await nextTick()
scrollToBottom(consoleBodyRef)
}
)
watch(
() => `${queryPlan.value.length}_${rounds.value.length}_${graphProbe.value.entityCount}_${graphProbe.value.keywordCount}`,
async () => {
await nextTick()
scrollToBottom(chatListRef)
}
)
const resetRunState = () => {
errorMsg.value = ''
resultMeta.value = null
rounds.value = []
queryPlan.value = []
graphProbe.value = {
keywordCount: 0,
entityCount: 0,
neighborCount: 0,
eventCount: 0,
entities: []
}
consoleLines.value = []
}
const updateAssistantMessage = (id, text, pending = false) => {
const idx = chatMessages.value.findIndex((item) => item.id === id)
if (idx < 0) return
const current = chatMessages.value[idx]
const next = { ...current, text, pending }
chatMessages.value.splice(idx, 1, next)
}
const handleProgress = (data) => {
if (!data?.stage) return
const label = stageLabel[data.stage] || data.stage
if (data.rerank || data.retrieval_mode_requested) {
resultMeta.value = {
...(resultMeta.value || {}),
...(data.rerank ? { rerank: data.rerank } : {}),
...(data.retrieval_mode_requested ? { retrieval_mode_requested: data.retrieval_mode_requested } : {})
}
}
if (data.stage === 'query_plan') {
queryPlan.value = Array.isArray(data.queries) ? data.queries : []
pushConsole(data.status || 'done', `${label} · 子问题 ${queryPlan.value.length}`)
return
}
if (data.stage === 'graph_probe') {
graphProbe.value = {
keywordCount: Number(data.keywordCount || 0),
entityCount: Number(data.entityCount || 0),
neighborCount: Number(data.neighborCount || 0),
eventCount: Number(data.eventCount || 0),
entities: Array.isArray(data.entities) ? data.entities : []
}
pushConsole(data.status || 'done', `${label} · 关键词 ${graphProbe.value.keywordCount} · 实体 ${graphProbe.value.entityCount}`)
return
}
if (data.stage === 'knowledge_retrieval') {
if (data.status === 'done') {
rounds.value = [...rounds.value.filter((r) => r.round !== data.round), data].sort((a, b) => a.round - b.round)
pushConsole('done', `${label} · 第 ${data.round} 轮完成(片段 ${data.chunkCount || 0} · 时间线 ${data.timelineCount || 0}`)
return
}
pushConsole(data.status || 'running', `${label} · 第 ${data.round}/${data.totalRounds} 轮执行中`)
return
}
if (data.stage === 'relation_organize') {
pushConsole(data.status || 'done', `${label} · 关系线索 ${data.relationHints?.length || 0}`)
return
}
if (data.stage === 'evidence_synthesis') {
pushConsole(data.status || 'done', `${label} · 证据 ${data.evidenceCount || 0}`)
return
}
if (data.stage === 'pipeline_start') {
pushConsole(data.status || 'done', `${label} · 问题:${data.question || '-'}`)
return
}
if (data.stage === 'final_review') {
pushConsole(data.status || 'done', `${label} · 已完成`)
}
}
const processSseBlock = (block) => {
const lines = block.split(/\r?\n/)
let event = 'message'
const dataLines = []
for (const line of lines) {
const normalized = line.trimStart()
if (normalized.startsWith('event:')) event = normalized.slice(6).trim()
if (normalized.startsWith('data:')) dataLines.push(normalized.slice(5).trim())
}
if (!dataLines.length) return null
const raw = dataLines.join('\n')
let data = null
try {
data = JSON.parse(raw)
} catch {
return null
}
if (event === 'progress') {
handleProgress(data)
return null
}
if (event === 'done') {
if (Array.isArray(data?.rounds)) rounds.value = data.rounds
if (data?.meta) resultMeta.value = data.meta
pushConsole('done', '流式响应结束')
return data
}
if (event === 'error') {
throw new Error(data?.message || '流式请求失败')
}
return null
}
const runQuery = async () => {
if (!question.value || loading.value) return
loading.value = true
resetRunState()
const userText = question.value
const userMessageId = `user_${Date.now()}`
const assistantMessageId = `assistant_${Date.now()}`
chatMessages.value.push({ id: userMessageId, role: 'user', text: userText, pending: false })
chatMessages.value.push({ id: assistantMessageId, role: 'assistant', text: '', pending: true })
pushConsole('running', '请求已发送')
try {
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
const res = await fetch(`${apiBase}/query/graphrag/multi/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: props.userId || 'default',
query_text: userText,
max_rounds: maxRounds.value,
top_k: topK.value,
retrieval_mode: retrievalMode.value
})
})
if (!res.ok || !res.body) {
const text = await res.text()
throw new Error(text || `请求失败(${res.status})`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
let donePayload = null
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split(/\r?\n\r?\n/)
buffer = parts.pop() || ''
for (const part of parts) {
if (!part.trim()) continue
const payload = processSseBlock(part)
if (payload) donePayload = payload
}
}
if (buffer.trim()) {
const payload = processSseBlock(buffer)
if (payload) donePayload = payload
}
const finalAnswer = donePayload?.final_review?.answer || donePayload?.final_answer || '未获取到最终回答'
updateAssistantMessage(assistantMessageId, finalAnswer, false)
} catch (e) {
const text = `问答失败:${e.message}`
errorMsg.value = text
pushConsole('error', text)
updateAssistantMessage(assistantMessageId, text, false)
} finally {
loading.value = false
question.value = ''
}
}
</script>
<style scoped>
.qa-wrap {
position: relative;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) clamp(170px, 30vh, 300px);
gap: 10px;
height: calc(100vh - 132px);
max-height: calc(100vh - 132px);
min-height: 0;
overflow: hidden;
color: #111111;
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif);
}
.ambient-glow {
position: absolute;
top: -220px;
left: -6%;
width: 112%;
height: 360px;
background: radial-gradient(circle at center, rgba(17, 17, 17, 0.12), rgba(17, 17, 17, 0) 66%);
pointer-events: none;
z-index: 0;
}
.panel-card,
.chat-card,
.console-card {
position: relative;
z-index: 1;
background: #ffffff;
border: 1px solid #dcdcdc;
border-radius: 10px;
padding: 16px;
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.04);
}
.panel-card::before,
.panel-card::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
border-color: #111111;
pointer-events: none;
}
.panel-card::before {
top: -1px;
left: -1px;
border-top: 1px solid #111111;
border-left: 1px solid #111111;
}
.panel-card::after {
right: -1px;
bottom: -1px;
border-right: 1px solid #111111;
border-bottom: 1px solid #111111;
}
.panel-card {
grid-row: 1 / 2;
}
.chat-card {
grid-row: 2 / 3;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.console-card {
grid-row: 3 / 4;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding: 12px;
background: #0f0f0f;
border-color: #2d2d2d;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.panel-head h3,
.chat-card h4,
.console-card h4 {
margin: 0;
font-size: 16px;
letter-spacing: 0.8px;
}
.user-tag {
font-size: 12px;
color: #333333;
background: #f8f8f8;
border: 1px solid #cfcfcf;
padding: 5px 10px;
border-radius: 999px;
letter-spacing: 0.4px;
}
.qa-input {
flex: 1;
border: 1px solid #cccccc;
border-radius: 8px;
padding: 10px 12px;
outline: none;
color: #111111;
background: #ffffff;
}
.qa-input:focus {
border-color: #111111;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08);
}
.qa-opts {
display: flex;
gap: 14px;
margin-top: 12px;
color: #555555;
font-size: 13px;
}
.qa-opts label {
display: flex;
align-items: center;
gap: 6px;
}
.opt-num,
.opt-select {
border: 1px solid #cccccc;
border-radius: 8px;
padding: 5px 8px;
background: #ffffff;
color: #111111;
outline: none;
}
.opt-num {
width: 70px;
}
.meta-tip {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #666666;
font-size: 12px;
}
.chat-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
min-height: 0;
flex: 1;
padding: 0 2px 12px;
}
.chat-item {
border: 1px solid #ebebeb;
border-radius: 10px;
padding: 12px 14px;
background: #ffffff;
max-width: min(920px, 96%);
width: fit-content;
}
.chat-item.assistant {
background: #fcfcfc;
align-self: flex-start;
}
.chat-item.user {
background: #f5f7fb;
border-color: #e7ebf4;
align-self: flex-end;
}
.chat-item.user .chat-role {
color: #667085;
}
.chat-role {
margin: 0;
color: #666666;
font-size: 12px;
}
.chat-text {
margin: 6px 0 0;
white-space: pre-wrap;
line-height: 1.6;
font-size: 14px;
}
.thinking-block {
margin: 6px 0 0;
border: 1px solid #e7e7e7;
border-radius: 8px;
background: #fafafa;
padding: 6px 8px;
}
.thinking-block summary {
cursor: pointer;
font-size: 12px;
color: #666666;
}
.thinking-line {
margin: 6px 0 0;
font-size: 12px;
color: #555555;
line-height: 1.5;
white-space: pre-wrap;
}
.chat-compose {
margin-top: 6px;
display: flex;
gap: 10px;
align-items: center;
border-top: 1px solid #e4e4e4;
padding-top: 10px;
position: sticky;
bottom: 0;
background: #ffffff;
z-index: 2;
}
.console-shell {
margin-top: 2px;
border: 1px solid #2d2d2d;
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.console-head {
display: flex;
align-items: center;
gap: 6px;
background: #0f0f0f;
border-bottom: 1px solid #2e2e2e;
padding: 8px 10px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #8a8a8a;
}
.console-title {
margin-left: 6px;
font-size: 12px;
color: #dfdfdf;
letter-spacing: 0.8px;
}
.console-body {
display: flex;
flex-direction: column;
gap: 6px;
overflow: auto;
background: #111111;
padding: 10px;
min-height: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
}
.console-line {
margin: 0;
font-size: 12px;
line-height: 1.6;
display: flex;
gap: 8px;
color: #e8e8e8;
}
.console-prefix {
min-width: 46px;
color: #9d9d9d;
}
.console-line.running .console-prefix {
color: #ffffff;
}
.console-line.done .console-prefix {
color: #cbcbcb;
}
.console-line.error .console-prefix {
color: #f1f1f1;
}
.tool-block,
.tool-sub-block {
border: 1px solid #343434;
border-radius: 6px;
padding: 6px 8px;
background: #161616;
color: #d9d9d9;
}
.tool-sub-block {
margin-top: 6px;
}
.tool-block summary,
.tool-sub-block summary {
cursor: pointer;
font-size: 12px;
}
.tool-line {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.5;
color: #c7c7c7;
white-space: pre-wrap;
}
.error-msg {
margin-top: 8px;
color: #333333;
font-size: 13px;
border: 1px solid #cfcfcf;
padding: 7px 9px;
border-radius: 8px;
background: #f8f8f8;
}
.empty {
color: #888888;
font-size: 13px;
margin: 2px 0;
}
.btn-container {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 100px;
border: 1px solid #111111;
border-radius: 8px;
padding: 10px 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.tech-btn.dark {
background: #111111;
color: #ffffff;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
}
.btn-label {
font-size: 13px;
letter-spacing: 1px;
}
.btn-arrow {
transition: transform 0.2s ease;
}
.btn-container:hover .btn-arrow {
transform: translateX(4px);
}
.corner {
position: absolute;
width: 8px;
height: 8px;
border-color: #ffffff;
transition: transform 0.2s ease;
}
.top-left {
top: 4px;
left: 4px;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
}
.top-right {
top: 4px;
right: 4px;
border-top: 1px solid #ffffff;
border-right: 1px solid #ffffff;
}
.bottom-left {
bottom: 4px;
left: 4px;
border-bottom: 1px solid #ffffff;
border-left: 1px solid #ffffff;
}
.bottom-right {
right: 4px;
bottom: 4px;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.btn-container:hover .top-left {
transform: translate(-2px, -2px);
}
.btn-container:hover .top-right {
transform: translate(2px, -2px);
}
.btn-container:hover .bottom-left {
transform: translate(-2px, 2px);
}
.btn-container:hover .bottom-right {
transform: translate(2px, 2px);
}
.btn-container:disabled {
cursor: not-allowed;
background: #666666;
border-color: #666666;
box-shadow: none;
}
.btn-container:disabled .corner {
border-color: #dddddd;
}
@media (max-width: 1100px) {
.qa-wrap {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) clamp(160px, 36vh, 260px);
height: calc(100vh - 116px);
max-height: calc(100vh - 116px);
}
}
@media (max-width: 840px) {
.chat-compose {
flex-direction: column;
}
.qa-opts {
flex-wrap: wrap;
gap: 8px 12px;
}
}
</style>

View File

@@ -8,6 +8,12 @@ const routes = [
},
{
path: '/',
name: 'UserList',
component: () => import('../views/UserList.vue'),
meta: { requiresAuth: true }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('../views/Dashboard.vue'),
meta: { requiresAuth: true }
@@ -32,4 +38,4 @@ router.beforeEach((to, from, next) => {
}
})
export default router
export default router

File diff suppressed because it is too large Load Diff

View File

@@ -129,59 +129,78 @@ const handleLogin = async () => {
.login-page {
display: flex;
min-height: 100vh;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif);
background: #ffffff;
}
.login-left {
flex: 1;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a2e 100%);
color: white;
padding: 60px;
background:
radial-gradient(circle at 14% 22%, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0) 35%),
linear-gradient(145deg, #0f0f0f 0%, #1a1a1a 100%);
color: #ffffff;
padding: 56px;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
border-right: 1px solid #2b2b2b;
}
.brand-content {
position: relative;
z-index: 2;
max-width: 540px;
}
.brand-logo {
margin-bottom: 32px;
margin-bottom: 26px;
width: 64px;
height: 64px;
display: grid;
place-items: center;
border: 1px solid #4a4a4a;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
}
.brand-content h1 {
font-size: 2.5rem;
font-size: 46px;
font-weight: 700;
margin-bottom: 12px;
letter-spacing: -0.02em;
margin-bottom: 10px;
line-height: 1.12;
letter-spacing: 1px;
}
.brand-desc {
font-size: 1.1rem;
color: rgba(255,255,255,0.6);
margin-bottom: 48px;
font-size: 16px;
color: #bbbbbb;
margin-bottom: 34px;
line-height: 1.7;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 16px;
gap: 12px;
}
.feature-item {
display: flex;
align-items: center;
gap: 12px;
font-size: 1rem;
color: rgba(255,255,255,0.85);
gap: 10px;
font-size: 14px;
color: #e6e6e6;
letter-spacing: 0.5px;
border: 1px solid #333333;
border-radius: 8px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
}
.feature-icon {
color: #6366f1;
font-size: 1.2rem;
color: #ffffff;
font-size: 14px;
}
.bg-decoration {
@@ -193,78 +212,111 @@ const handleLogin = async () => {
.circle {
position: absolute;
border-radius: 50%;
background: linear-gradient(135deg, rgba(99,102,241,0.3) 0%, rgba(99,102,241,0) 100%);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0) 100%);
}
.c1 { width: 400px; height: 400px; top: -100px; right: -100px; }
.c2 { width: 300px; height: 300px; bottom: -50px; left: -50px; }
.c3 { width: 200px; height: 200px; bottom: 20%; right: 10%; opacity: 0.5; }
.c1 { width: 380px; height: 380px; top: -120px; right: -120px; }
.c2 { width: 280px; height: 280px; bottom: -90px; left: -80px; }
.c3 { width: 200px; height: 200px; bottom: 16%; right: 8%; opacity: 0.4; }
.login-right {
width: 520px;
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
background: #fafafa;
padding: 48px;
background:
radial-gradient(circle at 90% -5%, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0) 38%),
#ffffff;
}
.login-card {
width: 100%;
max-width: 380px;
border: 1px solid #dcdcdc;
border-radius: 12px;
padding: 26px 24px 20px;
background: #ffffff;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.08);
position: relative;
}
.login-card::before,
.login-card::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
pointer-events: none;
}
.login-card::before {
top: -1px;
left: -1px;
border-top: 1px solid #111111;
border-left: 1px solid #111111;
}
.login-card::after {
right: -1px;
bottom: -1px;
border-right: 1px solid #111111;
border-bottom: 1px solid #111111;
}
.card-header {
margin-bottom: 40px;
margin-bottom: 28px;
}
.card-header h2 {
font-size: 1.75rem;
font-size: 28px;
font-weight: 700;
color: #0f0f0f;
margin-bottom: 8px;
color: #111111;
margin-bottom: 6px;
letter-spacing: 0.5px;
}
.card-header p {
color: #666;
font-size: 0.95rem;
color: #666666;
font-size: 13px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 24px;
gap: 18px;
}
.form-item label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #222222;
margin-bottom: 7px;
letter-spacing: 0.5px;
}
.input-wrapper {
display: flex;
align-items: center;
background: white;
border: 1.5px solid #e5e7eb;
border-radius: 10px;
padding: 0 16px;
background: #ffffff;
border: 1px solid #cccccc;
border-radius: 8px;
padding: 0 12px;
transition: all 0.2s ease;
}
.input-wrapper.focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99,102,241,0.1);
border-color: #111111;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08);
}
.input-wrapper.error {
border-color: #ef4444;
border-color: #7a7a7a;
}
.input-icon {
color: #9ca3af;
color: #888888;
flex-shrink: 0;
}
@@ -272,14 +324,14 @@ const handleLogin = async () => {
flex: 1;
border: none;
outline: none;
padding: 14px 12px;
font-size: 1rem;
padding: 12px 10px;
font-size: 14px;
background: transparent;
color: #0f0f0f;
color: #111111;
}
.input-wrapper input::placeholder {
color: #9ca3af;
color: #9a9a9a;
}
.input-wrapper input:disabled {
@@ -290,34 +342,42 @@ const handleLogin = async () => {
display: flex;
align-items: center;
gap: 6px;
color: #ef4444;
font-size: 0.875rem;
margin-top: -8px;
color: #444444;
font-size: 12px;
margin-top: -4px;
border: 1px solid #d2d2d2;
border-radius: 8px;
padding: 7px 9px;
background: #f8f8f8;
}
.login-btn {
width: 100%;
padding: 14px 24px;
background: #0f0f0f;
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
padding: 12px 22px;
background: #111111;
color: #ffffff;
border: 1px solid #111111;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 8px;
margin-top: 6px;
letter-spacing: 0.8px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
}
.login-btn:hover:not(:disabled) {
background: #1a1a1a;
background: #000000;
transform: translateY(-1px);
}
.login-btn:disabled {
background: #d1d5db;
background: #666666;
border-color: #666666;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.login-btn.loading {
@@ -333,7 +393,7 @@ const handleLogin = async () => {
.loading-dots span {
width: 6px;
height: 6px;
background: white;
background: #ffffff;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
@@ -347,17 +407,27 @@ const handleLogin = async () => {
}
.card-footer {
margin-top: 48px;
margin-top: 28px;
text-align: center;
padding-top: 14px;
border-top: 1px solid #e9e9e9;
}
.card-footer p {
font-size: 0.8rem;
color: #9ca3af;
font-size: 12px;
color: #8d8d8d;
letter-spacing: 0.4px;
}
@media (max-width: 900px) {
.login-left { display: none; }
.login-right { width: 100%; }
.login-right {
width: 100%;
padding: 20px;
}
.login-card {
max-width: 460px;
}
}
</style>

View File

@@ -0,0 +1,392 @@
<template>
<div class="user-list-page">
<header class="topbar">
<div>
<h1>用户管理</h1>
<p>查看所有用户并进入对应图谱或数据导入</p>
</div>
<div class="top-actions">
<button class="btn-ghost" @click="fetchUsers" :disabled="loading">
{{ loading ? '刷新中...' : '刷新列表' }}
</button>
<button class="btn-danger" @click="logout">退出登录</button>
</div>
</header>
<section class="manual-card">
<h3>手动进入用户</h3>
<div class="manual-row">
<input v-model.trim="manualUserId" class="user-input" placeholder="输入 userId例如 user_abc123" />
<button class="btn-primary" :disabled="!manualUserId" @click="gotoUser('graph', manualUserId)">进入图谱</button>
<button class="btn-outline" :disabled="!manualUserId" @click="gotoUser('qa', manualUserId)">进入问答</button>
<button class="btn-outline" :disabled="!manualUserId" @click="gotoUser('ingest', manualUserId)">进入导入</button>
</div>
</section>
<section class="table-card">
<div class="table-head">
<span>用户列表</span>
<span class="count">{{ users.length }} 个用户</span>
</div>
<div v-if="error" class="error-msg">{{ error }}</div>
<table v-else class="user-table">
<thead>
<tr>
<th>User ID</th>
<th>人物</th>
<th>组织</th>
<th>事件</th>
<th>主题</th>
<th>节点总数</th>
<th>最近活跃</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in users" :key="item.userId">
<td class="mono">{{ item.userId }}</td>
<td>{{ item.personCount }}</td>
<td>{{ item.organizationCount }}</td>
<td>{{ item.eventCount }}</td>
<td>{{ item.topicCount }}</td>
<td>{{ item.nodeCount }}</td>
<td>{{ formatDateTime(item.lastActive) }}</td>
<td class="action-cell">
<button class="link-btn" @click="gotoUser('graph', item.userId)">查看图谱</button>
<button class="link-btn" @click="gotoUser('qa', item.userId)">图谱问答</button>
<button class="link-btn" @click="gotoUser('ingest', item.userId)">数据导入</button>
</td>
</tr>
<tr v-if="!loading && users.length === 0">
<td colspan="8" class="empty">暂无用户数据</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const users = ref([])
const loading = ref(false)
const error = ref('')
const manualUserId = ref(localStorage.getItem('currentUserId') || '')
const formatDateTime = (value) => {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleString()
}
const fetchUsers = async () => {
loading.value = true
error.value = ''
try {
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
const res = await fetch(`${apiBase}/users?limit=500`)
const data = await res.json()
if (!res.ok || !data?.ok) throw new Error(data?.error || data?.message || `请求失败(${res.status})`)
users.value = Array.isArray(data.users) ? data.users : []
} catch (e) {
error.value = `获取用户列表失败: ${e.message}`
users.value = []
} finally {
loading.value = false
}
}
const gotoUser = (tab, uid) => {
const targetUserId = String(uid || '').trim()
if (!targetUserId) return
localStorage.setItem('currentUserId', targetUserId)
router.push({
name: 'Dashboard',
query: {
userId: targetUserId,
tab: ['ingest', 'qa', 'graph'].includes(tab) ? tab : 'graph'
}
})
}
const logout = () => {
localStorage.removeItem('auth_token')
router.push('/login')
}
onMounted(fetchUsers)
</script>
<style scoped>
.user-list-page {
min-height: 100vh;
background:
radial-gradient(circle at 95% -10%, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0) 36%),
#ffffff;
padding: 22px;
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif);
color: #111111;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
margin-bottom: 14px;
}
.topbar h1 {
margin: 0 0 6px;
font-size: 30px;
letter-spacing: 0.8px;
}
.topbar p {
margin: 0;
color: #666666;
font-size: 13px;
}
.top-actions {
display: flex;
gap: 8px;
}
.manual-card,
.table-card {
position: relative;
background: #fff;
border: 1px solid #dcdcdc;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.05);
}
.manual-card::before,
.manual-card::after,
.table-card::before,
.table-card::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
pointer-events: none;
}
.manual-card::before,
.table-card::before {
left: -1px;
top: -1px;
border-top: 1px solid #111111;
border-left: 1px solid #111111;
}
.manual-card::after,
.table-card::after {
right: -1px;
bottom: -1px;
border-right: 1px solid #111111;
border-bottom: 1px solid #111111;
}
.manual-card h3 {
margin: 0 0 12px;
font-size: 15px;
letter-spacing: 0.6px;
}
.manual-row {
display: flex;
gap: 8px;
}
.user-input {
flex: 1;
border: 1px solid #cccccc;
border-radius: 8px;
padding: 9px 12px;
outline: none;
color: #111111;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.user-input:focus {
border-color: #111111;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08);
}
.table-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
color: #111111;
font-size: 14px;
letter-spacing: 0.6px;
}
.count {
color: #666666;
font-size: 12px;
}
.user-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
color: #222222;
}
.user-table th,
.user-table td {
border-bottom: 1px solid #ececec;
padding: 10px 8px;
text-align: left;
}
.user-table thead th {
font-size: 12px;
letter-spacing: 0.7px;
color: #666666;
font-weight: 600;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.action-cell {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.empty {
text-align: center;
color: #888888;
}
.error-msg {
color: #444444;
margin-bottom: 10px;
border: 1px solid #cecece;
border-radius: 8px;
background: #f8f8f8;
padding: 8px 10px;
font-size: 13px;
}
.btn-primary,
.btn-outline,
.btn-ghost,
.btn-danger,
.link-btn {
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
border: 1px solid #cccccc;
background: #ffffff;
color: #333333;
font-size: 13px;
transition: all 0.2s ease;
}
.btn-primary {
background: #111111;
color: #ffffff;
border-color: #111111;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
}
.btn-outline {
border-color: #bbbbbb;
color: #333333;
background: #ffffff;
}
.btn-ghost {
background: #fff;
border-color: #cccccc;
}
.btn-danger {
background: #111111;
color: #ffffff;
border-color: #111111;
}
.link-btn {
padding: 5px 8px;
background: #f9f9f9;
border-color: #d4d4d4;
font-size: 12px;
}
.btn-primary:hover:not(:disabled),
.btn-outline:hover:not(:disabled),
.btn-ghost:hover:not(:disabled),
.btn-danger:hover:not(:disabled),
.link-btn:hover:not(:disabled) {
border-color: #111111;
color: #111111;
background: #ffffff;
transform: translateY(-1px);
}
.btn-primary:hover:not(:disabled),
.btn-danger:hover:not(:disabled) {
color: #ffffff;
background: #000000;
}
.btn-primary:disabled,
.btn-outline:disabled,
.btn-ghost:disabled,
.btn-danger:disabled,
.link-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
@media (max-width: 1080px) {
.manual-row {
flex-wrap: wrap;
}
}
@media (max-width: 900px) {
.user-list-page {
padding: 14px;
}
.topbar {
flex-direction: column;
align-items: flex-start;
}
.top-actions {
width: 100%;
}
.top-actions .btn-ghost,
.top-actions .btn-danger {
flex: 1;
}
.table-card {
overflow-x: auto;
}
.user-table {
min-width: 860px;
}
}
</style>