feat(assets): add DeepSeek assistant with streaming results and voice input
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user