- {(() => {
- 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 =
{title};
- return meta?.json ? (
+ {/* Right: transcript */}
+
+
+ {(() => {
+ 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 =
{title};
+ return meta?.json ? (
+
+ {titleEl}
+
+ {JSON.stringify(meta.json, null, 2)}
+
+
+ ) : (
+ titleEl
+ );
+ })()}
+ {activeResolution && (
-<<<<<<< Updated upstream
- {titleEl}
-
-
- {JSON.stringify(meta.json, null, 2)}
-
-=======
>>>>>> Stashed changes
- ) : titleEl;
- })()}
- {active.live && (
-
-
- 实时
-
- )}
- {activeState?.hasMore && (
-
- ↑ 更早历史
-
- )}
-
- {tokenTotal.any && (
-
- input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
+ )}
+ {activeAssets && activeAssets.length > 0 && }
+ {active.inherited && active.source_task_id && (
+ 来源任务 #{active.source_task_id} · 只读历史
+ )}
+ {active.live && (
+
+
+ 实时
)}
- {runDuration != null && (
-
-
- {fmtDuration(runDuration)}
+ {activeState?.hasMore && (
+
+ ↑ 更早历史
)}
- {isMain && 可交互}
+
+ {tokenTotal.any && (
+
+
+
+
+
+ 输入 {activeTokens.input_tokens.toLocaleString()} · 输出{" "}
+ {activeTokens.output_tokens.toLocaleString()} · 缓存读取{" "}
+ {activeTokens.cache_read_tokens.toLocaleString()} · 缓存写入{" "}
+ {activeTokens.cache_write_tokens.toLocaleString()}
+
+
+ )}
+ {runDuration != null && (
+
+
+ {fmtDuration(runDuration)}
+
+ )}
+ {isMain && 可交互}
+
-<<<<<<< Updated upstream
-
- {/* Force Radix's internal viewport wrapper (display:table, sizes to content)
-=======
{(() => {
const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined;
if (!dm?.deleted) return null;
@@ -1277,71 +1550,64 @@ export function SessionsTab({ taskId }: { taskId: string }) {
);
})()}
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
->>>>>>> Stashed changes
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. */}
-
-
- {activeState?.loadingMore && (
-
-
- 加载更早历史…
-
- )}
- {showLoader ? (
-
-
- 加载活动流…
-
- ) : activeState?.error ? (
-
-
- 加载失败:{activeState.error}
-
-
- ) : activity.length ? (
-
- ) : (
-
- {isMain
- ? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。"
- : "暂无活动记录。"}
-
- )}
-
-
- {isMain ? (
-
- {attachments.length > 0 && (
-
- {attachments.map((a) => (
-
-
-
{a.name}
-
{fmtBytes(a.size)}
-
-
- ) : (
-
- api.activityDetail(seq, taskId).then((r) => r.detail ?? "")}
- />
-
- )}
-=======
删除 Worker #{cancelIntent?.intent_id}?
- 删除会停止该意图(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞都会保留。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
+ 删除会停止该意图
+ (不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞
+ 都会保留。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
@@ -1493,9 +1722,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
->>>>>>> Stashed changes
-
);
}
diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx
index 3fe45e2..b07434a 100644
--- a/web/src/app/(main)/function/tasks/detail/page.tsx
+++ b/web/src/app/(main)/function/tasks/detail/page.tsx
@@ -9,15 +9,28 @@ import {
PauseIcon,
PlayIcon,
BrainIcon,
+ CheckIcon,
+ CircleAlertIcon,
} from "lucide-react";
+import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
-import type { Task } from "@/lib/types";
+import type { LLMProfile, Task } from "@/lib/types";
import { SessionsTab } from "./_tabs/sessions-tab";
import { OverviewTab } from "./_tabs/overview-tab";
@@ -39,6 +52,152 @@ const TABS = [
{ value: "report", label: "报告" },
];
+function taskProfileIDs(task: Task): string[] {
+ if (task.llm_profile_ids && task.llm_profile_ids.length > 0) {
+ return task.llm_profile_ids.map(String);
+ }
+ return task.llm_profile_id ? [String(task.llm_profile_id)] : [];
+}
+
+function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: LLMProfile[]; onUpdated: () => void }) {
+ const [open, setOpen] = React.useState(false);
+ const [profileIDs, setProfileIDs] = React.useState
(() => taskProfileIDs(task));
+ const [activeProfileID, setActiveProfileID] = React.useState(
+ task.active_llm_profile_id ? String(task.active_llm_profile_id) : (taskProfileIDs(task)[0] ?? ""),
+ );
+ const [saving, setSaving] = React.useState(false);
+ const popoverContentRef = React.useRef(null);
+
+ const chain = taskProfileIDs(task);
+ const exhausted = task.llm_failover_state === "chain_exhausted";
+ const terminal = ["done", "failed", "timeout"].includes(task.status);
+ const editable = !terminal && (task.status === "running" || task.status === "paused" || exhausted);
+ // A null active profile on an exhausted, non-empty chain is a persisted end
+ // cursor. Keep the status display honest; choosing the first profile is only
+ // the editor's reset draft and does not mean it is currently active.
+ let activeID = chain[0] ?? "";
+ if (exhausted) activeID = "";
+ if (task.active_llm_profile_id) activeID = String(task.active_llm_profile_id);
+ const activeProfile = profiles.find((profile) => profile.id === activeID);
+ const currentLabel = exhausted
+ ? "配置链已耗尽"
+ : activeProfile?.name ?? (activeID ? `配置 #${activeID}` : "跟随默认配置");
+ const activeIndex = chain.indexOf(activeID);
+ const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0;
+ const currentTitle = [currentLabel, activeProfile?.model, backupCount > 0 ? `${backupCount} 个备用` : ""]
+ .filter(Boolean)
+ .join(" · ");
+ let editorDescription = "仅运行中、暂停或配置链耗尽的任务可以修改配置。";
+ if (editable) editorDescription = "调整顺序或当前配置后,将从下一次 LLM 调用开始生效。";
+ if (terminal) editorDescription = "已结束的任务仅支持查看配置。";
+ let saveLabel = "保存";
+ if (exhausted) saveLabel = "保存并重置";
+ if (saving) saveLabel = "保存中";
+
+ const syncDraft = React.useCallback(() => {
+ const next = taskProfileIDs(task);
+ setProfileIDs(next);
+ setActiveProfileID(task.active_llm_profile_id ? String(task.active_llm_profile_id) : (next[0] ?? ""));
+ }, [task]);
+
+ const handleOpenChange = (next: boolean) => {
+ setOpen(next);
+ if (next) syncDraft();
+ };
+
+ const handleProfileIDsChange = (next: string[]) => {
+ setProfileIDs(next);
+ setActiveProfileID((current) => (next.includes(current) ? current : (next[0] ?? "")));
+ };
+
+ const save = async () => {
+ if (!editable) return;
+ setSaving(true);
+ try {
+ const result = await api.updateTaskLLMProfiles(
+ task.id,
+ profileIDs.map(Number),
+ activeProfileID ? Number(activeProfileID) : undefined,
+ );
+ if (result.switch_event) {
+ toast.info(result.switch_event.summary, { id: `task-${task.id}-llm-${result.switch_event.seq}` });
+ } else {
+ toast.success(
+ result.reopened_intents > 0
+ ? `LLM 配置已更新,并恢复 ${result.reopened_intents} 条额度阻塞意图`
+ : "LLM 配置已更新",
+ );
+ }
+ setOpen(false);
+ onUpdated();
+ } catch (error) {
+ toast.error("更新失败:" + (error as Error).message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {currentLabel}
+ {backupCount > 0 && +{backupCount}}
+
+
+
+
+ 任务 LLM 配置链
+ {editorDescription}
+
+
+ {exhausted && (
+
+
+ 配置链额度已耗尽
+
+ {task.llm_failover_reason ?? "所有已选配置均被判定为额度不足。保存配置链可重置故障状态。"}
+
+
+ )}
+
+
+
+
+ setOpen(false)}>
+ 关闭
+
+ {editable && (
+
+ {saving && }
+ {saveLabel}
+
+ )}
+
+
+
+ );
+}
+
function TaskDetailInner() {
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
@@ -47,42 +206,62 @@ function TaskDetailInner() {
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("sessions");
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
+ const [profiles, setProfiles] = React.useState([]);
+
+ React.useEffect(() => {
+ api
+ .llmProfiles()
+ .then(setProfiles)
+ .catch(() => setProfiles([]));
+ }, []);
React.useEffect(() => {
let alive = true;
const load = () =>
- api.interceptTask(id)
- .then((rows) => { if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); })
- .catch(() => {});
- load();
+ api
+ .interceptTask(id)
+ .then((rows) => {
+ if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length);
+ })
+ .catch(() => {
+ // Polling is best-effort; the next interval retries automatically.
+ });
+ void load();
const t = setInterval(load, 5000);
- return () => { alive = false; clearInterval(t); };
+ return () => {
+ alive = false;
+ clearInterval(t);
+ };
}, [id]);
const load = React.useCallback(() => {
- Promise.all([
- api.tasks(),
- api.stats(id).catch(() => null),
- ])
+ Promise.all([api.tasks(), api.stats(id).catch(() => null)])
.then(([r, s]) => {
const base = r.tasks.find((t) => t.id === id) ?? null;
- const at = (s as { active_task?: Partial & { paused?: boolean } } | null)?.active_task;
+ const at = s?.active_task;
if (base && at) {
base.in_flight = at.in_flight;
base.goals_total = at.goals_total;
base.goals_met = at.goals_met;
- base.engine_mode = at.engine_mode;
+ base.engine_mode = s?.engine_mode ?? at.engine_mode;
base.paused = at.paused;
}
setTask(base);
setPaused(at?.paused ?? base?.paused ?? false);
})
- .catch(() => {})
+ .catch(() => {
+ // Keep the last rendered task state during a transient poll failure.
+ })
.finally(() => setLoaded(true));
}, [id]);
- React.useEffect(() => { load(); }, [load]);
+ React.useEffect(() => {
+ load();
+ const timer = setInterval(load, 5000);
+ return () => clearInterval(timer);
+ }, [load]);
async function togglePause() {
+ if (task && ["done", "failed", "timeout"].includes(task.status)) return;
const next = !paused;
try {
await api.controlTask(id, next ? "pause" : "resume");
@@ -108,14 +287,24 @@ function TaskDetailInner() {
);
}
- const engineMode = paused ? "paused" : task.engine_mode ?? "idle";
+ const completed = task.status === "done";
+ const terminal = ["done", "failed", "timeout"].includes(task.status);
+ const engineMode = paused ? "paused" : (task.engine_mode ?? "idle");
+ let controlVariant: "default" | "secondary" | "outline" = "outline";
+ let controlIcon = ;
+ let controlLabel = "暂停";
+ if (terminal) {
+ controlVariant = "secondary";
+ controlIcon = ;
+ controlLabel = completed ? "已完成" : "已结束";
+ } else if (paused) {
+ controlVariant = "default";
+ controlIcon = ;
+ controlLabel = "恢复";
+ }
return (
-
+
{/* Top fixed area */}
@@ -125,48 +314,36 @@ function TaskDetailInner() {
-<<<<<<< Updated upstream
-
- {task.description}
-=======
{task.name || task.description}
->>>>>>> Stashed changes
-
+
{task.id}
-
- {/* status pills */}
-
- LLM 已配置
-
-
-
-
- {paused ? : }
- {paused ? "恢复" : "暂停"}
-
-
+
+
+
+
+ {controlIcon}
+ {controlLabel}
+
{task.goal}
{/* Tabs */}
-
- {TABS.map((t) => (
-
- {t.label}
- {t.value === "intercept" && interceptPendingCount > 0 && (
-
- {interceptPendingCount > 99 ? "99+" : interceptPendingCount}
-
- )}
-
- ))}
-
+
+
+ {TABS.map((t) => (
+
+ {t.label}
+ {t.value === "intercept" && interceptPendingCount > 0 && (
+
+ {interceptPendingCount > 99 ? "99+" : interceptPendingCount}
+
+ )}
+
+ ))}
+
+
{/* Tab content */}
diff --git a/web/src/components/exploration-graph.tsx b/web/src/components/exploration-graph.tsx
index 1fccfb5..34afa45 100644
--- a/web/src/components/exploration-graph.tsx
+++ b/web/src/components/exploration-graph.tsx
@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
+import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/status-badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -235,7 +236,7 @@ function ExploreNode({ data, selected }: NodeProps) {
const Icon = meta.icon;
const showState = n.type === "goal" || n.type === "intent";
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
- const isLive = n.type === "intent" && n.state === "running";
+ const isLive = !n.inherited && n.type === "intent" && n.state === "running";
return (
) {
{nodeSummary(n) || meta.label}
- {showState ? (
+ {n.inherited && n.source_task_id ? (
+
+ 来源 #{n.source_task_id}
+
+ ) : showState ? (
) : (
{meta.label}
@@ -334,6 +339,11 @@ function NodeDetailSheet({
{meta.label}
{node.id}
+ {node.inherited && node.source_task_id && (
+
+ 来源任务 #{node.source_task_id} · 只读
+
+ )}
@@ -355,6 +365,11 @@ function NodeDetailSheet({
{node.origin}
+ {node.inherited && node.source_task_id && (
+
+ #{node.source_task_id}(继承,只读)
+
+ )}
{new Date(node.ts).toLocaleString("zh-CN")}
@@ -398,7 +413,7 @@ function ExplorationGraphInner({
React.useEffect(() => {
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
const liveTargets = new Set(
- nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id),
+ nodes.filter((n) => !n.inherited && n.type === "intent" && n.state === "running").map((n) => n.id),
);
const layout = computeLayout(nodes, edges);
setRfNodes((prev) => {
diff --git a/web/src/components/ui/alert.tsx b/web/src/components/ui/alert.tsx
index 1fe3176..5990340 100644
--- a/web/src/components/ui/alert.tsx
+++ b/web/src/components/ui/alert.tsx
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
- "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
+ "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_minmax(0,1fr)] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
@@ -39,7 +39,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+ "min-w-0 font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
@@ -55,7 +55,7 @@ function AlertDescription({
= {
open: { label: "待领", tone: "slate" },
running: { label: "执行中", tone: "blue" },
+ paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },
blocked: { label: "被拦截", tone: "red" },
exhausted: { label: "已穷尽", tone: "violet" },
@@ -54,6 +55,7 @@ const intent: Record = {
const task: Record = {
created: { label: "已创建", tone: "slate" },
+ queued: { label: "排队中", tone: "amber" },
running: { label: "运行中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },