feat(asset-ai): add contextual analysis and unmet-demand feedback
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user