Files
ln-bi/src/modules/assets/assistant/AssetAssistant.tsx
T

952 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <Database size={size} />;
}
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<HTMLElement>(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<Turn[]>([]);
const turnsRef = useRef(turns);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
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 [activeResult, setActiveResult] = useState<AssistantResult>();
const sessionRef = useRef<string | undefined>(undefined);
const sessions = useRef(new Map<string, string | null>());
const nextId = useRef(0);
const composer = useRef<HTMLTextAreaElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const autoFollow = useRef<number | null>(null);
const readerRevision = useRef(0);
const scrollFrame = useRef<number | null>(null);
const [viewedTurn, setViewedTurn] = useState<number>();
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<string, Turn>();
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 (
<section
ref={page}
className={`aq-shell aq-page aq-${theme}`}
aria-label="AI问数"
>
<aside className="aq-sidebar">
<div className="aq-brand">
<div className="aq-brand-mark">
<Sparkles size={19} />
</div>
<div>
<strong>羚牛·资产智能</strong>
</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({ preventScroll: true });
}}
>
<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"
aria-label="本次对话的全部问题"
tabIndex={0}
>
{turns.length ? (
turns.map((turn, index) => (
<button key={turn.id} title={turn.question} aria-current={viewedTurn === turn.id ? "true" : undefined} onClick={() => revealTurn(turn.id)}>
<span className="aq-history-number">{String(index + 1).padStart(2, "0")}</span>
<span>{conversationTitle(turn.question, turn.result)}</span>
</button>
))
) : (
<p>
从一个问题开始,
<br />
让数据给你答案。
</p>
)}
</div>
<div className="aq-side-bottom">
<div>
<Database size={16} />
<span>
资产台账<strong>真实业务数据</strong>
</span>
<i />
</div>
<p>
<Building2 size={13} />
沿用当前账号数据权限
</p>
</div>
</aside>
<div className="aq-workspace">
<header className="aq-header">
<div>
<Sparkles size={17} />
<h1>AI问数</h1>
<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="返回资产总览"
>
<ArrowLeft size={19} />
</button>
</div>
</header>
<div className="aq-scope" aria-label="当前可查询数据范围">
<Building2 size={13} />
<span>{subject || "全部所属公司"}</span>
<span className="aq-dot">·</span>
<span>{dataScopeLabel}</span>
</div>
{showInfo && (
<div className="aq-info">
<strong>支持的分析</strong>
<p>
当前资产数量、运营率、库存分布、交退换车趋势与车辆明细。可按区域、城市、车型、型号和部门筛选或分组。
</p>
<p>
资产口径:当前台账;运营率 = 运营车辆 /
筛选车辆。库存包含异动和待交车。可连续追问、切换分组、对比周期并查看明细。暂无数据的历史库存、成本或预测,会说明原因并记录需求。
</p>
<p>
{capability?.message ||
(capabilityError
? "暂时无法读取服务状态,可以直接提问重试。"
: "正在检查分析服务连接状态。")}
</p>
</div>
)}
<div ref={scroller} className="aq-conversation" onScroll={trackViewedTurn}
onWheel={() => { 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 ? (
<div className="aq-welcome">
<div className="aq-welcome-mark">
<Sparkles size={30} strokeWidth={1.3} />
</div>
<h2>关于资产,你想了解什么?</h2>
<p>
用一句话,发现数据里的答案。
<span className="aq-welcome-detail"> 从全局统计,一路看到每一台车。</span>
</p>
<div className="aq-prompt-grid">
{PROMPTS.map((p) => (
<button key={p.title} onClick={() => void ask(p.text)}>
<div>
<p.icon size={18} aria-hidden="true" />
<ArrowUpRight size={14} aria-hidden="true" />
</div>
<strong>{mobile ? p.mobileTitle : p.title}</strong>
<span>{p.detail}</span>
</button>
))}
</div>
</div>
) : (
<div className="aq-turns">
{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 (
<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
className="aq-collapsed-answer"
onClick={() => revealTurn(turn.id)}
aria-expanded={false}
>
<Sparkles size={15} />
<span>
<strong>
{correction ? "此回答已撤回" : turn.result?.title}
</strong>
<span>
{correction?.result?.correction?.message ||
turn.result?.summary}
</span>
</span>
<ChevronDown size={16} />
</button>
) : (
<>
<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.stopped}
stopped={turn.stopped}
outcome={turn.result?.outcome}
/>
{turn.updating && <p className="aq-inline-progress" role="status">正在更新当前卡片…</p>}
{turn.updateError && <p className="aq-inline-progress" role="status">{turn.updateError} 可再次点击分析操作重试。</p>}
{turn.error ? (
<div
className={`aq-error ${turn.stopped ? "aq-stopped" : ""}`}
role={turn.stopped ? "status" : "alert"}
>
<strong>
{turn.stopped
? "已停止生成"
: turn.expired
? "会话已过期"
: "查询失败"}
</strong>
<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 ? (
<CompoundResultView
result={turn.result}
ask={(text, base) => 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}
</>
)}
</section>
);
})}
</div>
)}
</div>
<div className="aq-composer-area">
{turns.length > 0 && (!mobile || (viewedTurn !== undefined && viewedTurn !== turns.at(-1)?.id)) && (
<button
className="aq-latest-answer"
onClick={() => revealTurn(turns[turns.length - 1].id)}
>
回到最新回答 <ArrowUpRight size={12} />
</button>
)}
{lastPlan && (
<details className="aq-context-disclosure" open={!mobile || contextExpanded} onToggle={event => { if (mobile) setContextExpanded(event.currentTarget.open); }}><summary><span>追问范围:{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(' · ')}` : ''}</span><ChevronDown size={15} /></summary><div className="aq-context">
<span className="aq-context-heading">当前条件</span>
<span>
{lastPlan.kind === "flow"
? "交退换车"
: CATEGORY_LABELS[lastPlan.category]}
</span>
<span>{GROUP_LABELS[lastPlan.groupBy]}</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></details>
)}
{independent && (
<p className="aq-topic-notice">
<RotateCcw size={12} /> 已开始独立问题,不继承之前的筛选条件
</p>
)}
<form
className="aq-composer"
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={1}
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" title={capability?.message}>
<Sparkles size={13} />
{capability?.engine === "deepseek"
? "DeepSeek"
: capability?.engine === "local"
? "本地分析 · 未连接模型"
: capabilityError
? "服务状态待确认"
: "检查服务中"}
</span>
<span className="aq-composer-source">
<Database size={12} />
资产数据
</span>
</div>
<div>
{draft.length > 1800 && (
<span className="aq-character-count">
{draft.length}/2000
</span>
)}
{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>
<div className="aq-composer-foot">
<span>AI 可能产生偏差,统计以业务明细为准。</span>
<span>Enter 发送 · Shift + Enter 换行</span>
</div>
</div>
</div>
</section>
);
}