440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
import { Hono } from "hono";
|
||
import { streamSSE } from "hono/streaming";
|
||
import { bodyLimit } from "hono/body-limit";
|
||
import type { AuthUser } from "../../auth/types.js";
|
||
import { canManageFeedback } from "../../auth/types.js";
|
||
import { vehicleRepository } from "../vehicles/repository.js";
|
||
import { visibleVehicles } from "./model.js";
|
||
import { initialPlan, planConversation } from "./planner.js";
|
||
import { execute, applyAction } from "./execute.js";
|
||
import {
|
||
getSession,
|
||
getNode,
|
||
addNode,
|
||
trail,
|
||
deleteSession,
|
||
acquire,
|
||
conversationContext,
|
||
} from "./session.js";
|
||
import { gapStore } from "./gaps.js";
|
||
import {
|
||
CATEGORY_LABELS,
|
||
GAP_STATUSES,
|
||
GROUP_LABELS,
|
||
type AssistantRequest,
|
||
type AssistantResult,
|
||
type GapReason,
|
||
type GapStatus,
|
||
} from "../../../shared/assets/assistant.js";
|
||
const router = new Hono<{ Variables: { user: AuthUser } }>();
|
||
router.use("*", bodyLimit({ maxSize: 16000 }));
|
||
router.use("*", async (c, next) => {
|
||
if (!c.get("user")) return c.json({ error: "请先登录。" }, 401);
|
||
c.header("Cache-Control", "no-store");
|
||
await next();
|
||
});
|
||
router.get("/capabilities", (c) => {
|
||
const enabled =
|
||
!!process.env.DEEPSEEK_API_KEY &&
|
||
process.env.ASSET_AI_EXTERNAL_ENABLED !== "0";
|
||
return c.json({
|
||
engine: enabled ? "deepseek" : "local",
|
||
model: enabled ? "deepseek-flash" : null,
|
||
message: enabled
|
||
? "支持自然语言理解、连续追问和多层分析"
|
||
: "当前使用本地基础问数;配置 DeepSeek 后可开启自然语言分析",
|
||
});
|
||
});
|
||
router.get("/gaps", async (c) => {
|
||
const user = c.get("user"),
|
||
manage = canManageFeedback(user.roles);
|
||
if (c.req.query("scope") === "all" && !manage)
|
||
return c.json({ error: "无权限。" }, 403);
|
||
try {
|
||
return c.json({
|
||
items: await gapStore.list(
|
||
c.req.query("scope") === "all" ? null : user.userId,
|
||
),
|
||
canManage: manage,
|
||
});
|
||
} catch {
|
||
return c.json({ error: "需求记录暂不可用,请检查内部存储配置。" }, 503);
|
||
}
|
||
});
|
||
router.patch("/gaps/:id", async (c) => {
|
||
if (!canManageFeedback(c.get("user").roles))
|
||
return c.json({ error: "无权限。" }, 403);
|
||
const body = await c.req.json().catch(() => null),
|
||
id = Number(c.req.param("id"));
|
||
if (
|
||
!Number.isSafeInteger(id) ||
|
||
id <= 0 ||
|
||
!body ||
|
||
!Object.hasOwn(GAP_STATUSES, body.status)
|
||
)
|
||
return c.json({ error: "参数无效。" }, 400);
|
||
try {
|
||
await gapStore.update(id, body.status as GapStatus);
|
||
return c.json({ ok: true });
|
||
} catch {
|
||
return c.json({ error: "保存失败,请重试。" }, 503);
|
||
}
|
||
});
|
||
router.post("/feedback", async (c) => {
|
||
const body = await c.req.json().catch(() => null);
|
||
if (
|
||
!body ||
|
||
typeof body.sessionId !== "string" ||
|
||
typeof body.resultId !== "string" ||
|
||
(body.subject !== null && typeof body.subject !== "string")
|
||
)
|
||
return c.json({ error: "参数无效。" }, 400);
|
||
try {
|
||
const session = getSession(c.get("user"), body.subject, body.sessionId),
|
||
node = getNode(session, body.resultId)!;
|
||
if (!node.flagged) {
|
||
await gapStore.record(c.get("user").userId, node.demand, "unsatisfied");
|
||
node.flagged = true;
|
||
}
|
||
return c.json({ ok: true });
|
||
} catch {
|
||
return c.json({ error: "记录失败或会话已过期,请重试。" }, 400);
|
||
}
|
||
});
|
||
router.delete("/session/:id", async (c) => {
|
||
const subject = c.req.query("subject") || null;
|
||
try {
|
||
deleteSession(c.get("user"), subject, c.req.param("id"));
|
||
return c.json({ ok: true });
|
||
} catch {
|
||
return c.json({ error: "会话已过期。" }, 404);
|
||
}
|
||
});
|
||
router.post("/export", async (c) => {
|
||
const body = await c.req.json().catch(() => null);
|
||
if (
|
||
!body ||
|
||
typeof body.sessionId !== "string" ||
|
||
typeof body.resultId !== "string" ||
|
||
(body.subject !== null && typeof body.subject !== "string")
|
||
)
|
||
return c.json({ error: "参数无效。" }, 400);
|
||
const release = acquire(c.get("user").userId);
|
||
if (!release) return c.json({ error: "操作过于频繁。" }, 429);
|
||
try {
|
||
const session = getSession(c.get("user"), body.subject, body.sessionId),
|
||
node = getNode(session, body.resultId)!;
|
||
if (node.plan.kind === "clarify")
|
||
return c.json({ error: "暂无可导出结果。" }, 400);
|
||
const vehicles = visibleVehicles(
|
||
await vehicleRepository.getVehicles(),
|
||
c.get("user"),
|
||
body.subject,
|
||
);
|
||
const result = await execute(vehicles, node.plan);
|
||
const records = [
|
||
[
|
||
"车牌",
|
||
"车型",
|
||
"型号",
|
||
"区域",
|
||
"城市",
|
||
"部门",
|
||
"运营状态",
|
||
"异动类型",
|
||
"预计完成时间",
|
||
"日期",
|
||
"业务类型",
|
||
],
|
||
...result.rows.map((r) => [
|
||
r.plate,
|
||
r.type,
|
||
r.model,
|
||
r.region,
|
||
r.city,
|
||
r.department,
|
||
r.status,
|
||
r.abnormalType || "",
|
||
r.targetCompletionTime || "",
|
||
r.date || "",
|
||
r.event || "",
|
||
]),
|
||
];
|
||
const csv = records
|
||
.map((row) =>
|
||
row
|
||
.map(
|
||
(v) =>
|
||
'"' +
|
||
String(v)
|
||
.replace(/^[=+@\-\t\r\n]/, "'$&")
|
||
.replaceAll('"', '""') +
|
||
'"',
|
||
)
|
||
.join(","),
|
||
)
|
||
.join("\r\n");
|
||
c.header("Content-Type", "text/csv; charset=utf-8");
|
||
c.header(
|
||
"Content-Disposition",
|
||
'attachment; filename="asset-analysis.csv"',
|
||
);
|
||
return c.body("\uFEFF" + csv);
|
||
} catch {
|
||
return c.json({ error: "导出失败或权限已变化,请重新查询。" }, 400);
|
||
} finally {
|
||
release();
|
||
}
|
||
});
|
||
router.post("/ask", async (c) => {
|
||
const user = c.get("user");
|
||
const raw = await c.req.json().catch(() => null);
|
||
if (
|
||
!raw ||
|
||
typeof raw !== "object" ||
|
||
Array.isArray(raw) ||
|
||
Object.keys(raw).some(
|
||
(k) =>
|
||
!["message", "subject", "sessionId", "baseResultId", "action"].includes(
|
||
k,
|
||
),
|
||
) ||
|
||
(raw.subject != null &&
|
||
(typeof raw.subject !== "string" || raw.subject.length > 150)) ||
|
||
(raw.sessionId != null &&
|
||
(typeof raw.sessionId !== "string" || raw.sessionId.length > 64)) ||
|
||
(raw.baseResultId != null &&
|
||
(typeof raw.baseResultId !== "string" || !raw.sessionId)) ||
|
||
!!raw.action === !!raw.message ||
|
||
(raw.message != null &&
|
||
(typeof raw.message !== "string" ||
|
||
!raw.message.trim() ||
|
||
raw.message.length > 2000)) ||
|
||
(raw.action && (typeof raw.action !== "object" || !raw.baseResultId))
|
||
)
|
||
return c.json({ error: "请输入有效问题或选择分析操作。" }, 400);
|
||
const input: AssistantRequest = { ...raw, subject: raw.subject || null };
|
||
const release = acquire(user.userId);
|
||
if (!release)
|
||
return c.json({ error: "上一条查询仍在执行,请稍后重试。" }, 429);
|
||
let session, base;
|
||
try {
|
||
session = getSession(user, input.subject, input.sessionId);
|
||
base = getNode(session, input.baseResultId);
|
||
} catch {
|
||
release();
|
||
return c.json({ error: "会话已过期或范围已变化,请新建对话。" }, 409);
|
||
}
|
||
const currentSession = session,
|
||
baseNode = base;
|
||
return streamSSE(c, async (stream) => {
|
||
const controller = new AbortController();
|
||
stream.onAbort(() => controller.abort());
|
||
const emit = (event: object) =>
|
||
stream.writeSSE({ data: JSON.stringify(event) });
|
||
let demand = "查询执行",
|
||
reason: GapReason | undefined;
|
||
try {
|
||
await emit({
|
||
type: "step",
|
||
label: input.action ? "延续当前分析条件" : "理解问题与对话上下文",
|
||
});
|
||
const vehicles = visibleVehicles(
|
||
await vehicleRepository.getVehicles(),
|
||
user,
|
||
input.subject,
|
||
);
|
||
if (controller.signal.aborted) return;
|
||
const context = conversationContext(currentSession, baseNode);
|
||
const previous = context.previous;
|
||
let plan = previous || initialPlan();
|
||
let warning: string | undefined;
|
||
let reply: string | undefined;
|
||
let answerKind: "answer" | "clarify" | undefined;
|
||
let suggestions: string[] | undefined;
|
||
let engine: "deepseek" | "local" = "local";
|
||
let parent = baseNode;
|
||
if (input.action) {
|
||
if (!previous) throw new Error("请先完成一次统计。");
|
||
plan = applyAction(
|
||
previous,
|
||
input.action,
|
||
await execute(vehicles, previous),
|
||
);
|
||
demand = `${CATEGORY_LABELS[previous.category]} ${GROUP_LABELS[previous.groupBy]} ${input.action.type}`;
|
||
} else {
|
||
demand = input
|
||
.message!.trim()
|
||
.replace(/sk-[a-z0-9]{8,}/gi, "[凭据已隐藏]")
|
||
.slice(0, 600);
|
||
const decision = await planConversation({
|
||
message: input.message!,
|
||
previous,
|
||
history: context.history,
|
||
resultContext: context.resultContext,
|
||
candidates: context.candidates,
|
||
vehicles,
|
||
signal: controller.signal,
|
||
});
|
||
plan = decision.plan;
|
||
reason = decision.reason;
|
||
warning = decision.warning;
|
||
reply = decision.reply;
|
||
answerKind = decision.answerKind;
|
||
suggestions = decision.suggestions;
|
||
engine = decision.engine;
|
||
if (decision.baseResultId)
|
||
parent = getNode(currentSession, decision.baseResultId);
|
||
}
|
||
if (controller.signal.aborted) return;
|
||
await emit({
|
||
type: "step",
|
||
label:
|
||
plan.kind === "clarify"
|
||
? "组织回答与下一步建议"
|
||
: "查询数据并计算统计结果",
|
||
});
|
||
let result: AssistantResult =
|
||
plan.kind === "clarify"
|
||
? {
|
||
plan,
|
||
title:
|
||
answerKind === "answer"
|
||
? "资产问数助手"
|
||
: reason
|
||
? "这项分析还需要补充能力"
|
||
: "继续完善分析",
|
||
summary:
|
||
reply || plan.clarification || "请补充想看的指标与范围。",
|
||
source: "资产问数助手",
|
||
fetchedAt: new Date().toISOString(),
|
||
groups: [],
|
||
rows: [],
|
||
metrics: [],
|
||
answerKind: answerKind === "answer" ? "help" : "clarification",
|
||
}
|
||
: await execute(vehicles, plan);
|
||
if (controller.signal.aborted) return;
|
||
result.engine = engine;
|
||
if (suggestions?.length) result.suggestions = suggestions;
|
||
if (warning) result.warning = warning;
|
||
if (plan.kind !== "clarify") {
|
||
const includes = Object.entries(plan.filters).map(
|
||
([key, values]) =>
|
||
`${GROUP_LABELS[key as keyof typeof GROUP_LABELS]}:${values.join("、")}`,
|
||
);
|
||
const excludes = Object.entries(plan.excludeFilters || {}).map(
|
||
([key, values]) =>
|
||
`排除${GROUP_LABELS[key as keyof typeof GROUP_LABELS]}:${values.join("、")}`,
|
||
);
|
||
result.interpretation = [
|
||
plan.kind === "flow" ? "交退换车" : CATEGORY_LABELS[plan.category],
|
||
...includes,
|
||
...excludes,
|
||
...(plan.exception
|
||
? [
|
||
{
|
||
overdue: "已超预计完成时间",
|
||
dueSoon: "未来 7 天到期",
|
||
missingDueDate: "未填写预计完成时间",
|
||
}[plan.exception],
|
||
]
|
||
: []),
|
||
...(plan.vehicleIds ? [`指定 ${plan.vehicleIds.length} 台车辆`] : []),
|
||
...(plan.kind === "flow" ? [`${plan.start} 至 ${plan.end}`] : []),
|
||
].join(" · ");
|
||
if (parent) {
|
||
const baseLabel = trail(currentSession, parent).at(-1)?.label;
|
||
if (baseLabel)
|
||
result.contextNote = `承接「${baseLabel}」${parent.id !== baseNode?.id ? "的历史分析" : ""}`;
|
||
}
|
||
}
|
||
const label =
|
||
plan.kind === "clarify"
|
||
? "对话补充"
|
||
: `${CATEGORY_LABELS[plan.category]} · ${Object.values(plan.filters).flat().join(" / ") || (plan.kind === "flow" ? `${plan.start}—${plan.end}` : GROUP_LABELS[plan.groupBy])}`;
|
||
if (parent) {
|
||
const sourcePlan = conversationContext(currentSession, parent).previous;
|
||
if (sourcePlan) {
|
||
const scope = [
|
||
CATEGORY_LABELS[sourcePlan.category],
|
||
...Object.values(sourcePlan.filters).flat(),
|
||
...Object.values(sourcePlan.excludeFilters || {})
|
||
.flat()
|
||
.map((v) => `排除${v}`),
|
||
...(sourcePlan.kind === "flow"
|
||
? [`交退换 ${sourcePlan.start}—${sourcePlan.end}`]
|
||
: []),
|
||
...(sourcePlan.exception
|
||
? [
|
||
{
|
||
overdue: "已超期",
|
||
dueSoon: "未来 7 天到期",
|
||
missingDueDate: "缺预计完成时间",
|
||
}[sourcePlan.exception],
|
||
]
|
||
: []),
|
||
].join(" · ");
|
||
demand = `${demand.slice(0, 400)}(承接:${scope})`.slice(0, 600);
|
||
}
|
||
}
|
||
const node = addNode(currentSession, {
|
||
plan,
|
||
parent: parent?.id,
|
||
label,
|
||
demand,
|
||
reason,
|
||
question: input.message || demand,
|
||
answer: result.summary,
|
||
resultContext:
|
||
plan.kind === "clarify"
|
||
? undefined
|
||
: {
|
||
groups: result.groups.slice(0, 100),
|
||
metrics: result.metrics,
|
||
insights: result.insights,
|
||
summary: result.summary,
|
||
},
|
||
});
|
||
result = {
|
||
...result,
|
||
sessionId: currentSession.id,
|
||
resultId: node.id,
|
||
breadcrumbs: trail(currentSession, node),
|
||
};
|
||
if (reason) {
|
||
let recorded = false;
|
||
try {
|
||
await gapStore.record(user.userId, demand, reason);
|
||
recorded = true;
|
||
} catch {
|
||
/* Report failure explicitly, never pretend it was persisted. */
|
||
}
|
||
result.gap = { recorded, reason, demand };
|
||
}
|
||
await emit({
|
||
type: "step",
|
||
label: "分析完成",
|
||
});
|
||
await emit({ type: "result", result });
|
||
} catch {
|
||
if (!controller.signal.aborted) {
|
||
let recorded = false;
|
||
try {
|
||
await gapStore.record(user.userId, demand, "generation");
|
||
recorded = true;
|
||
} catch {
|
||
/* No raw error or query payload in logs. */
|
||
}
|
||
await emit({
|
||
type: "error",
|
||
message: `查询未完成,请检查条件后重试。${recorded ? "已记录本次未完成的需求。" : "需求记录暂未保存。"}`,
|
||
});
|
||
}
|
||
} finally {
|
||
release();
|
||
}
|
||
});
|
||
});
|
||
export default router;
|