diff --git a/docs/asset-ai-demo.md b/docs/asset-ai-demo.md new file mode 100644 index 0000000..07fac16 --- /dev/null +++ b/docs/asset-ai-demo.md @@ -0,0 +1,41 @@ +# 资产 AI 问数 Demo + +入口:本地运行 `npm run dev` 后打开 `http://localhost:3000/asset#assets`,点击「AI 问数」。 + +## 已实现 + +- 采用 Beautiful UI Thinking / Prompt Bar / Chat 的视觉语言;Thinking 步骤改为后端 SSE 实际查询进度,保留 MIT 许可于 `docs/licenses/beautiful-ui.txt`。 +- DeepSeek Flash 将自然语言和上一轮查询计划转成受校验的结构化查询;后端复用资产仓储、权限过滤和业务分类;第二次模型调用只解释汇总数据。 +- 查询当前资产数量、库存、可运营/待运营、运营率、异动和待交车;按区域、城市、车型、型号、部门和运营状态分组。 +- 查询最多 93 天的交车、还车、替换趋势。交车包含替换;流转按业务车次计数,不以车辆去重。 +- 图表/统计表/明细切换、分组下钻、明细搜索及分页、CSV 导出、连续追问、取消、错误重试、新对话、深浅主题及移动端。 +- 浏览器 SpeechRecognition 语音输入:用户点击麦克风启动,转写后可编辑再发送。不支持或拒绝授权时提示文字输入。 + +## 本机配置 + +`DEEPSEEK_API_KEY` 仅位于 Git 忽略的 `.env`,后端读取。客户端构建不包含密钥。无需配置 `VITE_` 密钥。更换临时密钥后重启后端。 + +现有开发环境本身已开启本地免登录;本次未改变该开关。问答路由挂在应用的认证中间件之后,未认证直接调用被拒绝。部署环境必须沿用正常认证配置。 + +模型接收问题、维度目录(区域/城市/车型/型号)、已校验的上一轮计划和汇总统计;不会主动发送车牌、VIN、客户或车辆明细。用户在提问中自行输入的文字会发送给模型。业务数据没有伪造或演示替换。 + +Portainer / Docker 部署见 [DeepSeek 环境变量注入](./portainer-deepseek.md)。 + +## 口径和边界 + +- 当前公司从资产页面继承,变更公司请关闭面板后选择公司;服务端执行账号权限与公司范围过滤。 +- 资产来源为当前台账与现有服务端缓存,结果标记查询时间,不声称是历史库存快照。 +- 分组与明细使用同一次查询返回的数据;区域按现有省市映射。 +- 运营率 = 当前筛选车辆中的运营车辆 / 当前筛选车辆数。 +- 历史库存、金额、成本、预测等暂不支持,助手应说明能力范围或追问,不执行模型生成的 SQL。 +- 会话只保存在打开的面板内,刷新/关闭不会保存。语音依赖浏览器和其在线识别服务,尚未接专用 ASR。 + +## 验证 + +- TypeScript 检查、Vite 生产构建;构建仍有现有 ECharts/AG Grid 大块提示。 +- 自动验证个人权限与所属公司范围、库存子集计数、无认证拒绝、非法查询/日期拒绝,以及模型请求不包含车辆明细。 +- 桌面及手机浏览器实际查询:库存 291 台 → 追问可运营库存 181 台;华东库存 198 条明细与分页对应。 +- 2026-09-01 至 2026-09-23 流转:交车 63 车次、还车 69 车次,其中替换 19 车次;2026-09-22 下钻及 CSV 均为 17 条记录。 +- 深浅模式、无横向页面溢出、空明细搜索和结果展示切换已验证。真实麦克风识别未自动录制测试。 + +开发大脑回执:DB-C-003,知识索引版本 1 校验通过,未发现冲突;本次是产品 Demo 实现,未形成新的跨任务复盘模式,无知识写入或 GitHub 同步。 diff --git a/docs/licenses/beautiful-ui.txt b/docs/licenses/beautiful-ui.txt new file mode 100644 index 0000000..6635631 --- /dev/null +++ b/docs/licenses/beautiful-ui.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Shane Levine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/auth/api-client.ts b/src/auth/api-client.ts index a200605..4cee583 100644 --- a/src/auth/api-client.ts +++ b/src/auth/api-client.ts @@ -6,7 +6,7 @@ export function setTokenGetter(fn: () => string | null) { tokenGetter = fn; } -export async function fetchJson(url: string, options?: RequestInit): Promise { +export async function fetchAuthenticated(url: string, options?: RequestInit): Promise { const token = tokenGetter(); const res = await fetch(url, { ...options, @@ -19,6 +19,11 @@ export async function fetchJson(url: string, options?: RequestInit): Promise< window.dispatchEvent(new CustomEvent('auth:unauthorized')); throw new Error('Unauthorized'); } + return res; +} + +export async function fetchJson(url: string, options?: RequestInit): Promise { + const res = await fetchAuthenticated(url, options); if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); return res.json(); } diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 1eb71c1..3b6d125 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -1,10 +1,10 @@ import "./library/asset-ui.css"; import "./library/inventory-matrix.css"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Skeleton } from "@/components/ui/skeleton"; -import { ArrowUpRight, Moon, RefreshCw, Sun, Truck } from "lucide-react"; +import { ArrowUpRight, Moon, RefreshCw, Sparkles, Sun, Truck } from "lucide-react"; import { Picker, Failure, @@ -43,6 +43,7 @@ import { CustomerView, } from "./library/BusinessViews"; import { VehicleDetails } from "./library/VehicleDetails"; +const AssetAssistant = lazy(() => import('./assistant/AssetAssistant')); interface Data { summary: SummaryData; @@ -53,6 +54,8 @@ interface Data { inventory: RegionalInventoryStats[]; } export default function AssetsModule() { + const [assistantOpen, setAssistantOpen] = useState(false); + const assistantTrigger = useRef(null); const [appearance, setAppearance] = useState<"light" | "dark">(() => { const saved = window.localStorage.getItem("asset-appearance"); return saved === "light" || saved === "dark" @@ -202,6 +205,7 @@ export default function AssetsModule() { />
+ {updated ? `更新于 ${updated.slice(11, 16)} · 台账截至 17:00` @@ -306,6 +310,7 @@ export default function AssetsModule() { subject={subject} close={() => setSelection(null)} /> + {assistantOpen && 正在打开 AI 问数…
}> { setAssistantOpen(false); requestAnimationFrame(() => assistantTrigger.current?.focus()); }} />} ); } diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx new file mode 100644 index 0000000..072b80c --- /dev/null +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -0,0 +1,640 @@ +import { useEffect, useRef, useState } from "react"; +import { + ArrowUp, + ArrowUpRight, + AudioLines, + BarChart3, + Building2, + CircleHelp, + Database, + History, + MessageSquare, + Mic, + Moon, + Plus, + ShieldCheck, + Sparkles, + Square, + Sun, + Truck, + X, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "../../../components/ui/dialog"; +import type { + AssistantResult, + QueryPlan, +} from "../../../shared/assets/assistant"; +import { + CATEGORY_LABELS, + GROUP_LABELS, +} from "../../../shared/assets/assistant"; +import { askAssetAssistant } from "./client"; +import { ThinkingState } from "./ThinkingState"; +import { ResultView } from "./ResultView"; +import "./assistant.css"; + +type Turn = { + id: number; + question: string; + started: number; + 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; +}; +const PROMPTS = [ + { + icon: BarChart3, + title: "库存分布", + text: "各区域的库存车辆有多少?按数量排序。", + detail: "区域对比 · 一键下钻", + }, + { + icon: Truck, + title: "运营情况", + text: "按车型统计当前资产数量和运营率。", + detail: "车型构成 · 运营率", + }, + { + icon: History, + title: "交退换趋势", + text: "本月的交车、还车和替换交车趋势怎么样?", + detail: "按日趋势 · 业务明细", + }, + { + icon: ListIcon, + title: "待交车清单", + text: "列出当前待交车车辆明细。", + detail: "车辆列表 · 导出结果", + }, +]; +function ListIcon({ size }: { size?: number }) { + return ; +} + +export default function AssetAssistant({ + subject, + close, + appearance, +}: { + subject: string | null; + close: () => void; + appearance: "light" | "dark"; +}) { + const [theme, setTheme] = useState(appearance); + const [turns, setTurns] = useState([]); + const [draft, setDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [listening, setListening] = useState(false); + const [voiceNote, setVoiceNote] = useState(""); + const [showInfo, setShowInfo] = useState(false); + const abortRef = useRef(null); + const recognitionRef = useRef(null); + 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; + useEffect( + () => () => { + abortRef.current?.abort(); + recognitionRef.current?.abort(); + }, + [], + ); + const stop = () => { + abortRef.current?.abort(); + abortRef.current = null; + setBusy(false); + setTurns((previous) => + previous.map((t) => + !t.result && !t.error + ? { ...t, error: "已停止生成,可以修改问题后重试。" } + : t, + ), + ); + }; + const ask = async (message: string, previous = lastPlan) => { + if (!message.trim() || abortRef.current) return; + recognitionRef.current?.abort(); + setListening(false); + setVoiceNote(""); + const id = ++nextId.current; + const controller = new AbortController(); + abortRef.current = controller; + setDraft(""); + setBusy(true); + setTurns((old) => [ + ...old, + { + id, + question: message.trim(), + started: Date.now(), + steps: ["连接资产分析服务"], + }, + ]); + requestAnimationFrame(() => + scroller.current?.scrollTo({ + top: scroller.current.scrollHeight, + behavior: "instant", + }), + ); + try { + await askAssetAssistant( + { message, subject, previous }, + 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", + }), + ); + setTurns((old) => + old.map((turn) => + turn.id !== id + ? turn + : event.type === "step" + ? { ...turn, steps: [...turn.steps, event.label] } + : event.type === "error" + ? { ...turn, error: event.message } + : { ...turn, result: event.result }, + ), + ); + }, + ); + } catch (error) { + if (!controller.signal.aborted) + setTurns((old) => + old.map((t) => + t.id === id + ? { + ...t, + error: + error instanceof Error + ? error.message + : "查询失败,请重试。", + } + : t, + ), + ); + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + setBusy(false); + composer.current?.focus({ preventScroll: true }); + } + } + }; + 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(""); + setTurns([]); + setDraft(""); + composer.current?.focus(); + }; + return ( + { + if (!open) close(); + }} + > + { + e.preventDefault(); + composer.current?.focus(); + }} + > + 资产 AI 问数 + + 通过自然语言查询当前账号可见的资产数据,支持图表、表格和明细下钻。 + + +
+
+
+ + AI 问数 + + 你的资产分析助手 +
+
+ + + + +
+
+
+ + {subject || "全部所属公司"} + · + 当前账号可见范围 + + + 已连接业务数据 + +
+ {showInfo && ( +
+ 支持的分析 +

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

+

+ 资产口径:当前台账;运营率 = 运营车辆 / + 筛选车辆。库存包含异动和待交车。暂不支持历史库存、成本或预测。模型接收问题和汇总统计,车辆明细留在业务系统。语音由浏览器提供,可能使用其在线识别服务。 +

+
+ )} +
+ {!turns.length ? ( +
+
+ +
+
+ LESS SEARCHING. MORE UNDERSTANDING. +
+

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

+

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

+
+ {PROMPTS.map((p) => ( + + ))} +
+
+ + 只读查询 · 真实数据 · 可追溯明细 +
+
+ ) : ( +
+ {turns.map((turn, index) => ( +
+
+ 你 +

{turn.question}

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

{turn.error}

+ +
+ ) : turn.result ? ( + + void ask( + text, + turn.result?.plan.kind === "clarify" + ? lastPlan + : turn.result?.plan, + ) + } + disabled={busy} + /> + ) : ( +
+ + + +
+ )} +
+ ))} +
+ )} +
+
+ {lastPlan && ( +
+ 追问上下文 + + {lastPlan.kind === "flow" + ? "交退换车" + : CATEGORY_LABELS[lastPlan.category]} + + {GROUP_LABELS[lastPlan.groupBy]} + {Object.values(lastPlan.filters).map((v) => ( + {v} + ))} +
+ )} +
{ + e.preventDefault(); + void ask(draft); + }} + > + +