新增worker直接对话

This commit is contained in:
pphua committed 2026-08-29 21:35:15 +08:00
1 parent 01c6d73614
commit 4157fa248b
17 files changed
+2095 -79

No files matched your search

@@ -13,7 +13,6 @@ import {
Loader2Icon,
PaperclipIcon,
PauseIcon,
PlayIcon,
RadioIcon,
RotateCwIcon,
ShieldAlertIcon,
@@ -42,6 +41,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
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 { api, sseUrl } from "@/lib/api";
@@ -92,6 +92,18 @@ const PAGE = 200; // history page size
const SYSTEM_SCAN_PAGE = 500; // generic activity pages scanned to recover sparse system audit events
const MAX_KEEP = 4000; // per-session in-memory cap; older pages re-fetched on scroll-up
const STREAM_WINDOW_MS = 5000; // "live" = activity seen within this window
const MAX_WORKER_MESSAGE_CHARS = 4000;
function newWorkerMessageRequestID(): string {
return (
globalThis.crypto?.randomUUID?.() ??
`worker-message-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
);
}
function workerMessageCharCount(value: string): number {
return Array.from(value).length;
}
// One session's lazily-loaded, reverse-paginated cache. `lastTs`/`unread` are kept
// live even for sessions that were never opened, so the list shows liveness + unread
@@ -312,8 +324,6 @@ function SessionItem({
hasPending,
unread,
onClick,
onPause,
onResume,
onCancel,
controlling,
deleted,
@@ -324,8 +334,6 @@ function SessionItem({
hasPending?: boolean;
unread?: number;
onClick: () => void;
onPause?: () => void;
onResume?: () => void;
onCancel?: () => void;
controlling?: boolean;
deleted?: boolean;
@@ -338,7 +346,8 @@ function SessionItem({
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
) : null;
const controllable = s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused");
const cancellable =
s.role === "worker" && !s.inherited && !deleted && (s.status === "running" || s.status === "paused");
return (
<div
className={cn(
@@ -385,33 +394,8 @@ function SessionItem({
</span>
)}
</button>
{controllable && (
{cancellable && (
<div className="flex shrink-0 items-center gap-0.5 pr-1 opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover/session:opacity-100 sm:group-focus-within/session:opacity-100">
{s.status === "running" ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onPause}
disabled={controlling}
title="暂停 Worker"
aria-label="暂停 Worker"
>
{controlling ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={onResume}
disabled={controlling}
title="恢复 Worker"
aria-label="恢复 Worker"
>
{controlling ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
</Button>
)}
<Button
type="button"
variant="ghost"
@@ -490,6 +474,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
const [cancelReason, setCancelReason] = React.useState("");
const [workerMessage, setWorkerMessage] = React.useState("");
const [workerMessageRequestId, setWorkerMessageRequestId] = React.useState("");
const [workerMessageSending, setWorkerMessageSending] = React.useState(false);
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
const [uploading, setUploading] = React.useState(false);
@@ -549,6 +536,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
},
[controllingIntent, patchIntentState, taskId],
);
// SSE connection state — surfaced so a dropped realtime link is visible, never
// silently shown as "no messages".
const [sseLive, setSseLive] = React.useState(false);
@@ -1333,6 +1321,55 @@ export function SessionsTab({ taskId }: { taskId: string }) {
.finally(() => setSending(false));
}
function sendWorkerChat() {
const intentId = active.intent_id;
const message = workerMessage.trim();
if (
!intentId ||
active.inherited ||
(active.status !== "paused" && active.status !== "pending") ||
workerMessageSending ||
!message
)
return;
if (workerMessageCharCount(message) > MAX_WORKER_MESSAGE_CHARS) {
toast.error(`消息不能超过 ${MAX_WORKER_MESSAGE_CHARS} 个字符`);
return;
}
const requestId = workerMessageRequestId || newWorkerMessageRequestID();
if (!workerMessageRequestId) setWorkerMessageRequestId(requestId);
setWorkerMessageSending(true);
api
.sendWorkerMessage(taskId, intentId, message, requestId)
.then((result) => {
const sentAt = new Date().toISOString();
const activity: Activity = {
seq: result.activity_seq,
intent_id: intentId,
worker: "user",
ts: sentAt,
kind: "user",
summary: message,
detail: message,
};
patchStore(`intent:${intentId}`, (state) => ({
...state,
items: mergeBySeq(state.items, [activity]),
lastTs: sentAt,
loaded: true,
unread: 0,
}));
patchIntentState(intentId, result.state);
setWorkerMessage("");
setWorkerMessageRequestId("");
toast.success(`消息已发送给 Worker #${intentId},已立即继续执行`);
})
.catch((error) => {
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
})
.finally(() => setWorkerMessageSending(false));
}
const mainLoaded = !!store.main?.loaded;
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
const sendMode = useChatSendMode();
@@ -1421,11 +1458,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
displayTitle={meta?.title ?? s.title}
hasPending={hasPendingForSession(s)}
unread={store[keyForSession(s)]?.unread}
onClick={() => setActiveId(s.id)}
onClick={() => {
setActiveId(s.id);
setWorkerMessage("");
setWorkerMessageRequestId("");
}}
controlling={controllingIntent === s.intent_id}
deleted={meta?.deleted}
onPause={() => void controlWorker(s, "pause")}
onResume={() => void controlWorker(s, "resume")}
onCancel={() => {
setCancelIntent(s);
}}
@@ -1679,6 +1718,74 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</InputGroupAddon>
</InputGroup>
</div>
) : active.role === "worker" &&
!active.inherited &&
(active.status === "running" || active.status === "paused" || active.status === "pending") ? (
<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",
)}
>
{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>
) : (
<InputGroupButton
className="ml-auto"
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
+8
View File
@@ -283,6 +283,14 @@ export const api = {
state: "paused" | "open" | "stopped";
deleted?: { intents: number; facts: number; findings: number; activities: number };
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }),
sendWorkerMessage: (taskId: string, intentId: string, message: string, requestId: string) =>
post<{
id: number;
state: "open" | "running" | "paused";
accepted: true;
activity_seq: number;
request_id: string;
}>(`/tasks/${taskId}/intents/${intentId}/messages`, { message, request_id: requestId }),
taskLLMResolution: (id: string) => get<TaskLLMResolutions>(`/tasks/${id}/llm/resolution`),
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
rerunIntent: (taskId: string, intentId: string) =>
+91 -5
View File
@@ -10,6 +10,7 @@ import {
normalizeCompanyScopeValue,
} from "../company-scope";
import type {
Activity,
ArchiveBatchItem,
Asset,
BatchControlItem,
@@ -42,6 +43,7 @@ const mockConversations = structuredClone(D.conversations);
const mockIntents = structuredClone(D.intents);
const mockCompanies = structuredClone(D.companies);
const mockAssets = structuredClone(D.assets);
const mockActivity = structuredClone(D.activity);
type MockTaskArchiveSnapshot = {
task: Task;
numericTaskID: number;
@@ -420,6 +422,15 @@ function bodyIDs(value: unknown): string[] {
}
type MockIntentControlResult = { ok: boolean; state?: string; error?: string };
type MockWorkerMessage = {
intentId: string;
message: string;
state: "open" | "running";
activitySeq: number;
};
const mockWorkerMessages = new Map<string, MockWorkerMessage>();
let nextMockWorkerMessageActivitySeq = mockActivity.reduce((maximum, item) => Math.max(maximum, item.seq), 0) + 1;
function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentControlResult {
const intent = mockIntents.find((item) => item.id === id);
@@ -432,6 +443,71 @@ function controlMockIntent(id: string, action: "pause" | "resume"): MockIntentCo
return { ok: true, state: intent.state };
}
function sendMockWorkerMessage(
id: string,
message: string,
requestId: string,
): MockIntentControlResult & { activitySeq?: number; requestId?: string } {
const normalizedMessage = message.trim();
const normalizedRequestId = requestId.trim();
if (!normalizedRequestId) return { ok: false, error: "request_id 不能为空" };
const previous = mockWorkerMessages.get(normalizedRequestId);
if (previous) {
if (previous.intentId !== id || previous.message !== normalizedMessage) {
return { ok: false, error: "request_id 已用于其他 Worker 消息" };
}
return {
ok: true,
state: previous.state,
activitySeq: previous.activitySeq,
requestId: normalizedRequestId,
};
}
const intent = mockIntents.find((item) => item.id === id);
if (!intent) return { ok: false, error: "意图不存在" };
if (intent.inherited || (intent.state !== "running" && intent.state !== "paused")) {
return { ok: false, state: intent.state, error: "Worker 状态已变化" };
}
if (!normalizedMessage) return { ok: false, state: intent.state, error: "消息不能为空" };
if (Array.from(normalizedMessage).length > 4000) {
return { ok: false, state: intent.state, error: "消息不能超过 4000 个字符" };
}
// The real endpoint stops the active turn, persists a normal user message,
// and immediately reopens the same intent. Keep the mock's transition in sync,
// then emulate a Worker reclaiming it shortly afterward.
intent.state = "open";
const activitySeq = nextMockWorkerMessageActivitySeq++;
const workerMessage: MockWorkerMessage = {
intentId: id,
message: normalizedMessage,
state: "open",
activitySeq,
};
mockWorkerMessages.set(normalizedRequestId, workerMessage);
mockActivity.push({
seq: activitySeq,
intent_id: id,
worker: "user",
ts: new Date().toISOString(),
kind: "user",
summary: normalizedMessage,
detail: normalizedMessage,
} satisfies Activity);
setTimeout(() => {
if (intent.state === "open") {
intent.state = "running";
workerMessage.state = "running";
}
}, 250);
return {
ok: true,
state: workerMessage.state,
activitySeq: workerMessage.activitySeq,
requestId: normalizedRequestId,
};
}
function controlMockTask(id: string, action: "pause" | "resume"): BatchControlItem {
const task = mockTasks.find((item) => item.id === id);
if (!task) return { id, ok: false, error: "任务不存在" };
@@ -917,7 +993,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
intents: 1,
facts: 1,
findings: 1,
activities: D.activity.filter((item) => item.intent_id === id).length,
activities: mockActivity.filter((item) => item.intent_id === id).length,
},
};
}
@@ -926,6 +1002,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (!result.ok) throw new Error(result.error ?? "Worker 状态已变化");
return { id: Number(id.replace(/\D/g, "")) || 0, state: result.state };
}
if (seg[0] === "tasks" && seg[2] === "intents" && seg[4] === "messages" && m === "POST") {
const id = seg[3];
const result = sendMockWorkerMessage(id, String(b.message ?? ""), String(b.request_id ?? ""));
if (!result.ok) throw new Error(result.error ?? "Worker 状态已变化");
return {
id: Number(id.replace(/\D/g, "")) || 0,
state: result.state ?? "open",
accepted: true,
activity_seq: result.activitySeq ?? 0,
request_id: result.requestId ?? "",
};
}
if (seg[0] === "tasks" && seg.length === 3 && seg[2] === "control" && m === "POST") {
const action = b.action === "resume" ? "resume" : "pause";
const result = controlMockTask(seg[1], action);
@@ -1464,13 +1552,11 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/exploration/activity" && seg.length === 2) {
const since = Number(q.get("since") ?? 0);
const limit = Math.max(1, Number(q.get("limit") ?? 300));
const items = D.activityForTask()
.filter((item) => item.seq > since)
.slice(0, limit);
const items = mockActivity.filter((item) => item.seq > since).slice(0, limit);
return { items, cursor: items.length ? items[items.length - 1].seq : since };
}
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
const a = D.activity.find((x) => x.seq === Number(seg[2]));
const a = mockActivity.find((x) => x.seq === Number(seg[2]));
return { detail: a?.detail ?? a?.summary ?? "" };
}
if (path === "/tokens/daily") return D.dailyTokens;