feat(asset-ai): add contextual analysis and unmet-demand feedback

This commit is contained in:
shishengliang
2026-09-23 16:01:10 +08:00
parent 0a32392215
commit 7e9760f5db
23 changed files with 5160 additions and 835 deletions
+403 -206
View File
@@ -2,17 +2,16 @@ import { useEffect, useRef, useState } from "react";
import {
ArrowUp,
ArrowUpRight,
AudioLines,
BarChart3,
Building2,
CircleHelp,
ChevronDown,
RotateCcw,
Database,
History,
MessageSquare,
Mic,
Moon,
Plus,
ShieldCheck,
Sparkles,
Square,
Sun,
@@ -27,13 +26,16 @@ import {
} from "../../../components/ui/dialog";
import type {
AssistantResult,
QueryPlan,
AssistantAction,
Dimension,
} from "../../../shared/assets/assistant";
import {
CATEGORY_LABELS,
GROUP_LABELS,
} from "../../../shared/assets/assistant";
import { askAssetAssistant } from "./client";
import { askAssetAssistant, AssistantRequestError } from "./client";
import { fetchAuthenticated } from "../../../auth/api-client";
import { DemandGaps } from "./DemandGaps";
import { ThinkingState } from "./ThinkingState";
import { ResultView } from "./ResultView";
import "./assistant.css";
@@ -45,46 +47,35 @@ type Turn = {
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;
expired?: boolean;
stopped?: boolean;
baseResultId?: string;
action?: AssistantAction;
};
const PROMPTS = [
{
icon: BarChart3,
title: "库存分布",
title: "库存主要集中在哪里?",
text: "各区域的库存车辆有多少?按数量排序。",
detail: "区域对比 · 一键下钻",
detail: "看分布,再逐层查看车辆",
},
{
icon: Truck,
title: "运营情况",
text: "按车型统计当前资产数量和运营率。",
title: "哪些车型运营率较低?",
text: "按车型对比当前资产数量和运营率。",
detail: "车型构成 · 运营率",
},
{
icon: History,
title: "交退换趋势",
title: "本月车辆流转怎么样?",
text: "本月的交车、还车和替换交车趋势怎么样?",
detail: "按日趋势 · 业务明细",
},
{
icon: ListIcon,
title: "待交车清单",
text: "列出当前待交车车辆明细。",
detail: "车辆列表 · 导出结果",
title: "有哪些车辆需要跟进?",
text: "查看当前异动车辆,显示异动类型和预计完成时间。",
detail: "异动详情 · 预计完成时间",
},
];
function ListIcon({ size }: { size?: number }) {
@@ -104,21 +95,74 @@ export default function AssetAssistant({
const [turns, setTurns] = useState<Turn[]>([]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [listening, setListening] = useState(false);
const [voiceNote, setVoiceNote] = useState("");
const [expandedTurns, setExpandedTurns] = useState<Set<number>>(
() => 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<AbortController | null>(null);
const recognitionRef = useRef<Recognition | null>(null);
const [activeResult, setActiveResult] = useState<AssistantResult>();
const sessionRef = useRef<string | undefined>(undefined);
const sessions = useRef(new Map<string, string | null>());
const [showGaps, setShowGaps] = useState(false);
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;
const scopeResult =
activeResult?.plan.kind === "clarify"
? activeResult.breadcrumbs
?.map(
(crumb) =>
turns.find((turn) => turn.result?.resultId === crumb.resultId)
?.result,
)
.filter((result) => result && result.plan.kind !== "clarify")
.at(-1)
: activeResult;
const lastPlan = scopeResult?.plan;
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) => {
setShowGaps(false);
setExpandedTurns((old) => new Set(old).add(id));
requestAnimationFrame(() =>
document
.getElementById(`aq-turn-${id}`)
?.scrollIntoView({ block: "start", behavior: "smooth" }),
);
};
const startTopic = () => {
setActiveResult(undefined);
sessionRef.current = undefined;
setIndependent(true);
composer.current?.focus();
};
useEffect(
() => () => {
abortRef.current?.abort();
recognitionRef.current?.abort();
for (const [sessionId, sessionSubject] of sessions.current)
void fetchAuthenticated(
`/api/asset-assistant/session/${sessionId}?subject=${encodeURIComponent(sessionSubject || "")}`,
{ method: "DELETE" },
).catch(() => {});
},
[],
);
@@ -129,16 +173,19 @@ export default function AssetAssistant({
setTurns((previous) =>
previous.map((t) =>
!t.result && !t.error
? { ...t, error: "已停止生成,可以修改问题后重试。" }
? { ...t, error: "已停止。可以修改问题,或继续生成。", stopped: true }
: t,
),
);
};
const ask = async (message: string, previous = lastPlan) => {
const ask = async (
message: string,
base: AssistantResult | null = activeResult ?? null,
action?: AssistantAction,
) => {
if (!message.trim() || abortRef.current) return;
recognitionRef.current?.abort();
setListening(false);
setVoiceNote("");
setShowGaps(false);
setIndependent(false);
const id = ++nextId.current;
const controller = new AbortController();
abortRef.current = controller;
@@ -150,35 +197,46 @@ export default function AssetAssistant({
id,
question: message.trim(),
started: Date.now(),
steps: ["连接资产分析服务"],
baseResultId: base?.resultId,
action,
steps: ["正在理解你的问题"],
},
]);
requestAnimationFrame(() =>
scroller.current?.scrollTo({
top: scroller.current.scrollHeight,
behavior: "instant",
}),
document
.getElementById(`aq-turn-${id}`)
?.scrollIntoView({ block: "start", behavior: "smooth" }),
);
try {
await askAssetAssistant(
{ message, subject, previous },
{
...(action ? { action } : { message }),
subject,
sessionId: base?.sessionId,
baseResultId: base?.resultId,
},
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",
}),
);
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" }),
);
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
@@ -187,7 +245,9 @@ export default function AssetAssistant({
? { ...turn, steps: [...turn.steps, event.label] }
: event.type === "error"
? { ...turn, error: event.message }
: { ...turn, result: event.result },
: event.type === "result"
? { ...turn, result: event.result }
: turn,
),
);
},
@@ -199,6 +259,9 @@ export default function AssetAssistant({
t.id === id
? {
...t,
expired:
error instanceof AssistantRequestError &&
error.status === 409,
error:
error instanceof Error
? error.message
@@ -215,68 +278,27 @@ export default function AssetAssistant({
}
}
};
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("");
setIndependent(false);
if (sessionRef.current)
void fetchAuthenticated(
`/api/asset-assistant/session/${sessionRef.current}?subject=${encodeURIComponent(subject || "")}`,
{ method: "DELETE" },
).catch(() => {});
sessionRef.current = undefined;
setActiveResult(undefined);
setShowGaps(false);
setTurns([]);
setExpandedTurns(new Set());
setDraft("");
composer.current?.focus();
};
useEffect(() => {
reset();
}, [subject]);
return (
<Dialog
open
@@ -326,20 +348,17 @@ export default function AssetAssistant({
返回资产总览
<ArrowUpRight size={14} />
</button>
<button className="aq-nav" onClick={() => setShowGaps(true)}>
<CircleHelp size={16} />
待完善需求
</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" })
}
>
<button key={turn.id} onClick={() => revealTurn(turn.id)}>
<MessageSquare size={13} />
<span>{turn.question}</span>
</button>
@@ -361,7 +380,7 @@ export default function AssetAssistant({
<i />
</div>
<p>
<ShieldCheck size={13} />
<Building2 size={13} />
沿用当前账号数据权限
</p>
</div>
@@ -375,6 +394,13 @@ export default function AssetAssistant({
<span className="aq-header-subtitle">你的资产分析助手</span>
</div>
<div>
<button
className="aq-icon-button"
onClick={() => setShowGaps(!showGaps)}
aria-label="查看待完善需求"
>
<History size={17} />
</button>
<button
className="aq-icon-button aq-mobile-new"
onClick={reset}
@@ -424,12 +450,20 @@ export default function AssetAssistant({
</p>
<p>
资产口径:当前台账;运营率 = 运营车辆 /
筛选车辆。库存包含异动和待交车。暂不支持历史库存、成本或预测。模型接收问题和汇总统计,车辆明细留在业务系统。语音由浏览器提供,可能使用其在线识别服务。
筛选车辆。库存包含异动和待交车。可连续追问、切换分组、对比周期并查看明细。暂无数据的历史库存、成本或预测,会说明原因并记录需求。
</p>
<p>
{capability?.message ||
(capabilityError
? "暂时无法读取服务状态,可以直接提问重试。"
: "正在检查分析服务连接状态。")}
</p>
</div>
)}
<div ref={scroller} className="aq-conversation">
{!turns.length ? (
{showGaps ? (
<DemandGaps close={() => setShowGaps(false)} />
) : !turns.length ? (
<div className="aq-welcome">
<div className="aq-welcome-mark">
<Sparkles size={30} strokeWidth={1.3} />
@@ -456,93 +490,265 @@ export default function AssetAssistant({
))}
</div>
<div className="aq-welcome-note">
<ShieldCheck size={14} />
<Database 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>
{turns.map((turn, index) => {
const collapsed =
index < turns.length - 1 &&
!!turn.result &&
!expandedTurns.has(turn.id);
return (
<section
id={`aq-turn-${turn.id}`}
key={turn.id}
className="aq-turn"
>
<div className="aq-question">
<span>你</span>
<p>{turn.question}</p>
</div>
{collapsed ? (
<button
disabled={busy}
onClick={() => void ask(turn.question)}
className="aq-collapsed-answer"
onClick={() => revealTurn(turn.id)}
aria-expanded={false}
>
重新尝试
<ArrowUpRight size={14} />
<Sparkles size={15} />
<span>
<strong>{turn.result?.title}</strong>
<span>{turn.result?.summary}</span>
</span>
<ChevronDown size={16} />
</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 className="aq-assistant-label">
<span className="aq-small-mark">
<Sparkles size={15} />
</span>
<strong>资产助手</strong>
<span>
{(turn.result?.engine ?? capability?.engine) ===
"deepseek"
? "DeepSeek"
: (turn.result?.engine ??
capability?.engine) === "local"
? "本地分析"
: "连接中"}
</span>
{index < turns.length - 1 && turn.result && (
<button
className="aq-collapse-link"
onClick={() =>
setExpandedTurns((old) => {
const next = new Set(old);
next.delete(turn.id);
return next;
})
}
>
收起
</button>
)}
</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 ${turn.stopped ? "aq-stopped" : ""}`}
role={turn.stopped ? "status" : "alert"}
>
<p>
{turn.expired
? "这次分析已过期,重新查询即可获取最新数据。"
: turn.error}
</p>
<button
disabled={busy}
onClick={() => {
if (turn.expired) {
startTopic();
setDraft(turn.question);
} else
void ask(
turn.question,
turns.find(
(t) =>
t.result?.resultId ===
turn.baseResultId,
)?.result ?? null,
turn.action,
);
}}
>
{turn.expired
? "重新开始查询"
: turn.stopped
? "继续生成"
: "重新尝试"}
<ArrowUpRight size={14} />
</button>
</div>
) : turn.result ? (
<ResultView
result={turn.result}
ask={(text) => 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}
/>
) : (
<div className="aq-waiting">
<span />
<span />
<span />
</div>
)}
</>
)}
</section>
);
})}
</div>
)}
</div>
<div className="aq-composer-area">
{lastPlan && (
<div className="aq-context">
<span>追问上下文</span>
<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>
{lastPlan.start && (
<span>
{lastPlan.start} — {lastPlan.end}
</span>
)}
{Object.entries(lastPlan.filters).map(([dimension, values]) => (
<button
key={dimension}
className="aq-filter-chip"
disabled={busy}
title={`移除${GROUP_LABELS[dimension as Dimension]}筛选`}
onClick={() =>
void ask(
`移除${GROUP_LABELS[dimension as Dimension]}筛选`,
scopeResult ?? null,
{
type: "clear-filter",
dimension: dimension as Dimension,
},
)
}
>
{GROUP_LABELS[dimension as Dimension]}:{values.join("、")}{" "}
<X size={11} />
</button>
))}
{Object.entries(lastPlan.excludeFilters || {}).map(
([dimension, values]) => (
<button
key={`exclude-${dimension}`}
className="aq-filter-chip"
disabled={busy}
title={`移除${GROUP_LABELS[dimension as Dimension]}排除条件`}
onClick={() =>
void ask(
`移除${GROUP_LABELS[dimension as Dimension]}排除条件`,
scopeResult ?? null,
{
type: "clear-filter",
dimension: dimension as Dimension,
},
)
}
>
排除{GROUP_LABELS[dimension as Dimension]}:
{values.join("、")} <X size={11} />
</button>
),
)}
{!!lastPlan.vehicleIds?.length && (
<span>已选 {lastPlan.vehicleIds.length} 台车辆</span>
)}
{lastPlan.exception && (
<span>
{lastPlan.exception === "overdue"
? "已超过预计完成时间"
: lastPlan.exception === "dueSoon"
? "即将到期"
: "未填写预计完成时间"}
</span>
)}
{Object.keys(lastPlan.filters).length > 0 ||
Object.keys(lastPlan.excludeFilters || {}).length > 0 ||
lastPlan.vehicleIds?.length ||
lastPlan.exception ? (
<button
className="aq-filter-chip"
disabled={busy}
onClick={() =>
void ask("清除全部筛选条件", scopeResult ?? null, {
type: "reset-filters",
})
}
>
清除筛选
</button>
) : null}
<button
className="aq-topic-button"
disabled={busy}
onClick={startTopic}
>
<Plus size={12} />
换个话题
</button>
</div>
)}
{independent && (
<p className="aq-topic-notice">
<RotateCcw size={12} /> 已开始独立问题,不继承之前的筛选条件
</p>
)}
<form
className={`aq-composer ${listening ? "aq-recording" : ""}`}
className="aq-composer"
onSubmit={(e) => {
e.preventDefault();
void ask(draft);
@@ -576,9 +782,15 @@ export default function AssetAssistant({
/>
<div className="aq-composer-bottom">
<div>
<span className="aq-model">
<span className="aq-model" title={capability?.message}>
<Sparkles size={13} />
DeepSeek Flash
{capability?.engine === "deepseek"
? "DeepSeek"
: capability?.engine === "local"
? "本地分析 · 未连接模型"
: capabilityError
? "服务状态待确认"
: "检查服务中"}
</span>
<span className="aq-composer-source">
<Database size={12} />
@@ -591,16 +803,6 @@ export default function AssetAssistant({
{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"
@@ -623,11 +825,6 @@ export default function AssetAssistant({
</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>