From dc0923b5663784bafd8e237c7dd4dd7ebc5dc209 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 17:52:33 +0800 Subject: [PATCH] feat(asset-ai): generate contextual presentations and drilldown plans --- docs/asset-ai-boss-acceptance.md | 67 + docs/asset-ai-composition.md | 30 + src/modules/assets/AssetsModule.tsx | 49 +- .../assets/assistant/AssetAssistant.tsx | 1194 +++++++++------- .../assets/assistant/CompoundResultView.tsx | 302 ++++ .../assets/assistant/GenerativeResultView.tsx | 682 +++++++++ src/modules/assets/assistant/ResultView.tsx | 1271 ++++++++++------- .../assets/assistant/ThinkingState.tsx | 24 +- src/modules/assets/assistant/assistant.css | 442 +++++- .../conversation-presentation.test.ts | 60 + .../assistant/conversation-presentation.ts | 55 + .../assistant/generative-presentation.test.ts | 253 ++++ .../assistant/generative-presentation.ts | 129 ++ .../assistant/result-presentation.test.ts | 219 +++ .../assets/assistant/result-presentation.ts | 194 +++ .../asset-assistant/boss-experience.test.ts | 258 ++++ .../asset-assistant/composition.test.ts | 264 ++++ .../routes/asset-assistant/composition.ts | 40 + src/server/routes/asset-assistant/execute.ts | 5 + .../routes/asset-assistant/experience.test.ts | 14 +- .../asset-assistant/generated-action.ts | 36 + .../asset-assistant/generative-route.test.ts | 238 +++ .../routes/asset-assistant/generative.test.ts | 259 ++++ .../routes/asset-assistant/generative.ts | 228 +++ src/server/routes/asset-assistant/index.ts | 121 +- .../routes/asset-assistant/model.test.ts | 12 +- src/server/routes/asset-assistant/model.ts | 46 +- .../routes/asset-assistant/planner.test.ts | 323 +++++ src/server/routes/asset-assistant/planner.ts | 387 ++++- .../asset-assistant/presentation.test.ts | 25 + .../routes/asset-assistant/presentation.ts | 55 + src/server/routes/asset-assistant/session.ts | 7 +- src/shared/assets/assistant.ts | 69 +- 33 files changed, 6193 insertions(+), 1165 deletions(-) create mode 100644 docs/asset-ai-boss-acceptance.md create mode 100644 docs/asset-ai-composition.md create mode 100644 src/modules/assets/assistant/CompoundResultView.tsx create mode 100644 src/modules/assets/assistant/GenerativeResultView.tsx create mode 100644 src/modules/assets/assistant/conversation-presentation.test.ts create mode 100644 src/modules/assets/assistant/conversation-presentation.ts create mode 100644 src/modules/assets/assistant/generative-presentation.test.ts create mode 100644 src/modules/assets/assistant/generative-presentation.ts create mode 100644 src/modules/assets/assistant/result-presentation.test.ts create mode 100644 src/modules/assets/assistant/result-presentation.ts create mode 100644 src/server/routes/asset-assistant/boss-experience.test.ts create mode 100644 src/server/routes/asset-assistant/composition.test.ts create mode 100644 src/server/routes/asset-assistant/composition.ts create mode 100644 src/server/routes/asset-assistant/generated-action.ts create mode 100644 src/server/routes/asset-assistant/generative-route.test.ts create mode 100644 src/server/routes/asset-assistant/generative.test.ts create mode 100644 src/server/routes/asset-assistant/generative.ts create mode 100644 src/server/routes/asset-assistant/presentation.test.ts create mode 100644 src/server/routes/asset-assistant/presentation.ts diff --git a/docs/asset-ai-boss-acceptance.md b/docs/asset-ai-boss-acceptance.md new file mode 100644 index 0000000..d055cbe --- /dev/null +++ b/docs/asset-ai-boss-acceptance.md @@ -0,0 +1,67 @@ +# 老板视角问数验收 + +## 来源与判定原则 + +根据用户提供的《AI资产助手-老板视角实测-20260923.md》完整复测设计。原测试于 2026-09-23 16:11—16:14 在 beta 环境执行。它确认了一次会话的前端表现,不代表数据库审计或权限验收。 + +本轮优先纠正三个产品问题:待交车不能被待运营替代;复合问题先返回已支持部分;每张结果带足以独立理解的范围与口径。自动化测试使用合成数据和模拟模型,没有访问真实数据库或真实 DeepSeek API。部署后的实机复测必须另行记录,不能用模拟通过替代。 + +## 原始十问与目标 + +| 题号 | 原始输入 | 修复完成判定 | +| --- | --- | --- | +| 01 | 我是公司负责人。请用当前数据给我一份资产经营简报:资产总数、运营和库存各多少、运营率多少;哪个车型库存最多,最值得我优先关注?请给数字依据、数据截至时间和统计口径。 | 返回四个总量指标及库存车型集中度;重点关注是基于库存数量的建议,不编造滞销原因或损失。源更新时间缺失时明确未提供。 | +| 02 | 现在总共有多少台车?运营多少、库存多少,整体运营率是多少? | 四项都在结果指标中;数值由内部计算。 | +| 03 | 按车型看库存,按库存数量从多到少排序,告诉我库存最多的车型占总库存多少。 | 库存降序、占完整库存比例;说明吨位粒度,4.5T 包含普货与冷链。 | +| 04 | 这些4.5T库存主要在哪些区域?只看可运营的,按数量降序。 | 保留 4.5T、可运营、区域分组、降序;单独复制结果仍可识别完整范围。 | +| 05 | 换个问题:最近7天交车、还车分别多少?其中替换交车多少?比前7天是改善还是变差?请写明起止日期和统计口径。 | 清除旧 4.5T 和库存限制,返回最近七天与前七天数字、日期;改善方向缺少业务目标时仅澄清这一部分。不得把交车减还车直接认定为运营车辆净增。 | +| 06 | 全部车辆,不沿用之前4.5T或库存筛选。查询2026年9月17日至9月23日交车、还车、替换交车数量,按天列出。 | 日期完整保留、旧筛选清除;交车含替换且不重复相加,单位为车次。 | +| 07 | 当前库存比上个月月底增加了多少?这些闲置车每个月损失多少钱?没有相应数据请直接说明,不要估算。 | 说明缺历史库存快照与成本数据,无估算;不能回退到当前库存并假装已经回答历史变化。 | +| 08 | 现在全部公司的待交车有多少?列出待交车清单。 | 当前账号可见范围内待交车真实数量和明细;0 展示为空结果,不展示失败或停止。 | +| 09 | 查询待交车数量 | 与第 08 题同口径;即使模型误提议 awaiting,也不能执行待运营替代。 | +| 10 | 我问的是“待交车”,不是“待运营”。请区分这两个业务状态,重新查询待交车数量;如果不支持,请明确说明,不能用待运营数量替代。 | 重新查询待交车;若历史结果确实答错,明确指出该结果不适用于待交车判断并关联已纠正的结果。 | + +## 合成测试基线 + +10 台资产,4 台运营,6 台库存,运营率 40%。待运营 3 台,待交车 0 台,故意让两者不同,避免状态替代错误被相同数量掩盖。 + +库存:4.5T 普货 1 台、4.5T 冷链 3 台、18T 1 台、49T 1 台。4.5T 合并为 4 台,占库存 66.7%。其中 4.5T 可运营 2 台,均位于华东。 + +流转:每个请求期间返回合成交车 1 笔、替换 1 笔、还车 1 笔;交车含替换合计 2 车次,总事件 3 笔。用于验证日期、筛选、计数与跨期调用,并不代表业务趋势。 + +## 自动化与实机分工 + +`src/server/routes/asset-assistant/boss-experience.test.ts` 校验业务计划实际执行、错误模型状态纠正、复合部分回答、完整十问本地降级链路、结果口径、4.5T 进一步分类和错误答案撤回关联。 + +2026-09-23 开发验收:以上 9 项路由集成场景全部通过,`npm run lint` 通过。执行命令为 `node --import tsx --test src/server/routes/asset-assistant/boss-experience.test.ts`。其中另外加入“真实待交 1 台但模型残留待运营过滤”的反例,验证不会产生假零结果。此处只记录本地自动化结论,不代表 beta 环境已经部署或真实模型十问已经全部通过。 + +实机需重点补验: + +1. 使用原始十问直接询问真实模型,保留每题结果和实际查询计划。 +2. 验证待交车推荐入口、短问、长问、多轮纠正与首页同口径。 +3. 分别触发空结果、能力缺口、服务异常、主动停止,检查标题和操作不同。 +4. 复制第 04 题结果检查公司、权限、4.5T、可运营、分组及两种时间说明。 +5. 库存车型结果不再推荐同样的车型分组;冷链/普货入口可正常执行。 +6. 连续十轮后能找到每条提问,新答案顶部的结论和指标可见;桌面短高度和手机都要测试。 + +## 独立审阅关注点 + +- 待交车是业务流程状态;待运营是运营状态。确定性纠正既要改 category,也要清掉旧的状态过滤与错误 unsupported 原因,否则仍可能得到假零或错标题。 +- 综合简报的库存集中度必须在库存子集上计算,不能直接用全部资产按车型排序替代“库存最多”。 +- 部分回答的未完成事项应单独列出并进入需求记录,不能把“经营好坏需要目标”误报为“交退换查询不支持”。 +- 纠正旧答案需能定位原结果。正常把待运营查询切换为待交车时,不应无依据地声称先前答案错误。 +- “查询于”是生成时间;没有源刷新证据时 `sourceUpdatedAt` 应为空并以文字说明,不能填当前时间。 +- 普货/冷链分组必须复用首页分类,分组汇总与 4.5T 合计可核对。 + +## 最终开发验证(2026-09-23) + +- AI 专项:服务端 assistant 与前端 assistant 共 89 项测试通过,包含原十问、错误计划、真实非零待交车、部分回答、复制范围以及分组示例不误变筛选等反例。 +- 类型检查、生产构建、`git diff --check` 通过。构建仍有既有的大体积 chunk 提示。 +- 真实 DeepSeek 验证仅提供合成车辆目录与合成汇总:原十问初跑 8 次模型成功、Q1/Q5 本地降级。定位到模型省略展示字段的问题后,Q1 与 Q5 单独复测均由 DeepSeek 正常规划;这不是“一次连续十题全部模型成功”的证据,也不是生产数据验收。 +- 浏览器使用真实前端与真实问答路由、合成车辆和内存需求记录:完成原十问连续交互;10 条历史可回看,新回答定位到结论和指标;第 4 问复制包含区域数字、4.5T、可运营、公司、权限、口径及两种时间说明;浏览器无控制台错误。 +- 390 × 844 手机视口与桌面视口检查:经营简报四项指标与输入框可见;部分回答、库存明细入口、独立待交车统计可正常显示。最后单独确认 Q7 分别列出缺历史快照、缺成本数据,并记录未完成需求。 +- 尚未提交、部署。生产数据及实际部署后的交互须在发布后复测。 + +全量测试另有一项车辆路由声明顺序断言失败:并行工作将 `/list` 从 `app.get` 改为 `app.on(['GET', 'POST'], ...)`,原测试仍仅扫描 `app.get`。它不属于本轮 AI 修改,未覆盖或撤销该并行工作;AI 专项 89 项均通过。 + +本次改动与验证属于该项目的业务语义和展示优化,未形成跨项目知识候选,未修改全局开发规则。 diff --git a/docs/asset-ai-composition.md b/docs/asset-ai-composition.md new file mode 100644 index 0000000..8179737 --- /dev/null +++ b/docs/asset-ai-composition.md @@ -0,0 +1,30 @@ +# 资产 AI 自主组合分析 + +## 行为 + +问数分两阶段:先规划并查询,再把已计算的结果汇总交给 DeepSeek 设计展示和下一步交互。简单问题通常一次查询规划和一次展示规划;多项结果共用一次展示规划调用。复合问题由 DeepSeek 在一次 JSON 规划中拆成最多四项不同的查询,并选择指标卡、图表、统计表或明细;数据由既有后台查询和计算产生。模型不会生成可执行的 SQL、HTML 或 JavaScript。 + +例如“华东的4.5T库存有多少?待交车占库存多少?按城市展示库存分布,并列出哪些异动车辆即将到期”,可组合库存指标、城市库存图和即将到期异动明细。待交车、异动已包含在库存内,占比的分母是完整筛选范围的库存,不受图表 Top N 截断影响。零分母时明确不计算占比。 + +AI 根据问题与实际结果,自主决定信息块顺序、重点指标、柱图/环图/折线、表格列和下钻目标。多项分析可由 AI 选择分页切换或连续排列;前端保持 Web/H5 的紧凑尺寸与统一操作区。切换项目同时切换追问范围;点击分组原位更新所属项目,其他项目保留。每项均有自己的服务端会话结果 ID,可独立下钻、返回、导出和反馈。复制整份回答包含所有项目及其统计范围与口径。 + +无法生成有效计划的部分列入“尚未回答”;数据源执行失败的项目显示错误和单独重试入口。健康项目照常返回。缺口沿用原有需求记录存储,保存失败会如实提示。该记录用于产品需求改进,不会自动训练或修改模型。 + +## 协议与维护 + +规划层沿用单项 `plan`,可选 `queries: [{id,title,question,plan,display}]`;每项独立规范化和验证,避免整句的一个业务状态覆盖全部项目。普通单项响应维持兼容格式。 + +结果层增加 `sections: [{id,title,question,display,result?,error?}]`。每个成功的 `result` 仍使用既有结果协议。父分析和子分析均保留各项汇总上下文 `analyses`,供“第二项”“这些待交车”等追问选择对应分析。AI 下钻入口按服务端会话保存的目标查询执行,并绑定实际点击的分组或日期。每次下钻完成后重新设计展示和下一步入口;不再走固定的维度遍历顺序。 + +所有项目使用同一轮授权后的车辆快照。交还车事件仍按既有数据源查询;不同数据源不承诺事务级同时快照。导出会按当前权限重新查询,数据可能随业务更新变化。模型只选择现有数据与计算能力;财务损失、历史库存等未接入能力仍不能生成真实结论。 + +展示协议为 `presentation: {title, blocks, interactions}`。`blocks` 是有序的语义信息块,可配置 summary、narrative、metrics、chart、table;指标、列和图形序列绑定已有结果字段,事实数字由后台数据提供。叙述支持 `{{metric:指标名称}}` 引用。`interactions` 保存 AI 生成的目标查询和操作名称,客户端只提交入口 ID 与点击的数据点,后台再按当前权限查询。 + +AI 展示规划不可用时,保留查询结果并回退基础展示,明确提示回退。停止生成会取消后续展示规划。展示定义本身不会直接执行浏览器脚本,也不会生成数据库查询语句。 + +## 验证 + +- `src/server/routes/asset-assistant/composition.test.ts`:复合查询的授权范围、首项/次项故障隔离、单次车辆快照、跨项上下文、子项下钻、无效计划部分降级、占比分母和切换区域。 +- `src/server/routes/asset-assistant/planner.test.ts`:自主规划协议、独立业务语义、展示回退及过滤条件继承。 +- `src/modules/assets/assistant/conversation-presentation.test.ts`:子项原位替换、保留其他项目、历史范围恢复。 +- 已对真实 DeepSeek 接口使用合成车辆做单次复合规划验证,未使用生产车辆数据。 diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 83b8c70..14f37b2 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -55,8 +55,40 @@ import { OverviewView } from './components/OverviewView'; const AssetAssistant = lazy(() => import('./assistant/AssetAssistant')); export default function AssetsModule() { - const [assistantOpen, setAssistantOpen] = useState(false); + const [assistantOpen, setAssistantOpen] = useState(() => window.location.hash === '#assets/assistant'); const assistantTrigger = useRef(null); + useEffect(() => { + const syncAssistantRoute = () => setAssistantOpen(window.location.hash === '#assets/assistant'); + window.addEventListener('hashchange', syncAssistantRoute); + window.addEventListener('popstate', syncAssistantRoute); + return () => { + window.removeEventListener('hashchange', syncAssistantRoute); + window.removeEventListener('popstate', syncAssistantRoute); + }; + }, []); + useEffect(() => { + if (!assistantOpen) return; + const previousTitle = document.title; + document.title = 'AI 问数 - 资产BI'; + return () => { document.title = previousTitle; }; + }, [assistantOpen]); + const openAssistant = () => { + if (window.location.hash !== '#assets/assistant') { + const { pathname, search } = window.location; + window.history.pushState({ ...window.history.state, assetAssistantPage: true }, '', `${pathname}${search}#assets/assistant`); + } + setAssistantOpen(true); + }; + const closeAssistant = () => { + if (window.history.state?.assetAssistantPage && window.location.hash === '#assets/assistant') { + window.history.back(); + } else { + const { pathname, search } = window.location; + window.history.replaceState(window.history.state, '', `${pathname}${search}#assets`); + setAssistantOpen(false); + } + requestAnimationFrame(() => requestAnimationFrame(() => assistantTrigger.current?.focus())); + }; const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview'); const [tabReady, setTabReady] = useState(true); const prevTabRef = useRef(activeTab); @@ -431,6 +463,14 @@ export default function AssetsModule() { [customerData, customerChartView, customerProvinceData], ); + if (assistantOpen) { + return ( + 正在加载 AI 问数页面…}> + + + ); + } + if (loading && !summary) { return ( @@ -1710,16 +1750,13 @@ export default function AssetsModule() { - {assistantOpen && 正在打开 AI 问数…}> { setAssistantOpen(false); requestAnimationFrame(() => assistantTrigger.current?.focus()); }} />} ); } diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx index 943cb15..f94abb5 100644 --- a/src/modules/assets/assistant/AssetAssistant.tsx +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { ArrowUp, ArrowUpRight, @@ -17,13 +17,8 @@ import { Sun, Truck, X, + ArrowLeft, } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, -} from "../../../components/ui/dialog"; import type { AssistantResult, AssistantAction, @@ -37,7 +32,8 @@ import { askAssetAssistant, AssistantRequestError } from "./client"; import { fetchAuthenticated } from "../../../auth/api-client"; import { DemandGaps } from "./DemandGaps"; import { ThinkingState } from "./ThinkingState"; -import { ResultView } from "./ResultView"; +import { CompoundResultView } from "./CompoundResultView"; +import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation"; import "./assistant.css"; type Turn = { @@ -46,6 +42,9 @@ type Turn = { started: number; steps: string[]; result?: AssistantResult; + resultHistory?: AssistantResult[]; + updating?: boolean; + updateError?: string; error?: string; expired?: boolean; stopped?: boolean; @@ -55,26 +54,26 @@ type Turn = { const PROMPTS = [ { icon: BarChart3, - title: "库存主要集中在哪里?", - text: "各区域的库存车辆有多少?按数量排序。", - detail: "看分布,再逐层查看车辆", + title: "现在多少车在运营、多少车在库存?", + text: BUSINESS_QUESTIONS[0], + detail: "总资产 · 运营 · 库存 · 运营率", }, { icon: Truck, - title: "哪些车型运营率较低?", - text: "按车型对比当前资产数量和运营率。", - detail: "车型构成 · 运营率", + title: "库存集中在哪些车型和区域?", + text: BUSINESS_QUESTIONS[1], + detail: "库存分布 · 继续查看车辆", }, { icon: History, - title: "本月车辆流转怎么样?", - text: "本月的交车、还车和替换交车趋势怎么样?", + title: "最近7天交车、还车分别多少?", + text: BUSINESS_QUESTIONS[2], detail: "按日趋势 · 业务明细", }, { icon: ListIcon, title: "有哪些车辆需要跟进?", - text: "查看当前异动车辆,显示异动类型和预计完成时间。", + text: BUSINESS_QUESTIONS[3], detail: "异动详情 · 预计完成时间", }, ]; @@ -93,6 +92,7 @@ export default function AssetAssistant({ }) { const [theme, setTheme] = useState(appearance); const [turns, setTurns] = useState([]); + const turnsRef = useRef(turns); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [expandedTurns, setExpandedTurns] = useState>( @@ -114,18 +114,66 @@ export default function AssetAssistant({ const nextId = useRef(0); const composer = useRef(null); const scroller = useRef(null); + const autoFollow = useRef(null); + const readerRevision = useRef(0); + const scrollFrame = useRef(null); + const [viewedTurn, setViewedTurn] = useState(); + useLayoutEffect(() => { + const input = composer.current; + if (!input) return; + input.style.height = "0px"; + input.style.height = `${Math.min(132, Math.max(42, input.scrollHeight))}px`; + }, [draft]); + useEffect(() => () => { + if (scrollFrame.current !== null) cancelAnimationFrame(scrollFrame.current); + }, []); + const scrollToTurn = (id: number, resultId?: string) => { + const container = scroller.current; + const target = (resultId ? document.getElementById(`aq-query-${resultId}`) || document.getElementById(`aq-answer-${resultId}`) : undefined) || document.getElementById(`aq-turn-${id}`); + if (!container || !target) return; + const anchor = resultId ? target.closest(".aq-result") || target : target; + container.scrollTo({ top: container.scrollTop + anchor.getBoundingClientRect().top - container.getBoundingClientRect().top - 12, behavior: "instant" }); + setViewedTurn(id); + }; + const trackViewedTurn = () => { + if (scrollFrame.current !== null) return; + scrollFrame.current = requestAnimationFrame(() => { + scrollFrame.current = null; + const container = scroller.current; + if (!container) return; + const top = container.getBoundingClientRect().top + 24; + const visible = turnsRef.current.find(turn => { + const el = document.getElementById(`aq-turn-${turn.id}`); + return el && el.getBoundingClientRect().bottom > top; + }); + if (visible) setViewedTurn(visible.id); + }); + }; + useEffect(() => { + turnsRef.current = turns; + }, [turns]); const scopeResult = activeResult?.plan.kind === "clarify" ? activeResult.breadcrumbs ?.map( (crumb) => - turns.find((turn) => turn.result?.resultId === crumb.resultId) - ?.result, + findConversationResult(turns, crumb.resultId), ) .filter((result) => result && result.plan.kind !== "clarify") .at(-1) : activeResult; const lastPlan = scopeResult?.plan; + const correctedBy = useMemo(() => { + const corrections = new Map(); + for (const turn of turns) { + const previousId = turn.result?.correction ? turn.result.correction.resultId || turn.baseResultId : undefined; + if (previousId) corrections.set(previousId, turn); + } + return corrections; + }, [turns]); + useEffect(() => { + composer.current?.focus({ preventScroll: true }); + }, []); useEffect(() => { const controller = new AbortController(); void fetchAuthenticated("/api/asset-assistant/capabilities", { @@ -143,13 +191,12 @@ export default function AssetAssistant({ const revealTurn = (id: number) => { setShowGaps(false); setExpandedTurns((old) => new Set(old).add(id)); - requestAnimationFrame(() => - document - .getElementById(`aq-turn-${id}`) - ?.scrollIntoView({ block: "start", behavior: "smooth" }), - ); + autoFollow.current = null; + setViewedTurn(id); + requestAnimationFrame(() => scrollToTurn(id, turnsRef.current.find(turn => turn.id === id)?.result?.resultId)); }; const startTopic = () => { + autoFollow.current = null; setActiveResult(undefined); sessionRef.current = undefined; setIndependent(true); @@ -167,13 +214,14 @@ export default function AssetAssistant({ [], ); const stop = () => { + autoFollow.current = null; abortRef.current?.abort(); abortRef.current = null; setBusy(false); setTurns((previous) => previous.map((t) => - !t.result && !t.error - ? { ...t, error: "已停止。可以修改问题,或继续生成。", stopped: true } + t.updating ? { ...t, updating: false, updateError: "已停止更新,保留原结果。" } : !t.result && !t.error + ? { ...t, error: "已停止生成。可以修改问题,或重新生成。", stopped: true } : t, ), ); @@ -186,12 +234,18 @@ export default function AssetAssistant({ if (!message.trim() || abortRef.current) return; setShowGaps(false); setIndependent(false); - const id = ++nextId.current; + const inlineTurn = action && base?.resultId + ? turnsRef.current.find(turn => !!findConversationResult([turn], base.resultId!)) + : undefined; + const id = inlineTurn?.id ?? ++nextId.current; + autoFollow.current = inlineTurn ? null : id; + if (!inlineTurn) setViewedTurn(id); const controller = new AbortController(); abortRef.current = controller; - setDraft(""); + if (!inlineTurn) setDraft(""); setBusy(true); - setTurns((old) => [ + if (inlineTurn) setTurns(old => old.map(turn => turn.id === id ? { ...turn, updating: true, updateError: undefined } : turn)); + else setTurns((old) => [ ...old, { id, @@ -202,11 +256,7 @@ export default function AssetAssistant({ steps: ["正在理解你的问题"], }, ]); - requestAnimationFrame(() => - document - .getElementById(`aq-turn-${id}`) - ?.scrollIntoView({ block: "start", behavior: "smooth" }), - ); + requestAnimationFrame(() => { if (autoFollow.current === id) scrollToTurn(id); }); try { await askAssetAssistant( { @@ -219,19 +269,21 @@ export default function AssetAssistant({ (event) => { if (controller.signal.aborted) return; 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" }), - ); + if (inlineTurn) { + const container = scroller.current; + const anchor = (base?.resultId ? document.getElementById(`aq-query-${base.resultId}`)?.closest(".aq-result") : undefined) || document.getElementById(`aq-turn-${id}`); + const top = anchor?.getBoundingClientRect().top; + const revision = readerRevision.current; + requestAnimationFrame(() => { + if (container && anchor && top !== undefined && readerRevision.current === revision) { + container.scrollTop += anchor.getBoundingClientRect().top - top; + } + }); + } + if (autoFollow.current === id) + requestAnimationFrame(() => requestAnimationFrame(() => { + if (autoFollow.current === id) scrollToTurn(id, event.result.resultId); + })); sessionRef.current = event.result.sessionId; if (event.result.sessionId) sessions.current.set(event.result.sessionId, subject); @@ -244,9 +296,9 @@ export default function AssetAssistant({ : event.type === "step" ? { ...turn, steps: [...turn.steps, event.label] } : event.type === "error" - ? { ...turn, error: event.message } + ? inlineTurn ? { ...turn, updating: false, updateError: event.message } : { ...turn, error: event.message } : event.type === "result" - ? { ...turn, result: event.result } + ? { ...replaceAnalysisResult(turn, event.result, action ? base?.resultId : undefined), updating: false, updateError: undefined } : turn, ), ); @@ -257,7 +309,7 @@ export default function AssetAssistant({ setTurns((old) => old.map((t) => t.id === id - ? { + ? inlineTurn ? { ...t, updating: false, updateError: error instanceof Error ? error.message : "更新失败,请重试。" } : { ...t, expired: error instanceof AssistantRequestError && @@ -274,11 +326,13 @@ export default function AssetAssistant({ if (abortRef.current === controller) { abortRef.current = null; setBusy(false); - composer.current?.focus({ preventScroll: true }); + if (autoFollow.current === id) composer.current?.focus({ preventScroll: true }); } } }; const reset = () => { + autoFollow.current = null; + setViewedTurn(undefined); abortRef.current?.abort(); abortRef.current = null; setBusy(false); @@ -300,375 +354,432 @@ export default function AssetAssistant({ reset(); }, [subject]); return ( - { - if (!open) close(); - }} +
- { - e.preventDefault(); - composer.current?.focus(); - }} - > - 资产 AI 问数 - - 通过自然语言查询当前账号可见的资产数据,支持图表、表格和明细下钻。 - - -
-
-
- - AI 问数 - - 你的资产分析助手 + )} +
{ autoFollow.current = null; readerRevision.current++; }} + onTouchMove={() => { autoFollow.current = null; readerRevision.current++; }} + onPointerDown={() => { autoFollow.current = null; readerRevision.current++; }} + onKeyDown={(event) => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) { autoFollow.current = null; readerRevision.current++; } }}> + {showGaps ? ( + setShowGaps(false)} /> + ) : !turns.length ? ( +
+
+ +
+

关于资产,你想了解什么?

+

+ 用一句话,发现数据里的答案。 +
{" "} + 从全局统计,一路看到每一台车。 +

+
+ {PROMPTS.map((p) => ( + + ))} +
+
+ + 只读查询 · 真实数据 · 可追溯明细 +
-
- - - - - -
-
-
- - {subject || "全部所属公司"} - · - 当前账号可见范围 - - - 已连接业务数据 - -
- {showInfo && ( -
- 支持的分析 -

- 当前资产数量、运营率、库存分布、交退换车趋势与车辆明细。可按区域、城市、车型、型号和部门筛选或分组。 -

-

- 资产口径:当前台账;运营率 = 运营车辆 / - 筛选车辆。库存包含异动和待交车。可连续追问、切换分组、对比周期并查看明细。暂无数据的历史库存、成本或预测,会说明原因并记录需求。 -

-

- {capability?.message || - (capabilityError - ? "暂时无法读取服务状态,可以直接提问重试。" - : "正在检查分析服务连接状态。")} -

+ ) : ( +
+ {turns.map((turn, index) => { + const correction = turn.result?.resultId + ? correctedBy.get(turn.result.resultId) + : undefined; + const collapsed = + index < turns.length - 1 && + !!turn.result && + !expandedTurns.has(turn.id); + return ( +
+
+ 你 +

{turn.question}

+
+ {collapsed ? ( + + ) : ( + <> +
+ + + + 资产助手 + + {(turn.result?.engine ?? capability?.engine) === + "deepseek" + ? "DeepSeek" + : (turn.result?.engine ?? capability?.engine) === + "local" + ? "本地分析" + : "连接中"} + + {index < turns.length - 1 && turn.result && ( + + )} +
+ + {turn.updating &&

正在更新当前卡片…

} + {turn.updateError &&

{turn.updateError} 可再次点击分析操作重试。

} + {turn.error ? ( +
+ + {turn.stopped + ? "已停止生成" + : turn.expired + ? "会话已过期" + : "查询失败"} + +

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

+ +
+ ) : turn.result ? ( + void ask(text, base)} + operate={(action, label, base) => + void ask(label, base, action) + } + restore={(resultId) => { + const target = findConversationResult(turnsRef.current, resultId); + if (target) + void ask("返回此前分析", target, { + type: "restore", + }); + }} + active={ + activeResult?.resultId === turn.result.resultId + } + activeResultId={activeResult?.resultId} + correctionFor={(resultId) => { + const corrected = correctedBy.get(resultId)?.result; + return corrected ? { message: corrected.correction?.message, resultId: corrected.resultId } : undefined; + }} + activate={(base) => { + setActiveResult(base); + sessionRef.current = base.sessionId; + setIndependent(false); + }} + subject={subject} + disabled={busy || !!correction} + correctionMessage={ + correction?.result?.correction?.message + } + correctionResultId={correction?.result?.resultId} + showCorrection={(resultId) => { + const target = turnsRef.current.find( + (item) => item.result?.resultId === resultId, + ); + if (target?.result) { + setActiveResult(target.result); + sessionRef.current = target.result.sessionId; + setIndependent(false); + revealTurn(target.id); + } + }} + /> + ) : ( +
+ + + +
+ )} + + )} +
+ ); + })}
)} -
- {showGaps ? ( - setShowGaps(false)} /> - ) : !turns.length ? ( -
-
- -
-
- LESS SEARCHING. MORE UNDERSTANDING. -
-

关于资产,你想了解什么?

-

- 用一句话,发现数据里的答案。 -
{" "} - 从全局统计,一路看到每一台车。 -

-
- {PROMPTS.map((p) => ( - - ))} -
-
- - 只读查询 · 真实数据 · 可追溯明细 -
-
- ) : ( -
- {turns.map((turn, index) => { - const collapsed = - index < turns.length - 1 && - !!turn.result && - !expandedTurns.has(turn.id); - return ( -
-
- 你 -

{turn.question}

-
- {collapsed ? ( - - ) : ( - <> -
- - - - 资产助手 - - {(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 && ( -
- 正在分析 +
+
+ {turns.length > 0 && ( + + )} + {lastPlan && ( +
+ 正在分析 + + {lastPlan.kind === "flow" + ? "交退换车" + : CATEGORY_LABELS[lastPlan.category]} + + {GROUP_LABELS[lastPlan.groupBy]} + {lastPlan.start && ( - {lastPlan.kind === "flow" - ? "交退换车" - : CATEGORY_LABELS[lastPlan.category]} + {lastPlan.start} — {lastPlan.end} - {GROUP_LABELS[lastPlan.groupBy]} - {lastPlan.start && ( - - {lastPlan.start} — {lastPlan.end} - - )} - {Object.entries(lastPlan.filters).map(([dimension, values]) => ( + )} + {Object.entries(lastPlan.filters).map(([dimension, values]) => ( + + ))} + {Object.entries(lastPlan.excludeFilters || {}).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} + ), + )} + {!!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 ? ( -
- )} - {independent && ( -

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

- )} -
{ - e.preventDefault(); - void ask(draft); - }} - > - -