feat(assets): add DeepSeek assistant with streaming results and voice input

This commit is contained in:
shishengliang
2026-09-23 14:16:36 +08:00
parent 822aabf2ca
commit 0c4a9bcd05
14 changed files with 3355 additions and 3 deletions
@@ -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 <Database size={size} />;
}
export default function AssetAssistant({
subject,
close,
appearance,
}: {
subject: string | null;
close: () => void;
appearance: "light" | "dark";
}) {
const [theme, setTheme] = useState(appearance);
const [turns, setTurns] = useState<Turn[]>([]);
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<AbortController | null>(null);
const recognitionRef = useRef<Recognition | null>(null);
const nextId = useRef(0);
const composer = useRef<HTMLTextAreaElement>(null);
const scroller = useRef<HTMLDivElement>(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 (
<Dialog
open
onOpenChange={(open) => {
if (!open) close();
}}
>
<DialogContent
className={`aq-shell aq-${theme}`}
showCloseButton={false}
onOpenAutoFocus={(e) => {
e.preventDefault();
composer.current?.focus();
}}
>
<DialogTitle className="sr-only">资产 AI 问数</DialogTitle>
<DialogDescription className="sr-only">
通过自然语言查询当前账号可见的资产数据,支持图表、表格和明细下钻。
</DialogDescription>
<aside className="aq-sidebar">
<div className="aq-brand">
<div className="aq-brand-mark">
<Sparkles size={19} />
</div>
<div>
<strong>羚牛 · 资产智能</strong>
<span>ASSET INTELLIGENCE</span>
</div>
</div>
<button className="aq-new" onClick={reset}>
<Plus size={16} />
新对话<span>+</span>
</button>
<div className="aq-side-label">工作空间</div>
<button
className="aq-nav-active"
onClick={() => {
scroller.current?.scrollTo({ top: 0 });
composer.current?.focus();
}}
>
<MessageSquare size={16} />
AI 问数<span className="aq-beta">BETA</span>
</button>
<button className="aq-nav" onClick={close}>
<BarChart3 size={16} />
返回资产总览
<ArrowUpRight size={14} />
</button>
<div className="aq-side-label aq-history-label">
本次对话<span>{turns.length.toString().padStart(2, "0")}</span>
</div>
<div className="aq-history">
{turns.length ? (
turns.map((turn) => (
<button
key={turn.id}
onClick={() =>
document
.getElementById(`aq-turn-${turn.id}`)
?.scrollIntoView({ block: "start", behavior: "smooth" })
}
>
<MessageSquare size={13} />
<span>{turn.question}</span>
</button>
))
) : (
<p>
从一个问题开始,
<br />
让数据给你答案。
</p>
)}
</div>
<div className="aq-side-bottom">
<div>
<Database size={16} />
<span>
资产台账<strong>真实业务数据</strong>
</span>
<i />
</div>
<p>
<ShieldCheck size={13} />
沿用当前账号数据权限
</p>
</div>
</aside>
<div className="aq-workspace">
<header className="aq-header">
<div>
<Sparkles size={17} />
<strong>AI 问数</strong>
<span className="aq-header-divider" />
<span className="aq-header-subtitle">你的资产分析助手</span>
</div>
<div>
<button
className="aq-icon-button aq-mobile-new"
onClick={reset}
aria-label="新对话"
>
<Plus size={17} />
</button>
<button
className="aq-icon-button"
onClick={() => setShowInfo(!showInfo)}
aria-label="查询能力与数据说明"
aria-expanded={showInfo}
>
<CircleHelp size={17} />
</button>
<button
className="aq-icon-button"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
aria-label={theme === "light" ? "切换深色" : "切换浅色"}
>
{theme === "light" ? <Moon size={17} /> : <Sun size={17} />}
</button>
<button
className="aq-icon-button"
onClick={close}
aria-label="关闭 AI 问数"
>
<X size={19} />
</button>
</div>
</header>
<div className="aq-scope">
<Building2 size={13} />
<span>{subject || "全部所属公司"}</span>
<span className="aq-dot">·</span>
<span>当前账号可见范围</span>
<span className="aq-live">
<i />
已连接业务数据
</span>
</div>
{showInfo && (
<div className="aq-info">
<strong>支持的分析</strong>
<p>
当前资产数量、运营率、库存分布、交退换车趋势与车辆明细。可按区域、城市、车型、型号和部门筛选或分组。
</p>
<p>
资产口径:当前台账;运营率 = 运营车辆 /
筛选车辆。库存包含异动和待交车。暂不支持历史库存、成本或预测。模型接收问题和汇总统计,车辆明细留在业务系统。语音由浏览器提供,可能使用其在线识别服务。
</p>
</div>
)}
<div ref={scroller} className="aq-conversation">
{!turns.length ? (
<div className="aq-welcome">
<div className="aq-welcome-mark">
<Sparkles size={30} strokeWidth={1.3} />
</div>
<div className="aq-eyebrow">
LESS SEARCHING. MORE UNDERSTANDING.
</div>
<h2>关于资产,你想了解什么?</h2>
<p>
用一句话,发现数据里的答案。
<br className="aq-mobile-break" />{" "}
从全局统计,一路看到每一台车。
</p>
<div className="aq-prompt-grid">
{PROMPTS.map((p) => (
<button key={p.title} onClick={() => void ask(p.text)}>
<div>
<p.icon size={18} />
<ArrowUpRight size={14} />
</div>
<strong>{p.title}</strong>
<span>{p.detail}</span>
</button>
))}
</div>
<div className="aq-welcome-note">
<ShieldCheck size={14} />
只读查询 · 真实数据 · 可追溯明细
</div>
</div>
) : (
<div className="aq-turns">
{turns.map((turn, index) => (
<section
id={`aq-turn-${turn.id}`}
key={turn.id}
className="aq-turn"
>
<div className="aq-question">
<span>你</span>
<p>{turn.question}</p>
</div>
<div className="aq-assistant-label">
<span className="aq-small-mark">
<Sparkles size={15} />
</span>
<strong>资产助手</strong>
<span>DeepSeek Flash</span>
</div>
<ThinkingState
steps={turn.steps}
working={
!turn.result &&
!turn.error &&
busy &&
index === turns.length - 1
}
started={turn.started}
failed={!!turn.error}
/>
{turn.error ? (
<div className="aq-error" role="alert">
<p>{turn.error}</p>
<button
disabled={busy}
onClick={() => void ask(turn.question)}
>
重新尝试
<ArrowUpRight size={14} />
</button>
</div>
) : turn.result ? (
<ResultView
result={turn.result}
ask={(text) =>
void ask(
text,
turn.result?.plan.kind === "clarify"
? lastPlan
: turn.result?.plan,
)
}
disabled={busy}
/>
) : (
<div className="aq-waiting">
<span />
<span />
<span />
</div>
)}
</section>
))}
</div>
)}
</div>
<div className="aq-composer-area">
{lastPlan && (
<div className="aq-context">
<span>追问上下文</span>
<span>
{lastPlan.kind === "flow"
? "交退换车"
: CATEGORY_LABELS[lastPlan.category]}
</span>
<span>{GROUP_LABELS[lastPlan.groupBy]}</span>
{Object.values(lastPlan.filters).map((v) => (
<span key={v}>{v}</span>
))}
</div>
)}
<form
className={`aq-composer ${listening ? "aq-recording" : ""}`}
onSubmit={(e) => {
e.preventDefault();
void ask(draft);
}}
>
<label className="sr-only" htmlFor="aq-question-input">
输入资产问题
</label>
<textarea
ref={composer}
id="aq-question-input"
value={draft}
maxLength={2000}
onChange={(e) => setDraft(e.target.value)}
rows={2}
placeholder={
turns.length
? "继续追问,或换一个问题…"
: "试着问:各区域有多少可运营库存?"
}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
if (!busy) void ask(draft);
}
}}
/>
<div className="aq-composer-bottom">
<div>
<span className="aq-model">
<Sparkles size={13} />
DeepSeek Flash
</span>
<span className="aq-composer-source">
<Database size={12} />
资产数据
</span>
</div>
<div>
{draft.length > 1800 && (
<span className="aq-character-count">
{draft.length}/2000
</span>
)}
<button
type="button"
disabled={busy}
className={`aq-icon-button ${listening ? "aq-mic-active" : ""}`}
onClick={toggleVoice}
aria-label={listening ? "停止语音输入" : "开始语音输入"}
title="语音转文字"
>
{listening ? <AudioLines size={18} /> : <Mic size={18} />}
</button>
{busy ? (
<button
type="button"
className="aq-send"
onClick={stop}
aria-label="停止生成"
>
<Square size={15} fill="currentColor" />
</button>
) : (
<button
className="aq-send"
type="submit"
disabled={!draft.trim()}
aria-label="发送问题"
>
<ArrowUp size={19} />
</button>
)}
</div>
</div>
</form>
{voiceNote && (
<p className="aq-voice-note" role="status">
{voiceNote}
</p>
)}
<div className="aq-composer-foot">
<span>AI 可能产生偏差,统计以业务明细为准。</span>
<span>Enter 发送 · Shift + Enter 换行</span>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}