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";
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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,/统计口径、权限与数据时间/);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user