Merge pull request #106 from RuoJi6/codex/btw-side-question
feat: add /btw side questions
This commit is contained in:
41 files changed
+4976
-318
No files matched your search
@@ -22,6 +22,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 {
|
||||
@@ -50,10 +51,12 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useSideQuestions } from "@/hooks/use-side-questions";
|
||||
import { mergeActivities } from "@/lib/activity-merge";
|
||||
import { api } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
import { isBtwCommand } from "@/lib/side-questions";
|
||||
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -154,6 +157,7 @@ function Composer({
|
||||
onPickFiles,
|
||||
onRemoveAttachment,
|
||||
uploading,
|
||||
allowBtw,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
@@ -169,6 +173,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 ?? [];
|
||||
@@ -236,10 +241,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).
|
||||
@@ -283,15 +293,17 @@ function LLMProfileRow({
|
||||
const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 px-3 pb-3">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1 px-1 pt-0.5 pb-1">
|
||||
<ZapIcon className="text-muted-foreground/50 size-3 shrink-0" />
|
||||
<span className="text-muted-foreground/70 text-xs">{label}</span>
|
||||
<span className="truncate text-muted-foreground/70 text-xs" title={label}>
|
||||
{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"
|
||||
className="flex shrink-0 items-center gap-0.5 text-primary text-xs hover:underline disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
更换
|
||||
<ChevronDownIcon className="size-3" />
|
||||
@@ -493,6 +505,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 {
|
||||
@@ -692,6 +705,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("");
|
||||
@@ -726,7 +740,7 @@ function ChatView({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideQuestionWorkspace side={side} label={agent?.name ?? conv.agent_key} composerLayout="inline">
|
||||
{/* 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" />
|
||||
@@ -741,6 +755,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">
|
||||
@@ -778,7 +793,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}
|
||||
@@ -794,7 +810,7 @@ function ChatView({
|
||||
disabled={running || sending}
|
||||
rightSlot={<TodoPopover seq={latestTodoSeq} fetchDetail={fetchDetail} />}
|
||||
/>
|
||||
</>
|
||||
</SideQuestionWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,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 {
|
||||
@@ -46,9 +47,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,
|
||||
@@ -1208,6 +1211,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";
|
||||
@@ -1409,7 +1419,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 || mainBusy) 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
|
||||
@@ -1435,6 +1446,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} 个字符`);
|
||||
@@ -1643,332 +1655,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 (!mainBusy) send();
|
||||
}}
|
||||
disabled={mainBusy}
|
||||
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={mainBusy || uploading}
|
||||
title="上传文件"
|
||||
aria-label="上传文件"
|
||||
>
|
||||
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
|
||||
</InputGroupButton>
|
||||
{mainBusy ? (
|
||||
<InputGroup className="min-h-9 has-disabled:opacity-100">
|
||||
<InputGroupTextarea
|
||||
rows={1}
|
||||
aria-label="给主 Agent 发消息"
|
||||
placeholder={mainBusy ? "主 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={mainBusy || 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",
|
||||
{mainBusy && 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>
|
||||
) : (
|
||||
<>
|
||||
{mainBusy ? (
|
||||
<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,265 @@
|
||||
"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";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ComposerLayout = "inline" | "stacked";
|
||||
|
||||
const preparationLabels = {
|
||||
preparing: "正在准备上下文…",
|
||||
summarizing_history: "正在整理早期旁路问答…",
|
||||
compressing_snapshot: "正在压缩旁路上下文副本…",
|
||||
retrying: "模型上下文超限,正在缩减后重试…",
|
||||
answering: "正在回答…",
|
||||
};
|
||||
|
||||
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,
|
||||
composerLayout,
|
||||
}: {
|
||||
side: SideQuestions;
|
||||
label: string;
|
||||
composerLayout: ComposerLayout;
|
||||
}) {
|
||||
const inlineComposer = composerLayout === "inline";
|
||||
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.context?.estimated_input_tokens != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
最近 {item.context.recent_exchanges} 组问答原文
|
||||
{item.context.history_summarized && " · 含早期问答摘要"}
|
||||
{item.context.snapshot_summarized && " · 使用主上下文摘要"}
|
||||
</p>
|
||||
)}
|
||||
{item.answer && <Markdown text={item.answer} />}
|
||||
{!item.answer && item.status === "running" && (
|
||||
<p role="status" className="text-muted-foreground text-sm">
|
||||
{preparationLabels[item.context?.phase ?? "answering"]}
|
||||
</p>
|
||||
)}
|
||||
{item.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{item.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-t p-3">
|
||||
{(side.error || side.snapshot?.reason) && (
|
||||
<Alert variant="destructive" className="mb-2">
|
||||
<AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<InputGroup className={inlineComposer ? "min-h-10" : "min-h-9"}>
|
||||
<InputGroupTextarea
|
||||
rows={1}
|
||||
className={cn("overflow-y-auto", inlineComposer ? "max-h-40 min-h-0" : "max-h-36 min-h-9")}
|
||||
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={inlineComposer ? "inline-end" : "block-end"}>
|
||||
{!inlineComposer && <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>
|
||||
{inlineComposer && (
|
||||
<div className="shrink-0 truncate px-3 pt-0.5 pb-1 text-muted-foreground text-xs">独立问答 · 无工具执行</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,
|
||||
composerLayout = "stacked",
|
||||
}: {
|
||||
side: SideQuestions;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
composerLayout?: ComposerLayout;
|
||||
}) {
|
||||
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} composerLayout={composerLayout} />
|
||||
</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} composerLayout={composerLayout} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"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 [loadError, setLoadError] = 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 accepted = useRef<{ id: string; question: string } | null>(null);
|
||||
const running = current ? items.find((item) => item.status === "running") : undefined;
|
||||
const runningID = running?.id;
|
||||
|
||||
const restoreFailedDraft = useCallback((incoming: SideExchange[]) => {
|
||||
const pending = accepted.current;
|
||||
if (!pending) return;
|
||||
const item = incoming.find((entry) => entry.id === pending.id);
|
||||
if (!item || item.status === "running") return;
|
||||
accepted.current = null;
|
||||
if (item.status === "failed" || item.status === "interrupted") {
|
||||
setDraft((old) => old || pending.question);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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));
|
||||
restoreFailedDraft(data.items);
|
||||
setSnapshot(data.snapshot);
|
||||
setLoadError("");
|
||||
if (before || !cursor.current) {
|
||||
cursor.current = data.next_cursor;
|
||||
setNextCursor(data.next_cursor);
|
||||
}
|
||||
} catch (err) {
|
||||
if (version === epoch.current) setLoadError((err as Error).message);
|
||||
}
|
||||
},
|
||||
[parent, restoreFailedDraft],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
epoch.current++;
|
||||
setStateParent(parent);
|
||||
setItems([]);
|
||||
setSnapshot(null);
|
||||
setNextCursor(0);
|
||||
setDraft("");
|
||||
setError("");
|
||||
setLoadError("");
|
||||
setBusy(false);
|
||||
submitting.current = false;
|
||||
retry.current = null;
|
||||
accepted.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]));
|
||||
restoreFailedDraft([item]);
|
||||
if (item.status !== "running") stream.close();
|
||||
} catch {
|
||||
setError("旁路数据解析失败,请重新打开面板");
|
||||
}
|
||||
});
|
||||
stream.addEventListener("cleared", () => {
|
||||
stream.close();
|
||||
if (version === epoch.current) setItems([]);
|
||||
});
|
||||
return () => stream.close();
|
||||
}, [runningID, streamEpoch, restoreFailedDraft]);
|
||||
|
||||
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);
|
||||
setError("");
|
||||
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]));
|
||||
accepted.current = { id: item.id, question };
|
||||
setDraft("");
|
||||
setError("");
|
||||
retry.current = null;
|
||||
void load();
|
||||
} catch (err) {
|
||||
if (version === epoch.current) {
|
||||
setError((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;
|
||||
accepted.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 || loadError : "",
|
||||
running,
|
||||
nextCursor: current ? nextCursor : 0,
|
||||
load,
|
||||
ask,
|
||||
handleCommand,
|
||||
clear,
|
||||
stop,
|
||||
enabled: !!parent,
|
||||
};
|
||||
}
|
||||
|
||||
export type SideQuestions = ReturnType<typeof useSideQuestions>;
|
||||
+1
-1
@@ -99,7 +99,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}`, {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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;
|
||||
context?: {
|
||||
phase?: "preparing" | "summarizing_history" | "compressing_snapshot" | "retrying" | "answering";
|
||||
recent_exchanges: number;
|
||||
history_summarized: boolean;
|
||||
snapshot_summarized: boolean;
|
||||
estimated_input_tokens?: number;
|
||||
input_budget?: number;
|
||||
output_tokens?: number;
|
||||
overflow_retried?: boolean;
|
||||
};
|
||||
}
|
||||
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());
|
||||
}
|
||||
Reference in new issue
Block a user