feat(agent): 支持持久化 /btw 旁路提问
This commit is contained in:
1 parent
8dae851b9b
commit
555539bc8e
34 files changed
+3744
-314
No files matched your search
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user