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;