944 lines
36 KiB
TypeScript
944 lines
36 KiB
TypeScript
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 "./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: "现在多少车在运营、多少车在库存?",
|
||
text: BUSINESS_QUESTIONS[0],
|
||
detail: "总资产 · 运营 · 库存 · 运营率",
|
||
},
|
||
{
|
||
icon: Truck,
|
||
title: "库存集中在哪些车型和区域?",
|
||
text: BUSINESS_QUESTIONS[1],
|
||
detail: "库存分布 · 继续查看车辆",
|
||
},
|
||
{
|
||
icon: History,
|
||
title: "最近7天交车、还车分别多少?",
|
||
text: BUSINESS_QUESTIONS[2],
|
||
detail: "按日趋势 · 业务明细",
|
||
},
|
||
{
|
||
icon: ListIcon,
|
||
title: "有哪些车辆需要跟进?",
|
||
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 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>{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 && (
|
||
<button
|
||
className="aq-latest-answer"
|
||
onClick={() => revealTurn(turns[turns.length - 1].id)}
|
||
>
|
||
回到最新回答 <ArrowUpRight size={12} />
|
||
</button>
|
||
)}
|
||
{lastPlan && (
|
||
<div className="aq-context">
|
||
<span>正在分析</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>
|
||
)}
|
||
{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>
|
||
);
|
||
}
|