feat(agent): in-drawer history tab and denser session layout (AGUI-2)
- Drawer tabs 当前/历史: searchable run list (status/source/time filters, cursor auto-load), opens runs inside the drawer, keeps scroll and filters, remembers the last tab; 在页面打开 is secondary - Ended runs: append to resume, or 用同目标新建 with 先看方案再执行 toggle - Session page: 88ch conversation column, turns grouped with one timestamp, tool cards collapse to one line, 3+ activities fold; steps/subagents/budget moved to a right rail (collapsible overview bar on narrow screens and in the drawer), long model reports fold behind 展开全文 - Smoke covers the drawer history flow Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ac478673bc
commit
20b63db254
14 files changed
+1234
-349
No files matched your search
@@ -51,6 +51,7 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami
|
||||
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
|
||||
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
|
||||
@@ -148,6 +149,38 @@ export function authDenial(call: ToolCallView): string | null {
|
||||
/** 被服务端拒绝(白名单拦截或授权校验失败) */
|
||||
export const isDenied = (call: ToolCallView) => call.status === "denied" || authDenial(call) !== null;
|
||||
|
||||
/**
|
||||
* 工具卡收起时那一行的摘要:错误取 message;数组 / items 取条数;对象取前几个简单字段;
|
||||
* 其余截断原文。只做展示,不做判断(纯函数,单测覆盖)。
|
||||
*/
|
||||
export function toolSummary(call: Pick<ToolCallView, "status" | "outputSummary">): string | null {
|
||||
const raw = call.outputSummary?.trim();
|
||||
if (!raw) return call.status === "running" ? "等待结果…" : null;
|
||||
const clip = (t: string) => (t.length > 120 ? `${t.slice(0, 120)}…` : t);
|
||||
let v: unknown;
|
||||
try {
|
||||
v = JSON.parse(raw);
|
||||
} catch {
|
||||
return clip(raw.replace(/\s+/g, " "));
|
||||
}
|
||||
if (Array.isArray(v)) return `${v.length} 条`;
|
||||
if (v && typeof v === "object") {
|
||||
const o = v as Record<string, unknown>;
|
||||
const err = o.error as { message?: unknown; code?: unknown } | undefined;
|
||||
if (err && typeof err === "object") return clip(String(err.message ?? err.code ?? "失败"));
|
||||
for (const k of ["summary", "message"]) if (typeof o[k] === "string") return clip(o[k] as string);
|
||||
for (const k of ["items", "results", "releases", "candidates"])
|
||||
if (Array.isArray(o[k])) return `${(o[k] as unknown[]).length} 条结果`;
|
||||
const simple = Object.entries(o)
|
||||
.filter(([, x]) => ["string", "number", "boolean"].includes(typeof x))
|
||||
.slice(0, 3)
|
||||
.map(([k, x]) => `${k}: ${String(x)}`);
|
||||
if (simple.length) return clip(simple.join(" · "));
|
||||
return `${Object.keys(o).length} 个字段`;
|
||||
}
|
||||
return clip(String(v));
|
||||
}
|
||||
|
||||
/** 工具调用卡片:脱敏输入/输出折叠、耗时、scope/风险徽标、幂等键(审计);服务端拒绝时换醒目拒绝态 */
|
||||
export function ToolCallCard({
|
||||
call,
|
||||
@@ -198,7 +231,7 @@ export function ToolCallCard({
|
||||
extra={call.recovery ? <StatusBadge tone="warning">恢复补记</StatusBadge> : null}
|
||||
/>
|
||||
}
|
||||
summary={call.outputSummary ?? undefined}
|
||||
summary={toolSummary(call) ?? undefined}
|
||||
>
|
||||
{call.inputSummary ? <ToolResultOutput label="输入(已脱敏)">{call.inputSummary}</ToolResultOutput> : null}
|
||||
{call.outputSummary ? (
|
||||
@@ -384,7 +417,112 @@ export function pairAudit(calls: ToolCallView[], audit: InvocationAudit[] | unde
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 会话流:用户目标 → 流式文本/思考/工具卡片/拒绝/压缩 → 结束;追加的用户消息按时间插入 */
|
||||
// ── 消息分组:同一助手回合(两条用户消息 / 系统提示之间)的文本、思考、工具卡归为一组,时间戳只在组头;
|
||||
// 组内连续 ≥3 条已结束的思考 / 工具调用再折叠成一行"活动"(被拒绝、运行中的保持展开,醒目可见) ──
|
||||
|
||||
export type ConvEntry = {
|
||||
key: string;
|
||||
at: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
/** 可并入"活动"折叠(已结束的思考 / 工具调用) */
|
||||
foldable: boolean;
|
||||
/** 折叠摘要里的名字(能力名 / "思考") */
|
||||
label?: string;
|
||||
failed?: boolean;
|
||||
node: ReactNode;
|
||||
};
|
||||
export type ConvPart = { kind: "item"; entry: ConvEntry } | { kind: "fold"; key: string; entries: ConvEntry[] };
|
||||
export type ConvBlock =
|
||||
| { kind: "single"; entry: ConvEntry }
|
||||
| { kind: "turn"; key: string; at: string; parts: ConvPart[] };
|
||||
|
||||
function foldRuns(parts: ConvPart[], min: number): ConvPart[] {
|
||||
const out: ConvPart[] = [];
|
||||
let run: ConvEntry[] = [];
|
||||
const flush = () => {
|
||||
if (run.length >= min) out.push({ kind: "fold", key: `f${run[0]?.key}`, entries: run });
|
||||
else for (const e of run) out.push({ kind: "item", entry: e });
|
||||
run = [];
|
||||
};
|
||||
for (const p of parts) {
|
||||
if (p.kind === "item" && p.entry.foldable) run.push(p.entry);
|
||||
else {
|
||||
flush();
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 纯函数(单测覆盖):按时间排好的条目 → 用户/系统单条 + 助手回合组 */
|
||||
export function groupConversation(entries: ConvEntry[], minFold = 3): ConvBlock[] {
|
||||
const out: ConvBlock[] = [];
|
||||
let turn: Extract<ConvBlock, { kind: "turn" }> | null = null;
|
||||
for (const e of entries) {
|
||||
if (e.role !== "assistant") {
|
||||
turn = null;
|
||||
out.push({ kind: "single", entry: e });
|
||||
continue;
|
||||
}
|
||||
if (!turn) {
|
||||
turn = { kind: "turn", key: `t${e.key}`, at: e.at, parts: [] };
|
||||
out.push(turn);
|
||||
}
|
||||
turn.parts.push({ kind: "item", entry: e });
|
||||
}
|
||||
for (const b of out) if (b.kind === "turn") b.parts = foldRuns(b.parts, minFold);
|
||||
return out;
|
||||
}
|
||||
|
||||
function clockText(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const sameDay = new Date().toDateString() === d.toDateString();
|
||||
return sameDay
|
||||
? d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false })
|
||||
: formatDateTime(iso);
|
||||
}
|
||||
|
||||
/** 折叠的一串活动:一行摘要(次数 + 能力名预览 + 失败数),展开看每张卡 */
|
||||
function ActivityFold({ entries }: { entries: ConvEntry[] }) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const tools = entries.filter((e) => e.label !== "思考").length;
|
||||
const thoughts = entries.length - tools;
|
||||
const failed = entries.filter((e) => e.failed).length;
|
||||
const names = [...new Set(entries.filter((e) => e.label && e.label !== "思考").map((e) => e.label))];
|
||||
return (
|
||||
<div className="w-full min-w-0 rounded-xl border border-border/60 bg-foreground/[0.015]">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex min-h-9 w-full min-w-0 cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-left text-meta text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
||||
<span className="shrink-0 text-foreground/85 tabular-nums">
|
||||
{tools ? `${tools} 次工具调用` : ""}
|
||||
{tools && thoughts ? "," : ""}
|
||||
{thoughts ? `思考 ${thoughts} 段` : ""}
|
||||
</span>
|
||||
{failed ? <span className="shrink-0 text-destructive tabular-nums">失败 {failed}</span> : null}
|
||||
<span className="min-w-0 flex-1 truncate">{names.slice(0, 4).join(" → ")}</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5 shrink-0", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
<Disclosure id={id} open={open}>
|
||||
<div className="flex flex-col gap-2 border-t border-border/50 p-2">
|
||||
{entries.map((e) => (
|
||||
<div key={e.key}>{e.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 会话流:用户目标 → 助手回合(流式文本/思考/工具卡片/方案/审批)→ 结束;追加的用户消息按时间插入 */
|
||||
export function AgentConversation({
|
||||
run,
|
||||
live,
|
||||
@@ -413,16 +551,28 @@ export function AgentConversation({
|
||||
const lastIndex = live.items.length - 1;
|
||||
const lastText = live.items.map((i) => i.kind).lastIndexOf("text");
|
||||
|
||||
type Row = { key: string; at: string; node: ReactNode };
|
||||
const rows: Row[] = live.items.map((it, idx) => {
|
||||
const entries: ConvEntry[] = [];
|
||||
live.items.forEach((it, idx) => {
|
||||
const streaming = active && idx === lastIndex;
|
||||
const anim = fresh(idx);
|
||||
let node: ReactNode = null;
|
||||
const wrap = (node: ReactNode) =>
|
||||
anim ? (
|
||||
<div className="animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none">
|
||||
{node}
|
||||
</div>
|
||||
) : (
|
||||
node
|
||||
);
|
||||
switch (it.kind) {
|
||||
case "text":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim}>
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "assistant",
|
||||
foldable: false,
|
||||
node: wrap(
|
||||
<StreamingResponse
|
||||
className="max-w-[72ch]"
|
||||
status={streaming ? "streaming" : "complete"}
|
||||
copyText={it.text}
|
||||
announce={false}
|
||||
@@ -430,77 +580,128 @@ export function AgentConversation({
|
||||
>
|
||||
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
|
||||
{streaming ? it.text : <Markdown text={it.text} />}
|
||||
</StreamingResponse>
|
||||
</AssistantRow>
|
||||
);
|
||||
</StreamingResponse>,
|
||||
),
|
||||
});
|
||||
break;
|
||||
case "thinking":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<ThinkingBlock text={it.text} streaming={streaming} />
|
||||
</AssistantRow>
|
||||
);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "assistant",
|
||||
foldable: !streaming,
|
||||
label: "思考",
|
||||
node: wrap(<ThinkingBlock text={it.text} streaming={streaming} />),
|
||||
});
|
||||
break;
|
||||
case "tool":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />
|
||||
</AssistantRow>
|
||||
);
|
||||
case "tool": {
|
||||
const denied = isDenied(it.call);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.call.startedAt,
|
||||
role: "assistant",
|
||||
foldable: !denied && it.call.status !== "running" && idx !== lastIndex,
|
||||
label: capabilityMeta(it.call.capabilityId)?.title ?? it.call.capabilityId,
|
||||
failed: it.call.status === "failed",
|
||||
node: wrap(<ToolCallCard call={it.call} audit={auditByCall.get(it.call.toolCallId)} />),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "compaction":
|
||||
node = (
|
||||
<MessageMarker>
|
||||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
||||
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
|
||||
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
|
||||
</MessageMarker>
|
||||
);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "system",
|
||||
foldable: false,
|
||||
node: (
|
||||
<MessageMarker>
|
||||
<Layers aria-hidden className="size-3.5 shrink-0" />
|
||||
上下文压缩({COMPACT_REASON[it.reason] ?? it.reason})·{" "}
|
||||
{it.done ? (COMPACT_STATUS[it.status ?? "completed"] ?? it.status) : "进行中…"}
|
||||
</MessageMarker>
|
||||
),
|
||||
});
|
||||
break;
|
||||
case "options":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<OptionsCard runId={run.id} view={it.view} waiting={waiting} />
|
||||
</AssistantRow>
|
||||
);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "assistant",
|
||||
foldable: false,
|
||||
node: wrap(<OptionsCard runId={run.id} view={it.view} waiting={waiting} />),
|
||||
});
|
||||
break;
|
||||
case "approval":
|
||||
node = (
|
||||
<AssistantRow animateIn={anim} avatar={false}>
|
||||
<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />
|
||||
</AssistantRow>
|
||||
);
|
||||
entries.push({
|
||||
key: it.key,
|
||||
at: it.at,
|
||||
role: "assistant",
|
||||
foldable: false,
|
||||
node: wrap(<InlineApproval view={it.view} stepTitle={stepTitle(it.view.stepId)} />),
|
||||
});
|
||||
break;
|
||||
case "end":
|
||||
node = it.ok ? null : <EndNotice item={it} />;
|
||||
if (!it.ok)
|
||||
entries.push({ key: it.key, at: it.at, role: "system", foldable: false, node: <EndNotice item={it} /> });
|
||||
break;
|
||||
}
|
||||
return { key: it.key, at: it.kind === "tool" ? it.call.startedAt : it.at, node };
|
||||
});
|
||||
for (const m of userMessages)
|
||||
rows.push({
|
||||
entries.push({
|
||||
key: `u${m.at}`,
|
||||
at: m.at,
|
||||
role: "user",
|
||||
foldable: false,
|
||||
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
|
||||
});
|
||||
rows.sort((a, b) => a.at.localeCompare(b.at));
|
||||
entries.sort((a, b) => a.at.localeCompare(b.at));
|
||||
const blocks = groupConversation(entries);
|
||||
const tail = live.items[lastIndex]?.kind;
|
||||
const typing =
|
||||
active && tail !== "text" && tail !== "thinking" ? (
|
||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||||
{run.status === "waiting"
|
||||
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
||||
: run.status === "queued"
|
||||
? "排队中…"
|
||||
: "正在处理…"}
|
||||
</span>
|
||||
) : null;
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
const typingInTurn = typing && lastBlock?.kind === "turn";
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-1 py-2">
|
||||
<div className="space-y-5 px-1 py-2">
|
||||
<UserMessage text={run.goal} at={run.createdAt} tag={isProposeFirst(run) ? "先看方案再执行" : undefined} />
|
||||
{rows.map((r) => (r.node ? <div key={r.key}>{r.node}</div> : null))}
|
||||
{active && live.items[lastIndex]?.kind !== "text" && live.items[lastIndex]?.kind !== "thinking" ? (
|
||||
<AssistantRow avatar={false}>
|
||||
<span className="inline-flex items-center gap-2 text-meta text-muted-foreground">
|
||||
<MessageTyping label={run.status === "waiting" ? "等待中" : "正在处理"} />
|
||||
{run.status === "waiting"
|
||||
? `${run.summary === "等待选择方案" || run.summary === "等待审批" ? run.summary : "等待中"}…`
|
||||
: run.status === "queued"
|
||||
? "排队中…"
|
||||
: "正在处理…"}
|
||||
</span>
|
||||
</AssistantRow>
|
||||
) : null}
|
||||
{blocks.map((b) =>
|
||||
b.kind === "single" ? (
|
||||
<div key={b.entry.key}>{b.entry.node}</div>
|
||||
) : (
|
||||
<Message key={b.key} from="assistant" aria-label="Agent 回合">
|
||||
<MessageAvatar>
|
||||
<Bot />
|
||||
</MessageAvatar>
|
||||
<MessageContent className="gap-2">
|
||||
<MessageHeader>
|
||||
<span className="font-medium text-foreground/80">Agent</span>
|
||||
<time dateTime={b.at}>{clockText(b.at)}</time>
|
||||
</MessageHeader>
|
||||
{b.parts.map((p) =>
|
||||
p.kind === "fold" ? (
|
||||
<ActivityFold key={p.key} entries={p.entries} />
|
||||
) : (
|
||||
<div key={p.entry.key} className="w-full min-w-0">
|
||||
{p.entry.node}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{typingInTurn && b === lastBlock ? typing : null}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
),
|
||||
)}
|
||||
{typing && !typingInTurn ? <AssistantRow avatar>{typing}</AssistantRow> : null}
|
||||
{after}
|
||||
</div>
|
||||
);
|
||||
@@ -552,7 +753,16 @@ function Meter({
|
||||
}
|
||||
|
||||
/** 预算用量条:工具调用/token/时长/费用;超限的一项标红,上游未报告的显示"未报告",费用标注估算 */
|
||||
export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamState }) {
|
||||
export function BudgetBar({
|
||||
run,
|
||||
live,
|
||||
stacked = false,
|
||||
}: {
|
||||
run: AgentRunView;
|
||||
live: AgentStreamState;
|
||||
/** 侧栏里单列排 */
|
||||
stacked?: boolean;
|
||||
}) {
|
||||
const u = { ...run.usage, ...(live.usage ?? {}) };
|
||||
const toolCalls = Math.max(run.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
|
||||
const end = [...live.items].reverse().find((i) => i.kind === "end");
|
||||
@@ -563,7 +773,7 @@ export function BudgetBar({ run, live }: { run: AgentRunView; live: AgentStreamS
|
||||
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
|
||||
: undefined);
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-4">
|
||||
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2 sm:grid-cols-4")}>
|
||||
<Meter
|
||||
label="工具调用"
|
||||
used={toolCalls}
|
||||
@@ -672,6 +882,42 @@ const PLAN_STATUS_TONE = (s: string): StatusTone =>
|
||||
? "destructive"
|
||||
: "warning";
|
||||
|
||||
/** 长汇报:超过 ~12 行或 600 字按长文处理(纯函数,单测覆盖) */
|
||||
export function isLongReport(text: string): boolean {
|
||||
return text.length > 600 || text.split("\n").length > 12;
|
||||
}
|
||||
|
||||
/** 长文折叠:收起时限高 + 底部渐隐,"展开全文"/"收起" */
|
||||
function LongText({ long, children }: { long: boolean; children: ReactNode }) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!long) return <>{children}</>;
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
"relative",
|
||||
!open && "max-h-56 overflow-hidden [mask-image:linear-gradient(to_bottom,black_70%,transparent)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="mt-1"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
{open ? "收起" : "展开全文"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 最终汇报:左"模型说的(未经核查)",右"系统核实的结果"(服务端从下载计划/下载任务/整理记录汇总,不采信模型自述) */
|
||||
export function FinalReport({
|
||||
run,
|
||||
@@ -719,15 +965,17 @@ export function FinalReport({
|
||||
const imported = report?.organization.filter((o) => o.targetPath) ?? [];
|
||||
|
||||
return (
|
||||
<section aria-label="最终汇报" className="grid gap-3 md:grid-cols-2">
|
||||
<section aria-label="最终汇报" className="grid gap-3 @2xl:grid-cols-2">
|
||||
<div className="min-w-0 rounded-xl border border-dashed border-border p-3">
|
||||
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
<Bot aria-hidden className="size-3.5" />
|
||||
模型说的(未经核查)
|
||||
</p>
|
||||
<StreamingResponse status="complete" copyText={said || undefined} contentClassName="max-h-60 overflow-auto">
|
||||
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
||||
</StreamingResponse>
|
||||
<LongText long={isLongReport(said)}>
|
||||
<StreamingResponse status="complete" copyText={said || undefined}>
|
||||
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
|
||||
</StreamingResponse>
|
||||
</LongText>
|
||||
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
|
||||
|
||||
Reference in new issue
Block a user