feat(asset-ai): simplify count answers and refine mobile conversation layout
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -30,7 +30,6 @@ import {
|
|||||||
} from "../../../shared/assets/assistant";
|
} from "../../../shared/assets/assistant";
|
||||||
import { askAssetAssistant, AssistantRequestError } from "./client";
|
import { askAssetAssistant, AssistantRequestError } from "./client";
|
||||||
import { fetchAuthenticated } from "../../../auth/api-client";
|
import { fetchAuthenticated } from "../../../auth/api-client";
|
||||||
import { DemandGaps } from "./DemandGaps";
|
|
||||||
import { ThinkingState } from "./ThinkingState";
|
import { ThinkingState } from "./ThinkingState";
|
||||||
import { CompoundResultView } from "./CompoundResultView";
|
import { CompoundResultView } from "./CompoundResultView";
|
||||||
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
|
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
|
||||||
@@ -90,6 +89,23 @@ export default function AssetAssistant({
|
|||||||
close: () => void;
|
close: () => void;
|
||||||
appearance: "light" | "dark";
|
appearance: "light" | "dark";
|
||||||
}) {
|
}) {
|
||||||
|
const page = useRef<HTMLElement>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
const updateViewport = () => {
|
||||||
|
page.current?.style.setProperty('--aq-viewport-height', `${viewport?.height ?? window.innerHeight}px`);
|
||||||
|
page.current?.style.setProperty('--aq-viewport-top', `${viewport?.offsetTop ?? 0}px`);
|
||||||
|
};
|
||||||
|
updateViewport();
|
||||||
|
viewport?.addEventListener('resize', updateViewport);
|
||||||
|
viewport?.addEventListener('scroll', updateViewport);
|
||||||
|
window.addEventListener('resize', updateViewport);
|
||||||
|
return () => {
|
||||||
|
viewport?.removeEventListener('resize', updateViewport);
|
||||||
|
viewport?.removeEventListener('scroll', updateViewport);
|
||||||
|
window.removeEventListener('resize', updateViewport);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
const [theme, setTheme] = useState(appearance);
|
const [theme, setTheme] = useState(appearance);
|
||||||
const [turns, setTurns] = useState<Turn[]>([]);
|
const [turns, setTurns] = useState<Turn[]>([]);
|
||||||
const turnsRef = useRef(turns);
|
const turnsRef = useRef(turns);
|
||||||
@@ -110,7 +126,6 @@ export default function AssetAssistant({
|
|||||||
const [activeResult, setActiveResult] = useState<AssistantResult>();
|
const [activeResult, setActiveResult] = useState<AssistantResult>();
|
||||||
const sessionRef = useRef<string | undefined>(undefined);
|
const sessionRef = useRef<string | undefined>(undefined);
|
||||||
const sessions = useRef(new Map<string, string | null>());
|
const sessions = useRef(new Map<string, string | null>());
|
||||||
const [showGaps, setShowGaps] = useState(false);
|
|
||||||
const nextId = useRef(0);
|
const nextId = useRef(0);
|
||||||
const composer = useRef<HTMLTextAreaElement>(null);
|
const composer = useRef<HTMLTextAreaElement>(null);
|
||||||
const scroller = useRef<HTMLDivElement>(null);
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
@@ -189,7 +204,6 @@ export default function AssetAssistant({
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
const revealTurn = (id: number) => {
|
const revealTurn = (id: number) => {
|
||||||
setShowGaps(false);
|
|
||||||
setExpandedTurns((old) => new Set(old).add(id));
|
setExpandedTurns((old) => new Set(old).add(id));
|
||||||
autoFollow.current = null;
|
autoFollow.current = null;
|
||||||
setViewedTurn(id);
|
setViewedTurn(id);
|
||||||
@@ -232,7 +246,6 @@ export default function AssetAssistant({
|
|||||||
action?: AssistantAction,
|
action?: AssistantAction,
|
||||||
) => {
|
) => {
|
||||||
if (!message.trim() || abortRef.current) return;
|
if (!message.trim() || abortRef.current) return;
|
||||||
setShowGaps(false);
|
|
||||||
setIndependent(false);
|
setIndependent(false);
|
||||||
const inlineTurn = action && base?.resultId
|
const inlineTurn = action && base?.resultId
|
||||||
? turnsRef.current.find(turn => !!findConversationResult([turn], base.resultId!))
|
? turnsRef.current.find(turn => !!findConversationResult([turn], base.resultId!))
|
||||||
@@ -344,7 +357,6 @@ export default function AssetAssistant({
|
|||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
sessionRef.current = undefined;
|
sessionRef.current = undefined;
|
||||||
setActiveResult(undefined);
|
setActiveResult(undefined);
|
||||||
setShowGaps(false);
|
|
||||||
setTurns([]);
|
setTurns([]);
|
||||||
setExpandedTurns(new Set());
|
setExpandedTurns(new Set());
|
||||||
setDraft("");
|
setDraft("");
|
||||||
@@ -355,6 +367,7 @@ export default function AssetAssistant({
|
|||||||
}, [subject]);
|
}, [subject]);
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
ref={page}
|
||||||
className={`aq-shell aq-page aq-${theme}`}
|
className={`aq-shell aq-page aq-${theme}`}
|
||||||
aria-label="资产 AI 问数"
|
aria-label="资产 AI 问数"
|
||||||
>
|
>
|
||||||
@@ -388,10 +401,6 @@ export default function AssetAssistant({
|
|||||||
返回资产总览
|
返回资产总览
|
||||||
<ArrowUpRight size={14} />
|
<ArrowUpRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button className="aq-nav" onClick={() => setShowGaps(true)}>
|
|
||||||
<CircleHelp size={16} />
|
|
||||||
待完善需求
|
|
||||||
</button>
|
|
||||||
<div className="aq-side-label aq-history-label">
|
<div className="aq-side-label aq-history-label">
|
||||||
本次对话<span>{turns.length.toString().padStart(2, "0")}</span>
|
本次对话<span>{turns.length.toString().padStart(2, "0")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -438,13 +447,6 @@ export default function AssetAssistant({
|
|||||||
<span className="aq-header-subtitle">你的资产分析助手</span>
|
<span className="aq-header-subtitle">你的资产分析助手</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button
|
|
||||||
className="aq-icon-button"
|
|
||||||
onClick={() => setShowGaps(!showGaps)}
|
|
||||||
aria-label="查看待完善需求"
|
|
||||||
>
|
|
||||||
<History size={17} />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="aq-icon-button aq-mobile-new"
|
className="aq-icon-button aq-mobile-new"
|
||||||
onClick={reset}
|
onClick={reset}
|
||||||
@@ -509,9 +511,7 @@ export default function AssetAssistant({
|
|||||||
onTouchMove={() => { autoFollow.current = null; readerRevision.current++; }}
|
onTouchMove={() => { autoFollow.current = null; readerRevision.current++; }}
|
||||||
onPointerDown={() => { 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++; } }}>
|
onKeyDown={(event) => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) { autoFollow.current = null; readerRevision.current++; } }}>
|
||||||
{showGaps ? (
|
{!turns.length ? (
|
||||||
<DemandGaps close={() => setShowGaps(false)} />
|
|
||||||
) : !turns.length ? (
|
|
||||||
<div className="aq-welcome">
|
<div className="aq-welcome">
|
||||||
<div className="aq-welcome-mark">
|
<div className="aq-welcome-mark">
|
||||||
<Sparkles size={30} strokeWidth={1.3} />
|
<Sparkles size={30} strokeWidth={1.3} />
|
||||||
@@ -519,15 +519,14 @@ export default function AssetAssistant({
|
|||||||
<h2>关于资产,你想了解什么?</h2>
|
<h2>关于资产,你想了解什么?</h2>
|
||||||
<p>
|
<p>
|
||||||
用一句话,发现数据里的答案。
|
用一句话,发现数据里的答案。
|
||||||
<br className="aq-mobile-break" />{" "}
|
<span className="aq-welcome-detail"> 从全局统计,一路看到每一台车。</span>
|
||||||
从全局统计,一路看到每一台车。
|
|
||||||
</p>
|
</p>
|
||||||
<div className="aq-prompt-grid">
|
<div className="aq-prompt-grid">
|
||||||
{PROMPTS.map((p) => (
|
{PROMPTS.map((p) => (
|
||||||
<button key={p.title} onClick={() => void ask(p.text)}>
|
<button key={p.title} onClick={() => void ask(p.text)}>
|
||||||
<div>
|
<div>
|
||||||
<p.icon size={18} />
|
<p.icon size={18} aria-hidden="true" />
|
||||||
<ArrowUpRight size={14} />
|
<ArrowUpRight size={14} aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<strong>{p.title}</strong>
|
<strong>{p.title}</strong>
|
||||||
<span>{p.detail}</span>
|
<span>{p.detail}</span>
|
||||||
@@ -711,13 +710,7 @@ export default function AssetAssistant({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<div className="aq-waiting">
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function CompoundResultView({
|
|||||||
{result.gap && (
|
{result.gap && (
|
||||||
<p className="aq-compound-notice">
|
<p className="aq-compound-notice">
|
||||||
{result.gap.recorded
|
{result.gap.recorded
|
||||||
? "未满足的需求已记录,可在待完善需求中查看。"
|
? "未满足的需求已记录。"
|
||||||
: "未满足的需求暂未保存。"}
|
: "未满足的需求暂未保存。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ function ResultViewContent({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className={`aq-result ${active ? "aq-result-active" : ""}`}>
|
<div className={`aq-result ${active ? "aq-result-active" : ""}`}>
|
||||||
{plan.kind !== "clarify" && (
|
{plan.kind !== "clarify" && !result.conciseCount && (
|
||||||
<header
|
<header
|
||||||
className="aq-query-heading"
|
className="aq-query-heading"
|
||||||
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
|
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
|
||||||
@@ -362,7 +362,7 @@ function ResultViewContent({
|
|||||||
>
|
>
|
||||||
{result.summary}
|
{result.summary}
|
||||||
</p>
|
</p>
|
||||||
{!!visibleMetrics.length && (
|
{!result.conciseCount && !!visibleMetrics.length && (
|
||||||
<div className="aq-metrics aq-primary-metrics">
|
<div className="aq-metrics aq-primary-metrics">
|
||||||
{visibleMetrics.map((metric) => (
|
{visibleMetrics.map((metric) => (
|
||||||
<div key={metric.label}>
|
<div key={metric.label}>
|
||||||
@@ -375,7 +375,7 @@ function ResultViewContent({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!extraMetrics.length && (
|
{!result.conciseCount && !!extraMetrics.length && (
|
||||||
<details className="aq-extra-metrics">
|
<details className="aq-extra-metrics">
|
||||||
<summary>查看其余 {extraMetrics.length} 项指标</summary>
|
<summary>查看其余 {extraMetrics.length} 项指标</summary>
|
||||||
<div>
|
<div>
|
||||||
@@ -405,11 +405,11 @@ function ResultViewContent({
|
|||||||
{result.gap && (
|
{result.gap && (
|
||||||
<p className="aq-gap-note">
|
<p className="aq-gap-note">
|
||||||
{result.gap.recorded
|
{result.gap.recorded
|
||||||
? "这个需求已记下,可在「待完善需求」中查看。"
|
? "这个需求已记下。"
|
||||||
: "可以点击下方「未解决我的问题」记录需求。"}
|
: "可以点击下方「未解决我的问题」记录需求。"}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{plan.kind !== "clarify" && (
|
{plan.kind !== "clarify" && !result.conciseCount && (
|
||||||
<>
|
<>
|
||||||
<div className="aq-result-card">
|
<div className="aq-result-card">
|
||||||
{result.inventoryBreakdown && display !== "metrics" && (
|
{result.inventoryBreakdown && display !== "metrics" && (
|
||||||
@@ -849,6 +849,11 @@ function ResultViewContent({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{result.conciseCount && result.metrics[0]?.value > 0 && (
|
||||||
|
<button className="aq-more" disabled={disabled} onClick={() => run({ type: "view", view: "list" }, "查看车辆明细")}>
|
||||||
|
查看车辆明细 <ArrowUpRight size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="aq-result-footer">
|
<div className="aq-result-footer">
|
||||||
<details className="aq-result-definitions">
|
<details className="aq-result-definitions">
|
||||||
<summary>
|
<summary>
|
||||||
@@ -873,7 +878,7 @@ function ResultViewContent({
|
|||||||
<p>来源:{result.source}</p>
|
<p>来源:{result.source}</p>
|
||||||
</details>
|
</details>
|
||||||
<div className="aq-answer-actions">
|
<div className="aq-answer-actions">
|
||||||
{plan.kind !== "clarify" && (
|
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
|
||||||
<button
|
<button
|
||||||
disabled={disabled || saving}
|
disabled={disabled || saving}
|
||||||
onClick={() => void download()}
|
onClick={() => void download()}
|
||||||
@@ -912,7 +917,7 @@ function ResultViewContent({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!!suggestions.length && (
|
{!result.conciseCount && !!suggestions.length && (
|
||||||
<div className="aq-followups">
|
<div className="aq-followups">
|
||||||
<span>{plan.kind === "clarify" ? "你可以这样继续" : "继续分析"}</span>
|
<span>{plan.kind === "clarify" ? "你可以这样继续" : "继续分析"}</span>
|
||||||
{suggestions.slice(0, 4).map((question) => (
|
{suggestions.slice(0, 4).map((question) => (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Adapted from Beautiful UI's MIT-licensed ThinkingState (Steps).
|
// Adapted from Beautiful UI's MIT-licensed ThinkingState (Steps).
|
||||||
// Uses real server progress rather than the reference's timed demo sequence.
|
// Uses real server progress rather than the reference's timed demo sequence.
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { Check, ChevronDown, LoaderCircle, Sparkles } from "lucide-react";
|
import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
export function ThinkingState({
|
export function ThinkingState({
|
||||||
steps,
|
steps,
|
||||||
@@ -18,27 +18,30 @@ export function ThinkingState({
|
|||||||
stopped?: boolean;
|
stopped?: boolean;
|
||||||
outcome?: string;
|
outcome?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const traceId = useId();
|
||||||
const [manualExpanded, setExpanded] = useState<boolean | null>(null);
|
const [manualExpanded, setExpanded] = useState<boolean | null>(null);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!working) return;
|
if (!working) return;
|
||||||
const update = () => setElapsed((Date.now() - started) / 1000);
|
const update = () => setElapsed((Date.now() - started) / 1000);
|
||||||
update();
|
update();
|
||||||
const timer = window.setInterval(update, 100);
|
const timer = window.setInterval(update, 1000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [working, started]);
|
}, [working, started]);
|
||||||
const expanded = manualExpanded ?? false;
|
const expanded = manualExpanded ?? working;
|
||||||
|
const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles;
|
||||||
return (
|
return (
|
||||||
<div className="aq-thinking">
|
<div className={`aq-thinking${working ? " aq-thinking-working" : ""}`} data-state={working ? "working" : stopped ? "stopped" : failed ? "failed" : "complete"}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
|
aria-controls={traceId}
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
<Sparkles size={15} aria-hidden="true" />
|
<StatusIcon size={16} className={working ? "aq-spin" : ""} aria-hidden="true" />
|
||||||
<span className={working ? "aq-shimmer" : ""}>
|
<span className={`aq-thinking-title${working ? " aq-shimmer" : ""}`}>
|
||||||
{working
|
{working
|
||||||
? steps.at(-1) || "正在分析"
|
? "正在分析"
|
||||||
: stopped
|
: stopped
|
||||||
? "已由用户停止"
|
? "已由用户停止"
|
||||||
: failed
|
: failed
|
||||||
@@ -55,7 +58,10 @@ export function ThinkingState({
|
|||||||
? "已回答"
|
? "已回答"
|
||||||
: "分析完成"}
|
: "分析完成"}
|
||||||
</span>
|
</span>
|
||||||
{elapsed > 0 && <span className="aq-time">{elapsed.toFixed(1)}s</span>}
|
<span className="aq-thinking-meta">
|
||||||
|
{steps.length > 0 && <span>{steps.length} 个步骤</span>}
|
||||||
|
{elapsed > 0 && <span className="aq-time">{Math.floor(elapsed)} 秒</span>}
|
||||||
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={14}
|
size={14}
|
||||||
className={expanded ? "aq-rotated" : ""}
|
className={expanded ? "aq-rotated" : ""}
|
||||||
@@ -63,6 +69,7 @@ export function ThinkingState({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
|
id={traceId}
|
||||||
className="aq-trace-collapse"
|
className="aq-trace-collapse"
|
||||||
aria-hidden={!expanded}
|
aria-hidden={!expanded}
|
||||||
style={{
|
style={{
|
||||||
@@ -72,14 +79,17 @@ export function ThinkingState({
|
|||||||
>
|
>
|
||||||
<div className="aq-trace-clip">
|
<div className="aq-trace-clip">
|
||||||
<ol className="aq-trace">
|
<ol className="aq-trace">
|
||||||
|
{!steps.length && <li><span>{working ? "正在连接分析服务…" : "暂无执行步骤"}</span></li>}
|
||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<li key={`${index}-${step}`}>
|
<li key={`${index}-${step}`} data-current={index === steps.length - 1}>
|
||||||
{working && index === steps.length - 1 ? (
|
{working && index === steps.length - 1 ? (
|
||||||
<LoaderCircle
|
<LoaderCircle
|
||||||
className="aq-spin"
|
className="aq-spin"
|
||||||
size={14}
|
size={14}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
) : index === steps.length - 1 && (failed || stopped) ? (
|
||||||
|
<StatusIcon size={14} aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<Check size={14} aria-hidden="true" />
|
<Check size={14} aria-hidden="true" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -405,9 +405,6 @@
|
|||||||
color: var(--aq-muted);
|
color: var(--aq-muted);
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
.aq-mobile-break {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.aq-turns {
|
.aq-turns {
|
||||||
max-width: 820px;
|
max-width: 820px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -1124,9 +1121,6 @@
|
|||||||
.aq-welcome > p {
|
.aq-welcome > p {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.aq-mobile-break {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.aq-welcome-mark {
|
.aq-welcome-mark {
|
||||||
width: 52px;
|
width: 52px;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
@@ -2014,3 +2008,93 @@
|
|||||||
.aq-generated-metrics strong { font-size: 21px; }
|
.aq-generated-metrics strong { font-size: 21px; }
|
||||||
.aq-stacked-section { padding: 7px; }
|
.aq-stacked-section { padding: 7px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The full-page assistant owns the mobile viewport, including keyboard resize. */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.app-shell:has(.aq-page) .app-mobile-nav { display: none; }
|
||||||
|
.app-shell:has(.aq-page) .app-shell-content { padding-bottom: 0; }
|
||||||
|
.aq-shell.aq-page {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--aq-viewport-top, 0px);
|
||||||
|
left: 0;
|
||||||
|
height: var(--aq-viewport-height, 100dvh);
|
||||||
|
max-height: none;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.aq-page .aq-sidebar { display: none; }
|
||||||
|
.aq-page .aq-composer-area { flex-shrink: 0; }
|
||||||
|
.aq-page .aq-composer textarea { font-size: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quiet, expandable execution traces inspired by Beautiful UI. */
|
||||||
|
.aq-thinking {
|
||||||
|
border: 1px solid var(--aq-line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--aq-bg);
|
||||||
|
padding: 2px 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.aq-thinking-working { background: var(--aq-sidebar); }
|
||||||
|
.aq-thinking > button {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.aq-thinking > button > svg { flex-shrink: 0; }
|
||||||
|
.aq-thinking-title { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.aq-thinking-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.aq-thinking .aq-time { font-size: 12px; opacity: 1; }
|
||||||
|
.aq-trace { margin: 0 0 12px 7px; padding: 0 0 0 18px; }
|
||||||
|
.aq-trace li { align-items: flex-start; gap: 10px; padding: 7px 0; line-height: 1.6; overflow-wrap: anywhere; }
|
||||||
|
.aq-trace li svg { flex-shrink: 0; margin-top: 3px; }
|
||||||
|
.aq-thinking-working .aq-trace li[data-current="true"] { color: var(--aq-ink); }
|
||||||
|
.aq-thinking[data-state="failed"] > button > svg { color: var(--aq-danger); }
|
||||||
|
.aq-question p { padding: 12px 16px; border-radius: 18px 18px 4px 18px; font-weight: 400; }
|
||||||
|
.aq-assistant-label { margin-bottom: 10px; }
|
||||||
|
.aq-assistant-label strong { font-size: 13px; }
|
||||||
|
.aq-assistant-label > span:last-child { font-size: 12px; }
|
||||||
|
.aq-composer { border-radius: 18px; box-shadow: 0 2px 8px #00000006; }
|
||||||
|
.aq-composer:focus-within { border-color: var(--aq-accent); box-shadow: 0 0 0 3px var(--aq-soft); }
|
||||||
|
.aq-welcome h2 { text-wrap: balance; }
|
||||||
|
.aq-prompt-grid > button { transition: background 160ms ease, border-color 160ms ease; }
|
||||||
|
.aq-prompt-grid > button:hover { background: var(--aq-sidebar); border-color: var(--aq-accent); }
|
||||||
|
.aq-page button:focus-visible { outline: 2px solid var(--aq-accent); outline-offset: 3px; }
|
||||||
|
.aq-page button:active:not(:disabled) { opacity: .75; }
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.aq-header { min-height: 64px; height: auto; padding: 8px 12px; }
|
||||||
|
.aq-header > div { gap: 4px; }
|
||||||
|
.aq-header .aq-icon-button { width: 44px; height: 44px; min-width: 44px; }
|
||||||
|
.aq-thinking { margin-left: 0; padding: 2px 10px; }
|
||||||
|
.aq-thinking-meta { gap: 6px; }
|
||||||
|
.aq-question p { max-width: 88%; font-size: 15px; }
|
||||||
|
.aq-welcome { padding: 24px 16px 16px; }
|
||||||
|
.aq-welcome-mark { display: none; }
|
||||||
|
.aq-welcome h2 { margin: 0 0 8px; font-size: 22px; line-height: 1.4; letter-spacing: -.5px; }
|
||||||
|
.aq-welcome > p { margin: 0; font-size: 13px; line-height: 1.5; }
|
||||||
|
.aq-welcome-detail { display: none; }
|
||||||
|
.aq-prompt-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 20px; gap: 8px; }
|
||||||
|
.aq-prompt-grid > button { flex-direction: row; align-items: center; justify-content: flex-start; gap: 8px; min-width: 0; min-height: 48px; padding: 12px; border-radius: 12px; }
|
||||||
|
.aq-prompt-grid > button > div { flex-shrink: 0; margin: 0; color: var(--aq-ink); }
|
||||||
|
.aq-prompt-grid > button > div > svg:last-child { display: none; }
|
||||||
|
.aq-prompt-grid > button strong { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||||
|
.aq-prompt-grid > button > span { display: none; }
|
||||||
|
.aq-welcome-note { margin-top: 12px; font-size: 12px; line-height: 1.5; flex-wrap: nowrap; }
|
||||||
|
.aq-welcome-note svg { flex-shrink: 0; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.aq-page .aq-spin, .aq-page .aq-shimmer, .aq-page .aq-trace li { animation: none; }
|
||||||
|
.aq-page .aq-shimmer { background: none; color: var(--aq-muted); }
|
||||||
|
.aq-page .aq-trace-collapse { transition: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { ResultView } from "./ResultView";
|
||||||
|
import type { AssistantResult } from "../../../shared/assets/assistant";
|
||||||
|
|
||||||
|
test("count answer renders prose without report cards and only offers records for positive counts", () => {
|
||||||
|
for (const count of [0, 7]) {
|
||||||
|
const result: AssistantResult = {
|
||||||
|
plan: {kind:"assets",category:"abnormal",groupBy:"none",view:"chart",filters:{}},
|
||||||
|
conciseCount:true,outcome:"complete",summary:`当前异动车辆为 ${count} 台。`,
|
||||||
|
title:"异动车辆概览",metrics:[{label:"异动车辆",value:count,unit:"台"}],
|
||||||
|
rows:[],groups:[],source:"测试台账",fetchedAt:"2026-09-23T00:00:00Z",suggestions:["查看全部车辆"],
|
||||||
|
};
|
||||||
|
const noop = () => {};
|
||||||
|
const html = renderToStaticMarkup(createElement(ResultView,{result,ask:noop,operate:noop,restore:noop,active:true,activate:noop,subject:null,disabled:false,showCorrection:noop}));
|
||||||
|
assert.match(html,new RegExp(`当前异动车辆为 ${count} 台`));
|
||||||
|
assert.doesNotMatch(html,/aq-primary-metrics|aq-result-card|aq-followups|aq-query-heading/);
|
||||||
|
assert.equal(html.includes("查看车辆明细"),count > 0);
|
||||||
|
assert.equal(html.includes("导出明细"),count > 0);
|
||||||
|
assert.match(html,/统计口径、权限与数据时间/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -257,3 +257,15 @@ test("metric references keep one unit when the model appends it in prose", async
|
|||||||
await generatePresentation(value,"说明库存",signal());
|
await generatePresentation(value,"说明库存",signal());
|
||||||
assert.equal(value.presentation?.blocks[0].text,"库存2 台,其中待交车0台。");
|
assert.equal(value.presentation?.blocks[0].text,"库存2 台,其中待交车0台。");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("verified single count bypasses generated report and external request", async (t) => {
|
||||||
|
configure(t);
|
||||||
|
t.mock.method(globalThis, "fetch", async () => { throw new Error("count must not call presentation model"); });
|
||||||
|
const r = result({plan:plan({category:"abnormal",groupBy:"none"}),outcome:"empty",rows:[],groups:[],metrics:[{label:"异动车辆",value:0,unit:"台"}]});
|
||||||
|
await generatePresentation(r,"当前有多少异动车辆?",new AbortController().signal);
|
||||||
|
assert.equal(r.conciseCount,true);
|
||||||
|
assert.equal(r.outcome,"complete");
|
||||||
|
assert.equal(r.presentation,undefined);
|
||||||
|
assert.equal(r.presentationWarning,undefined);
|
||||||
|
assert.match(r.summary,/异动车辆为 0 台/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ResultPresentation,
|
ResultPresentation,
|
||||||
} from "../../../shared/assets/assistant.js";
|
} from "../../../shared/assets/assistant.js";
|
||||||
import { validatePlan } from "./model.js";
|
import { validatePlan } from "./model.js";
|
||||||
|
import { simplifyCountAnswer } from "./presentation.js";
|
||||||
|
|
||||||
type Dataset = "groups" | "rows" | "daily" | "comparison";
|
type Dataset = "groups" | "rows" | "daily" | "comparison";
|
||||||
type Field = { field: string; label: string; type: "number" | "string" | "boolean" };
|
type Field = { field: string; label: string; type: "number" | "string" | "boolean" };
|
||||||
@@ -158,6 +159,8 @@ function fallback(target: AssistantResult, warning: string) {
|
|||||||
}
|
}
|
||||||
/** Generate presentation metadata only; query data and authorization are unchanged. */
|
/** Generate presentation metadata only; query data and authorization are unchanged. */
|
||||||
export async function generatePresentation(result: AssistantResult, question: string, signal: AbortSignal): Promise<void> {
|
export async function generatePresentation(result: AssistantResult, question: string, signal: AbortSignal): Promise<void> {
|
||||||
|
if (signal.aborted) throw signal.reason || new Error("已取消");
|
||||||
|
if (simplifyCountAnswer(result, question)) return;
|
||||||
if (process.env.ASSET_AI_EXTERNAL_ENABLED === "0") return;
|
if (process.env.ASSET_AI_EXTERNAL_ENABLED === "0") return;
|
||||||
if (signal.aborted) throw signal.reason || new Error("已取消");
|
if (signal.aborted) throw signal.reason || new Error("已取消");
|
||||||
const targets = targetsFor(result).filter(target => target.result.plan.responseMode !== "vehicle-status");
|
const targets = targetsFor(result).filter(target => target.result.plan.responseMode !== "vehicle-status");
|
||||||
|
|||||||
@@ -158,3 +158,11 @@ test("presentation receives authorized aggregates while vehicle rows stay in the
|
|||||||
);
|
);
|
||||||
assert.equal(events.filter((e) => e.type === "step").length, 4);
|
assert.equal(events.filter((e) => e.type === "step").length, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("inventory subsets do not inherit parent inventory metric cards", () => {
|
||||||
|
for (const category of ["abnormal", "pending", "available", "awaiting"] as const) {
|
||||||
|
const r = assetResult(vehicles, {kind:"assets", category, groupBy:"none", view:"chart", filters:{}});
|
||||||
|
assert.equal(r.metrics.length, 1, category);
|
||||||
|
assert.doesNotMatch(r.summary, /其中异动.*待交车/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ export function assetResult(
|
|||||||
missingDueDate: "未填写预计完成时间",
|
missingDueDate: "未填写预计完成时间",
|
||||||
}[plan.exception || "overdue"];
|
}[plan.exception || "overdue"];
|
||||||
let summary = rows.length
|
let summary = rows.length
|
||||||
? `${scope}共有 ${rows.length.toLocaleString()} 台${CATEGORY_LABELS[plan.category]}${plan.exception ? `,均为${exceptionLabel}的异动车辆` : ""}。${abnormal || pending ? `其中异动 ${abnormal} 台、待交车 ${pending} 台,已计入库存。` : ""}${plan.topN ? `下方展示排名前 ${plan.topN} 的分组,总量与占比仍按完整筛选范围计算。` : ""}`
|
? `${scope}共有 ${rows.length.toLocaleString()} 台${CATEGORY_LABELS[plan.category]}${plan.exception ? `,均为${exceptionLabel}的异动车辆` : ""}。${["all", "inventory"].includes(plan.category) && (abnormal || pending) ? `其中异动 ${abnormal} 台、待交车 ${pending} 台,已计入库存。` : ""}${plan.topN ? `下方展示排名前 ${plan.topN} 的分组,总量与占比仍按完整筛选范围计算。` : ""}`
|
||||||
: `${scope}的${CATEGORY_LABELS[plan.category]}为 0 台,暂无符合条件车辆。`;
|
: `${scope}的${CATEGORY_LABELS[plan.category]}为 0 台,暂无符合条件车辆。`;
|
||||||
let inventoryBreakdown: AssistantResult["inventoryBreakdown"];
|
let inventoryBreakdown: AssistantResult["inventoryBreakdown"];
|
||||||
if (plan.category === "all") summary = `${scope}共有资产 ${rows.length.toLocaleString()} 台,其中运营 ${operating.toLocaleString()} 台、库存 ${inventory.toLocaleString()} 台,运营率 ${operationRate}%。`;
|
if (plan.category === "all") summary = `${scope}共有资产 ${rows.length.toLocaleString()} 台,其中运营 ${operating.toLocaleString()} 台、库存 ${inventory.toLocaleString()} 台,运营率 ${operationRate}%。`;
|
||||||
@@ -405,7 +405,7 @@ export function assetResult(
|
|||||||
unit: "%",
|
unit: "%",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [
|
: plan.category === "inventory" ? [
|
||||||
{
|
{
|
||||||
label: "其中可运营",
|
label: "其中可运营",
|
||||||
value: selected.filter((v) =>
|
value: selected.filter((v) =>
|
||||||
@@ -420,9 +420,9 @@ export function assetResult(
|
|||||||
).length,
|
).length,
|
||||||
unit: "台",
|
unit: "台",
|
||||||
},
|
},
|
||||||
]),
|
] : []),
|
||||||
...(plan.analysis === "briefing" ? [{ label: "其中可运营库存", value: selected.filter(v => isInventoryVehicle(v) && ["3", "可运营"].includes(v.operationStatus || "")).length, unit: "台" }, { label: "其中待运营库存", value: selected.filter(v => isInventoryVehicle(v) && ["4", "待运营"].includes(v.operationStatus || "")).length, unit: "台" }] : []),
|
...(plan.analysis === "briefing" ? [{ label: "其中可运营库存", value: selected.filter(v => isInventoryVehicle(v) && ["3", "可运营"].includes(v.operationStatus || "")).length, unit: "台" }, { label: "其中待运营库存", value: selected.filter(v => isInventoryVehicle(v) && ["4", "待运营"].includes(v.operationStatus || "")).length, unit: "台" }] : []),
|
||||||
...(!["all", "operating"].includes(plan.category) || plan.analysis === "briefing"
|
...(plan.category === "inventory" || plan.analysis === "briefing"
|
||||||
? [
|
? [
|
||||||
{ label: "其中异动", value: abnormal, unit: "台" },
|
{ label: "其中异动", value: abnormal, unit: "台" },
|
||||||
{ label: "其中待交车", value: pending, unit: "台" },
|
{ label: "其中待交车", value: pending, unit: "台" },
|
||||||
|
|||||||
@@ -23,3 +23,30 @@ test("opening inventory from a briefing leaves briefing mode and keeps inherited
|
|||||||
const next=applyAction(briefing,{type:"category",category:"inventory"},result(briefing));
|
const next=applyAction(briefing,{type:"category",category:"inventory"},result(briefing));
|
||||||
assert.equal(next.category,"inventory");assert.equal(next.analysis,"distribution");assert.deepEqual(next.filters,briefing.filters);
|
assert.equal(next.category,"inventory");assert.equal(next.analysis,"distribution");assert.deepEqual(next.filters,briefing.filters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("simple counts answer directly, including verified zero, while preserving scope", async () => {
|
||||||
|
const { simplifyCountAnswer } = await import("./presentation.js");
|
||||||
|
for (const count of [0, 7]) {
|
||||||
|
const r = result({...plan, category:"abnormal", groupBy:"none", view:"chart"});
|
||||||
|
r.outcome = count ? "complete" : "empty";
|
||||||
|
r.metrics = [{label:"异动车辆",value:count,unit:"台"},{label:"其中待交车",value:0,unit:"台"}];
|
||||||
|
r.scope = resultScope(r.plan,"测试公司");
|
||||||
|
assert.equal(simplifyCountAnswer(r,"当前有多少异动车辆?"),true);
|
||||||
|
assert.match(r.summary,new RegExp(`异动车辆为 ${count} 台`));
|
||||||
|
assert.match(r.summary,/测试公司.*4.5T.*嘉兴市/);
|
||||||
|
assert.equal(r.metrics.length,1);
|
||||||
|
assert.equal(r.outcome,"complete");
|
||||||
|
assert.deepEqual(r.suggestions,[]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("counts never collapse analysis requests, missing metrics or incomplete results", async () => {
|
||||||
|
const { simplifyCountAnswer } = await import("./presentation.js");
|
||||||
|
const make = () => ({...result({...plan,category:"abnormal",groupBy:"none",view:"chart"}), outcome:"complete" as const,metrics:[{label:"异动车辆",value:7,unit:"台"}]});
|
||||||
|
for (const question of ["各区域有多少异动车辆", "异动和待交车分别多少", "异动有多少,占比多少", "有多少异动车辆,查看明细", "分析异动车辆数量变化"]) {
|
||||||
|
assert.equal(simplifyCountAnswer(make(),question),false,question);
|
||||||
|
}
|
||||||
|
for (const override of [{outcome:"partial" as const},{warning:"源数据未接入"},{metrics:[]},{plan:{...make().plan,view:"list" as const}},{plan:{...make().plan,groupBy:"city" as const}}]) {
|
||||||
|
assert.equal(simplifyCountAnswer({...make(),...override},"当前有多少异动车辆?"),false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
import { CATEGORY_LABELS, GROUP_LABELS, type AssistantResult, type QueryPlan } from "../../../shared/assets/assistant.js";
|
import { CATEGORY_LABELS, GROUP_LABELS, type AssistantResult, type QueryPlan } from "../../../shared/assets/assistant.js";
|
||||||
|
|
||||||
|
/** Only simplify verified, single-scope count queries; never infer zero from missing data. */
|
||||||
|
export function simplifyCountAnswer(result: AssistantResult, question: string): boolean {
|
||||||
|
const p = result.plan;
|
||||||
|
const metric = result.metrics.find(m => m.label === CATEGORY_LABELS[p.category] && m.unit === "台");
|
||||||
|
if (result.sections?.length || p.kind !== "assets" || p.responseMode ||
|
||||||
|
p.groupBy !== "none" || p.secondaryGroupBy || p.view !== "chart" ||
|
||||||
|
(p.analysis && p.analysis !== "distribution") || p.comparePrevious || p.topN ||
|
||||||
|
result.warning || result.unanswered?.length || result.gap ||
|
||||||
|
!["complete", "empty"].includes(result.outcome || "") ||
|
||||||
|
!metric || !Number.isFinite(metric.value) || metric.value < 0 ||
|
||||||
|
!/(多少|几台|几辆|数量|总数)/.test(question) ||
|
||||||
|
/(分别|各|分布|占比|比例|运营率|对比|比较|趋势|变化|原因|为什么|分析|明细|清单|列表|表格|图表|排行|最多|最少|以及|和|与|、)/.test(question)) return false;
|
||||||
|
const scope = result.scope || resultScope(p, null);
|
||||||
|
const filters = scope.filters.slice(1);
|
||||||
|
result.conciseCount = true;
|
||||||
|
result.summary = `${scope.company}${filters.length ? `,${filters.join(",")}` : ""},当前${CATEGORY_LABELS[p.category]}为 ${metric.value.toLocaleString()} 台。`;
|
||||||
|
result.metrics = [metric];
|
||||||
|
result.suggestions = [];
|
||||||
|
// A successful zero count is an answer, not a query failure or missing dataset.
|
||||||
|
result.outcome = "complete";
|
||||||
|
delete result.presentation;
|
||||||
|
delete result.presentationStatus;
|
||||||
|
delete result.presentationWarning;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function resultScope(plan: QueryPlan, subject: string | null): NonNullable<AssistantResult["scope"]> {
|
export function resultScope(plan: QueryPlan, subject: string | null): NonNullable<AssistantResult["scope"]> {
|
||||||
const filters = [
|
const filters = [
|
||||||
plan.kind === "flow" ? "交退换车业务" : CATEGORY_LABELS[plan.category],
|
plan.kind === "flow" ? "交退换车业务" : CATEGORY_LABELS[plan.category],
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export type DemandGap = {
|
|||||||
};
|
};
|
||||||
export type AssistantResult = {
|
export type AssistantResult = {
|
||||||
plan: QueryPlan;
|
plan: QueryPlan;
|
||||||
|
conciseCount?: boolean;
|
||||||
presentation?: ResultPresentation;
|
presentation?: ResultPresentation;
|
||||||
presentationStatus?: "generated" | "fallback";
|
presentationStatus?: "generated" | "fallback";
|
||||||
presentationWarning?: string;
|
presentationWarning?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user