diff --git a/docker-compose.yml b/docker-compose.yml index b6fba50..a5eda1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: environment: # Runtime only: set this variable in Portainer Stack environment variables. DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}" + # Enabled when a Key is configured; set 0 to use the local planner only. + ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}" DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com" DB_PORT: "3306" DB_USER: "oneos_db_prod" diff --git a/docs/asset-ai-experience-cases.md b/docs/asset-ai-experience-cases.md new file mode 100644 index 0000000..5188191 --- /dev/null +++ b/docs/asset-ai-experience-cases.md @@ -0,0 +1,53 @@ +# 资产 AI 问答体验验收 + +本轮目标是恢复自然语言理解和连续分析。模型负责理解与查询规划,权限与业务数量继续由现有后端执行。验收需要分开检查“有没有理解”与“算得对不对”,不能仅以接口返回 200 为通过。 + +## 自动回归的数据与边界 + +`src/server/routes/asset-assistant/experience.test.ts` 使用合成车辆、模拟 DeepSeek 响应和内存需求记录,不访问真实数据库或模型。集成测试验证模型决策能穿过会话、查询执行和结果组装,不能证明真实模型对任意表达的理解率。 + +本轮已通过 17 项路由集成回归,包含五轮会话、澄清前后范围、历史结果分支、排名引用的汇总证据、异常到期筛选、历史流转日期及切回当前库存、模型故障、中文历史库存口径,以及需求记录的上下文可读性。执行命令:`node --import tsx --test src/server/routes/asset-assistant/experience.test.ts`。 + +## 中文真实问法语料 + +| 场景 | 连续输入 / 操作 | 应有行为 | +| --- | --- | --- | +| 口语表达 | 帮我瞅瞅华东现在有多少闲着的车,按城市排一下 | 理解库存意图;若“闲着”口径不明确,用简短说明或一次澄清,不要求用户使用固定关键词 | +| 指代和继承 | 查华东库存 → 只看嘉兴 → 这里面哪些快到期了? | 继承华东、嘉兴范围;“到期”可能指异动预计完成时间或合同到期,缺少明确指向时澄清 | +| 澄清恢复 | 查嘉兴库存 → 看下到期情况 → 我指异动预计完成时间 | 澄清不丢掉嘉兴范围;执行异动时间相关分析 | +| 排序与取前几名 | 哪些城市库存最多?只看前三名 → 换成最少的三个 | 正确应用降序、升序与 Top 3;展示截取口径,不能把前三名当作全量总数 | +| 排除条件 | 看华东库存,杭州先不要 → 还是把杭州加回来 | 支持排除和取消排除;展示当前条件 | +| 修改而非叠加 | 看嘉兴库存 → 不对,我想看杭州 | 城市替换为杭州,不把嘉兴和杭州错误叠加 | +| 恢复全局 | 看嘉兴异动车辆 → 重新看全部库存 | 清除旧地区、异常等限制,不残留不可见条件 | +| 任意结果分支 | 看华东库存 → 只看嘉兴 → 回到第一张图,按车型拆 | 从指定历史结果继续,使用华东范围;不能使用最近嘉兴结果 | +| 图表点击联动 | 按区域看库存 → 点击华东 → 按车型拆开 → 点击 4.5T → 列出异动车 | 保留区域和车型;统计与明细可核对;显示异动类型、预计完成时间 | +| 交叉对比 | 华东和华南的库存,按车型对比一下 | 地区与车型双维度;对比当前分布,不冒称历史涨跌 | +| 库存子集 | 这些库存里面有多少异动和待交车? | 总库存包含两个子集;不能将总库存与子集相加 | +| 超期定位 | 异动维修还没结束而且已经超期的有哪些? | 仅包括未完成且超过预计完成时间的车辆,显示明细依据 | +| 时间口语 | 最近一周交退换怎么样?和前一周比一下 → 换成表格 | 固定相同长度比较期间;切换展示不改变时间和比较条件 | +| 车辆指定 | 看一下浙A12345当前是什么情况 | 现有权限内查询对应车辆,不因自然文本被固定词表拦截 | +| 正常零结果 | 嘉兴有待交车吗?(样本中为 0) | 清楚回答 0,保留范围;不标记为未识别或生成失败,不自动进入需求池 | +| 能力说明 | 你能帮我分析些什么? → 库存和可运营库存有什么区别? | 直接说明口径或给可用示例;不把正常帮助对话登记为未满足需求 | +| 缺少数据 | 能算每台车的租赁毛利吗? | 说明当前缺哪些数据;不拿运营率替代毛利;记录实际需求且给出相关可用方向 | +| 原因解释 | 为什么这个城市库存高? | 先给已知分布证据,区分相关性和原因;没有业务原因数据时明确说明,不编造客户流失等原因 | +| 模型异常 | 模拟超时、非 JSON、截断或无效计划 | 简单可识别问题可以规则降级,并明确提示;复杂问题不能静默退成全部资产 | +| 权限一致 | 无权查看的车辆 / 公司 / 历史结果 | 沿用账号权限;模型选择不能扩大可见范围;导出重新核验权限 | +| 需求反馈 | 未支持问题 → 我的需求 → 标记未解决 → 管理员处理 | 保存用户实际需求,状态可追踪;重复点击同一结果的主动反馈只记录一次 | + +## 实机验收方法 + +1. 用真实模型跑口语、修正、澄清、历史分支四组会话,每组至少五轮;保留问题、生成计划和可核对结果。 +2. 用图表操作和文字提问交替完成同一个分析,核对筛选条件、总数和明细。 +3. 用桌面和手机分别完成输入、停止、重试、切换结果、导出;不得把旧结果误标成当前上下文。 +4. 检查需求池:正常帮助、澄清和零匹配不能污染需求池;不支持的真实需求应可读、可归类。 +5. 报告分别列出通过、失败及未验证项,明确真实模型验证与模拟集成测试的区别。 + + +## 本轮验证结果(2026-09-23) + +- 全量 176 项自动化测试通过,TypeScript 检查与生产构建通过;含 22 项规划器测试、17 项会话路由体验测试。 +- 真实 DeepSeek Flash 使用合成车辆验证口语库存、车型/型号区分、正负筛选、库存范围重置、Top N 指代、宽泛异常分析、异动详情和历史/预测能力说明。发现的理解偏差修复后重新调用验证通过;这是有限样本验收,不代表所有自然语言均已覆盖。 +- 浏览器合成数据验证桌面 1440×960 与手机 390×844:总库存 4 → 华东 3 → 嘉兴 2 → 取消城市恢复 3;异动 1 台并保留类型和预计完成时间;帮助后可继续提问,旧回答可折叠。手机无页面横向溢出,控制台无错误和警告。 +- 未进行生产部署、生产数据库需求表建表验证或长期真实用户效果评估。本轮保留工作区修改,未提交、推送或发布。 + +开发复盘:问题根因是封闭词表先行拦截、模型只负责展示、会话没有传递实际问答及结果上下文。改为模型完整规划并用真实中文多轮样本验证。本轮证据属于项目改造,不新增全局开发知识候选。 diff --git a/docs/asset-ai-safe-analysis.md b/docs/asset-ai-safe-analysis.md new file mode 100644 index 0000000..45cd504 --- /dev/null +++ b/docs/asset-ai-safe-analysis.md @@ -0,0 +1,36 @@ +# 资产 AI 会话分析与需求反哺 + +## 会话能力 + +- DeepSeek Flash 理解完整自然语言、当前分析状态、近期问答和可回到的历史结果,生成完整统计方案。支持口语、纠正、否定、切换主题和连续追问。 +- 内部程序执行筛选和计算;图表点击、文字追问、历史结果分支共享同一分析条件。澄清后补充条件仍承接最近有效的分析范围。 +- 区域、城市、车型、型号、部门、运营状态支持多层下钻和双维度交叉统计;支持排除条件、指定车辆、排名和排序。Top N 仅限制分组展示,总量、占比和明细仍基于完整筛选范围,界面明确显示口径。 +- 库存包含异动和待交车,子集不重复累加。支持超预计完成时间、未来 7 天到期、未填写预计完成时间的异动车辆定位,明细保留异动类型与预计完成时间。 +- 交退换车按日趋势支持上一等长周期比较,单次最多 93 天;替换交车包含在交车中,基期零不计算增长率。资产历史快照、成本、预测等数据能力尚未接入,说明原因并给下一步建议。 +- 展示包含指标、结论、图表、统计表、异常入口、车辆明细、导出。历史回答默认折叠,可展开或作为追问起点;当前范围可修改、清除或另起话题。 + +## 模型接入 + +按本轮产品要求,停用此前新增的封闭词表、未知文本拦截与实体代号投影。有 `DEEPSEEK_API_KEY` 时默认启用模型;显式 `ASSET_AI_EXTERNAL_ENABLED=0` 才使用本地规则。Compose 默认值已改为 1,既有容器如果显式设置了 0,需要重新配置并部署。 + +模型请求包含当前问题原文、最近会话文本、结构化分析条件、历史结果候选、当前结果的分组与指标和当前可见车辆的维度目录;问题明确提及某辆车时也包含匹配的车辆标识。历史回答和当前结果上下文包含之前计算得到的汇总数值。**这些内容会发送到 DeepSeek API。** 没有将完整数据库、轨迹或业务台账行打包发送;模型不执行 SQL。正常统计结论仍由内部确定性程序产生。 + +保留应用既有登录与数据权限、服务端密钥及查询执行机制。界面显示本次使用 DeepSeek 还是本地规则;模型超时、坏 JSON 或服务异常时给出降级提示,不把规则结果伪装成 AI。不会为本轮额外增设安全审批或词表限制。 + +## 需求反哺 + +未识别、暂不支持、执行生成失败会自动记录;每个结果也可标记“未解决”。正常帮助、针对性澄清和零匹配数据不作为需求失败。成功的本地降级只显示提示,不冒充理解能力完整的模型回答。 + +内部 MySQL 表 `bi_asset_ai_gaps` 保存未完成的问题文本(最多 600 字、常见 Key 格式隐藏)、原因、用户、次数和处理状态,不保存完整会话。按用户、问题与原因去重;用户对同一个结果重复点击“未解决”只记一次。普通用户查看本人记录;沿用反馈管理角色查看全部、设置待评估/已纳入计划/已解决/暂不处理。已解决需求再次出现会重新打开。 + +最近出现超过 90 天的记录会清理。数据库不可用时明确显示未保存。需求记录用于产品改进,不自动训练模型,不建立长期个人记忆,不向模型同步需求池。 + +## 部署与会话生命周期 + +环境变量和 Portainer 操作见 [部署说明](portainer-deepseek.md)。需求表复用现有 MySQL 连接,首次访问自动创建;预建 SQL 在 `src/server/routes/asset-assistant/gaps.ts`。表不可用不影响正常统计。 + +会话仅在应用进程内保存,闲置 1 小时失效;每用户最多 10 个会话,每会话最多 100 步。重启后可用原问题开启新对话,但不能恢复旧的分析范围。多副本需要粘性路由或共享会话存储。取消本轮新增的每日/每分钟问数额度,仅阻止同用户同时重复执行请求。 + +## 验证 + +自动化场景覆盖自然语言规划真实影响查询、连续纠正和排除、澄清上下文、历史分支、Top N 统计口径、异常期限、清除筛选、帮助与零结果、能力缺口与反馈、模型故障降级和现有权限。见 [体验验收语料](asset-ai-experience-cases.md)。浏览器与真实模型探针均使用合成车辆,不向业务库写测试记录。 diff --git a/docs/portainer-deepseek.md b/docs/portainer-deepseek.md index 6227547..44d915c 100644 --- a/docs/portainer-deepseek.md +++ b/docs/portainer-deepseek.md @@ -1,5 +1,7 @@ # Portainer 部署资产 AI 问数 +配置 Key 后默认使用 DeepSeek Flash 理解自然语言与会话上下文;`ASSET_AI_EXTERNAL_ENABLED=0` 可显式关闭模型。此前版本显式配置了 0 的 Stack,请改为 1。模型会收到问题原文、近期会话、分析条件和相关维度目录,详情见 [会话分析说明](asset-ai-safe-analysis.md)。 + DeepSeek Key 由 Node 服务端在运行时读取,变量名为 `DEEPSEEK_API_KEY`。模型固定为 `deepseek-flash`,接口为 `https://api.deepseek.com/chat/completions`,部署节点需要能够访问该 HTTPS 地址。 ## Stack 配置 @@ -11,15 +13,16 @@ services: ln-bi: environment: DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}" + ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}" ``` -1. 等待当前分支的 Woodpecker 构建与镜像推送成功。本次版本 1.1.17 对应镜像 `harbor.lnh2e.com/lingniu-v1/ln-bi:feature-asset-statistics-1.1.17`。 -2. Portainer → Stacks → 现有 `ln-bi` Stack。确认 Compose 的 `image` 使用上述镜像,且保留现有数据库、认证、网络和端口配置。 +1. 等待当前分支的 Woodpecker 构建与镜像推送成功,取得本次构建对应的镜像标签。 +2. Portainer → Stacks → 现有 `ln-bi` Stack。确认 Compose 的 `image` 使用本次构建的镜像,且保留现有数据库、认证、网络和端口配置。 3. 在 **Environment variables** 中新增名称 `DEEPSEEK_API_KEY`,值粘贴实际 API Key。Compose 必须包含上面的变量引用,才能将 Stack 变量传入容器。 4. 点击 **Update the stack** 并重新部署,确保拉取新镜像。Git 管理的 Stack 需要从仓库拉取新版 Compose;Web Editor 管理的 Stack 则直接更新编辑器内容。 5. 登录应用,在资产管理中打开「AI 问数」,查询“当前库存按区域统计”验证。 -后续更换 Key 只需更新 Stack 环境变量并重新部署/创建容器,无需重新构建镜像。仅重启旧容器不会刷新其环境变量。未配置 Key 时 AI 问数返回配置提示,其他资产功能可继续使用。 +后续更换 Key 只需更新 Stack 环境变量并重新部署/创建容器,无需重新构建镜像。仅重启旧容器不会刷新其环境变量。未配置 Key 或显式关闭模型时使用本地规则,并在界面提示,其他资产功能可继续使用。 ## 检查是否注入 diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx index 072b80c..943cb15 100644 --- a/src/modules/assets/assistant/AssetAssistant.tsx +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -2,17 +2,16 @@ import { useEffect, useRef, useState } from "react"; import { ArrowUp, ArrowUpRight, - AudioLines, BarChart3, Building2, CircleHelp, + ChevronDown, + RotateCcw, Database, History, MessageSquare, - Mic, Moon, Plus, - ShieldCheck, Sparkles, Square, Sun, @@ -27,13 +26,16 @@ import { } from "../../../components/ui/dialog"; import type { AssistantResult, - QueryPlan, + AssistantAction, + Dimension, } from "../../../shared/assets/assistant"; import { CATEGORY_LABELS, GROUP_LABELS, } from "../../../shared/assets/assistant"; -import { askAssetAssistant } from "./client"; +import { askAssetAssistant, AssistantRequestError } from "./client"; +import { fetchAuthenticated } from "../../../auth/api-client"; +import { DemandGaps } from "./DemandGaps"; import { ThinkingState } from "./ThinkingState"; import { ResultView } from "./ResultView"; import "./assistant.css"; @@ -45,46 +47,35 @@ type Turn = { steps: string[]; result?: AssistantResult; error?: string; -}; -type Recognition = { - lang: string; - continuous: boolean; - interimResults: boolean; - start(): void; - stop(): void; - abort(): void; - onresult: - | ((event: { - results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>; - }) => void) - | null; - onerror: ((event: { error: string }) => void) | null; - onend: (() => void) | null; + expired?: boolean; + stopped?: boolean; + baseResultId?: string; + action?: AssistantAction; }; const PROMPTS = [ { icon: BarChart3, - title: "库存分布", + title: "库存主要集中在哪里?", text: "各区域的库存车辆有多少?按数量排序。", - detail: "区域对比 · 一键下钻", + detail: "看分布,再逐层查看车辆", }, { icon: Truck, - title: "运营情况", - text: "按车型统计当前资产数量和运营率。", + title: "哪些车型运营率较低?", + text: "按车型对比当前资产数量和运营率。", detail: "车型构成 · 运营率", }, { icon: History, - title: "交退换趋势", + title: "本月车辆流转怎么样?", text: "本月的交车、还车和替换交车趋势怎么样?", detail: "按日趋势 · 业务明细", }, { icon: ListIcon, - title: "待交车清单", - text: "列出当前待交车车辆明细。", - detail: "车辆列表 · 导出结果", + title: "有哪些车辆需要跟进?", + text: "查看当前异动车辆,显示异动类型和预计完成时间。", + detail: "异动详情 · 预计完成时间", }, ]; function ListIcon({ size }: { size?: number }) { @@ -104,21 +95,74 @@ export default function AssetAssistant({ const [turns, setTurns] = useState([]); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); - const [listening, setListening] = useState(false); - const [voiceNote, setVoiceNote] = useState(""); + const [expandedTurns, setExpandedTurns] = useState>( + () => new Set(), + ); + const [independent, setIndependent] = useState(false); + const [capability, setCapability] = useState<{ + engine: "deepseek" | "local"; + model: string | null; + message: string; + }>(); const [showInfo, setShowInfo] = useState(false); + const [capabilityError, setCapabilityError] = useState(false); const abortRef = useRef(null); - const recognitionRef = useRef(null); + const [activeResult, setActiveResult] = useState(); + const sessionRef = useRef(undefined); + const sessions = useRef(new Map()); + const [showGaps, setShowGaps] = useState(false); const nextId = useRef(0); const composer = useRef(null); const scroller = useRef(null); - const lastPlan = turns - .filter((t) => t.result && t.result.plan.kind !== "clarify") - .at(-1)?.result?.plan; + const scopeResult = + activeResult?.plan.kind === "clarify" + ? activeResult.breadcrumbs + ?.map( + (crumb) => + turns.find((turn) => turn.result?.resultId === crumb.resultId) + ?.result, + ) + .filter((result) => result && result.plan.kind !== "clarify") + .at(-1) + : activeResult; + const lastPlan = scopeResult?.plan; + useEffect(() => { + const controller = new AbortController(); + void fetchAuthenticated("/api/asset-assistant/capabilities", { + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new Error("服务状态不可用"); + setCapability(await response.json()); + }) + .catch(() => { + if (!controller.signal.aborted) setCapabilityError(true); + }); + return () => controller.abort(); + }, []); + const revealTurn = (id: number) => { + setShowGaps(false); + setExpandedTurns((old) => new Set(old).add(id)); + requestAnimationFrame(() => + document + .getElementById(`aq-turn-${id}`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }), + ); + }; + const startTopic = () => { + setActiveResult(undefined); + sessionRef.current = undefined; + setIndependent(true); + composer.current?.focus(); + }; useEffect( () => () => { abortRef.current?.abort(); - recognitionRef.current?.abort(); + for (const [sessionId, sessionSubject] of sessions.current) + void fetchAuthenticated( + `/api/asset-assistant/session/${sessionId}?subject=${encodeURIComponent(sessionSubject || "")}`, + { method: "DELETE" }, + ).catch(() => {}); }, [], ); @@ -129,16 +173,19 @@ export default function AssetAssistant({ setTurns((previous) => previous.map((t) => !t.result && !t.error - ? { ...t, error: "已停止生成,可以修改问题后重试。" } + ? { ...t, error: "已停止。可以修改问题,或继续生成。", stopped: true } : t, ), ); }; - const ask = async (message: string, previous = lastPlan) => { + const ask = async ( + message: string, + base: AssistantResult | null = activeResult ?? null, + action?: AssistantAction, + ) => { if (!message.trim() || abortRef.current) return; - recognitionRef.current?.abort(); - setListening(false); - setVoiceNote(""); + setShowGaps(false); + setIndependent(false); const id = ++nextId.current; const controller = new AbortController(); abortRef.current = controller; @@ -150,35 +197,46 @@ export default function AssetAssistant({ id, question: message.trim(), started: Date.now(), - steps: ["连接资产分析服务"], + baseResultId: base?.resultId, + action, + steps: ["正在理解你的问题"], }, ]); requestAnimationFrame(() => - scroller.current?.scrollTo({ - top: scroller.current.scrollHeight, - behavior: "instant", - }), + document + .getElementById(`aq-turn-${id}`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }), ); try { await askAssetAssistant( - { message, subject, previous }, + { + ...(action ? { action } : { message }), + subject, + sessionId: base?.sessionId, + baseResultId: base?.resultId, + }, controller.signal, (event) => { if (controller.signal.aborted) return; - const container = scroller.current; - const follow = - container && - container.scrollHeight - - container.scrollTop - - container.clientHeight < - 150; - if (follow && event.type === "result") - requestAnimationFrame(() => - container.scrollTo({ - top: container.scrollHeight, - behavior: "instant", - }), - ); + if (event.type === "result") { + const container = scroller.current; + const following = + container && + container.scrollHeight - + container.scrollTop - + container.clientHeight < + 150; + if (following) + requestAnimationFrame(() => + document + .getElementById(`aq-turn-${id}`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }), + ); + sessionRef.current = event.result.sessionId; + if (event.result.sessionId) + sessions.current.set(event.result.sessionId, subject); + setActiveResult(event.result); + } setTurns((old) => old.map((turn) => turn.id !== id @@ -187,7 +245,9 @@ export default function AssetAssistant({ ? { ...turn, steps: [...turn.steps, event.label] } : event.type === "error" ? { ...turn, error: event.message } - : { ...turn, result: event.result }, + : event.type === "result" + ? { ...turn, result: event.result } + : turn, ), ); }, @@ -199,6 +259,9 @@ export default function AssetAssistant({ t.id === id ? { ...t, + expired: + error instanceof AssistantRequestError && + error.status === 409, error: error instanceof Error ? error.message @@ -215,68 +278,27 @@ export default function AssetAssistant({ } } }; - const toggleVoice = () => { - if (listening) { - recognitionRef.current?.stop(); - return; - } - const win = window as typeof window & { - SpeechRecognition?: new () => Recognition; - webkitSpeechRecognition?: new () => Recognition; - }; - const Constructor = win.SpeechRecognition || win.webkitSpeechRecognition; - if (!Constructor) { - setVoiceNote( - "当前浏览器不支持语音识别,请使用 Chrome / Edge 或直接输入文字。", - ); - return; - } - const recognition = new Constructor(); - recognitionRef.current = recognition; - recognition.lang = "zh-CN"; - recognition.continuous = false; - recognition.interimResults = true; - const base = draft; - recognition.onresult = (event) => - setDraft( - (base ? base + " " : "") + - Array.from(event.results) - .map((r) => r[0].transcript) - .join(""), - ); - recognition.onerror = (event) => { - setListening(false); - setVoiceNote( - event.error === "not-allowed" - ? "麦克风权限未开启,你仍可以输入文字。" - : event.error === "no-speech" - ? "没有识别到语音,请再试一次。" - : "语音识别暂时不可用,请使用文字输入。", - ); - }; - recognition.onend = () => { - setListening(false); - composer.current?.focus(); - }; - try { - recognition.start(); - setListening(true); - setVoiceNote("正在聆听,点击麦克风结束。转写后可修改再发送。"); - } catch { - setVoiceNote("无法启动语音识别,请使用文字输入。"); - } - }; const reset = () => { abortRef.current?.abort(); abortRef.current = null; - recognitionRef.current?.abort(); setBusy(false); - setListening(false); - setVoiceNote(""); + setIndependent(false); + if (sessionRef.current) + void fetchAuthenticated( + `/api/asset-assistant/session/${sessionRef.current}?subject=${encodeURIComponent(subject || "")}`, + { method: "DELETE" }, + ).catch(() => {}); + sessionRef.current = undefined; + setActiveResult(undefined); + setShowGaps(false); setTurns([]); + setExpandedTurns(new Set()); setDraft(""); composer.current?.focus(); }; + useEffect(() => { + reset(); + }, [subject]); return ( +
本次对话{turns.length.toString().padStart(2, "0")}
{turns.length ? ( turns.map((turn) => ( - @@ -361,7 +380,7 @@ export default function AssetAssistant({

- + 沿用当前账号数据权限

@@ -375,6 +394,13 @@ export default function AssetAssistant({ 你的资产分析助手
+
)}
- {!turns.length ? ( + {showGaps ? ( + setShowGaps(false)} /> + ) : !turns.length ? (
@@ -456,93 +490,265 @@ export default function AssetAssistant({ ))}
- + 只读查询 · 真实数据 · 可追溯明细
) : (
- {turns.map((turn, index) => ( -
-
- 你 -

{turn.question}

-
-
- - - - 资产助手 - DeepSeek Flash -
- - {turn.error ? ( -
-

{turn.error}

+ {turns.map((turn, index) => { + const collapsed = + index < turns.length - 1 && + !!turn.result && + !expandedTurns.has(turn.id); + return ( +
+
+ 你 +

{turn.question}

+
+ {collapsed ? ( -
- ) : turn.result ? ( - - void ask( - text, - turn.result?.plan.kind === "clarify" - ? lastPlan - : turn.result?.plan, - ) - } - disabled={busy} - /> - ) : ( -
- - - -
- )} -
- ))} + ) : ( + <> +
+ + + + 资产助手 + + {(turn.result?.engine ?? capability?.engine) === + "deepseek" + ? "DeepSeek" + : (turn.result?.engine ?? + capability?.engine) === "local" + ? "本地分析" + : "连接中"} + + {index < turns.length - 1 && turn.result && ( + + )} +
+ + {turn.error ? ( +
+

+ {turn.expired + ? "这次分析已过期,重新查询即可获取最新数据。" + : turn.error} +

+ +
+ ) : turn.result ? ( + void ask(text, turn.result!)} + operate={(action, label) => + void ask(label, turn.result!, action) + } + restore={(resultId) => { + const target = turns.find( + (t) => t.result?.resultId === resultId, + )?.result; + if (target) + void ask("返回此前分析", target, { + type: "restore", + }); + }} + active={ + activeResult?.resultId === turn.result.resultId + } + activate={() => { + setActiveResult(turn.result); + sessionRef.current = turn.result?.sessionId; + setIndependent(false); + composer.current?.focus(); + }} + subject={subject} + disabled={busy} + /> + ) : ( +
+ + + +
+ )} + + )} + + ); + })}
)}
{lastPlan && (
- 追问上下文 + 正在分析 {lastPlan.kind === "flow" ? "交退换车" : CATEGORY_LABELS[lastPlan.category]} {GROUP_LABELS[lastPlan.groupBy]} - {Object.values(lastPlan.filters).map((v) => ( - {v} + {lastPlan.start && ( + + {lastPlan.start} — {lastPlan.end} + + )} + {Object.entries(lastPlan.filters).map(([dimension, values]) => ( + ))} + {Object.entries(lastPlan.excludeFilters || {}).map( + ([dimension, values]) => ( + + ), + )} + {!!lastPlan.vehicleIds?.length && ( + 已选 {lastPlan.vehicleIds.length} 台车辆 + )} + {lastPlan.exception && ( + + {lastPlan.exception === "overdue" + ? "已超过预计完成时间" + : lastPlan.exception === "dueSoon" + ? "即将到期" + : "未填写预计完成时间"} + + )} + {Object.keys(lastPlan.filters).length > 0 || + Object.keys(lastPlan.excludeFilters || {}).length > 0 || + lastPlan.vehicleIds?.length || + lastPlan.exception ? ( + + ) : null} +
)} + {independent && ( +

+ 已开始独立问题,不继承之前的筛选条件 +

+ )}
{ e.preventDefault(); void ask(draft); @@ -576,9 +782,15 @@ export default function AssetAssistant({ />
- + - DeepSeek Flash + {capability?.engine === "deepseek" + ? "DeepSeek" + : capability?.engine === "local" + ? "本地分析 · 未连接模型" + : capabilityError + ? "服务状态待确认" + : "检查服务中"} @@ -591,16 +803,6 @@ export default function AssetAssistant({ {draft.length}/2000 )} - {busy ? (
- {voiceNote && ( -

- {voiceNote} -

- )}
AI 可能产生偏差,统计以业务明细为准。 Enter 发送 · Shift + Enter 换行 diff --git a/src/modules/assets/assistant/DemandGaps.tsx b/src/modules/assets/assistant/DemandGaps.tsx new file mode 100644 index 0000000..420d867 --- /dev/null +++ b/src/modules/assets/assistant/DemandGaps.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; +import { fetchAuthenticated } from "../../../auth/api-client"; +import { + GAP_REASONS, + GAP_STATUSES, + type DemandGap, + type GapStatus, +} from "../../../shared/assets/assistant"; + +export function DemandGaps({ close }: { close: () => void }) { + const [items, setItems] = useState([]), + [all, setAll] = useState(false), + [canManage, setCanManage] = useState(false); + const [error, setError] = useState(""), + [loading, setLoading] = useState(true), + [revision, setRevision] = useState(0); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + void fetchAuthenticated( + `/api/asset-assistant/gaps?scope=${all ? "all" : "mine"}`, + { signal: controller.signal }, + ) + .then(async (r) => { + const data = await r.json(); + if (!r.ok) throw new Error(data.error); + return data; + }) + .then((data) => { + setItems(data.items); + setCanManage(data.canManage); + }) + .catch((e) => { + if (!controller.signal.aborted) setError(e.message || "读取失败。"); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [all, revision]); + const update = async (id: number, status: GapStatus) => { + setError(""); + try { + const r = await fetchAuthenticated(`/api/asset-assistant/gaps/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (!r.ok) throw new Error("状态保存失败。"); + setRevision((x) => x + 1); + } catch { + setError("状态保存失败,请重试。"); + } + }; + return ( +
+
+
+

待完善需求

+

保留90天 · 记录未完成的问题与反馈,用于完善分析能力

+
+ +
+
+ {canManage && ( + + )} + +
+ {error && ( +

+ {error} +

+ )} + {loading ? ( +

正在读取需求记录…

+ ) : ( + <> +

+ {items.length}项需求 · 累计 + {items.reduce((n, v) => n + v.occurrences, 0)}次(最近200项) +

+ {items.length === 0 && !error && ( +

暂无未满足需求

+ )} + {items.map((item) => ( +
+ {item.demand} +

+ {GAP_REASONS[item.reason]} · {item.occurrences}次 · 最近{" "} + {new Date(item.lastSeen).toLocaleString("zh-CN")} +

+ {canManage ? ( + + ) : ( + {GAP_STATUSES[item.status]} + )} +
+ ))} + + )} +
+ ); +} diff --git a/src/modules/assets/assistant/ResultView.tsx b/src/modules/assets/assistant/ResultView.tsx index 8056d3b..a816e08 100644 --- a/src/modules/assets/assistant/ResultView.tsx +++ b/src/modules/assets/assistant/ResultView.tsx @@ -1,53 +1,35 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { ArrowDownToLine, - ArrowLeft, ArrowUpRight, - BarChart3, Check, ChevronLeft, ChevronRight, Copy, - List, - Table2, } from "lucide-react"; -import type { - AssistantResult, - AssistantRow, +import { + CATEGORY_LABELS, + DIMENSIONS, + GROUP_LABELS, + type AssistantAction, + type AssistantResult, + type AssistantRow, + type Dimension, + type QueryPlan, } from "../../../shared/assets/assistant"; -import { GROUP_LABELS } from "../../../shared/assets/assistant"; +import { fetchAuthenticated } from "../../../auth/api-client"; import { Chart } from "./Chart"; -function downloadCsv(rows: string[][], title: string) { - const content = rows - .map((row) => - row - .map( - (value) => - `"${String(value) - .replace(/^[=+@\-\t\r]/, "'$&") - .replaceAll('"', '""')}"`, - ) - .join(","), - ) - .join("\r\n"); - const url = URL.createObjectURL( - new Blob(["\uFEFF" + content], { type: "text/csv;charset=utf-8;" }), - ); - const a = document.createElement("a"); - a.href = url; - a.download = `${title}.csv`; - a.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); -} function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) { - const [page, setPage] = useState(0); - const [search, setSearch] = useState(""); - const filtered = rows.filter((row) => - Object.values(row).some((value) => String(value).includes(search)), + const [page, setPage] = useState(0), + [search, setSearch] = useState(""); + const filtered = rows.filter((r) => + [r.plate, r.city, r.type, r.model, r.department, r.abnormalType].some((v) => + v?.includes(search), + ), ); - const max = Math.max(1, Math.ceil(filtered.length / 8)); - const current = Math.min(page, max - 1); + const pages = Math.max(1, Math.ceil(filtered.length / 8)), + current = Math.min(page, pages - 1); return ( <>
@@ -55,50 +37,58 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) { 筛选明细 { setSearch(e.target.value); setPage(0); }} /> - {filtered.length.toLocaleString()} 条记录 + {filtered.length}条
- - - - - {flow && } + {[ + "车牌", + "车型 / 型号", + "城市", + flow ? "业务类型" : "运营状态", + ...(flow ? ["日期"] : ["库存子集", "异动类型", "预计完成时间"]), + ].map((h) => ( + + ))} - {filtered.slice(current * 8, current * 8 + 8).map((row) => ( - - + {filtered.slice(current * 8, current * 8 + 8).map((r) => ( + + - - - {flow && } + + + {flow ? ( + + ) : ( + <> + + + + + )} ))}
车牌车型 / 型号城市{flow ? "业务类型" : "运营状态"}日期{h}
{row.plate || "未登记车牌"}
{r.plate || "未登记车牌"} - {row.type} - {row.model} + {r.type} + {r.model} {row.city} - - {flow ? row.event : row.status} - - {row.date}{r.city}{flow ? r.event : r.status}{r.date}{r.abnormal ? "异动" : r.pending ? "待交车" : "—"}{r.abnormalType || "—"}{r.targetCompletionTime || "—"}
- {!filtered.length &&
没有符合条件的明细
} + {!filtered.length &&

没有符合条件的明细

}
- 第 {current + 1} / {max} 页 + 第{current + 1}/{pages}页
+ {search && ( +

+ 此搜索仅筛选当前明细表;继续追问仍使用上方分析条件。 +

+ )} ); } - export function ResultView({ result, ask, + operate, + restore, + active, + activate, + subject, disabled, }: { result: AssistantResult; ask: (text: string) => void; + operate: (action: AssistantAction, label: string) => void; + restore: (resultId: string) => void; + active: boolean; + activate: () => void; + subject: string | null; disabled: boolean; }) { - const [view, setView] = useState(result.plan.view); - const [drill, setDrill] = useState(null); - const [copied, setCopied] = useState(false); - const [copyError, setCopyError] = useState(false); - const [showAll, setShowAll] = useState(false); - const flow = result.plan.kind === "flow"; - const inventory = !["all", "operating"].includes(result.plan.category); - const rows = - drill === null - ? result.rows - : result.rows.filter((row) => - flow - ? row.date === drill - : result.plan.groupBy === "none" || - row[result.plan.groupBy] === drill, - ); + const [showAll, setShowAll] = useState(false), + [notice, setNotice] = useState(""), + [flagged, setFlagged] = useState(false), + [saving, setSaving] = useState(false); + const { plan } = result, + flow = plan.kind === "flow"; + const run = (action: AssistantAction, label: string) => + operate(action, label); + const flag = async () => { + setSaving(true); + setNotice(""); + try { + const r = await fetchAuthenticated("/api/asset-assistant/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: result.sessionId, + resultId: result.resultId, + subject, + }), + }); + if (!r.ok) throw new Error(); + setFlagged(true); + setNotice("已记录,我们会据此完善问答能力。"); + } catch { + setNotice("需求未保存,请稍后重试。"); + } finally { + setSaving(false); + } + }; + const download = async () => { + setSaving(true); + setNotice(""); + try { + const r = await fetchAuthenticated("/api/asset-assistant/export", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: result.sessionId, + resultId: result.resultId, + subject, + }), + }); + if (!r.ok) throw new Error(); + const url = URL.createObjectURL(await r.blob()); + const a = document.createElement("a"); + a.href = url; + a.download = "资产分析明细.csv"; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + setNotice("已按当前权限重新查询并导出明细,数量可能随业务更新变化。"); + } catch { + setNotice("导出失败,请重新查询后重试。"); + } finally { + setSaving(false); + } + }; + const suggestions = result.suggestions?.length + ? result.suggestions + : plan.kind === "clarify" + ? [ + "查看当前库存分布", + "查看本月交退换车趋势", + "列出异动车辆及预计完成时间", + ] + : !result.rows.length + ? ["清除筛选,查看全部库存", "查看全部资产分布"] + : flow + ? ["对比上一周期", "显示车辆明细"] + : plan.category === "abnormal" + ? ["哪些异动车辆已经超期?", "按异动所在城市统计", "显示车辆明细"] + : plan.view === "list" + ? ["按城市统计这些车辆", "这些车里面有多少异动和待交车"] + : [ + plan.groupBy === "type" ? "再按城市拆分" : "再按车型拆分", + "这些车里面有多少异动和待交车", + "显示车辆明细", + ]; const largest = Math.max(1, ...result.groups.map((g) => g.count)); - const total = result.groups.reduce((n, g) => n + g.count, 0); - const option = useMemo( - () => ({ - color: ["#668874", "#b5bdc4", "#a58b65"], - tooltip: { trigger: "axis" }, - legend: { bottom: 0, textStyle: { color: "#7b7b80" } }, - grid: { left: 42, right: 16, top: 20, bottom: 60 }, - xAxis: { - type: "category", - data: result.daily?.map((d) => d.date.slice(5)), - axisLine: { lineStyle: { color: "#b5bdc4" } }, - axisLabel: { color: "#7b7b80" }, - }, - yAxis: { - type: "value", - minInterval: 1, - splitLine: { lineStyle: { color: "#b5bdc430" } }, - axisLabel: { color: "#7b7b80" }, - }, - series: ["delivered", "returned", "replaced"].map((key, i) => ({ - name: ["交车(含替换)", "还车", "其中替换交车"][i], - type: "line", - smooth: false, - symbolSize: 5, - data: result.daily?.map((d) => d[key as "delivered"]), - })), - animation: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - }), - [result.daily], - ); - const exportResult = () => { - if (drill !== null || view === "list") - downloadCsv( - [ - [ - "车牌", - "车型", - "型号", - "区域", - "城市", - "部门", - "运营状态", - "日期", - "业务类型", - ], - ...rows.map((r) => [ - r.plate, - r.type, - r.model, - r.region, - r.city, - r.department, - r.status, - r.date || "", - r.event || "", - ]), - ], - result.title + "-明细", - ); - else if (flow) - downloadCsv( - [ - ["日期", "交车(含替换)", "还车", "其中替换交车", "业务总车次"], - ...(result.daily || []).map((d) => [ - d.date, - String(d.delivered), - String(d.returned), - String(d.replaced), - String(d.total), - ]), - ], - result.title, - ); - else if (inventory) - downloadCsv( - [ - [GROUP_LABELS[result.plan.groupBy], "车辆数", "占比"], - ...result.groups.map((g) => [ - g.name, - String(g.count), - `${total ? ((g.count / total) * 100).toFixed(1) : 0}%`, - ]), - ], - result.title, - ); - else - downloadCsv( - [ - [GROUP_LABELS[result.plan.groupBy], "车辆数", "运营车辆数", "运营率"], - ...result.groups.map((g) => [ - g.name, - String(g.count), - String(g.operating), - `${g.rate}%`, - ]), - ], - result.title, - ); + const option = { + color: ["#668874", "#b5bdc4", "#a58b65"], + tooltip: { trigger: "axis", renderMode: "richText" }, + legend: { bottom: 0 }, + grid: { left: 45, right: 15, top: 20, bottom: 65 }, + xAxis: { + type: "category", + data: result.daily?.map((d) => d.date.slice(5)), + }, + yAxis: { type: "value", minInterval: 1 }, + series: (["delivered", "returned", "replaced"] as const).map((key, i) => ({ + name: ["交车(含替换)", "还车", "其中替换"][i], + type: "line", + data: result.daily?.map((d) => d[key]), + })), + animation: false, }; return ( -
+

{result.summary}

+ {result.contextNote && ( +

{result.contextNote}

+ )} + {result.interpretation && ( +
+ 查看问题理解 +

{result.interpretation}

+
+ )} {result.warning && ( -

+

{result.warning}

)} - {result.plan.kind !== "clarify" && ( + {result.gap && ( +

+ {result.gap.recorded + ? "这个需求已记下,可在「待完善需求」中查看。" + : "可以点击下方「未解决我的问题」记录需求。"} +

+ )} + {plan.kind !== "clarify" && ( <> +
真实业务数据 {result.source} + {result.engine === "local" && 本地查询结果}
@@ -268,196 +286,418 @@ export function ResultView({

{result.title}

- {result.metrics.map((metric) => ( -
- {metric.label} + {result.metrics.map((m) => ( +
+ {m.label} - {metric.value.toLocaleString()} - {metric.unit} + {m.value.toLocaleString()} + {m.unit}
))}
-
- {drill !== null ? ( - - ) : ( -
- {( - [ - { id: "chart", name: "图表", icon: BarChart3 }, - { id: "table", name: "统计表", icon: Table2 }, - { id: "list", name: "明细", icon: List }, - ] as const - ).map((tab) => ( - - ))} -
+
+ 调整维度与分析方式 +
+ {!flow && ( + <> + + + + + )} + {flow && ( + + )} +
+
+
+ {CATEGORY_LABELS[plan.category]} + {plan.exception && ( + + {plan.exception === "overdue" + ? "已超过预计完成时间" + : plan.exception === "dueSoon" + ? "即将到期" + : "未填写预计完成时间"} + )} - - {flow - ? `${result.plan.start} — ${result.plan.end}` - : "单位:台"} + {Object.entries(plan.filters).map(([d, v]) => ( + + {GROUP_LABELS[d as Dimension]}:{v.join("、")} + + ))} + {Object.entries(plan.excludeFilters || {}).map( + ([dimension, values]) => ( + + 排除{GROUP_LABELS[dimension as Dimension]}: + {values.join("、")} + + ), + )} + {!!plan.vehicleIds?.length && ( + 已选 {plan.vehicleIds.length} 台车辆 + )} + {plan.topN && ( + 展示前 {plan.topN} 组 · 总计与明细包含全部匹配车辆 + )} + {flow && ( + + {plan.start} — {plan.end} + + )} +
+ {!!result.insights?.length && ( +
    + {result.insights.map((text) => ( +
  • {text}
  • + ))} +
+ )} + {!flow && ( +
+ {result.exceptions?.map((e) => ( + + ))} +
+ )} + {result.groupComparison && ( +
+

+ 当前分组对比 · 数量最多的分组为基准: + {result.groupComparison.baseline} +

+ + + + {[ + "分组", + "车辆数", + "较基准差值", + "占比", + "运营率差(百分点)", + ].map((h) => ( + + ))} + + + + {result.groupComparison.rows.map((r) => ( + + + + + + + + ))} + +
{h}
{r.name}{r.count}{r.delta}{r.share}%{r.rateDelta}
+
+ )} + {result.comparison && ( +
+

+ 上一等长周期:{result.comparisonPeriod?.start} —{" "} + {result.comparisonPeriod?.end} +

+ + + + {["指标", "本期", "上期", "差值", "变化率"].map((h) => ( + + ))} + + + + {result.comparison.map((m) => ( + + + + + + + + ))} + +
{h}
{m.label}{m.current}{m.previous} + {m.delta > 0 ? "+" : ""} + {m.delta} + + {m.rate === null ? "基期为0,不计算" : `${m.rate}%`} +
+
+ )} +
+
+ {(["chart", "table", "list"] as const).map((v, i) => ( + + ))} +
+ + {result.rows.length} + {flow ? "笔业务" : "台车辆"}
- {drill !== null || view === "list" ? ( - - ) : view === "chart" && flow ? ( -
- -
- {result.groups - .filter((g) => g.count) - .map((g) => ( - - ))} + {plan.view === "list" ? ( + + ) : flow ? ( + <> + {plan.view === "chart" && ( +
+ +
+ )} +
+ + + + {[ + "日期", + "交车(含替换)", + "还车", + "其中替换", + "查看", + ].map((h) => ( + + ))} + + + + {result.daily?.map((d) => ( + + + + + + + + ))} + +
{h}
{d.date}{d.delivered}{d.returned}{d.replaced} + +
-
- ) : view === "chart" ? ( + + ) : plan.view === "chart" ? (
{(showAll ? result.groups : result.groups.slice(0, 8)).map( - (group, i) => ( + (g) => ( ), )} - {!result.groups.length && ( -
- 当前筛选范围没有车辆,可调整条件后重试。 -
- )} {result.groups.length > 8 && ( )} -
- 按数量降序排列 · 点击任意分组查看明细 -
+

+ 点击分组继续下钻 · 筛选条件自动用于下一次追问 +

) : (
- {(flow - ? ["日期", "交车(含替换)", "还车", "其中替换", "明细"] - : inventory - ? [ - GROUP_LABELS[result.plan.groupBy], - "车辆数", - "占比", - "明细", - ] - : [ - GROUP_LABELS[result.plan.groupBy], - "车辆数", - "运营数", - "运营率", - "明细", - ] - ).map((h) => ( + {[ + "分组", + "车辆数", + "占比", + "运营率", + "异动", + "待交车", + "下钻", + ].map((h) => ( ))} - {flow - ? result.daily?.map((d) => ( - - - - - - - - )) - : result.groups.map((g) => ( - - - - {inventory ? ( - - ) : ( - <> - - - - )} - - - ))} + {result.groups.map((g) => ( + + + + + + + + + + ))}
{h}
{d.date}{d.delivered}{d.returned}{d.replaced} - -
{g.name}{g.count} - {total - ? ((g.count / total) * 100).toFixed(1) - : 0} - % - {g.operating}{g.rate}% - -
{g.name}{g.count}{g.share}%{g.rate}%{g.abnormal}{g.pending} + +
- {!result.groups.length && ( -
暂无符合条件的数据
- )}
)} + {!result.rows.length && ( +

+ 当前条件没有匹配数据,可返回上层调整。 +

+ )}
查询于{" "} @@ -465,44 +705,50 @@ export function ResultView({ hour12: false, })} - 口径与权限已应用 + 每次操作重新查询,数据可能更新
-
- - {copyError && ( - 复制失败,请手动选择文字复制。 - )} - AI 解读供参考,请以统计表和明细为准。 -
-
- 继续探索 - {(flow - ? ["改成表格", "显示车辆明细"] - : ["再按车型拆分", "只看可运营库存", "改成表格"] - ).map((q) => ( - - ))} -
)} +
+ {plan.kind === "clarify" ? "你可以这样继续" : "继续分析"} + {suggestions.slice(0, 4).map((question) => ( + + ))} +
+
+ {plan.kind !== "clarify" && ( + + )} + + +
+ {notice && ( +

+ {notice} +

+ )}
); } diff --git a/src/modules/assets/assistant/ThinkingState.tsx b/src/modules/assets/assistant/ThinkingState.tsx index 9a51fff..07d288c 100644 --- a/src/modules/assets/assistant/ThinkingState.tsx +++ b/src/modules/assets/assistant/ThinkingState.tsx @@ -23,7 +23,7 @@ export function ThinkingState({ const timer = window.setInterval(update, 100); return () => window.clearInterval(timer); }, [working, started]); - const expanded = manualExpanded ?? working; + const expanded = manualExpanded ?? false; return (