feat(graphrag): 新增多轮检索流式问答与用户管理界面
- 新增多轮 GraphRAG 检索功能,支持流式进度输出(SSE) - 新增用户管理界面,可查看所有用户图谱统计并快速导航 - 新增多 Agent 任务拆解与执行服务,支持复杂任务协作处理 - 改进 embedding 和 rerank 服务的容错机制,支持备用模型和端点 - 更新前端样式遵循 Acmetone 设计规范,优化视觉一致性 - 新增流式分析接口,支持并行处理和专家评审选项
This commit is contained in:
@@ -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))
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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 最多 12,organizations 最多 12,events 最多 12,topics 最多 10,relations 最多 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
392
OnceLove/oncelove-graphrag/frontend/src/views/UserList.vue
Normal file
392
OnceLove/oncelove-graphrag/frontend/src/views/UserList.vue
Normal 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>
|
||||
Reference in New Issue
Block a user