import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { ArrowUp, ArrowUpRight, BarChart3, Building2, CircleHelp, ChevronDown, RotateCcw, Database, History, MessageSquare, Moon, Plus, Sparkles, Square, Sun, Truck, X, ArrowLeft, } from "lucide-react"; import type { AssistantResult, AssistantAction, Dimension, } from "../../../shared/assets/assistant"; import { CATEGORY_LABELS, GROUP_LABELS, } from "../../../shared/assets/assistant"; import { askAssetAssistant, AssistantRequestError } from "./client"; import { fetchAuthenticated } from "../../../auth/api-client"; import { useAuth } from "../../../auth/useAuth"; import { ThinkingState } from "./ThinkingState"; import { CompoundResultView } from "./CompoundResultView"; import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation"; import { useMobileLayout } from "../useMobileLayout"; import "./assistant.css"; import "./mobile-assistant.css"; type Turn = { id: number; question: string; started: number; steps: string[]; result?: AssistantResult; resultHistory?: AssistantResult[]; updating?: boolean; updateError?: string; error?: string; expired?: boolean; stopped?: boolean; baseResultId?: string; action?: AssistantAction; }; const PROMPTS = [ { icon: BarChart3, title: "现在多少车在运营、多少车在库存?", mobileTitle: "运营与库存", text: BUSINESS_QUESTIONS[0], detail: "总资产 · 运营 · 库存 · 运营率", }, { icon: Truck, title: "库存集中在哪些车型和区域?", mobileTitle: "库存分布", text: BUSINESS_QUESTIONS[1], detail: "库存分布 · 继续查看车辆", }, { icon: History, title: "最近7天交车、还车分别多少?", mobileTitle: "近 7 天交还车", text: BUSINESS_QUESTIONS[2], detail: "按日趋势 · 业务明细", }, { icon: ListIcon, title: "有哪些车辆需要跟进?", mobileTitle: "待跟进车辆", text: BUSINESS_QUESTIONS[3], detail: "异动详情 · 预计完成时间", }, ]; function ListIcon({ size }: { size?: number }) { return ; } export default function AssetAssistant({ subject, close, appearance, }: { subject: string | null; close: () => void; appearance: "light" | "dark"; }) { const { user } = useAuth(); const dataScopeLabel = !user ? "正在确认数据范围" : user.permissionLevel === "full" ? "全量数据" : user.permissionLevel === "department" ? "部门数据" : "本人数据"; const mobile = useMobileLayout(); const [contextExpanded, setContextExpanded] = useState(false); const page = useRef(null); useLayoutEffect(() => { const viewport = window.visualViewport; const mobile = window.matchMedia('(max-width: 767px)'); let frame = 0; let unlock: (() => void) | undefined; const updateViewport = () => { frame = 0; if (!mobile.matches || (viewport && viewport.scale !== 1)) return; page.current?.style.setProperty('--aq-viewport-height', `${viewport?.height ?? window.innerHeight}px`); page.current?.style.setProperty('--aq-viewport-top', `${viewport?.offsetTop ?? 0}px`); }; const scheduleViewport = () => { if (!frame) frame = requestAnimationFrame(updateViewport); }; const syncMobile = () => { unlock?.(); unlock = undefined; if (mobile.matches) { const scrollY = window.scrollY; const body = document.body; const previous = body.style.top; body.style.top = `${-scrollY}px`; body.classList.add('aq-mobile-open'); unlock = () => { body.classList.remove('aq-mobile-open'); body.style.top = previous; window.scrollTo({ top: scrollY, behavior: 'instant' }); }; } updateViewport(); }; syncMobile(); mobile.addEventListener('change', syncMobile); viewport?.addEventListener('resize', scheduleViewport); viewport?.addEventListener('scroll', scheduleViewport); window.addEventListener('resize', scheduleViewport); return () => { cancelAnimationFrame(frame); mobile.removeEventListener('change', syncMobile); viewport?.removeEventListener('resize', scheduleViewport); viewport?.removeEventListener('scroll', scheduleViewport); window.removeEventListener('resize', scheduleViewport); unlock?.(); }; }, []); 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>( () => 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 [activeResult, setActiveResult] = useState(); const sessionRef = useRef(undefined); const sessions = useRef(new Map()); 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) => 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", { 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) => { setExpandedTurns((old) => new Set(old).add(id)); 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); composer.current?.focus({ preventScroll: true }); }; useEffect( () => () => { abortRef.current?.abort(); for (const [sessionId, sessionSubject] of sessions.current) void fetchAuthenticated( `/api/asset-assistant/session/${sessionId}?subject=${encodeURIComponent(sessionSubject || "")}`, { method: "DELETE" }, ).catch(() => {}); }, [], ); const stop = () => { autoFollow.current = null; abortRef.current?.abort(); abortRef.current = null; setBusy(false); setTurns((previous) => previous.map((t) => t.updating ? { ...t, updating: false, updateError: "已停止更新,保留原结果。" } : !t.result && !t.error ? { ...t, error: "已停止生成。可以修改问题,或重新生成。", stopped: true } : t, ), ); }; const ask = async ( message: string, base: AssistantResult | null = activeResult ?? null, action?: AssistantAction, ) => { if (!message.trim() || abortRef.current) return; setIndependent(false); 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; if (!inlineTurn) setDraft(""); setBusy(true); if (inlineTurn) setTurns(old => old.map(turn => turn.id === id ? { ...turn, updating: true, updateError: undefined } : turn)); else setTurns((old) => [ ...old, { id, question: message.trim(), started: Date.now(), baseResultId: base?.resultId, action, steps: ["正在理解你的问题"], }, ]); requestAnimationFrame(() => { if (autoFollow.current === id) scrollToTurn(id); }); try { await askAssetAssistant( { ...(action ? { action } : { message }), subject, sessionId: base?.sessionId, baseResultId: base?.resultId, }, controller.signal, (event) => { if (controller.signal.aborted) return; if (event.type === "result") { 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); setActiveResult(event.result); } setTurns((old) => old.map((turn) => turn.id !== id ? turn : event.type === "step" ? { ...turn, steps: [...turn.steps, event.label] } : event.type === "error" ? inlineTurn ? { ...turn, updating: false, updateError: event.message } : { ...turn, error: event.message } : event.type === "result" ? { ...replaceAnalysisResult(turn, event.result, action ? base?.resultId : undefined), updating: false, updateError: undefined } : turn, ), ); }, ); } catch (error) { if (!controller.signal.aborted) setTurns((old) => old.map((t) => t.id === id ? inlineTurn ? { ...t, updating: false, updateError: error instanceof Error ? error.message : "更新失败,请重试。" } : { ...t, expired: error instanceof AssistantRequestError && error.status === 409, error: error instanceof Error ? error.message : "查询失败,请重试。", } : t, ), ); } finally { if (abortRef.current === controller) { abortRef.current = null; setBusy(false); if (autoFollow.current === id) composer.current?.focus({ preventScroll: true }); } } }; const reset = () => { autoFollow.current = null; setViewedTurn(undefined); abortRef.current?.abort(); abortRef.current = null; setBusy(false); setIndependent(false); if (sessionRef.current) void fetchAuthenticated( `/api/asset-assistant/session/${sessionRef.current}?subject=${encodeURIComponent(subject || "")}`, { method: "DELETE" }, ).catch(() => {}); sessionRef.current = undefined; setActiveResult(undefined); setTurns([]); setExpandedTurns(new Set()); setDraft(""); composer.current?.focus({ preventScroll: true }); }; useEffect(() => { reset(); }, [subject]); return (

AI问数

用对话分析资产数据
{subject || "全部所属公司"} · {dataScopeLabel}
{showInfo && (
支持的分析

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

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

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

)}
{ 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++; } }}> {!turns.length ? (

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

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

{PROMPTS.map((p) => ( ))}
) : (
{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); } }} /> ) : null} )}
); })}
)}
{turns.length > 0 && (!mobile || (viewedTurn !== undefined && viewedTurn !== turns.at(-1)?.id)) && ( )} {lastPlan && (
{ if (mobile) setContextExpanded(event.currentTarget.open); }}>追问范围:{lastPlan.kind === 'flow' ? '交还车' : CATEGORY_LABELS[lastPlan.category]}{Object.keys(lastPlan.filters).length ? ` · ${Object.entries(lastPlan.filters).map(([dimension,values]) => `${GROUP_LABELS[dimension as Dimension]} ${values.length === 1 ? values[0] : `${values.length}项`}`).join(' · ')}` : ''}
当前条件 {lastPlan.kind === "flow" ? "交退换车" : CATEGORY_LABELS[lastPlan.category]} {GROUP_LABELS[lastPlan.groupBy]} {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); }} >