feat(agent): 支持持久化 /btw 旁路提问

This commit is contained in:
ji ruo committed 2026-09-10 21:42:25 +08:00
1 parent 8dae851b9b
commit 555539bc8e
34 files changed
+3744 -314

No files matched your search

+18 -4
View File
@@ -21,6 +21,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace";
import { TodoPopover } from "@/components/todo-popover";
import { Transcript } from "@/components/transcript";
import {
@@ -48,8 +49,10 @@ 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 { useSideQuestions } from "@/hooks/use-side-questions";
import { api } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import { isBtwCommand } from "@/lib/side-questions";
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -114,6 +117,7 @@ function Composer({
onPickFiles,
onRemoveAttachment,
uploading,
allowBtw,
}: {
value: string;
onChange: (v: string) => void;
@@ -129,6 +133,7 @@ function Composer({
onPickFiles?: (files: FileList | null) => void;
onRemoveAttachment?: (path: string) => void;
uploading?: boolean;
allowBtw?: boolean;
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const atts = attachments ?? [];
@@ -196,10 +201,15 @@ function Composer({
rows={1}
placeholder={placeholder}
value={value}
disabled={disabled}
disabled={disabled && !allowBtw}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
{running && allowBtw && isBtwCommand(value) && (
<Button size="icon" onClick={onSend} aria-label="发送旁路问题" title="发送旁路问题">
<ArrowUpIcon />
</Button>
)}
{running ? (
// while a run is in flight the send button becomes a stop button —
// aborts just this session (the trigger queue keeps going).
@@ -451,6 +461,7 @@ function ChatView({
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;
const side = useSideQuestions(`/api/conversations/${conv.id}`);
async function changeProfile(id: number | null) {
try {
@@ -641,6 +652,7 @@ function ChatView({
async function send() {
const msg = input.trim();
const atts = attachments;
if (side.handleCommand(msg, () => setInput(""))) return;
if ((!msg && atts.length === 0) || sending || running) return;
setSending(true);
setInput("");
@@ -677,7 +689,7 @@ function ChatView({
}
return (
<>
<SideQuestionWorkspace side={side} label={agent?.name ?? conv.agent_key}>
{/* 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" />
@@ -692,6 +704,7 @@ function ChatView({
<span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span>
)}
{running && <LiveBadge />}
<SideQuestionButton side={side} />
<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">
@@ -729,7 +742,8 @@ function ChatView({
onChange={setInput}
onSend={send}
disabled={running}
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 发送,Shift+Enter 换行"}
allowBtw
placeholder={running ? "Agent 正在回复,可输入 /btw 提问…" : "输入消息,Enter 发送,Shift+Enter 换行"}
running={running}
onStop={stop}
stopDisabled={stopping}
@@ -745,7 +759,7 @@ function ChatView({
disabled={running || sending}
rightSlot={<TodoPopover seq={latestTodoSeq} fetchDetail={fetchDetail} />}
/>
</>
</SideQuestionWorkspace>
);
}
@@ -26,6 +26,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { SideQuestionButton, SideQuestionWorkspace } from "@/components/side-question-workspace";
import { TodoPopover } from "@/components/todo-popover";
import { Transcript } from "@/components/transcript";
import {
@@ -45,9 +46,11 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useSideQuestions } from "@/hooks/use-side-questions";
import { api, sseUrl } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import { MOCK } from "@/lib/mock/enabled";
import { isBtwCommand } from "@/lib/side-questions";
import { taskAssetSourceLabel, taskAssetTypeLabel } from "@/lib/task-assets";
import type {
Activity,
@@ -1118,6 +1121,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
};
const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION;
const side = useSideQuestions(
active.role === "mainagent"
? `/api/tasks/${taskId}/chat`
: active.role === "worker" && !active.inherited && active.intent_id
? `/api/tasks/${taskId}/intents/${active.intent_id}`
: null,
);
const isMain = active.role === "mainagent";
const isPlanner = active.role === "planner";
const isSystem = active.role === "system";
@@ -1310,7 +1320,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
function send() {
const text = input.trim();
const atts = attachments;
if ((!text && atts.length === 0) || sending) return;
if (side.handleCommand(text, () => setInput(""))) return;
if ((!text && atts.length === 0) || sending || mainLive) return;
// No optimistic echo: the backend persists+broadcasts the human turn before it
// returns, so it streams back over SSE (worker="mainagent") with its real DB
// seq — the transcript renders it from server data like every other step. Clear
@@ -1336,6 +1347,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
function sendWorkerChat() {
const intentId = active.intent_id;
const message = workerMessage.trim();
if (side.handleCommand(message, () => setWorkerMessage(""))) return;
if (!intentId || active.inherited || active.status !== "paused" || workerMessageSending || !message) return;
if (workerMessageCharCount(message) > MAX_WORKER_MESSAGE_CHARS) {
toast.error(`消息不能超过 ${MAX_WORKER_MESSAGE_CHARS} 个字符`);
@@ -1527,332 +1539,356 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</div>
{/* Right: transcript */}
<div className="flex min-w-0 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 border-b px-3 py-2 sm:px-4 sm:py-2.5">
{(() => {
const isWorker = active.role === "worker";
const meta = isWorker ? sessionMeta.get(active.id) : undefined;
// Worker: the intent moved into the transcript as a message, so the
// header shows a stable generic label (intent JSON stays on hover).
const title = isWorker ? "Worker 执行会话" : active.title;
const titleEl = <span className="min-w-0 truncate text-sm font-medium">{title}</span>;
return meta?.json ? (
<SideQuestionWorkspace
side={side}
label={active.role === "worker" ? `Worker #${active.intent_id} · ${activeDisplayTitle}` : activeDisplayTitle}
>
<div className="flex min-h-0 flex-1 min-w-0 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 border-b px-3 py-2 sm:px-4 sm:py-2.5">
{(() => {
const isWorker = active.role === "worker";
const meta = isWorker ? sessionMeta.get(active.id) : undefined;
// Worker: the intent moved into the transcript as a message, so the
// header shows a stable generic label (intent JSON stays on hover).
const title = isWorker ? "Worker 执行会话" : active.title;
const titleEl = <span className="min-w-0 truncate text-sm font-medium">{title}</span>;
return meta?.json ? (
<Tooltip>
<TooltipTrigger asChild>{titleEl}</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-h-80 max-w-sm overflow-auto p-0">
<pre className="p-2 text-[10px] leading-relaxed">{JSON.stringify(meta.json, null, 2)}</pre>
</TooltipContent>
</Tooltip>
) : (
titleEl
);
})()}
<SideQuestionButton side={side} />
{activeResolution && (
<Tooltip>
<TooltipTrigger asChild>{titleEl}</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-h-80 max-w-sm overflow-auto p-0">
<pre className="p-2 text-[10px] leading-relaxed">{JSON.stringify(meta.json, null, 2)}</pre>
</TooltipContent>
</Tooltip>
) : (
titleEl
);
})()}
{activeResolution && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="max-w-28 shrink-0 font-normal"
aria-label={
activeResolution.available ? `当前配置:${resolutionLabel(activeResolution)}` : "模型不可用"
}
>
<span className="truncate">
{activeResolution.available ? resolutionLabel(activeResolution) : "模型不可用"}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]">
{activeResolution.available
? [resolutionLabel(activeResolution), activeResolution.model].filter(Boolean).join(" / ")
: activeResolution.reason || "没有可用的 LLM 配置"}
</TooltipContent>
</Tooltip>
)}
{activeAssets && activeAssets.length > 0 && <WorkerAssetBadge assets={activeAssets} />}
{active.inherited && active.source_task_id && (
<Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge>
)}
{active.live && (
<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>
)}
{activeState?.hasMore && (
<span className="text-[10px] text-muted-foreground" title="向上滚动加载更早历史">
↑ 更早历史
</span>
)}
<div className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground max-sm:w-full max-sm:flex-wrap">
{tokenTotal.any && (
<Tooltip>
{/* 手机端用短标签(入/缓/出):长标签会把这一行撑成两行,进一步压缩记录区。 */}
<TooltipTrigger asChild>
<span className="inline-flex min-w-0 items-center">
<TokenMetrics
input={tokenTotal.i}
cache={tokenTotal.cr}
output={tokenTotal.o}
labels="short"
className="sm:hidden"
/>
<TokenMetrics
input={tokenTotal.i}
cache={tokenTotal.cr}
output={tokenTotal.o}
labels="long"
className="max-sm:hidden"
/>
</span>
<Badge
variant="outline"
className="max-w-28 shrink-0 font-normal"
aria-label={
activeResolution.available ? `当前配置:${resolutionLabel(activeResolution)}` : "模型不可用"
}
>
<span className="truncate">
{activeResolution.available ? resolutionLabel(activeResolution) : "模型不可用"}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>
输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "}
{activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "}
{activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "}
{activeTokens.cache_write_tokens.toLocaleString()}
<TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]">
{activeResolution.available
? [resolutionLabel(activeResolution), activeResolution.model].filter(Boolean).join(" / ")
: activeResolution.reason || "没有可用的 LLM 配置"}
</TooltipContent>
</Tooltip>
)}
{runDuration != null && (
<span className="inline-flex items-center gap-1" title="运行时长(首步 → 末步)">
<ClockIcon className="size-3" />
{fmtDuration(runDuration)}
{activeAssets && activeAssets.length > 0 && <WorkerAssetBadge assets={activeAssets} />}
{active.inherited && active.source_task_id && (
<Badge variant="outline">来源任务 #{active.source_task_id} · 只读历史</Badge>
)}
{active.live && (
<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>
)}
{isMain && <span>可交互</span>}
</div>
</div>
{(() => {
const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined;
if (!dm?.deleted) return null;
return (
<div className="flex items-start gap-2 border-b border-destructive/30 bg-destructive/5 px-4 py-2.5 text-xs">
<Trash2Icon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
<div className="min-w-0">
<span className="font-medium text-destructive">此意图已被用户删除</span>
<span className="text-muted-foreground">
(已停止执行,规划者已收到通知;意图与产出保留,可在下方查看历史)
{activeState?.hasMore && (
<span className="text-[10px] text-muted-foreground" title="向上滚动加载更早历史">
↑ 更早历史
</span>
)}
<div className="ml-auto flex min-w-0 max-w-full items-center justify-end gap-x-3 gap-y-1 text-xs text-muted-foreground max-sm:w-full max-sm:flex-wrap">
{tokenTotal.any && (
<Tooltip>
{/* 手机端用短标签(入/缓/出):长标签会把这一行撑成两行,进一步压缩记录区。 */}
<TooltipTrigger asChild>
<span className="inline-flex min-w-0 items-center">
<TokenMetrics
input={tokenTotal.i}
cache={tokenTotal.cr}
output={tokenTotal.o}
labels="short"
className="sm:hidden"
/>
<TokenMetrics
input={tokenTotal.i}
cache={tokenTotal.cr}
output={tokenTotal.o}
labels="long"
className="max-sm:hidden"
/>
</span>
</TooltipTrigger>
<TooltipContent>
输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "}
{activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "}
{activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "}
{activeTokens.cache_write_tokens.toLocaleString()}
</TooltipContent>
</Tooltip>
)}
{runDuration != null && (
<span className="inline-flex items-center gap-1" title="运行时长(首步 → 末步)">
<ClockIcon className="size-3" />
{fmtDuration(runDuration)}
</span>
{dm.deleteReason && (
<p className="mt-1 break-words text-foreground">
<span className="text-muted-foreground">删除原因:</span>
{dm.deleteReason}
</p>
)}
</div>
)}
{isMain && <span>可交互</span>}
</div>
);
})()}
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
</div>
{(() => {
const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined;
if (!dm?.deleted) return null;
return (
<div className="flex items-start gap-2 border-b border-destructive/30 bg-destructive/5 px-4 py-2.5 text-xs">
<Trash2Icon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
<div className="min-w-0">
<span className="font-medium text-destructive">此意图已被用户删除</span>
<span className="text-muted-foreground">
(已停止执行,规划者已收到通知;意图与产出保留,可在下方查看历史)
</span>
{dm.deleteReason && (
<p className="mt-1 break-words text-foreground">
<span className="text-muted-foreground">删除原因:</span>
{dm.deleteReason}
</p>
)}
</div>
</div>
);
})()}
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
out the width and defeat the truncation below — the transcript wraps to
the panel instead of overflowing horizontally. */}
<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 p-4" ref={contentRef}>
{activeState?.loadingMore && (
<div className="flex items-center justify-center gap-2 pb-2 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载更早历史…
</div>
)}
{showLoader ? (
<div className="flex items-center gap-2 pl-9 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载活动流…
</div>
) : activeState?.error ? (
<div className="flex items-center gap-2 pl-9 text-xs text-red-500">
<CircleXIcon className="size-3.5" />
加载失败:{activeState.error}
<Button size="sm" variant="ghost" className="h-6 px-2 text-xs" onClick={() => loadSession(activeKey)}>
重试
</Button>
</div>
) : activity.length ? (
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} />
) : (
<div className="pl-9 text-xs text-muted-foreground">
{isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
</div>
)}
</div>
</ScrollArea>
{isMain ? (
<div className="border-t p-3">
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.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}
<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 p-4" ref={contentRef}>
{activeState?.loadingMore && (
<div className="flex items-center justify-center gap-2 pb-2 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载更早历史…
</div>
)}
{showLoader ? (
<div className="flex items-center gap-2 pl-9 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载活动流…
</div>
) : activeState?.error ? (
<div className="flex items-center gap-2 pl-9 text-xs text-red-500">
<CircleXIcon className="size-3.5" />
加载失败:{activeState.error}
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-xs"
onClick={() => loadSession(activeKey)}
>
<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>
<button
type="button"
className="ml-0.5 text-muted-foreground hover:text-foreground"
onClick={() => setAttachments((p) => p.filter((x) => x.path !== a.path))}
title="移除"
重试
</Button>
</div>
) : activity.length ? (
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} />
) : (
<div className="pl-9 text-xs text-muted-foreground">
{isMain ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。" : "暂无活动记录。"}
</div>
)}
</div>
</ScrollArea>
{isMain ? (
<div className="border-t p-3">
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.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}
>
<XIcon className="size-3" />
</button>
</div>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<InputGroup className="min-h-9 has-disabled:opacity-100">
<InputGroupTextarea
rows={1}
aria-label="给主 Agent 发消息"
placeholder="给主 Agent 发消息,引导探索方向…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault();
if (!mainLive) send();
}}
disabled={mainLive}
className="max-h-36 min-h-9 overflow-y-auto"
<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>
<button
type="button"
className="ml-0.5 text-muted-foreground hover:text-foreground"
onClick={() => setAttachments((p) => p.filter((x) => x.path !== a.path))}
title="移除"
>
<XIcon className="size-3" />
</button>
</div>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<InputGroupAddon align="block-end">
<InputGroupButton
size="icon-xs"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={mainLive || uploading}
title="上传文件"
aria-label="上传文件"
>
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
</InputGroupButton>
{mainLive ? (
<InputGroup className="min-h-9 has-disabled:opacity-100">
<InputGroupTextarea
rows={1}
aria-label="给主 Agent 发消息"
placeholder={mainLive ? "主 Agent 正在运行,可输入 /btw 提问…" : "给主 Agent 发消息,引导探索方向…"}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault();
send();
}}
className="max-h-36 min-h-9 overflow-y-auto"
/>
<InputGroupAddon align="block-end">
<InputGroupButton
className="ml-auto"
size="icon-xs"
variant="destructive"
onClick={stop}
disabled={stopping}
title="停止当前执行"
aria-label="停止当前执行"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={mainLive || uploading}
title="上传文件"
aria-label="上传文件"
>
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
size="icon-xs"
variant="default"
onClick={send}
disabled={(!input.trim() && attachments.length === 0) || sending}
title="发送消息"
aria-label="发送消息"
>
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div>
) : active.role === "worker" &&
!active.inherited &&
(active.status === "running" || active.status === "paused") ? (
<div className="border-t p-3">
<InputGroup className="min-h-9 has-disabled:opacity-100">
<InputGroupTextarea
rows={1}
aria-label={`给 Worker #${active.intent_id} 发消息`}
placeholder={`给 Worker #${active.intent_id} 发消息,调整执行方向…`}
value={workerMessage}
onChange={(event) => {
setWorkerMessage(event.target.value);
setWorkerMessageRequestId("");
}}
onKeyDown={(event) => {
if (!shouldSubmitOnKey(event, sendMode)) return;
event.preventDefault();
sendWorkerChat();
}}
disabled={active.status === "running" || workerMessageSending}
aria-invalid={workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS}
className="max-h-36 min-h-9 overflow-y-auto"
/>
<InputGroupAddon align="block-end">
<span
className={cn(
"px-1 text-[10px] tabular-nums text-muted-foreground",
workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS && "text-destructive",
{mainLive && isBtwCommand(input) && (
<InputGroupButton size="icon-xs" onClick={send} aria-label="发送旁路问题">
<ArrowUpIcon />
</InputGroupButton>
)}
>
{workerMessageCharCount(workerMessage)}/{MAX_WORKER_MESSAGE_CHARS}
</span>
{active.status === "running" ? (
<InputGroupButton
className="ml-auto"
size="icon-xs"
variant="destructive"
onClick={() => void controlWorker(active, "pause")}
disabled={controllingIntent === active.intent_id}
title="暂停当前 Worker"
aria-label="暂停当前 Worker"
>
{controllingIntent === active.intent_id ? (
<Loader2Icon className="animate-spin" />
) : (
<SquareIcon />
)}
</InputGroupButton>
) : (
<>
{mainLive ? (
<InputGroupButton
className="ml-auto"
size="xs"
variant="ghost"
onClick={() => void controlWorker(active, "resume")}
disabled={controllingIntent === active.intent_id || workerMessageSending}
title="不发消息,直接继续执行"
aria-label="直接继续执行"
size="icon-xs"
variant="destructive"
onClick={stop}
disabled={stopping}
title="停止当前执行"
aria-label="停止当前执行"
>
{controllingIntent === active.intent_id ? <Loader2Icon className="animate-spin" /> : "直接继续"}
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
size="icon-xs"
variant="default"
onClick={sendWorkerChat}
disabled={
workerMessageSending ||
!workerMessage.trim() ||
workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS
}
onClick={send}
disabled={(!input.trim() && attachments.length === 0) || sending}
title="发送消息"
aria-label="发送消息"
>
{workerMessageSending ? <Spinner /> : <ArrowUpIcon />}
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
</InputGroupButton>
</>
)}
</InputGroupAddon>
</InputGroup>
</div>
) : (
<div className="flex items-center border-t px-4 py-2">
<TodoPopover
seq={latestTodoSeq}
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail ?? "")}
/>
</div>
)}
</div>
)}
</InputGroupAddon>
</InputGroup>
</div>
) : active.role === "worker" &&
!active.inherited &&
(active.status === "running" || active.status === "paused") ? (
<div className="border-t p-3">
<InputGroup className="min-h-9 has-disabled:opacity-100">
<InputGroupTextarea
rows={1}
aria-label={`给 Worker #${active.intent_id} 发消息`}
placeholder={`给 Worker #${active.intent_id} 发消息,调整执行方向…`}
value={workerMessage}
onChange={(event) => {
setWorkerMessage(event.target.value);
setWorkerMessageRequestId("");
}}
onKeyDown={(event) => {
if (!shouldSubmitOnKey(event, sendMode)) return;
event.preventDefault();
sendWorkerChat();
}}
disabled={workerMessageSending}
aria-invalid={workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS}
className="max-h-36 min-h-9 overflow-y-auto"
/>
<InputGroupAddon align="block-end">
<span
className={cn(
"px-1 text-[10px] tabular-nums text-muted-foreground",
workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS && "text-destructive",
)}
>
{workerMessageCharCount(workerMessage)}/{MAX_WORKER_MESSAGE_CHARS}
</span>
{active.status === "running" && isBtwCommand(workerMessage) && (
<InputGroupButton size="icon-xs" onClick={sendWorkerChat} aria-label="发送旁路问题">
<ArrowUpIcon />
</InputGroupButton>
)}
{active.status === "running" ? (
<InputGroupButton
className="ml-auto"
size="icon-xs"
variant="destructive"
onClick={() => void controlWorker(active, "pause")}
disabled={controllingIntent === active.intent_id}
title="暂停当前 Worker"
aria-label="暂停当前 Worker"
>
{controllingIntent === active.intent_id ? (
<Loader2Icon className="animate-spin" />
) : (
<SquareIcon />
)}
</InputGroupButton>
) : (
<>
<InputGroupButton
className="ml-auto"
size="xs"
variant="ghost"
onClick={() => void controlWorker(active, "resume")}
disabled={controllingIntent === active.intent_id || workerMessageSending}
title="不发消息,直接继续执行"
aria-label="直接继续执行"
>
{controllingIntent === active.intent_id ? (
<Loader2Icon className="animate-spin" />
) : (
"直接继续"
)}
</InputGroupButton>
<InputGroupButton
size="icon-xs"
variant="default"
onClick={sendWorkerChat}
disabled={
workerMessageSending ||
!workerMessage.trim() ||
workerMessageCharCount(workerMessage) > MAX_WORKER_MESSAGE_CHARS
}
title="发送消息"
aria-label="发送消息"
>
{workerMessageSending ? <Spinner /> : <ArrowUpIcon />}
</InputGroupButton>
</>
)}
</InputGroupAddon>
</InputGroup>
</div>
) : (
<div className="flex items-center border-t px-4 py-2">
<TodoPopover
seq={latestTodoSeq}
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail ?? "")}
/>
</div>
)}
</div>
</SideQuestionWorkspace>
<AlertDialog
open={cancelIntent !== null}
onOpenChange={(open) => {
@@ -0,0 +1,231 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { ArrowUpIcon, MessageCircleQuestionIcon, SquareIcon, Trash2Icon, XIcon } from "lucide-react";
import { Markdown } from "@/components/markdown";
import { Alert, AlertDescription } from "@/components/ui/alert";
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 { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-mobile";
import type { SideQuestions } from "@/hooks/use-side-questions";
export function SideQuestionButton({ side }: { side: SideQuestions }) {
if (!side.enabled) return null;
return (
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title="/btw 旁路提问">
<MessageCircleQuestionIcon data-icon="inline-start" />
旁路提问
</Button>
);
}
function SidePanel({ side, label }: { side: SideQuestions; label: string }) {
const [confirm, setConfirm] = useState(false);
const viewport = useRef<HTMLDivElement>(null);
const pinned = useRef(true);
const tail = side.items.at(-1);
// biome-ignore lint/correctness/useExhaustiveDependencies: New cumulative text scrolls only readers who remain at the bottom.
useEffect(() => {
if (pinned.current && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight;
}, [tail?.answer, tail?.id]);
const status = { running: "回答中", completed: "已完成", failed: "失败", cancelled: "已停止", interrupted: "已中断" };
return (
<section className="flex h-full min-h-0 flex-col bg-background" aria-label="旁路提问面板">
<div className="flex items-center gap-2 border-b p-3">
<div className="min-w-0 flex-1">
<p className="font-medium">
旁路提问 <span className="text-muted-foreground">/btw</span>
</p>
<p className="truncate text-muted-foreground text-xs">{label}</p>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirm(true)}
disabled={!side.items.length || side.busy}
aria-label="清空旁路历史"
>
<Trash2Icon />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => side.setOpen(false)} aria-label="关闭旁路面板">
<XIcon />
</Button>
</div>
<div className="border-b px-3 py-2 text-muted-foreground text-xs">
{side.snapshot ? (
<>
<p>{side.snapshot.model.model}</p>
<p>上下文更新于 {new Date(side.snapshot.captured_at).toLocaleString()}</p>
</>
) : (
"主 Agent 首次运行后即可提问"
)}
</div>
<div
ref={viewport}
onScroll={(event) => {
const el = event.currentTarget;
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}}
className="min-h-0 flex-1 overflow-y-auto p-3"
>
{side.nextCursor > 0 && (
<Button variant="ghost" size="sm" onClick={() => void side.load(side.nextCursor)}>
加载更早的旁路问答
</Button>
)}
{side.loading && <Skeleton className="h-16 w-full" />}
{!side.loading && side.items.length === 0 && (
<Empty>
<EmptyHeader>
<EmptyTitle>随时问一个问题</EmptyTitle>
<EmptyDescription>根据当前 Agent 的上下文回答,主任务继续运行。</EmptyDescription>
</EmptyHeader>
</Empty>
)}
<div className="flex flex-col gap-5">
{side.items.map((item) => (
<article key={item.id} className="flex min-w-0 flex-col gap-2">
<div className="whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm">{item.question}</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<Badge variant="secondary">{status[item.status]}</Badge>
<span className="truncate">{item.model.model}</span>
<time dateTime={item.snapshot_at} title={new Date(item.snapshot_at).toLocaleString()}>
上下文 {new Date(item.snapshot_at).toLocaleTimeString()}
</time>
</div>
{item.answer && <Markdown text={item.answer} />}
{!item.answer && item.status === "running" && (
<p role="status" className="text-muted-foreground text-sm">
正在回答…
</p>
)}
{item.error && (
<Alert variant="destructive">
<AlertDescription>{item.error}</AlertDescription>
</Alert>
)}
</article>
))}
</div>
</div>
{(side.error || side.snapshot?.reason) && (
<Alert variant="destructive">
<AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription>
</Alert>
)}
<div className="border-t p-3">
<InputGroup>
<InputGroupTextarea
aria-label="旁路问题"
placeholder="询问当前上下文…"
value={side.draft}
maxLength={4000}
disabled={side.busy}
onChange={(event) => side.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
void side.ask(side.draft);
}
}}
/>
<InputGroupAddon align="block-end">
<span className="text-muted-foreground text-xs">独立问答 · 无工具执行</span>
{side.running ? (
<InputGroupButton
className="ml-auto"
variant="destructive"
size="icon-xs"
onClick={() => void side.stop()}
aria-label="停止旁路回答"
>
<SquareIcon />
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
variant="default"
size="icon-xs"
onClick={() => void side.ask(side.draft)}
disabled={side.busy || !side.draft.trim() || !side.snapshot?.available}
aria-label="发送旁路问题"
>
<ArrowUpIcon />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div>
<AlertDialog open={confirm} onOpenChange={setConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清空旁路历史?</AlertDialogTitle>
<AlertDialogDescription>
删除当前 Agent 的旁路问答,并停止正在生成的旁路回答。主会话和上下文快照会保留。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => void side.clear()}>清空历史</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export function SideQuestionWorkspace({
side,
label,
children,
}: {
side: SideQuestions;
label: string;
children: ReactNode;
}) {
const mobile = useIsMobile();
return (
<>
<ResizablePanelGroup orientation="horizontal" className="min-h-0 min-w-0 flex-1">
<ResizablePanel id="main-conversation" minSize="35%" className="flex min-h-0 min-w-0 flex-col">
{children}
</ResizablePanel>
{side.open && side.enabled && !mobile && (
<>
<ResizableHandle withHandle />
<ResizablePanel id="side-question" defaultSize="38%" minSize="280px" maxSize="65%">
<SidePanel side={side} label={label} />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
<Drawer open={mobile && side.open && side.enabled} onOpenChange={side.setOpen}>
<DrawerContent className="h-[85svh]">
<DrawerHeader className="sr-only">
<DrawerTitle>旁路提问</DrawerTitle>
<DrawerDescription>{label} 的独立问答</DrawerDescription>
</DrawerHeader>
<SidePanel side={side} label={label} />
</DrawerContent>
</Drawer>
</>
);
}
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { sseUrl } from "@/lib/api";
import { isBtwCommand, type SideExchange, type SideHistory, sideAPI } from "@/lib/side-questions";
function merge(old: SideExchange[], incoming: SideExchange[]) {
const byID = new Map(old.map((item) => [item.id, item]));
for (const item of incoming) {
if ((byID.get(item.id)?.sequence ?? -1) <= item.sequence) byID.set(item.id, item);
}
return [...byID.values()].sort((a, b) => a.ordinal - b.ordinal);
}
export function useSideQuestions(parent: string | null) {
const [stateParent, setStateParent] = useState(parent);
const current = stateParent === parent;
const [open, setOpen] = useState(false);
const [items, setItems] = useState<SideExchange[]>([]);
const [snapshot, setSnapshot] = useState<SideHistory["snapshot"]>(null);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [nextCursor, setNextCursor] = useState(0);
const [error, setError] = useState("");
const epoch = useRef(0);
const [streamEpoch, setStreamEpoch] = useState(0);
const cursor = useRef(0);
const submitting = useRef(false);
const retry = useRef<{ question: string; id: string } | null>(null);
const running = current ? items.find((item) => item.status === "running") : undefined;
const runningID = running?.id;
const load = useCallback(
async (before = 0) => {
if (!parent) return;
const version = epoch.current;
try {
const data = await sideAPI.history(parent, before);
if (version !== epoch.current) return;
setItems((old) => merge(old, data.items));
setSnapshot(data.snapshot);
setError("");
if (before || !cursor.current) {
cursor.current = data.next_cursor;
setNextCursor(data.next_cursor);
}
} catch (err) {
if (version === epoch.current) setError((err as Error).message);
}
},
[parent],
);
useEffect(() => {
epoch.current++;
setStateParent(parent);
setItems([]);
setSnapshot(null);
setNextCursor(0);
setDraft("");
setError("");
setBusy(false);
submitting.current = false;
retry.current = null;
cursor.current = 0;
if (!parent) {
setOpen(false);
setLoading(false);
return;
}
const version = epoch.current;
setLoading(true);
void sideAPI
.history(parent)
.then((data) => {
if (version !== epoch.current) return;
setItems((old) => merge(old, data.items));
setSnapshot(data.snapshot);
setNextCursor(data.next_cursor);
cursor.current = data.next_cursor;
})
.catch((err: Error) => {
if (version === epoch.current) setError(err.message);
})
.finally(() => {
if (version === epoch.current) setLoading(false);
});
return () => {
epoch.current++;
};
}, [parent]);
useEffect(() => {
if (!open || !parent) return;
void load();
const timer = setInterval(() => void load(), 2000);
return () => clearInterval(timer);
}, [open, parent, load]);
// biome-ignore lint/correctness/useExhaustiveDependencies: Reconnect after a clear attempt invalidates older callbacks.
useEffect(() => {
if (!runningID) return;
const version = epoch.current;
const stream = new EventSource(sseUrl(`/api/side-questions/${runningID}/events`));
stream.addEventListener("snapshot", (event) => {
if (version !== epoch.current) return;
try {
const item = JSON.parse((event as MessageEvent).data) as SideExchange;
if (item.id !== runningID) return;
setItems((old) => merge(old, [item]));
if (item.status !== "running") stream.close();
} catch {
setError("旁路数据解析失败,请重新打开面板");
}
});
stream.addEventListener("cleared", () => {
stream.close();
if (version === epoch.current) setItems([]);
});
return () => stream.close();
}, [runningID, streamEpoch]);
const ask = async (input: string) => {
const question = input.trim();
if (!parent || !question || submitting.current || running) return;
const version = epoch.current;
submitting.current = true;
setBusy(true);
setDraft(question);
setOpen(true);
if (retry.current?.question !== question) retry.current = { question, id: crypto.randomUUID() };
try {
const item = await sideAPI.ask(parent, question, retry.current.id);
if (version !== epoch.current) return;
setItems((old) => merge(old, [item]));
setDraft("");
setError("");
retry.current = null;
void load();
} catch (err) {
if (version === epoch.current) {
setError((err as Error).message);
toast.error((err as Error).message);
}
} finally {
if (version === epoch.current) {
submitting.current = false;
setBusy(false);
}
}
};
const handleCommand = (text: string, clear: () => void) => {
if (!parent || !isBtwCommand(text)) return false;
const question = text.trim().slice(4).trim();
setOpen(true);
clear();
if (question) {
setDraft(question);
void ask(question);
}
return true;
};
const clear = async () => {
if (!parent || submitting.current) return;
submitting.current = true;
setBusy(true);
const version = ++epoch.current;
try {
await sideAPI.clear(parent);
if (version !== epoch.current) return;
setItems([]);
setNextCursor(0);
cursor.current = 0;
setError("");
retry.current = null;
} catch (err) {
if (version === epoch.current) toast.error((err as Error).message);
} finally {
if (version === epoch.current) {
submitting.current = false;
setBusy(false);
setStreamEpoch((value) => value + 1);
void load();
}
}
};
const stop = async () => {
if (running) {
try {
await sideAPI.cancel(running.id);
} catch (err) {
toast.error((err as Error).message);
}
}
};
return {
open,
setOpen,
items: current ? items : [],
snapshot: current ? snapshot : null,
draft: current ? draft : "",
setDraft,
busy: current && busy,
loading: !current || loading,
error: current ? error : "",
running,
nextCursor: current ? nextCursor : 0,
load,
ask,
handleCommand,
clear,
stop,
enabled: !!parent,
};
}
export type SideQuestions = ReturnType<typeof useSideQuestions>;
+1 -1
View File
@@ -91,7 +91,7 @@ function getToken(): string | null {
return localStorage.getItem("artex_token");
}
async function http<T>(path: string, init?: RequestInit): Promise<T> {
export async function http<T>(path: string, init?: RequestInit): Promise<T> {
if (MOCK) return mockHandle<T>(init?.method ?? "GET", path, init?.body ?? null);
const token = getToken();
const r = await fetch(`/api${path}`, {
+43
View File
@@ -0,0 +1,43 @@
import { http } from "@/lib/api";
export interface SideModel {
model: string;
name: string;
format: string;
profile_id: number;
}
export interface SideExchange {
id: string;
ordinal: number;
client_request_id: string;
question: string;
answer: string;
status: "running" | "completed" | "failed" | "cancelled" | "interrupted";
error?: string;
model: SideModel;
snapshot_at: string;
created_at: string;
sequence: number;
}
export interface SideHistory {
items: SideExchange[];
current: SideExchange | null;
next_cursor: number;
snapshot: { captured_at: string; model: SideModel; available: boolean; reason: string } | null;
}
async function request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
return http<T>(path.replace(/^\/api/, ""), { method, body: body === undefined ? undefined : JSON.stringify(body) });
}
export const sideAPI = {
history: (parent: string, before = 0) => request<SideHistory>(`${parent}/side-questions?before=${before}`),
ask: (parent: string, question: string, client_request_id: string) =>
request<SideExchange>(`${parent}/side-questions`, "POST", { question, client_request_id }),
clear: (parent: string) => request(`${parent}/side-questions`, "DELETE"),
cancel: (id: string) => request(`/api/side-questions/${id}/cancel`, "POST"),
};
export function isBtwCommand(text: string) {
return /^\/btw(?:\s|$)/.test(text.trim());
}