Files
ARTEX/web/src/app/(main)/chat/page.tsx
T

1196 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import * as React from "react";
import {
ArrowUpIcon,
Bot,
ChevronDownIcon,
Loader2Icon,
MoreHorizontalIcon,
PaperclipIcon,
PencilIcon,
PinIcon,
PinOffIcon,
PlusIcon,
Square,
Trash2Icon,
XIcon,
ZapIcon,
} from "lucide-react";
import { toast } from "sonner";
import { TodoPopover } from "@/components/todo-popover";
import { Transcript } from "@/components/transcript";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
// fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx).
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
return `${n} B`;
}
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k`;
return String(n);
}
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
const h = Math.floor(m / 60);
return `${h}h${String(m % 60).padStart(2, "0")}m`;
}
// HISTORY_PAGE is how many steps one history page loads: the latest page on open,
// then one more page each time the user scrolls to the top. Kept modest so a long
// thread stays snappy (only ~a page of rows is in the DOM until you scroll up).
const HISTORY_PAGE = 200;
function conversationIsPinned(conversation: Conversation): boolean {
return conversation.pinned ?? Boolean(conversation.pinned_at);
}
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
// console — shown while a turn is streaming.
function LiveBadge() {
return (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
);
}
// Composer is the shared bottom input (textarea grows to a cap, Enter sends,
// Shift+Enter newlines) — the same affordance across DraftChat and ChatView.
function Composer({
value,
onChange,
onSend,
disabled,
placeholder,
leftSlot,
running,
onStop,
stopDisabled,
attachments,
onPickFiles,
onRemoveAttachment,
uploading,
}: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
disabled: boolean;
placeholder: string;
leftSlot?: React.ReactNode;
running?: boolean;
onStop?: () => void;
stopDisabled?: boolean;
// 方式1 文件上传:传了 onPickFiles 才显示回形针按钮 + 附件 chip 预览。
attachments?: ChatAttachment[];
onPickFiles?: (files: FileList | null) => void;
onRemoveAttachment?: (path: string) => void;
uploading?: boolean;
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const atts = attachments ?? [];
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
const sendMode = useChatSendMode();
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault();
onSend();
}
return (
<div className="border-t p-3">
{atts.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{atts.map((a) => (
<div
key={a.path}
className="flex items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs"
title={a.path}
>
<PaperclipIcon className="size-3 shrink-0 text-primary" />
<span className="max-w-[160px] truncate">{a.name}</span>
<span className="text-muted-foreground">{fmtBytes(a.size)}</span>
{onRemoveAttachment && (
<button
type="button"
className="ml-0.5 text-muted-foreground hover:text-foreground"
onClick={() => onRemoveAttachment(a.path)}
title="移除"
>
<XIcon className="size-3" />
</button>
)}
</div>
))}
</div>
)}
<div className="flex flex-wrap items-end gap-2">
{leftSlot ? <div className="w-full sm:w-auto">{leftSlot}</div> : null}
{onPickFiles && (
<>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
onPickFiles(e.target.files);
e.target.value = ""; // allow re-picking the same file
}}
/>
<Button
size="icon"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
title="上传文件"
>
{uploading ? <Loader2Icon className="size-4 animate-spin" /> : <PaperclipIcon className="size-4" />}
</Button>
</>
)}
<Textarea
className="max-h-40 min-h-10 min-w-0 flex-1 resize-none"
rows={1}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
{running ? (
// while a run is in flight the send button becomes a stop button —
// aborts just this session (the trigger queue keeps going).
<Button size="icon" variant="destructive" onClick={onStop} disabled={stopDisabled} title="停止本次运行">
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button
size="icon"
onClick={onSend}
disabled={disabled || (!value.trim() && atts.length === 0)}
title="发送消息"
aria-label="发送消息"
>
<ArrowUpIcon />
</Button>
)}
</div>
</div>
);
}
// LLMProfileRow shows the active LLM config below the composer and lets the user
// switch it via a Popover. `selected` is the profile id or null for default.
function LLMProfileRow({
profiles,
selected,
onChange,
disabled,
rightSlot,
}: {
profiles: LLMProfile[];
selected: number | null;
onChange: (id: number | null) => void;
disabled?: boolean;
rightSlot?: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const activeDefault = profiles.find((p) => p.is_default);
const current = selected != null ? profiles.find((p) => Number(p.id) === selected) : null;
const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`;
return (
<div className="flex items-center gap-1 px-1 pb-1 pt-0.5">
<ZapIcon className="text-muted-foreground/50 size-3 shrink-0" />
<span className="text-muted-foreground/70 text-xs">{label}</span>
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className="text-primary flex items-center gap-0.5 text-xs hover:underline disabled:pointer-events-none disabled:opacity-40"
>
更换
<ChevronDownIcon className="size-3" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<p className="text-muted-foreground px-2 py-1 text-[11px] font-medium">选择 LLM 配置</p>
{/* default option */}
<button
type="button"
onClick={() => {
onChange(null);
setOpen(false);
}}
className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected == null && "bg-accent",
)}
>
<span className="text-sm">默认{activeDefault ? `(${activeDefault.name})` : ""}</span>
{activeDefault && (
<span className="text-muted-foreground text-[11px]">
{activeDefault.format} · {activeDefault.model}
</span>
)}
</button>
{profiles.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
onChange(Number(p.id));
setOpen(false);
}}
className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected === Number(p.id) && "bg-accent",
)}
>
<span className="text-sm">{p.name}</span>
<span className="text-muted-foreground text-[11px]">
{p.format} · {p.model}
</span>
</button>
))}
</PopoverContent>
</Popover>
{rightSlot}
</div>
);
}
// DraftChat is the default right-pane view: a fresh chat (agent picker in the
// header, centered empty state, composer) with NO conversation created yet. The
// conversation is created lazily on the first send (ChatGPT-style), then the
// parent switches to the real ChatView.
function DraftChat({
agents,
profiles,
onStarted,
}: {
agents: Agent[];
profiles: LLMProfile[];
onStarted: (c: Conversation, pending?: { input?: string; attachments?: ChatAttachment[] }) => void;
}) {
const [agentKey, setAgentKey] = React.useState("");
const [llmProfileId, setLlmProfileId] = React.useState<number | null>(null);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [uploading, setUploading] = React.useState(false);
// default the agent to Auto once agents load.
React.useEffect(() => {
if (!agentKey && agents.some((a) => a.key === "auto")) setAgentKey("auto");
}, [agentKey, agents]);
const agent = agents.find((a) => a.key === agentKey);
async function send() {
const msg = input.trim();
if (!msg || !agentKey || sending) return;
setSending(true);
try {
const c = await api.createConversation(agentKey, "", llmProfileId);
await api.sendConversationMessage(c.id, msg);
onStarted(c);
} catch (e) {
toast.error("发送失败:" + (e as Error).message);
setSending(false);
}
}
// Attachments need a conversation to own the uploads dir (sessions/conv-<id>/),
// and a draft has none yet — so picking a file CREATES the conversation, uploads
// into it, then hands off to ChatView carrying the typed text + attachments (the
// user sends from there). Mirrors the task main-agent console's upload, adapted to
// the ChatGPT-style lazy-create flow.
async function pickFiles(files: FileList | null) {
if (!files || files.length === 0 || !agentKey || uploading || sending) return;
setUploading(true);
try {
const c = await api.createConversation(agentKey, "", llmProfileId);
const r = await api.chatUpload("session", `conv-${c.id}`, Array.from(files));
onStarted(c, { input, attachments: r.attachments });
} catch (e) {
toast.error("上传失败:" + (e as Error).message);
setUploading(false);
}
}
const agentPicker = (
<Select value={agentKey} onValueChange={setAgentKey}>
<SelectTrigger size="sm" className="w-full sm:w-40">
<SelectValue placeholder="选择 Agent…" />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.key} value={a.key}>
<span className="flex items-center gap-2">
<Bot className="size-3.5" />
{a.name}
{!a.builtin && (
<Badge variant="outline" className="px-1 py-0 text-[9px]">
自定义
</Badge>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
return (
<>
{/* empty / landing state fills the panel */}
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<div className="bg-primary/10 flex size-12 items-center justify-center rounded-full">
<Bot className="text-primary size-6" />
</div>
<div className="text-sm font-medium">开始和「{agent?.name ?? "Agent"}」对话</div>
{agent?.description && <p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>}
</div>
<Composer
value={input}
onChange={setInput}
onSend={send}
disabled={sending || uploading || !agentKey}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
leftSlot={agentPicker}
onPickFiles={pickFiles}
uploading={uploading}
/>
<LLMProfileRow
profiles={profiles}
selected={llmProfileId}
onChange={setLlmProfileId}
disabled={sending || uploading}
/>
</>
);
}
// ChatView is the right pane for one conversation — mirrors the task detail's
// main-agent console: header with agent + live badge + token/duration meta, a
// scroll-stick transcript (chat mode), and the composer.
function ChatView({
conv,
agents,
profiles,
initial,
onTitleMaybeChanged,
onConvUpdated,
}: {
conv: Conversation;
agents: Agent[];
profiles: LLMProfile[];
// pending text + already-uploaded attachments handed off from a draft that
// created this conversation via the paperclip (consumed once, on mount).
initial?: { input?: string; attachments?: ChatAttachment[] };
onTitleMaybeChanged: () => void;
onConvUpdated: () => void;
}) {
const [messages, setMessages] = React.useState<Activity[]>([]);
const [running, setRunning] = React.useState(false);
const [input, setInput] = React.useState(initial?.input ?? "");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
// 方式1 文件上传:已上传的附件(落到 sessions/conv-<id>/uploads/),随下条消息一起发。
const [attachments, setAttachments] = React.useState<ChatAttachment[]>(initial?.attachments ?? []);
const [uploading, setUploading] = React.useState(false);
const cursorRef = React.useRef(0); // newest loaded id — incremental-tail anchor
const earliestRef = React.useRef(0); // earliest loaded id — reverse-pagination anchor
const hasMoreRef = React.useRef(false); // older history remains above the loaded window
const loadingMoreRef = React.useRef(false); // guard: one scroll-up load at a time
const [hasMore, setHasMore] = React.useState(false); // drives the "load earlier" hint
const agent = agents.find((a) => a.key === conv.agent_key);
const currentProfileId = conv.llm_profile_id ?? null;
async function changeProfile(id: number | null) {
try {
await api.updateConversationProfile(conv.id, id);
onConvUpdated();
} catch (e) {
toast.error("切换 LLM 失败:" + (e as Error).message);
}
}
// conversation-scoped detail fetcher for the reused Transcript renderer.
const fetchDetail = React.useCallback((seq: number) => api.conversationMsgDetail(conv.id, seq), [conv.id]);
// seq of the most-recent TodoWrite tool call (for the Todo popover); null if none.
const latestTodoSeq = React.useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const a = messages[i];
if (a.kind === "tool_use" && a.tool === "TodoWrite") return a.seq;
}
return null;
}, [messages]);
// reset + load whenever the selected conversation changes. Load only the LATEST
// page on open — a long thread's final answer sits at the very end, so the newest
// page shows it immediately (issue #3: opening/refresh used to load the oldest
// page, so the completed result was missing until another message advanced the
// cursor). Older history streams in on scroll-up (loadEarlier below).
React.useEffect(() => {
cursorRef.current = 0;
earliestRef.current = 0;
hasMoreRef.current = false;
setHasMore(false);
setMessages([]);
setRunning(false);
let live = true;
api
.conversationHistory(conv.id, 0, HISTORY_PAGE)
.then((r) => {
if (!live) return;
setMessages(r.items);
cursorRef.current = r.cursor; // newest id → incremental-tail anchor
earliestRef.current = r.items.length ? r.items[0].seq : 0;
hasMoreRef.current = r.hasMore;
setHasMore(r.hasMore);
setRunning(r.running);
})
.catch(() => {
/* The empty state remains usable when history loading fails. */
});
return () => {
live = false;
};
}, [conv.id]);
// poll while a turn is running: pull new steps after the cursor.
React.useEffect(() => {
if (!running) return;
let live = true;
const tick = async () => {
try {
const r = await api.conversationMessages(conv.id, cursorRef.current);
if (!live) return;
if (r.items.length) {
setMessages((prev) => [...prev, ...r.items]);
cursorRef.current = r.cursor;
}
setRunning(r.running);
if (!r.running) onTitleMaybeChanged(); // first-turn auto-title landed
} catch {
/* transient — keep polling */
}
};
const h = setInterval(tick, 1000);
return () => {
live = false;
clearInterval(h);
};
}, [running, conv.id, onTitleMaybeChanged]);
// ---- transcript auto-scroll (open → bottom; stick to bottom unless scrolled up) ----
const contentRef = React.useRef<HTMLDivElement | null>(null);
const atBottomRef = React.useRef(true);
const viewport = React.useCallback(
() => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null,
[],
);
// Scroll-up loads one older page and prepends it, preserving the visual position
// so the view doesn't jump (record height/offset before, restore the delta after).
const loadEarlier = React.useCallback(async () => {
if (loadingMoreRef.current || !hasMoreRef.current) return;
const vp = viewport();
if (!vp) return;
loadingMoreRef.current = true;
const prevH = vp.scrollHeight;
const prevTop = vp.scrollTop;
try {
const r = await api.conversationHistory(conv.id, earliestRef.current, HISTORY_PAGE);
if (r.items.length) {
setMessages((prev) => {
const seen = new Set(prev.map((a) => a.seq));
return [...r.items.filter((a) => !seen.has(a.seq)), ...prev];
});
earliestRef.current = r.items[0].seq;
}
hasMoreRef.current = r.hasMore;
setHasMore(r.hasMore);
requestAnimationFrame(() => {
const v = viewport();
if (v) v.scrollTop = prevTop + (v.scrollHeight - prevH);
});
} catch {
/* transient — a later scroll retries */
} finally {
loadingMoreRef.current = false;
}
}, [conv.id, viewport]);
React.useEffect(() => {
const vp = viewport();
if (!vp) return;
const onScroll = () => {
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
if (vp.scrollTop <= 80) void loadEarlier(); // near top → pull an older page
};
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport, loadEarlier]);
// open/switch a conversation → jump to the latest (bottom)
// biome-ignore lint/correctness/useExhaustiveDependencies: changing conversations intentionally retriggers the scroll reset.
React.useLayoutEffect(() => {
const vp = viewport();
if (vp) {
vp.scrollTop = vp.scrollHeight;
atBottomRef.current = true;
}
}, [conv.id, viewport]);
// new activity → stick to bottom only if the user is already pinned there
// biome-ignore lint/correctness/useExhaustiveDependencies: message and running changes intentionally retrigger bottom anchoring.
React.useLayoutEffect(() => {
if (!atBottomRef.current) return;
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
}, [messages, running, viewport]);
// Per-conversation token total, live — same accounting as the main-agent
// console: completed runs' `result` sum + the in-progress run's latest `usage`.
const tokenTotal = React.useMemo(() => {
let i = 0,
o = 0,
cr = 0;
let li = 0,
lo = 0,
lcr = 0;
let turns = 0; // agent 循环轮次 = 模型调用次数(每次一条 kind='usage')
for (const a of messages) {
if (a.kind === "result") {
i += a.input_tokens ?? 0;
o += a.output_tokens ?? 0;
cr += a.cache_read_tokens ?? 0;
li = lo = lcr = 0;
} else if (a.kind === "usage") {
turns += 1;
li = a.input_tokens ?? 0;
lo = a.output_tokens ?? 0;
lcr = a.cache_read_tokens ?? 0;
}
}
const I = i + li,
O = o + lo,
CR = cr + lcr;
return { i: I, o: O, cr: CR, turns, any: I + O + CR > 0 };
}, [messages]);
// pickFiles uploads into this conversation's session dir (sessions/conv-<id>/
// uploads/) and queues the returned metadata to send with the next message.
async function pickFiles(files: FileList | null) {
if (!files || files.length === 0) return;
setUploading(true);
try {
const r = await api.chatUpload("session", `conv-${conv.id}`, Array.from(files));
setAttachments((prev) => [...prev, ...r.attachments]);
} catch (e) {
toast.error("上传失败:" + (e as Error).message);
} finally {
setUploading(false);
}
}
async function send() {
const msg = input.trim();
const atts = attachments;
if ((!msg && atts.length === 0) || sending || running) return;
setSending(true);
setInput("");
setAttachments([]);
try {
await api.sendConversationMessage(conv.id, msg, atts.length ? atts : undefined);
setRunning(true);
// pull the just-persisted human turn immediately.
const r = await api.conversationMessages(conv.id, cursorRef.current);
setMessages((prev) => [...prev, ...r.items]);
cursorRef.current = r.cursor;
} catch (e) {
toast.error("发送失败:" + (e as Error).message);
setInput(msg); // restore so the user doesn't lose their text
setAttachments(atts); // and their attachments
} finally {
setSending(false);
}
}
// stop aborts the in-flight run for this conversation. running flips to false on
// the next 1s poll once the backend unwinds the agent; the trigger queue is not
// affected — the agent's next queued fire still starts.
async function stop() {
if (stopping) return;
setStopping(true);
try {
await api.stopConversation(conv.id);
} catch (e) {
toast.error("停止失败:" + (e as Error).message);
} finally {
setStopping(false);
}
}
return (
<>
{/* header: which agent + live + token meta */}
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-4 py-2.5">
<Bot className="text-muted-foreground size-4 shrink-0" />
<span className="min-w-0 max-w-48 truncate text-sm font-medium">{agent?.name ?? conv.agent_key}</span>
<span className="text-muted-foreground hidden shrink-0 font-mono text-xs sm:inline">{conv.agent_key}</span>
{agent && !agent.builtin && (
<Badge variant="outline" className="shrink-0 px-1.5 py-0 text-[10px]">
自定义
</Badge>
)}
{agent?.description && (
<span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span>
)}
{running && <LiveBadge />}
<div className="text-muted-foreground ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs max-sm:w-full max-sm:flex-wrap">
{tokenTotal.turns > 0 && (
<span title="agent 循环轮次(模型调用次数)" className="tabular-nums">
{tokenTotal.turns} 轮
</span>
)}
{tokenTotal.any && (
<span title="input / cache(read) / output tokens" className="min-w-0 truncate tabular-nums">
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
</span>
)}
</div>
</div>
{/* messages */}
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="min-w-0 max-w-full px-4 py-3" ref={contentRef}>
{messages.length === 0 && !running ? (
<div className="text-muted-foreground py-10 text-center text-sm">
开始和「{agent?.name ?? conv.agent_key}」对话
</div>
) : (
<>
{hasMore && (
<div className="text-muted-foreground/70 pb-2 text-center text-[11px]">向上滚动加载更早的消息…</div>
)}
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
</>
)}
</div>
</ScrollArea>
<Composer
value={input}
onChange={setInput}
onSend={send}
disabled={running}
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 发送,Shift+Enter 换行"}
running={running}
onStop={stop}
stopDisabled={stopping}
attachments={attachments}
onPickFiles={pickFiles}
onRemoveAttachment={(path) => setAttachments((p) => p.filter((x) => x.path !== path))}
uploading={uploading}
/>
<LLMProfileRow
profiles={profiles}
selected={currentProfileId}
onChange={changeProfile}
disabled={running || sending}
rightSlot={<TodoPopover seq={latestTodoSeq} fetchDetail={fetchDetail} />}
/>
</>
);
}
// ConversationItem is one row in the left rail: title, agent subtitle, inline
// rename, pin marker, and a compact action menu.
function ConversationItem({
conv,
agent,
active,
renaming,
renameText,
onSelect,
onStartRename,
onRenameText,
onCommitRename,
onCancelRename,
onTogglePinned,
onDelete,
selectedForDelete,
onSelectedForDeleteChange,
}: {
conv: Conversation;
agent?: Agent;
active: boolean;
renaming: boolean;
renameText: string;
onSelect: () => void;
onStartRename: () => void;
onRenameText: (v: string) => void;
onCommitRename: () => void;
onCancelRename: () => void;
onTogglePinned: () => void;
onDelete: () => void;
selectedForDelete: boolean;
onSelectedForDeleteChange: (checked: boolean) => void;
}) {
const [deleteOpen, setDeleteOpen] = React.useState(false);
const renameInputRef = React.useRef<HTMLInputElement>(null);
const cancelRenameRef = React.useRef(false);
const pinned = conversationIsPinned(conv);
React.useEffect(() => {
if (!renaming) return;
cancelRenameRef.current = false;
renameInputRef.current?.focus();
renameInputRef.current?.select();
}, [renaming]);
return (
<div
className={cn(
"group flex min-w-0 items-center gap-1 rounded-md pr-1 transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
<Checkbox
checked={selectedForDelete}
onCheckedChange={(checked) => onSelectedForDeleteChange(checked === true)}
aria-label={`选择对话「${conv.title || "新对话"}」`}
className="ml-1 shrink-0"
/>
{renaming ? (
<input
ref={renameInputRef}
value={renameText}
onChange={(e) => onRenameText(e.target.value)}
onBlur={() => {
if (!cancelRenameRef.current) onCommitRename();
}}
onKeyDown={(e) => {
if (e.key === "Enter") onCommitRename();
if (e.key === "Escape") {
e.preventDefault();
cancelRenameRef.current = true;
onCancelRename();
}
}}
className="border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
/>
) : (
<button
type="button"
onClick={onSelect}
onDoubleClick={onStartRename}
title="双击重命名"
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
>
<div className="flex min-w-0 items-center gap-1.5">
{pinned && <PinIcon className="text-primary size-3 shrink-0" aria-label="已置顶" />}
<div className="truncate text-sm">{conv.title || "新对话"}</div>
</div>
<div className="text-muted-foreground flex min-w-0 items-center gap-1 text-[11px]">
<Bot className="size-3 shrink-0" />
<span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</span>
<span className="shrink-0">·</span>
<span className="shrink-0">
{new Date(conv.created_at).toLocaleDateString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
<span className="shrink-0 opacity-60">#{conv.id}</span>
</div>
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground shrink-0"
aria-label={`管理对话「${conv.title || "新对话"}」`}
>
<MoreHorizontalIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={onStartRename}>
<PencilIcon />
重命名
</DropdownMenuItem>
<DropdownMenuItem onSelect={onTogglePinned}>
{pinned ? <PinOffIcon /> : <PinIcon />}
{pinned ? "取消置顶" : "置顶"}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={() => setDeleteOpen(true)}>
<Trash2Icon />
删除
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除对话「{conv.title || "新对话"}」?</AlertDialogTitle>
<AlertDialogDescription>此操作不可撤销。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default function ChatPage() {
const [agents, setAgents] = React.useState<Agent[]>([]);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [convs, setConvs] = React.useState<Conversation[]>([]);
const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [renamingId, setRenamingId] = React.useState<number | null>(null);
const [renameText, setRenameText] = React.useState("");
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
const [bulkDeleting, setBulkDeleting] = React.useState(false);
// Pending text + uploaded attachments handed off from a draft that created a
// conversation via the paperclip, keyed by the new conversation id (consumed once
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
const [pendingByConv, setPendingByConv] = React.useState<
Record<number, { input?: string; attachments?: ChatAttachment[] }>
>({});
const reloadConvs = React.useCallback(() => {
api
.conversations()
.then(setConvs)
.catch(() => setConvs([]));
}, []);
React.useEffect(() => {
api
.agents()
.then(setAgents)
.catch(() => {
/* Agent metadata is optional for rendering an existing conversation. */
});
api
.llmProfiles()
.then(setProfiles)
.catch(() => {
/* The conversation remains usable without profile labels. */
});
reloadConvs();
}, [reloadConvs]);
React.useEffect(() => {
setSelectedConversationIds((current) => {
if (current.size === 0) return current;
const live = new Set(convs.map((conversation) => conversation.id));
const next = new Set([...current].filter((id) => live.has(id)));
return next.size === current.size ? current : next;
});
}, [convs]);
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
// returns to the same thread instead of the empty draft view. Runs after
// hydration (not a lazy useState init) to avoid a server/client mismatch.
React.useEffect(() => {
const c = new URLSearchParams(window.location.search).get("c");
const id = c ? Number(c) : NaN;
if (Number.isFinite(id)) setSelectedId(id);
}, []);
// Mirror the current selection into the URL (replaceState → no history spam). A
// selectedId with no matching conversation (e.g. a stale ?c=, or a just-created
// one before reloadConvs lands) simply renders the draft view — harmless — so we
// deliberately do NOT auto-clear it here (that raced new-conversation creation).
React.useEffect(() => {
const url = new URL(window.location.href);
if (selectedId != null) url.searchParams.set("c", String(selectedId));
else url.searchParams.delete("c");
window.history.replaceState(null, "", url);
}, [selectedId]);
const selected = convs.find((c) => c.id === selectedId) ?? null;
// conversation agents: custom agents + conversational built-ins (role=assistant,
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
// are task-specific and stay hidden from the chat page.
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
const selectedConversationCount = selectedConversationIds.size;
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length;
const someConversationsSelected = selectedConversationCount > 0 && !allConversationsSelected;
let conversationHeaderChecked: boolean | "indeterminate" = false;
if (allConversationsSelected) conversationHeaderChecked = true;
else if (someConversationsSelected) conversationHeaderChecked = "indeterminate";
function toggleConversationSelected(id: number, checked: boolean) {
setSelectedConversationIds((current) => {
const next = new Set(current);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
function toggleAllConversations(checked: boolean) {
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
}
async function del(id: number) {
try {
await api.deleteConversation(id);
if (selectedId === id) setSelectedId(null);
setSelectedConversationIds((current) => {
if (!current.has(id)) return current;
const next = new Set(current);
next.delete(id);
return next;
});
reloadConvs();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
async function deleteSelectedConversations() {
const ids = [...selectedConversationIds];
if (ids.length === 0 || bulkDeleting) return;
setBulkDeleting(true);
const deleted = new Set<number>();
const failed: { id: number; error: string }[] = [];
try {
for (let offset = 0; offset < ids.length; offset += 100) {
const result = await api.deleteConversations(ids.slice(offset, offset + 100));
for (const item of result.items) {
if (item.ok) deleted.add(item.id);
else failed.push({ id: item.id, error: item.error ?? "对话不存在" });
}
}
if (deleted.has(selectedId ?? -1)) setSelectedId(null);
setSelectedConversationIds((current) => {
const next = new Set(current);
for (const id of deleted) next.delete(id);
return next;
});
if (deleted.size > 0) toast.success(`已删除 ${deleted.size} 个对话`);
if (failed.length > 0) {
const details = failed
.slice(0, 3)
.map((item) => `#${item.id}(${item.error})`)
.join(";");
toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`);
}
setBulkDeleteOpen(false);
reloadConvs();
} catch (error) {
toast.error(`批量删除失败:${(error as Error).message}`);
reloadConvs();
} finally {
setBulkDeleting(false);
}
}
async function togglePinned(conversation: Conversation) {
const pinned = conversationIsPinned(conversation);
try {
await api.pinConversation(conversation.id, !pinned);
reloadConvs();
} catch (e) {
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(e as Error).message}`);
}
}
function startRename(c: Conversation) {
setRenamingId(c.id);
setRenameText(c.title || "");
}
async function commitRename() {
const id = renamingId;
const title = renameText.trim();
setRenamingId(null);
if (!id || !title) return;
try {
await api.renameConversation(id, title);
reloadConvs();
} catch (e) {
toast.error("重命名失败:" + (e as Error).message);
}
}
return (
<div
data-content-padding="false"
className="flex h-[calc(100svh-3rem)] min-w-0 flex-col overflow-hidden p-3 sm:p-4 md:h-[calc(100svh-4rem)] md:p-6"
>
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-[minmax(10rem,15rem)_minmax(0,1fr)] gap-3 md:grid-cols-[18rem_minmax(0,1fr)] md:grid-rows-[minmax(0,1fr)] md:gap-4">
{/* left: conversation list */}
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
<div className="flex flex-col gap-2 border-b p-2">
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
<PlusIcon /> 新建对话
</Button>
{convs.length > 0 && (
<div className="flex items-center gap-2 px-1">
<Checkbox
checked={conversationHeaderChecked}
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
aria-label="选择全部对话"
/>
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
</span>
{selectedConversationCount > 0 && (
<Button
size="sm"
variant="destructive"
disabled={bulkDeleting}
onClick={() => setBulkDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
删除
</Button>
)}
</div>
)}
</div>
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex min-w-0 flex-col gap-0.5 p-2">
{convs.length === 0 && <p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>}
{convs.map((c) => (
<ConversationItem
key={c.id}
conv={c}
agent={agents.find((a) => a.key === c.agent_key)}
active={selectedId === c.id}
renaming={renamingId === c.id}
renameText={renameText}
onSelect={() => setSelectedId(c.id)}
onStartRename={() => startRename(c)}
onRenameText={setRenameText}
onCommitRename={commitRename}
onCancelRename={() => setRenamingId(null)}
onTogglePinned={() => void togglePinned(c)}
onDelete={() => del(c.id)}
selectedForDelete={selectedConversationIds.has(c.id)}
onSelectedForDeleteChange={(checked) => toggleConversationSelected(c.id, checked)}
/>
))}
</div>
</ScrollArea>
</div>
{/* right: chat view */}
<div className="bg-card flex min-w-0 flex-col overflow-hidden rounded-lg border">
{selected ? (
<ChatView
key={selected.id}
conv={selected}
agents={agents}
profiles={profiles}
initial={pendingByConv[selected.id]}
onTitleMaybeChanged={reloadConvs}
onConvUpdated={reloadConvs}
/>
) : (
<DraftChat
agents={chatAgents}
profiles={profiles}
onStarted={(c, pending) => {
// Insert the new conversation immediately so `selected` resolves to
// it on this render (switching to ChatView right away, before the
// async reloadConvs lands); reloadConvs then reconciles titles etc.
if (pending) setPendingByConv((p) => ({ ...p, [c.id]: pending }));
setConvs((prev) => {
if (prev.some((item) => item.id === c.id)) return prev;
const firstUnpinned = prev.findIndex((item) => !conversationIsPinned(item));
const insertAt = firstUnpinned < 0 ? prev.length : firstUnpinned;
return [...prev.slice(0, insertAt), c, ...prev.slice(insertAt)];
});
setSelectedId(c.id);
reloadConvs();
}}
/>
)}
</div>
</div>
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除选中的 {selectedConversationCount} 个对话?</AlertDialogTitle>
<AlertDialogDescription>对话消息和执行记录将一并删除,此操作不可撤销。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkDeleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={bulkDeleting || selectedConversationCount === 0}
onClick={(event) => {
event.preventDefault();
void deleteSelectedConversations();
}}
>
{bulkDeleting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{bulkDeleting ? "删除中" : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}