feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
@@ -0,0 +1,73 @@
import { useState } from "react";
import { Check, Copy, LoaderCircle } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import { MessageAction, MessageActions } from "@/shared/ai-elements/message";
import type { AnswerUiPart } from "../answer-ui/contract";
export function AgentChatMessageHeader({
running,
interactive
}: {
running: boolean;
interactive: boolean;
}) {
if (!running && !interactive) return null;
return (
<div className="agent-chat-message-header">
{running ? (
<span
role="status"
className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-muted-foreground"
>
<LoaderCircle size={12} className="motion-safe:animate-spin" aria-hidden="true" />
正在回复
</span>
) : interactive ? (
<Badge
variant="secondary"
className="ml-auto border-0 px-2 py-0.5 text-[10px] font-medium shadow-none"
>
交互回答
</Badge>
) : null}
</div>
);
}
export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
const text = parts
.map((part) => (part.type === "text" ? part.text : part.block.fallback_text))
.join("\n\n");
if (!text.trim()) return null;
return (
<MessageActions className="agent-chat-message-actions">
<MessageAction
tooltip={copied ? "回复已复制" : "复制回复"}
label={copied ? "回复已复制" : "复制回复"}
size="sm"
className="h-10 gap-1.5 px-2 text-[11px] font-normal shadow-none"
aria-label={copied ? "回复已复制" : "复制回复"}
onClick={async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setError(false);
} catch {
setCopied(false);
setError(true);
}
}}
>
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />}
{copied ? "已复制" : "复制回复"}
</MessageAction>
<span role="status" className="text-[11px] text-muted-foreground">
{error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""}
</span>
</MessageActions>
);
}
@@ -1,7 +1,8 @@
import { ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button";
import { AgentPersona } from "./agent-persona";
import { agentLayoutTransition } from "./agent-motion";
type AgentCollapsedRailProps = {
personaState?: PersonaState;
@@ -15,26 +16,27 @@ export function AgentCollapsedRail({
onExpand
}: AgentCollapsedRailProps) {
const expandLabel = `展开 Agent 助手面板,当前状态:${statusLabel}`;
const prefersReducedMotion = useReducedMotion();
return (
<aside
<motion.aside
aria-label="Agent 折叠栏"
className={cn(
"agent-rail-enter acrylic-control pointer-events-auto w-[72px] rounded-2xl border p-1.5"
)}
initial={prefersReducedMotion ? false : { opacity: 0, x: -5, scale: 0.94 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : agentLayoutTransition}
style={{ transformOrigin: "left top" }}
className="agent-collapsed-rail pointer-events-auto size-[var(--workbench-agent-rail-width,64px)] rounded-full"
>
<button
<Button
variant="ghost"
type="button"
aria-label={expandLabel}
title={expandLabel}
onClick={onExpand}
className="agent-rail-item group relative flex h-14 w-full items-center justify-center rounded-xl border border-transparent bg-transparent hover:bg-[var(--action-selection-soft)]"
className="flex h-full w-full items-center justify-center rounded-full border-0 bg-transparent p-0"
>
<AgentPersona className="h-11 w-11" state={personaState} />
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
<ChevronRight size={13} strokeWidth={2.5} aria-hidden="true" />
</span>
</button>
</aside>
<AgentPersona className="h-full w-full" state={personaState} />
</Button>
</motion.aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,333 +1,196 @@
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Check, ChevronDown, FolderInput, MoreHorizontal, Pencil, RefreshCw, Search, Trash2, X } from "lucide-react";
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/shared/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { Separator } from "@/shared/ui/separator";
import { cn } from "@/shared/ui/cn";
import { StatusBadge, StatusDot } from "@/shared/ui/status";
import type { AgentChatSessionSummary } from "../api/client";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants
} from "./agent-motion";
import { StatusBadge } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentTopic } from "../api/client";
type AgentHistoryPanelProps = {
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
triggerRef: RefObject<HTMLButtonElement | null>;
scopeLabel: string;
activeSessionId?: string | null;
loading: boolean;
loadingSessionId: string | null;
sessions: AgentChatSessionSummary[];
topics?: AgentTopic[];
topicId?: string;
onTopicChange?: (id: string | undefined) => void;
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>;
query?: string;
onQueryChange?: (value: string) => void;
hasMore?: boolean;
loadingMore?: boolean;
error?: string;
pendingUpdate?: boolean;
onLoadMore?: () => Promise<unknown> | void;
onRefresh?: () => Promise<void> | void;
onSelectSession: (sessionId: string) => void;
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
onDeleteSession?: (sessionId: string) => Promise<void> | void;
};
export function AgentHistoryPanel({
activeSessionId,
loading,
loadingSessionId,
sessions,
onRefresh,
onSelectSession,
onRenameSession,
onDeleteSession
}: AgentHistoryPanelProps) {
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
const beginRename = (session: AgentChatSessionSummary) => {
setEditingSessionId(session.id);
setDraftTitle(session.title);
export function AgentHistoryPanel({ open, onOpenChange, triggerRef, scopeLabel, activeSessionId, loading, loadingSessionId,
sessions, query = "", onQueryChange, hasMore, loadingMore, error, pendingUpdate, onLoadMore, onRefresh,
topics = [], topicId, onTopicChange, onMoveSessionToTopic, onSelectSession, onRenameSession, onDeleteSession }: Props) {
const [search, setSearch] = useState(query);
const [movingId, setMovingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [viewport, setViewport] = useState<{ height: number; top: number } | null>(null);
useEffect(() => {
if (!open) return;
const visual = window.visualViewport;
const update = () => {
const next = { height: visual?.height ?? window.innerHeight, top: visual?.offsetTop ?? 0 };
setViewport((current) => current?.height === next.height && current.top === next.top ? current : next);
};
update();
visual?.addEventListener("resize", update);
visual?.addEventListener("scroll", update);
window.addEventListener("resize", update);
return () => {
visual?.removeEventListener("resize", update);
visual?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [open]);
useEffect(() => {
if (!open || search === query) return;
const timer = setTimeout(() => onQueryChange?.(search.trim()), 250);
return () => clearTimeout(timer);
}, [onQueryChange, open, query, search]);
const perform = async (id: string, action: () => Promise<void> | void) => {
setBusy(id); setActionError(null);
try { await action(); setEditingId(null); setDeletingId(null); }
catch (reason) { setActionError(reason instanceof Error ? reason.message : "操作失败,请重试"); }
finally { setBusy(null); }
};
const cancelRename = () => {
setEditingSessionId(null);
setDraftTitle("");
};
const saveRename = (session: AgentChatSessionSummary) => {
const normalizedTitle = draftTitle.trim();
if (!normalizedTitle || normalizedTitle === session.title) {
cancelRename();
return;
}
setSavingSessionId(session.id);
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
.then(cancelRename)
.finally(() => setSavingSessionId(null));
};
const confirmDelete = (sessionId: string) => {
setDeletingSessionId(sessionId);
void Promise.resolve(onDeleteSession?.(sessionId))
.then(() => setConfirmingDeleteSessionId(null))
.finally(() => setDeletingSessionId(null));
};
return (
<div className="px-3 pb-3">
<div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-950">历史记录</p>
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
</p>
let previousGroup = "";
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} data-agent-theme="chat"
overlayClassName="agent-history-overlay motion-reduce:animate-none"
className="agent-history-dialog flex flex-col gap-0 overflow-hidden bg-card p-0 text-foreground motion-reduce:animate-none"
style={viewport ? { "--history-viewport-height": `${viewport.height}px`, "--history-viewport-top": `${viewport.top}px` } as CSSProperties : undefined}
onOpenAutoFocus={(event) => { event.preventDefault(); searchRef.current?.focus(); }}
onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}>
<div data-agent-history-header className="shrink-0 space-y-4 p-4 sm:p-6 sm:pb-4">
<div className="flex items-start justify-between gap-4">
<DialogHeader className="min-w-0 text-left">
<DialogTitle>历史对话</DialogTitle>
<DialogDescription className="break-words">{scopeLabel}</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center gap-2">
<Button variant="ghost" size="icon-sm" aria-label="刷新 Agent 历史记录" loading={loadingMore}
onClick={() => void perform("refresh", () => onRefresh?.())}><RefreshCw aria-hidden="true" /></Button>
<Button variant="ghost" size="icon-sm" aria-label="关闭历史对话" onClick={() => onOpenChange(false)}><X aria-hidden="true" /></Button>
</div>
<Button
type="button"
aria-label="刷新 Agent 历史记录"
title="刷新 Agent 历史记录"
size="icon"
variant="ghost"
disabled={loading && sessions.length === 0}
loading={loading && sessions.length > 0}
loadingLabel="正在刷新 Agent 历史记录"
onClick={() => void Promise.resolve(onRefresh?.())}
>
<RefreshCw size={15} aria-hidden="true" />
</Button>
</div>
<motion.div
className="max-h-56 space-y-1 overflow-y-auto pr-1"
variants={agentPanelListVariants}
animate="animate"
>
<AnimatePresence initial={false} mode="popLayout">
{loading && sessions.length === 0 ? (
<motion.div
key="history-loading"
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-[var(--ink-muted)]"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
正在加载
</motion.div>
) : sessions.length > 0 ? (
sessions.map((session) => {
const active = session.id === activeSessionId;
const itemLoading = loadingSessionId === session.id;
const editing = editingSessionId === session.id;
const saving = savingSessionId === session.id;
const deleting = deletingSessionId === session.id;
const confirmingDelete = confirmingDeleteSessionId === session.id;
return (
<motion.div
key={session.id}
layout
className={cn(
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
active && !editing
? "agent-history-item-active border border-[var(--surface-border)]"
: "border border-transparent"
)}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
transition={agentLayoutTransition}
>
<AnimatePresence initial={false} mode="popLayout">
{confirmingDelete ? (
<motion.div
key="confirm-delete"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
<span
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
title={session.title}
>
删除“{session.title}”?
</span>
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
删除后无法从历史记录中恢复
</span>
</motion.div>
) : editing ? (
<motion.form
key="rename"
className="min-w-0"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
onSubmit={(event) => {
event.preventDefault();
saveRename(session);
}}
>
<Input
autoFocus
className="agent-history-rename-input h-8 rounded-lg bg-[var(--surface-reading)] text-xs"
value={draftTitle}
disabled={saving}
onChange={(event) => setDraftTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
cancelRename();
}
}}
/>
</motion.form>
) : (
<motion.button
key="session-summary"
type="button"
className="agent-history-session-trigger min-w-0 text-left"
disabled={itemLoading || deleting}
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
onClick={() => onSelectSession(session.id)}
>
<span
className="block truncate text-xs font-semibold text-slate-900"
title={session.title}
>
{session.title}
</span>
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
{formatSessionUpdatedAt(session.updatedAt)}
</span>
</motion.button>
)}
</AnimatePresence>
<span className="flex items-center gap-2">
{session.runStatus === "running" || session.isStreaming ? (
<StatusDot tone="info" activity="live" />
) : null}
{itemLoading || saving || deleting ? (
<Loader2
size={13}
className="animate-spin text-[var(--action-blue-hover)]"
aria-hidden="true"
/>
) : null}
{active && !editing && !confirmingDelete ? (
<StatusBadge tone="info">当前</StatusBadge>
) : null}
{confirmingDelete ? (
<>
<Button
type="button"
variant="destructive"
size="sm"
loading={deleting}
loadingLabel="正在删除对话"
onClick={() => confirmDelete(session.id)}
>
确认
</Button>
<Button
type="button"
aria-label="取消删除"
title="取消删除"
size="icon-sm"
variant="ghost"
disabled={deleting}
onClick={() => setConfirmingDeleteSessionId(null)}
>
<X size={14} aria-hidden="true" />
</Button>
</>
) : editing ? (
<>
<Button
type="button"
aria-label="保存对话主题"
title="保存对话主题"
size="icon-sm"
loading={saving}
loadingLabel="正在保存对话主题"
onClick={() => saveRename(session)}
>
<Check size={14} aria-hidden="true" />
</Button>
<Button
type="button"
aria-label="取消重命名"
title="取消重命名"
size="icon-sm"
variant="ghost"
disabled={saving}
onClick={cancelRename}
>
<X size={14} aria-hidden="true" />
</Button>
</>
) : (
<>
<Button
type="button"
aria-label="重命名对话"
title="重命名对话"
size="icon-sm"
variant="ghost"
disabled={itemLoading || deleting || !onRenameSession}
onClick={() => beginRename(session)}
>
<Pencil size={14} aria-hidden="true" />
</Button>
<Button
type="button"
aria-label="删除对话"
title="删除对话"
size="icon-sm"
variant="ghost"
className="text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600"
disabled={itemLoading || deleting || !onDeleteSession}
onClick={() => {
cancelRename();
setConfirmingDeleteSessionId(session.id);
}}
>
<Trash2 size={14} aria-hidden="true" />
</Button>
</>
)}
</span>
</motion.div>
);
})
) : (
<motion.div
key="history-empty"
className="agent-history-state rounded-xl px-3 py-3 text-xs text-[var(--ink-muted)]"
initial="initial"
animate="animate"
exit="exit"
variants={agentPanelItemVariants}
>
暂无历史记录
</motion.div>
)}
</AnimatePresence>
</motion.div>
<div className="relative">
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input ref={searchRef} type="search" maxLength={200} aria-label="搜索标题和对话内容" placeholder="搜索标题或对话内容"
value={search} onChange={(event) => setSearch(event.target.value)} className="pl-9" />
</div>
{onTopicChange || pendingUpdate ? <div className="flex flex-wrap items-center justify-between gap-2">
{onTopicChange ? <DropdownMenu><DropdownMenuTrigger asChild><Button variant="outline" size="sm" aria-label="筛选历史主题" className="max-w-full">
<FolderInput aria-hidden="true" /><span className="truncate">{topicId === "unclassified" ? "未分类" : topics.find((topic) => topic.id === topicId)?.name ?? "全部主题"}</span><ChevronDown aria-hidden="true" />
</Button></DropdownMenuTrigger><DropdownMenuContent data-agent-theme="chat" align="start" className="z-[112] max-h-64 overflow-y-auto">
<DropdownMenuRadioGroup value={topicId ?? "all"} onValueChange={(id) => onTopicChange(id === "all" ? undefined : id)}>
<DropdownMenuRadioItem value="all">全部主题</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="unclassified">未分类</DropdownMenuRadioItem>
{topics.map((topic) => <DropdownMenuRadioItem key={topic.id} value={topic.id}>{topic.name}</DropdownMenuRadioItem>)}
</DropdownMenuRadioGroup>
</DropdownMenuContent></DropdownMenu> : null}
{pendingUpdate ? <Button variant="link" className="text-xs" onClick={() => void perform("refresh", () => onRefresh?.())}>
历史记录有更新,点击刷新
</Button> : null}
</div> : null}
{error || actionError ? <p role="alert" className="text-sm text-destructive">{actionError ?? error}</p> : null}
</div>
</div>
);
<Separator />
<div role="region" aria-label="Agent 历史记录" aria-busy={loading || undefined}
className="agent-history-list min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-4 sm:px-4">
{loading && !sessions.length ? <p role="status" className="px-3 py-8 text-center text-sm text-muted-foreground">正在加载历史对话…</p>
: !sessions.length ? <p className="px-3 py-8 text-center text-sm text-muted-foreground">{error ? "历史记录暂不可用,请刷新重试" : query ? "没有找到匹配的会话" : "暂无历史记录"}</p> : null}
{sessions.map((session) => {
const group = historyGroup(session.updatedAt);
const showGroup = group !== previousGroup;
previousGroup = group;
const active = session.id === activeSessionId;
const editing = session.id === editingId;
const deleting = session.id === deletingId;
const itemBusy = busy === session.id || loadingSessionId === session.id;
return <div key={session.id}>
{showGroup ? <h3 className="px-3 pb-2 pt-4 text-xs font-medium text-muted-foreground">{group}</h3> : null}
<div className={cn("agent-history-item flex items-center gap-3 rounded-md p-3", active ? "agent-history-item-active bg-accent" : "hover:bg-accent/50")}>
{editing ? <form className="flex min-w-0 flex-1 flex-wrap items-center gap-2" onSubmit={(event) => {
event.preventDefault(); if (title.trim()) void perform(session.id, () => onRenameSession?.(session.id, title.trim()));
}} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditingId(null); } }}>
<Input autoFocus aria-label="会话标题" value={title} disabled={itemBusy} onChange={(event) => setTitle(event.target.value)} className="min-w-0 flex-1" />
<Button type="submit" size="icon" aria-label="保存对话标题" loading={itemBusy} disabled={!title.trim()}><Check aria-hidden="true" /></Button>
<Button type="button" size="icon" variant="ghost" aria-label="取消重命名" onClick={() => setEditingId(null)}><X aria-hidden="true" /></Button>
</form> : movingId === session.id ? <div className="min-w-0 flex-1 space-y-2">
<p className="text-xs font-medium text-muted-foreground">移入主题</p>
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
{[{ id: "", name: "未分类" }, ...topics].map((topic) => <Button key={topic.id} variant="ghost" disabled={Boolean(busy)} className="h-auto min-h-9 justify-start whitespace-normal break-words text-left" onClick={() => void perform(session.id, async () => {
await onMoveSessionToTopic?.(session.id, topic.id || null); setMovingId(null);
})}>{topic.name}</Button>)}
</div><Button size="sm" variant="ghost" onClick={() => setMovingId(null)}>取消移动</Button>
</div> : deleting ? <div className="flex min-w-0 flex-1 flex-wrap items-center justify-between gap-2">
<div className="min-w-0 flex-1"><p className="break-words text-sm">删除“{session.title}”?</p><p className="mt-1 text-xs text-muted-foreground">删除后无法从历史记录中恢复</p></div>
<Button variant="destructive" loading={itemBusy} onClick={() => void perform(session.id, () => onDeleteSession?.(session.id))}>确认</Button>
<Button variant="ghost" size="icon" aria-label="取消删除" onClick={() => setDeletingId(null)}><X aria-hidden="true" /></Button>
</div> : <>
<button type="button" className="agent-history-session-trigger min-w-0 flex-1 rounded-sm text-left outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-current={active ? "true" : undefined}
disabled={itemBusy || Boolean(loadingSessionId)} onClick={() => onSelectSession(session.id)}>
<span className="line-clamp-2 break-words text-sm font-medium leading-6 text-foreground" title={session.title}>{session.title}</span>
<span className="mt-1 block truncate text-xs text-muted-foreground">{topics.find((topic) => topic.id === session.topicId)?.name ?? session.topicName ?? "未分类"}</span>
{session.searchSnippet ? <span className="mt-1 line-clamp-2 break-words text-xs leading-5 text-muted-foreground">{session.searchSnippet}</span> : null}
<span className="mt-1 block text-xs tabular-nums text-muted-foreground">{formatUpdatedAt(session.updatedAt)}{itemBusy ? " · 正在打开" : ""}</span>
</button>
<span className="flex shrink-0 flex-col items-end gap-1">
{session.purpose === "task_collaboration" ? <StatusBadge tone="neutral">任务协作</StatusBadge> : null}
{active ? <StatusBadge tone="info">当前</StatusBadge> : null}
{session.needsInput ? <StatusBadge tone="warning">需要确认</StatusBadge> : null}
{session.isStreaming || session.runStatus === "running" ? <StatusBadge tone="info">处理中</StatusBadge> : null}
{session.runStatus === "error" ? <StatusBadge tone="danger">处理失败</StatusBadge> : null}
</span>
<DropdownMenu><DropdownMenuTrigger asChild><Button size="icon" variant="ghost" disabled={Boolean(loadingSessionId)} aria-label={`会话操作:${session.title}`}><MoreHorizontal aria-hidden="true" /></Button></DropdownMenuTrigger>
<DropdownMenuContent data-agent-theme="chat" align="end" className="z-[112]">
<DropdownMenuItem disabled={!onRenameSession} onSelect={() => { setEditingId(session.id); setDeletingId(null); setTitle(session.title); }}><Pencil aria-hidden="true" />重命名对话</DropdownMenuItem>
<DropdownMenuItem disabled={!onMoveSessionToTopic} onSelect={() => { setMovingId(session.id); setEditingId(null); setDeletingId(null); }}><FolderInput aria-hidden="true" />移入主题</DropdownMenuItem>
<DropdownMenuItem disabled={!onDeleteSession} onSelect={() => { setDeletingId(session.id); setEditingId(null); }}><Trash2 aria-hidden="true" />删除对话</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>}
</div>
</div>;
})}
{hasMore ? <div className="px-3 pt-4"><Button className="w-full" variant="outline" loading={loadingMore} onClick={() => void onLoadMore?.()}>加载更多</Button></div> : null}
</div>
</DialogContent>
</Dialog>;
}
function formatSessionUpdatedAt(value: number) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
}).format(value);
function historyGroup(value: number) {
const now = new Date();
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const date = new Date(value);
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).getTime();
const week = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime();
return start >= day ? "今天" : start === yesterday ? "昨天" : start >= week ? "近 7 天" : "更早";
}
function formatUpdatedAt(value: number) {
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(value);
}
@@ -27,8 +27,10 @@ export function AgentMessageInbox({ messages, loading, error, hasMore, loadingMo
<span>{messageStatusLabels[message.status]}</span>
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{message.text}</p>
<p className="break-all text-[var(--ink-muted)]">task_id: <span className="select-text font-mono">{message.task_id}</span></p>
<p className="break-all text-[var(--ink-muted)]">run_id: <span className="select-text font-mono">{message.run_id}</span></p>
<details className="text-[var(--ink-muted)]"><summary className="cursor-pointer">消息详情</summary>
<p className="break-all">任务编号:<span className="select-text font-mono">{message.task_id}</span></p>
<p className="break-all">运行编号:<span className="select-text font-mono">{message.run_id}</span></p>
</details>
{message.failure_detail ? <p className="break-words text-[var(--status-danger-foreground)]">{message.failure_detail}</p> : null}
</article>)}
{hasMore ? <Button variant="ghost" size="sm" disabled={loadingMore} onClick={() => void onLoadMore?.().catch(() => undefined)}>{loadingMore ? "正在加载…" : "加载更多消息"}</Button> : null}
@@ -0,0 +1,128 @@
import { useEffect, useRef, useState } from "react";
import { LoaderCircle, MoreHorizontal, Pencil, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import type { AgentInputDelivery, AgentResponseInput } from "../api/client";
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
type AgentPendingInputsProps = {
presentation: AgentCommandPanelPresentation;
inputs: AgentResponseInput[];
onDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise<void> | void;
onEdit?: (id: string) => Promise<void> | void;
onCancel?: (id: string) => Promise<void> | void;
};
const menuItemClassName = "min-h-10 rounded-lg border border-transparent px-2.5 py-2 text-xs focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)]";
export function AgentPendingInputs({ presentation, inputs, onDeliveryChange, onEdit, onCancel }: AgentPendingInputsProps) {
const [busyId, setBusyId] = useState<string | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const latestId = inputs.at(-1)?.id;
useEffect(() => {
const list = listRef.current;
if (list) list.scrollTop = list.scrollHeight;
}, [latestId]);
const performAction = (id: string, action: () => Promise<void> | void) => {
setBusyId(id);
void Promise.resolve().then(action).catch(() => {}).finally(() => setBusyId(null));
};
if (!inputs.length) return null;
return (
<section aria-label="待处理消息队列" className="order-first w-full min-w-0 border-b border-[var(--surface-divider)] bg-[var(--surface-reading)]">
<p className="px-3 pt-2 text-[11px] leading-5 text-muted-foreground" aria-live="polite">
等待处理 <span className="ml-1 tabular-nums">{inputs.length}</span>
</p>
<ul ref={listRef} aria-label="待处理消息" className="max-h-[min(12rem,28dvh)] divide-y divide-[var(--surface-divider)] overflow-y-auto overscroll-contain px-2 pb-1">
{inputs.map((input) => (
<li key={input.id} aria-busy={busyId === input.id} className="flex min-w-0 items-start gap-1 py-1">
<PendingInputText text={input.text} />
<span className="shrink-0 py-2 text-[11px] leading-6 text-muted-foreground" title={input.delivery === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}>
{input.delivery === "queue" ? "排队" : "引导"}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon" aria-label={`消息操作:${input.text}`}
disabled={Boolean(busyId)} className="agent-panel-icon-button size-10 shrink-0 active:scale-95 motion-reduce:transform-none">
{busyId === input.id
? <LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden />
: <MoreHorizontal size={16} aria-hidden />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6} collisionPadding={12}
className="agent-panel-overlay w-48 rounded-xl p-1.5">
<DropdownMenuItem disabled={!onEdit} className={menuItemClassName}
onSelect={() => performAction(input.id, () => onEdit?.(input.id))}>
<Pencil size={14} aria-hidden />编辑消息
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 py-1 text-[11px] font-normal text-muted-foreground">处理方式</DropdownMenuLabel>
<DropdownMenuRadioGroup aria-label="消息处理方式" value={input.delivery} onValueChange={(delivery) => {
if (onDeliveryChange && (delivery === "steer" || delivery === "queue")) {
void Promise.resolve().then(() => onDeliveryChange(input.id, delivery)).catch(() => {});
}
}}>
<DropdownMenuRadioItem value="steer" disabled={!onDeliveryChange} className={`${menuItemClassName} pl-8`}>引导当前任务</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="queue" disabled={!onDeliveryChange} className={`${menuItemClassName} pl-8`}>排队处理</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!onCancel} className={menuItemClassName}
onSelect={() => performAction(input.id, () => onCancel?.(input.id))}>
<X size={14} aria-hidden />关闭排队
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
</section>
);
}
function PendingInputText({ text }: { text: string }) {
const [expanded, setExpanded] = useState(false);
const [overflows, setOverflows] = useState(false);
const textRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const node = textRef.current;
if (!node) return;
const measure = () => {
setOverflows(node.scrollHeight > Number.parseFloat(getComputedStyle(node).lineHeight) * 2 + 1);
};
const observer = new ResizeObserver(measure);
observer.observe(node);
measure();
return () => observer.disconnect();
}, [text]);
useEffect(() => {
if (expanded) textRef.current?.parentElement?.scrollIntoView({ block: "nearest" });
}, [expanded]);
return (
<div className="min-w-0 flex-1 px-1 py-2">
<p ref={textRef} className={cn("text-sm leading-6 text-foreground [overflow-wrap:anywhere]", expanded ? "whitespace-pre-wrap" : "whitespace-normal line-clamp-2")}>
{text}
</p>
{overflows ? <button type="button" aria-expanded={expanded} onClick={() => setExpanded((current) => !current)}
className="relative mt-1 rounded-sm text-[11px] leading-5 text-muted-foreground underline underline-offset-4 after:absolute after:-inset-y-2.5 after:inset-x-0 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:opacity-70">
{expanded ? "收起全文" : "展开全文"}
</button> : null}
</div>
);
}
+80 -29
View File
@@ -1,18 +1,37 @@
import { Bot } from "lucide-react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import asset from "@/shared/ai-elements/persona-resource-manifest.json";
import { cn } from "@/shared/ui/cn";
const LazyPersona = lazy(async () => {
const module = await import("@/shared/ai-elements/persona");
return { default: module.Persona };
});
const Persona = lazy(() =>
import("@/shared/ai-elements/persona").then((module) => ({ default: module.Persona }))
);
const stillSources = {
idle: "/agent/obsidian-water-idle.png",
asleep: "/agent/obsidian-water-offline.png"
};
type AgentPersonaProps = {
className?: string;
state?: PersonaState;
};
function usePrefersReducedMotion() {
const [reducedMotion, setReducedMotion] = useState(
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReducedMotion(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return reducedMotion;
}
function useElementVisibility() {
const containerRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false);
@@ -20,60 +39,92 @@ function useElementVisibility() {
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let intersectsViewport = false;
const updateVisibility = () => {
const updateVisibility = () =>
setVisible(intersectsViewport && document.visibilityState === "visible");
};
const observer = new IntersectionObserver(([entry]) => {
intersectsViewport = Boolean(entry?.isIntersecting);
updateVisibility();
});
observer.observe(container);
document.addEventListener("visibilitychange", updateVisibility);
return () => {
observer.disconnect();
document.removeEventListener("visibilitychange", updateVisibility);
};
}, []);
return { containerRef, visible };
}
function PersonaFallback() {
function PersonaStill({ state }: { state: PersonaState }) {
const source = stillSources[state === "asleep" ? "asleep" : "idle"];
return <img className="agent-orb-still" src={`${source}?v=${asset.sha256}`} alt="" draggable={false} />;
}
function LivePersona({ state }: { state: PersonaState }) {
const [loaded, setLoaded] = useState(false);
const [ready, setReady] = useState(false);
const [failed, setFailed] = useState(false);
const onLoad = useCallback(() => setLoaded(true), []);
const onLoadError = useCallback(() => setFailed(true), []);
useEffect(() => {
if (!loaded) return;
// Keep the matching still visible while Rive completes its entrance animation.
const timer = window.setTimeout(() => setReady(true), 600);
return () => window.clearTimeout(timer);
}, [loaded]);
return (
<span className="grid h-full w-full place-items-center rounded-full border border-[var(--surface-border)]/70 bg-[var(--surface-control)] text-[var(--ink-muted)]">
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
<span className="agent-orb-live" data-ready={ready && !failed} data-failed={failed}>
<PersonaStill state={state} />
{!failed ? (
<Suspense fallback={null}>
<Persona
className="agent-orb-canvas"
state={state}
onLoad={onLoad}
onLoadError={onLoadError}
/>
</Suspense>
) : null}
</span>
);
}
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
const { containerRef, visible } = useElementVisibility();
const [failed, setFailed] = useState(false);
const handleLoadError = useCallback(() => setFailed(true), []);
const reducedMotion = usePrefersReducedMotion();
const [hovered, setHovered] = useState(false);
const live = visible && !reducedMotion;
const shouldRenderAnimation = !failed && visible;
useEffect(() => {
if (!live) setHovered(false);
}, [live]);
return (
<span
ref={containerRef}
aria-label="Agent 状态"
className={cn("relative block shrink-0", className)}
className={cn("relative block h-16 w-16 shrink-0", className)}
>
{shouldRenderAnimation ? (
<Suspense fallback={<PersonaFallback />}>
<LazyPersona
className="h-full w-full scale-110"
onLoadError={handleLoadError}
state={state}
/>
</Suspense>
) : (
<PersonaFallback />
)}
<span
className="agent-orb"
data-agent-orb={state}
data-animated={live}
data-hovered={hovered && live}
aria-hidden="true"
onPointerEnter={(event) => {
if (event.isPrimary && event.pointerType !== "touch") setHovered(true);
}}
onPointerLeave={() => setHovered(false)}
onPointerCancel={() => setHovered(false)}
>
<span className="agent-orb-response">
{live ? <LivePersona state={state} /> : <PersonaStill state={state} />}
<span className="agent-orb-contrast" />
</span>
</span>
</span>
);
}
@@ -0,0 +1,55 @@
import { ChevronsUpDown, Folder, FolderPlus, Settings2 } from "lucide-react";
import { useRef, useState } from "react";
import type { AgentTopic } from "../api/client";
import { Button } from "@/shared/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { AgentTopicDialog } from "./agent-topic-dialog";
export type AgentTopicControlsProps = {
topics: AgentTopic[];
topicId?: string | null;
loading?: boolean;
error?: string;
onOpen: () => Promise<unknown>;
onSelect: (id: string | null) => void;
onCreate: (name: string) => Promise<unknown>;
onRename: (id: string, name: string) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
};
export function AgentTopicControls({ topics, topicId, loading, error, onOpen, onSelect, onCreate, onRename, onDelete }: AgentTopicControlsProps) {
const triggerRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"create" | "manage">("create");
const [dialogKey, setDialogKey] = useState(0);
const current = topics.find((topic) => topic.id === topicId);
const begin = (next: "create" | "manage") => {
setMode(next);
setDialogKey((key) => key + 1);
setOpen(true);
};
return <>
<DropdownMenu onOpenChange={(next) => { if (next) void onOpen().catch(() => {}); }}>
<DropdownMenuTrigger asChild><Button ref={triggerRef} variant="outline" size="sm" aria-label="选择主题" aria-description={current?.name ?? "未分类"} title={`主题:${current?.name ?? "未分类"}`} className="min-w-0 max-w-full justify-start gap-1.5 px-1.5 text-xs font-medium text-foreground">
<Folder aria-hidden="true" className="text-muted-foreground" /><span className="truncate">{current?.name ?? "未分类"}</span><ChevronsUpDown aria-hidden="true" className="ml-auto text-muted-foreground" />
</Button></DropdownMenuTrigger>
<DropdownMenuContent data-agent-theme="chat" align="start" side="bottom" className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-56 max-w-[min(20rem,calc(100vw-1rem))]">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">切换主题</DropdownMenuLabel>
<DropdownMenuRadioGroup value={current?.id ?? "unclassified"} onValueChange={(id) => onSelect(id === "unclassified" ? null : id)} className="max-h-[min(45dvh,15rem)] overflow-y-auto">
<DropdownMenuRadioItem value="unclassified">未分类</DropdownMenuRadioItem>
{topics.map((topic) => <DropdownMenuRadioItem key={topic.id} value={topic.id}>
<span className="min-w-0 line-clamp-2 break-words" title={topic.name}>{topic.name}</span>
</DropdownMenuRadioItem>)}
</DropdownMenuRadioGroup>
{loading ? <p role="status" className="px-2 py-2 text-xs text-muted-foreground">正在加载主题…</p> : null}
{error ? <p role="alert" className="px-2 py-2 text-xs text-destructive">{error}</p> : null}
<DropdownMenuSeparator className="bg-border/50" />
<DropdownMenuItem onSelect={() => begin("create")}><FolderPlus size={15} />新建主题</DropdownMenuItem>
<DropdownMenuItem onSelect={() => begin("manage")}><Settings2 size={15} />管理主题</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AgentTopicDialog key={dialogKey} open={open} initialMode={mode} onOpenChange={setOpen} triggerRef={triggerRef}
topics={topics} onCreate={onCreate} onRename={onRename} onDelete={onDelete} />
</>;
}
@@ -0,0 +1,108 @@
import { Folder, FolderPlus, Pencil, Trash2, X } from "lucide-react";
import { useId, useRef, useState, type RefObject } from "react";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import type { AgentTopic } from "../api/client";
type TopicView = { kind: "list" | "create" } | { kind: "rename" | "delete"; topic: AgentTopic };
type Props = {
open: boolean;
initialMode: "create" | "manage";
onOpenChange: (open: boolean) => void;
triggerRef: RefObject<HTMLButtonElement | null>;
topics: AgentTopic[];
onCreate: (name: string) => Promise<unknown>;
onRename: (id: string, name: string) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
};
export function AgentTopicDialog({ open, initialMode, onOpenChange, triggerRef, topics, onCreate, onRename, onDelete }: Props) {
const [view, setView] = useState<TopicView>({ kind: initialMode === "create" ? "create" : "list" });
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const fieldId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const newTopicRef = useRef<HTMLButtonElement>(null);
const isForm = view.kind === "create" || view.kind === "rename";
const title = view.kind === "create" ? "新建主题" : view.kind === "rename" ? "重命名主题" : view.kind === "delete" ? "删除主题" : "管理主题";
const description = view.kind === "create" ? "将相关事件、问题和对话整理到同一主题。"
: view.kind === "rename" ? "修改名称不会影响主题中的现有会话。"
: view.kind === "delete" ? "仅删除分类,其中的会话会保留在未分类中。"
: `当前项目共有 ${topics.length} 个主题,用于整理相关事件与问题。`;
const changeView = (next: TopicView) => {
setError(undefined);
setName(next.kind === "rename" ? next.topic.name : "");
setView(next);
};
const perform = async (action: () => Promise<unknown>, done: () => void) => {
setBusy(true);
setError(undefined);
try { await action(); done(); }
catch (reason) { setError(reason instanceof Error ? reason.message : "主题操作失败,请重试"); }
finally { setBusy(false); }
};
const cancel = () => initialMode === "create" ? onOpenChange(false) : changeView({ kind: "list" });
const footerClass = "gap-2 px-6 pb-6 sm:gap-0";
return <Dialog open={open} onOpenChange={(next) => { if (!busy) onOpenChange(next); }}>
<DialogContent data-agent-theme="chat" overlayClassName="agent-history-overlay" showCloseButton={false}
className="agent-topic-dialog z-[111] flex max-h-[min(80dvh,40rem)] w-[calc(100vw-2rem)] max-w-md flex-col gap-0 overflow-hidden bg-card p-0"
onOpenAutoFocus={(event) => { event.preventDefault(); (inputRef.current ?? newTopicRef.current)?.focus(); }}
onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}>
<DialogHeader className="p-6 pb-4 pr-14 text-left">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<Button variant="ghost" size="icon-sm" className="absolute right-4 top-4" aria-label="关闭主题窗口" disabled={busy} onClick={() => onOpenChange(false)}>
<X aria-hidden="true" />
</Button>
{isForm ? <form className="flex min-h-0 flex-col" onSubmit={(event) => {
event.preventDefault();
if (!name.trim() || busy) return;
void perform(() => view.kind === "rename" ? onRename(view.topic.id, name.trim()) : onCreate(name.trim()),
() => view.kind === "create" ? onOpenChange(false) : changeView({ kind: "list" }));
}}>
<div className="min-h-0 space-y-2 overflow-y-auto px-6 pb-6">
<label htmlFor={fieldId} className="text-sm font-medium">主题名称</label>
<Input ref={inputRef} id={fieldId} autoFocus maxLength={80} value={name} disabled={busy}
aria-describedby={`${fieldId}-hint`} onChange={(event) => setName(event.target.value)} placeholder="例如:城南压力异常排查" />
<p id={`${fieldId}-hint`} className="text-xs text-muted-foreground">名称最多 80 个字符。</p>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter className={footerClass}>
<Button type="button" variant="outline" disabled={busy} onClick={cancel}>取消</Button>
<Button type="submit" loading={busy} disabled={!name.trim()}>{view.kind === "rename" ? "保存主题" : "创建主题"}</Button>
</DialogFooter>
</form> : view.kind === "delete" ? <>
<div className="min-h-0 space-y-3 overflow-y-auto px-6 pb-6">
<p className="break-words text-sm">确认删除“{view.topic.name}”?</p>
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter className={footerClass}>
<Button type="button" autoFocus variant="outline" disabled={busy} onClick={() => changeView({ kind: "list" })}>取消</Button>
<Button variant="destructive" loading={busy} onClick={() => void perform(() => onDelete(view.topic.id), () => changeView({ kind: "list" }))}>确认删除主题</Button>
</DialogFooter>
</> : <>
<div role="list" aria-label="主题列表" className="min-h-0 overflow-y-auto px-6 pb-4">
{topics.length === 0 ? <p className="py-6 text-center text-sm text-muted-foreground">还没有主题,可以新建一个来整理对话。</p> : topics.map((topic) => (
<div role="listitem" key={topic.id} className="flex items-center gap-3 border-b border-border/40 py-3 last:border-0">
<Folder aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 break-words text-sm font-medium">{topic.name}</span>
<div className="flex shrink-0 items-center gap-2">
<Button variant="ghost" size="icon-sm" aria-label={`重命名主题:${topic.name}`} onClick={() => changeView({ kind: "rename", topic })}><Pencil aria-hidden="true" /></Button>
<Button variant="ghost" size="icon-sm" aria-label={`删除主题:${topic.name}`} onClick={() => changeView({ kind: "delete", topic })}><Trash2 aria-hidden="true" /></Button>
</div>
</div>
))}
</div>
<DialogFooter className={footerClass}>
<Button ref={newTopicRef} autoFocus variant="outline" onClick={() => changeView({ kind: "create" })}><FolderPlus aria-hidden="true" />新建主题</Button>
<Button onClick={() => onOpenChange(false)}>完成</Button>
</DialogFooter>
</>}
</DialogContent>
</Dialog>;
}
@@ -71,11 +71,11 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
);
}
export function AgentChartView({ chart }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>> }) {
return <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
export function AgentChartView({ chart, embedded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean }) {
return <SafeChartEnvelope embedded={embedded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
}
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean }) {
const spec = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data, 100);
@@ -84,9 +84,9 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
}
return (
<div className="agent-panel-nested rounded-xl p-3">
<div className={embedded ? "min-w-0" : "agent-panel-nested rounded-xl p-3"}>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
{!embedded ? <div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
{spec.title ?? "Agent 图表"}
</p>
@@ -94,7 +94,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
{spec.chartType === "bar" ? "柱状图" : "折线图"}
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
</p>
</div>
</div> : null}
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
{data.series.slice(0, 3).map((series, index) => (
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
@@ -0,0 +1,20 @@
import { createElement, type ComponentType, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it } from "vitest";
import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown";
it("keeps external link safety and rejects forged custom export links", () => {
const Renderer = artifactLinkComponents["agent-export-link"] as ComponentType<{ href: string; children: ReactNode }>;
const path = "/api/v1/session/s/files/mcp-11111111-1111-1111-1111-111111111111.zip";
const render = (href: string) => renderToStaticMarkup(createElement(Renderer, { href, children: "下载" }));
expect(render(path)).toContain(`<a href="${path}"`);
for (const href of ["https://foreign.test/x", "javascript:alert(1)", "/api/v1/admin/secrets"]) {
expect(render(href)).toBe("<span>下载</span>");
}
const root = { type: "root", children: [
{ type: "element", tagName: "a", properties: { href: path } },
{ type: "element", tagName: "a", properties: { href: "https://example.com" } },
] };
artifactLinksPlugin()(root);
expect(root.children.map((child) => child.tagName)).toEqual(["agent-export-link", "a"]);
});
@@ -0,0 +1,32 @@
import type { Components } from "streamdown";
import type { ReactNode } from "react";
type MarkdownNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: MarkdownNode[];
};
const artifactPath = /^\/api\/v1\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx)$/;
// Only verified-format first-party download paths use the authenticated click
// handler. All other anchors retain Streamdown's built-in link safety dialog.
export function artifactLinksPlugin() {
return (tree: MarkdownNode) => {
const visit = (node: MarkdownNode) => {
if (node.tagName === "a" && typeof node.properties?.href === "string" && artifactPath.test(node.properties.href)) {
node.tagName = "agent-export-link";
}
node.children?.forEach(visit);
};
visit(tree);
};
}
export const artifactLinkComponents: Components = {
"agent-export-link": ({ href, children }) => {
if (typeof href !== "string" || !artifactPath.test(href)) return <span>{children as ReactNode}</span>;
return <a href={href} className="wrap-anywhere font-medium text-primary underline">{children as ReactNode}</a>;
}
};
@@ -1,5 +1,9 @@
import { cn } from "@/shared/ui/cn";
import { MessageResponse } from "@/shared/ai-elements/message";
import { defaultRehypePlugins } from "streamdown";
import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown";
const responseRehypePlugins = [...Object.values(defaultRehypePlugins), artifactLinksPlugin];
type StreamingTokenResponseProps = {
content: string;
@@ -27,6 +31,8 @@ export function StreamingTokenResponse({
isAnimating={isAnimating}
mode="streaming"
parseIncompleteMarkdown={true}
rehypePlugins={responseRehypePlugins}
components={artifactLinkComponents}
>
{content}
</MessageResponse>