feat(asset-ai): simplify count answers and refine mobile conversation layout
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-23 18:35:31 +08:00
parent fd514177c6
commit 5be6240bbf
13 changed files with 251 additions and 58 deletions
+23 -30
View File
@@ -30,7 +30,6 @@ import {
} from "../../../shared/assets/assistant";
import { askAssetAssistant, AssistantRequestError } from "./client";
import { fetchAuthenticated } from "../../../auth/api-client";
import { DemandGaps } from "./DemandGaps";
import { ThinkingState } from "./ThinkingState";
import { CompoundResultView } from "./CompoundResultView";
import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation";
@@ -90,6 +89,23 @@ export default function AssetAssistant({
close: () => void;
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 [turns, setTurns] = useState<Turn[]>([]);
const turnsRef = useRef(turns);
@@ -110,7 +126,6 @@ export default function AssetAssistant({
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);
@@ -189,7 +204,6 @@ export default function AssetAssistant({
return () => controller.abort();
}, []);
const revealTurn = (id: number) => {
setShowGaps(false);
setExpandedTurns((old) => new Set(old).add(id));
autoFollow.current = null;
setViewedTurn(id);
@@ -232,7 +246,6 @@ export default function AssetAssistant({
action?: AssistantAction,
) => {
if (!message.trim() || abortRef.current) return;
setShowGaps(false);
setIndependent(false);
const inlineTurn = action && base?.resultId
? turnsRef.current.find(turn => !!findConversationResult([turn], base.resultId!))
@@ -344,7 +357,6 @@ export default function AssetAssistant({
).catch(() => {});
sessionRef.current = undefined;
setActiveResult(undefined);
setShowGaps(false);
setTurns([]);
setExpandedTurns(new Set());
setDraft("");
@@ -355,6 +367,7 @@ export default function AssetAssistant({
}, [subject]);
return (
<section
ref={page}
className={`aq-shell aq-page aq-${theme}`}
aria-label="资产 AI 问数"
>
@@ -388,10 +401,6 @@ 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>
@@ -438,13 +447,6 @@ 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}
@@ -509,9 +511,7 @@ export default function AssetAssistant({
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++; } }}>
{showGaps ? (
<DemandGaps close={() => setShowGaps(false)} />
) : !turns.length ? (
{!turns.length ? (
<div className="aq-welcome">
<div className="aq-welcome-mark">
<Sparkles size={30} strokeWidth={1.3} />
@@ -519,15 +519,14 @@ export default function AssetAssistant({
<h2>关于资产,你想了解什么?</h2>
<p>
用一句话,发现数据里的答案。
<br className="aq-mobile-break" />{" "}
从全局统计,一路看到每一台车。
<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} />
<ArrowUpRight size={14} />
<p.icon size={18} aria-hidden="true" />
<ArrowUpRight size={14} aria-hidden="true" />
</div>
<strong>{p.title}</strong>
<span>{p.detail}</span>
@@ -711,13 +710,7 @@ export default function AssetAssistant({
}
}}
/>
) : (
<div className="aq-waiting">
<span />
<span />
<span />
</div>
)}
) : null}
</>
)}
</section>
@@ -169,7 +169,7 @@ export function CompoundResultView({
{result.gap && (
<p className="aq-compound-notice">
{result.gap.recorded
? "未满足的需求已记录,可在待完善需求中查看。"
? "未满足的需求已记录。"
: "未满足的需求暂未保存。"}
</p>
)}
+12 -7
View File
@@ -281,7 +281,7 @@ function ResultViewContent({
);
return (
<div className={`aq-result ${active ? "aq-result-active" : ""}`}>
{plan.kind !== "clarify" && (
{plan.kind !== "clarify" && !result.conciseCount && (
<header
className="aq-query-heading"
id={result.resultId ? `aq-query-${result.resultId}` : undefined}
@@ -362,7 +362,7 @@ function ResultViewContent({
>
{result.summary}
</p>
{!!visibleMetrics.length && (
{!result.conciseCount && !!visibleMetrics.length && (
<div className="aq-metrics aq-primary-metrics">
{visibleMetrics.map((metric) => (
<div key={metric.label}>
@@ -375,7 +375,7 @@ function ResultViewContent({
))}
</div>
)}
{!!extraMetrics.length && (
{!result.conciseCount && !!extraMetrics.length && (
<details className="aq-extra-metrics">
<summary>查看其余 {extraMetrics.length} 项指标</summary>
<div>
@@ -405,11 +405,11 @@ function ResultViewContent({
{result.gap && (
<p className="aq-gap-note">
{result.gap.recorded
? "这个需求已记下,可在「待完善需求」中查看。"
? "这个需求已记下。"
: "可以点击下方「未解决我的问题」记录需求。"}
</p>
)}
{plan.kind !== "clarify" && (
{plan.kind !== "clarify" && !result.conciseCount && (
<>
<div className="aq-result-card">
{result.inventoryBreakdown && display !== "metrics" && (
@@ -849,6 +849,11 @@ function ResultViewContent({
</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">
<details className="aq-result-definitions">
<summary>
@@ -873,7 +878,7 @@ function ResultViewContent({
<p>来源:{result.source}</p>
</details>
<div className="aq-answer-actions">
{plan.kind !== "clarify" && (
{plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && (
<button
disabled={disabled || saving}
onClick={() => void download()}
@@ -912,7 +917,7 @@ function ResultViewContent({
</p>
)}
</div>
{!!suggestions.length && (
{!result.conciseCount && !!suggestions.length && (
<div className="aq-followups">
<span>{plan.kind === "clarify" ? "你可以这样继续" : "继续分析"}</span>
{suggestions.slice(0, 4).map((question) => (
+20 -10
View File
@@ -1,7 +1,7 @@
// Adapted from Beautiful UI's MIT-licensed ThinkingState (Steps).
// Uses real server progress rather than the reference's timed demo sequence.
import { useEffect, useState } from "react";
import { Check, ChevronDown, LoaderCircle, Sparkles } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react";
export function ThinkingState({
steps,
@@ -18,27 +18,30 @@ export function ThinkingState({
stopped?: boolean;
outcome?: string;
}) {
const traceId = useId();
const [manualExpanded, setExpanded] = useState<boolean | null>(null);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!working) return;
const update = () => setElapsed((Date.now() - started) / 1000);
update();
const timer = window.setInterval(update, 100);
const timer = window.setInterval(update, 1000);
return () => window.clearInterval(timer);
}, [working, started]);
const expanded = manualExpanded ?? false;
const expanded = manualExpanded ?? working;
const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles;
return (
<div className="aq-thinking">
<div className={`aq-thinking${working ? " aq-thinking-working" : ""}`} data-state={working ? "working" : stopped ? "stopped" : failed ? "failed" : "complete"}>
<button
type="button"
aria-expanded={expanded}
aria-controls={traceId}
onClick={() => setExpanded(!expanded)}
>
<Sparkles size={15} aria-hidden="true" />
<span className={working ? "aq-shimmer" : ""}>
<StatusIcon size={16} className={working ? "aq-spin" : ""} aria-hidden="true" />
<span className={`aq-thinking-title${working ? " aq-shimmer" : ""}`}>
{working
? steps.at(-1) || "正在分析"
? "正在分析"
: stopped
? "已由用户停止"
: failed
@@ -55,7 +58,10 @@ export function ThinkingState({
? "已回答"
: "分析完成"}
</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
size={14}
className={expanded ? "aq-rotated" : ""}
@@ -63,6 +69,7 @@ export function ThinkingState({
/>
</button>
<div
id={traceId}
className="aq-trace-collapse"
aria-hidden={!expanded}
style={{
@@ -72,14 +79,17 @@ export function ThinkingState({
>
<div className="aq-trace-clip">
<ol className="aq-trace">
{!steps.length && <li><span>{working ? "正在连接分析服务…" : "暂无执行步骤"}</span></li>}
{steps.map((step, index) => (
<li key={`${index}-${step}`}>
<li key={`${index}-${step}`} data-current={index === steps.length - 1}>
{working && index === steps.length - 1 ? (
<LoaderCircle
className="aq-spin"
size={14}
aria-hidden="true"
/>
) : index === steps.length - 1 && (failed || stopped) ? (
<StatusIcon size={14} aria-hidden="true" />
) : (
<Check size={14} aria-hidden="true" />
)}
+90 -6
View File
@@ -405,9 +405,6 @@
color: var(--aq-muted);
margin-top: 24px;
}
.aq-mobile-break {
display: none;
}
.aq-turns {
max-width: 820px;
margin: 0 auto;
@@ -1124,9 +1121,6 @@
.aq-welcome > p {
font-size: 12px;
}
.aq-mobile-break {
display: block;
}
.aq-welcome-mark {
width: 52px;
height: 52px;
@@ -2014,3 +2008,93 @@
.aq-generated-metrics strong { font-size: 21px; }
.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,/统计口径、权限与数据时间/);
}
});