Add runtime guidance input queue
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string; itemId: string }> },
|
||||
) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const { sessionId: rawSessionId, itemId: rawItemId } = await params;
|
||||
const sessionId = rawSessionId.trim();
|
||||
const itemId = rawItemId.trim();
|
||||
const payload = await req.json().catch(() => ({}));
|
||||
const url = new URL(
|
||||
`${CLAW_API_URL}/api/sessions/${sessionId}/input-queue/${itemId}`,
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
cache: "no-store",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...payload, account_id: account.id }),
|
||||
});
|
||||
const responsePayload = await response.text();
|
||||
return new Response(responsePayload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string; itemId: string }> },
|
||||
) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const { sessionId: rawSessionId, itemId: rawItemId } = await params;
|
||||
const sessionId = rawSessionId.trim();
|
||||
const itemId = rawItemId.trim();
|
||||
const url = new URL(
|
||||
`${CLAW_API_URL}/api/sessions/${sessionId}/input-queue/${itemId}`,
|
||||
);
|
||||
url.searchParams.set("account_id", account.id);
|
||||
const response = await fetch(url, { method: "DELETE", cache: "no-store" });
|
||||
const responsePayload = await response.text();
|
||||
return new Response(responsePayload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> },
|
||||
) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const { sessionId: rawSessionId } = await params;
|
||||
const sessionId = rawSessionId.trim();
|
||||
const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}/input-queue`);
|
||||
url.searchParams.set("account_id", account.id);
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> },
|
||||
) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const { sessionId: rawSessionId } = await params;
|
||||
const sessionId = rawSessionId.trim();
|
||||
const payload = await req.json().catch(() => ({}));
|
||||
const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}/input-queue`);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...payload, account_id: account.id }),
|
||||
});
|
||||
const responsePayload = await response.text();
|
||||
return new Response(responsePayload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> },
|
||||
) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const { sessionId: rawSessionId } = await params;
|
||||
const sessionId = rawSessionId.trim();
|
||||
const incoming = new URL(req.url);
|
||||
const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}/state`);
|
||||
url.searchParams.set("account_id", account.id);
|
||||
for (const key of ["after_message_seq", "after_event_seq"]) {
|
||||
const value = incoming.searchParams.get(key);
|
||||
if (value) url.searchParams.set(key, value);
|
||||
}
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -61,6 +61,11 @@ const technicalDocs = {
|
||||
file: "11-subagents.md",
|
||||
image: null,
|
||||
},
|
||||
"12-runtime-guidance-queue": {
|
||||
title: "运行中输入队列",
|
||||
file: "12-runtime-guidance-queue.md",
|
||||
image: null,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type TechnicalDocSlug = keyof typeof technicalDocs;
|
||||
|
||||
@@ -144,6 +144,27 @@ const sections: Section[] = [
|
||||
decision:
|
||||
"子 Agent 负责观察和验证,主 Agent 负责用户上下文、最终决策和产物写入。",
|
||||
},
|
||||
{
|
||||
id: "runtime-guidance",
|
||||
no: "12",
|
||||
title: "运行中输入队列",
|
||||
summary:
|
||||
"运行中的用户输入先入队,只有用户显式引导时才在 Agent loop 安全边界注入当前 run。",
|
||||
doc: "/doc/12-runtime-guidance-queue",
|
||||
points: [
|
||||
"display_messages、model_messages、run_events 和 input_queue 分离,避免刷新、compact 和多会话切换互相污染。",
|
||||
"运行中 Enter 写入队列,不创建新 run,不取消当前 run。",
|
||||
"引导消息以 hidden runtime guidance 注入模型,不作为普通用户历史展示。",
|
||||
],
|
||||
code: [
|
||||
"src/session_store.py",
|
||||
"src/run_state_store.py",
|
||||
"src/agent_runtime.py",
|
||||
"frontend input queue chips",
|
||||
],
|
||||
decision:
|
||||
"UI、模型和持久化不能共享同一份 mutable messages;运行中交互必须事件化、可恢复、可取消。",
|
||||
},
|
||||
];
|
||||
|
||||
const skillSections: Section[] = [
|
||||
@@ -235,7 +256,8 @@ const skillSections: Section[] = [
|
||||
const exampleSessions = [
|
||||
{
|
||||
title: "product-data",
|
||||
description: "数据生成链路:目标抽取、计划确认、分批生成、canonical records 和训练/评测格式导出。",
|
||||
description:
|
||||
"数据生成链路:目标抽取、计划确认、分批生成、canonical records 和训练/评测格式导出。",
|
||||
links: [
|
||||
"http://10.189.47.6/session/__LOCALID_ZgwXdfP",
|
||||
"http://10.189.47.6/session/__LOCALID_QOecDTu",
|
||||
@@ -243,7 +265,8 @@ const exampleSessions = [
|
||||
},
|
||||
{
|
||||
title: "标签大师",
|
||||
description: "标签知识判断链路:复杂度、多指令、自动任务、function/tag 输出形态和边界知识引用。",
|
||||
description:
|
||||
"标签知识判断链路:复杂度、多指令、自动任务、function/tag 输出形态和边界知识引用。",
|
||||
links: [
|
||||
"http://10.189.47.6/session/__LOCALID_ji5lCfb",
|
||||
"http://10.189.47.6/session/__LOCALID_Z22r3pn",
|
||||
@@ -254,12 +277,14 @@ const exampleSessions = [
|
||||
},
|
||||
{
|
||||
title: "标签大师 + online-mining",
|
||||
description: "组合链路:先用线上日志定位样本,再结合标签知识做判断、整理和格式转换。",
|
||||
description:
|
||||
"组合链路:先用线上日志定位样本,再结合标签知识做判断、整理和格式转换。",
|
||||
links: ["http://10.189.47.6/session/__LOCALID_OLKxKnf"],
|
||||
},
|
||||
{
|
||||
title: "online-mining",
|
||||
description: "线上挖掘链路:查询策略、ELK 字段探索、候选样本抽样 review 和标准数据沉淀。",
|
||||
description:
|
||||
"线上挖掘链路:查询策略、ELK 字段探索、候选样本抽样 review 和标准数据沉淀。",
|
||||
links: [
|
||||
"http://10.189.47.6/session/__LOCALID_BnxMLqn",
|
||||
"http://10.189.47.6/session/__LOCALID_xZRilR5",
|
||||
@@ -357,7 +382,8 @@ export default function DocPage() {
|
||||
<div>
|
||||
<h2>ZK-Data-Agent 架构</h2>
|
||||
<p>
|
||||
基座是一个通用 Code Agent Loop;我们的工作不是重写这个闭环,而是在关键节点补上团队业务能力,让执行、业务上下文和流程沉淀进入同一套工作台。
|
||||
基座是一个通用 Code Agent
|
||||
Loop;我们的工作不是重写这个闭环,而是在关键节点补上团队业务能力,让执行、业务上下文和流程沉淀进入同一套工作台。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,19 +405,23 @@ export default function DocPage() {
|
||||
<div>
|
||||
<h3>基座提供执行闭环</h3>
|
||||
<p>
|
||||
Code Agent 负责把任务放进多轮循环:组装上下文、模型推理、调用工具、观察结果,再继续规划或完成。它让模型从一次回答变成可以持续执行的运行时。
|
||||
Code Agent
|
||||
负责把任务放进多轮循环:组装上下文、模型推理、调用工具、观察结果,再继续规划或完成。它让模型从一次回答变成可以持续执行的运行时。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>增强层体现我们的工作</h3>
|
||||
<p>
|
||||
ZK-Data-Agent 在上下文、工具、产物和团队协作节点接入 Skill 体系、Skill 热更新、用户记忆、Skill 记忆、portable scripts、会话工作区、活动流和管理后台。
|
||||
ZK-Data-Agent 在上下文、工具、产物和团队协作节点接入 Skill
|
||||
体系、Skill 热更新、用户记忆、Skill 记忆、portable
|
||||
scripts、会话工作区、活动流和管理后台。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>效率来自工程化补齐</h3>
|
||||
<p>
|
||||
工具和工作区提升研发执行效率;Skill 与记忆提升业务嵌入深度;Git 化 Skill、热更新和团队治理提升流程可复用度。
|
||||
工具和工作区提升研发执行效率;Skill 与记忆提升业务嵌入深度;Git 化
|
||||
Skill、热更新和团队治理提升流程可复用度。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +441,9 @@ export default function DocPage() {
|
||||
<div>
|
||||
<h2>示例 Session</h2>
|
||||
<p>
|
||||
下面这些是真实跑过的会话,适合讲解时直接打开。它们展示的不是单次问答,而是 Skill 如何在 Agent Loop 中完成计划、工具调用、人工确认、产物生成和格式转换。
|
||||
下面这些是真实跑过的会话,适合讲解时直接打开。它们展示的不是单次问答,而是
|
||||
Skill 如何在 Agent Loop
|
||||
中完成计划、工具调用、人工确认、产物生成和格式转换。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,12 +91,14 @@ import {
|
||||
writeActiveSessionId,
|
||||
writePendingWorkspaceSessionId,
|
||||
} from "@/lib/claw-active-session";
|
||||
import { fetchSessionReplaySnapshot } from "@/lib/claw-session-cache";
|
||||
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
||||
import { readSessionIdFromUrl } from "@/lib/claw-session-url";
|
||||
import { dispatchSkillToggled } from "@/lib/use-training-mode";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ComposerInsertEvent = CustomEvent<{ text: string }>;
|
||||
const INPUT_QUEUE_CHANGED_EVENT = "claw-input-queue-changed";
|
||||
|
||||
type SkillSummary = {
|
||||
name: string;
|
||||
@@ -129,11 +131,23 @@ type ClawState = {
|
||||
active_session_id?: string | null;
|
||||
};
|
||||
|
||||
type ClawStoredSession = {
|
||||
session_id?: string;
|
||||
type ClawStoredSession = Parameters<typeof toReplayRepository>[0] & {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type SessionInputQueueItem = {
|
||||
id: number;
|
||||
session_id: string;
|
||||
run_id?: string;
|
||||
kind: "next_turn" | "guidance";
|
||||
status: "pending" | "consumed" | "cancelled";
|
||||
content: string;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
consumed_at?: number | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ModelOption = {
|
||||
id: string;
|
||||
provider?: string;
|
||||
@@ -220,32 +234,27 @@ function useRefreshReplayedRun() {
|
||||
if (!replayedRun.active || !replayedRun.sessionId) return;
|
||||
const activeSessionId = replayedRun.sessionId;
|
||||
let cancelled = false;
|
||||
let lastSignature: string | null = null;
|
||||
|
||||
async function refreshIfFinished() {
|
||||
const runStatus = await fetchLatestRunStatus(activeSessionId);
|
||||
if (cancelled) return;
|
||||
if (runStatus?.status === "queued" || runStatus?.status === "running") {
|
||||
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok || cancelled) return;
|
||||
const payload = await response.json();
|
||||
replaySession(
|
||||
activeSessionId,
|
||||
toReplayRepository(payload, activeSessionId, runStatus),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok || cancelled) return;
|
||||
const payload = await response.json();
|
||||
const snapshot = await fetchSessionReplaySnapshot<
|
||||
ClawStoredSession,
|
||||
ClawRunStatus
|
||||
>(activeSessionId);
|
||||
if (cancelled || !snapshot) return;
|
||||
if (snapshot.signature === lastSignature) return;
|
||||
lastSignature = snapshot.signature;
|
||||
replaySession(
|
||||
activeSessionId,
|
||||
toReplayRepository(payload, activeSessionId, runStatus),
|
||||
toReplayRepository(
|
||||
snapshot.session,
|
||||
activeSessionId,
|
||||
snapshot.runStatus,
|
||||
),
|
||||
);
|
||||
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||
if (!isActiveRunStatus(snapshot.runStatus)) {
|
||||
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||
}
|
||||
}
|
||||
|
||||
refreshIfFinished();
|
||||
@@ -279,7 +288,11 @@ function useRefreshCurrentRun() {
|
||||
|
||||
async function refreshIfSettled() {
|
||||
if (!sessionId) return;
|
||||
const runStatus = await fetchLatestRunStatus(sessionId);
|
||||
const snapshot = await fetchSessionReplaySnapshot<
|
||||
ClawStoredSession,
|
||||
ClawRunStatus
|
||||
>(sessionId);
|
||||
const runStatus = snapshot?.runStatus ?? null;
|
||||
if (cancelled) return;
|
||||
if (runStatus?.status === "queued" || runStatus?.status === "running") {
|
||||
const runKey = runStatus.run_id ?? "active";
|
||||
@@ -291,14 +304,10 @@ function useRefreshCurrentRun() {
|
||||
// useRefreshReplayedRun will take over polling once replayedRun.active
|
||||
// becomes true.
|
||||
if (!runtimeRunning && previousRunKey !== runKey) {
|
||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok || cancelled) return;
|
||||
const payload = await response.json();
|
||||
if (!snapshot || cancelled) return;
|
||||
replaySession(
|
||||
sessionId,
|
||||
toReplayRepository(payload, sessionId, runStatus),
|
||||
toReplayRepository(snapshot.session, sessionId, runStatus),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -312,14 +321,10 @@ function useRefreshCurrentRun() {
|
||||
runStatus?.elapsed_ms ?? "",
|
||||
].join(":");
|
||||
if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
|
||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok || cancelled) return;
|
||||
const payload = await response.json();
|
||||
if (!snapshot || cancelled) return;
|
||||
replaySession(
|
||||
sessionId,
|
||||
toReplayRepository(payload, sessionId, runStatus),
|
||||
toReplayRepository(snapshot.session, sessionId, runStatus),
|
||||
);
|
||||
activeRunRefMap.current.delete(sessionId);
|
||||
appliedTerminalRefMap.current.set(sessionId, terminalKey);
|
||||
@@ -893,15 +898,10 @@ const ThreadSuggestionItem: FC = () => {
|
||||
};
|
||||
|
||||
const Composer: FC = () => {
|
||||
const replayedRun = useReplayedRunState();
|
||||
const workspaceSessionId = useCurrentThreadSessionId({
|
||||
includePending: true,
|
||||
includeActive: true,
|
||||
});
|
||||
const replayedRunIsCurrent =
|
||||
replayedRun.active &&
|
||||
Boolean(workspaceSessionId) &&
|
||||
replayedRun.sessionId === workspaceSessionId;
|
||||
return (
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||
<ComposerPrimitive.AttachmentDropzone asChild>
|
||||
@@ -911,13 +911,13 @@ const Composer: FC = () => {
|
||||
>
|
||||
<ComposerAttachments />
|
||||
<ComposerWorkspaceStatus sessionId={workspaceSessionId} />
|
||||
<ComposerPendingInputQueue sessionId={workspaceSessionId} />
|
||||
<ImeComposerInput
|
||||
placeholder="Send a message..."
|
||||
className="aui-composer-input max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none placeholder:text-muted-foreground/80"
|
||||
rows={1}
|
||||
autoFocus
|
||||
aria-label="Message input"
|
||||
disabled={replayedRunIsCurrent}
|
||||
/>
|
||||
<ComposerAction />
|
||||
</div>
|
||||
@@ -926,6 +926,92 @@ const Composer: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerPendingInputQueue: FC<{ sessionId: string | null }> = ({
|
||||
sessionId,
|
||||
}) => {
|
||||
const backendRunStatus = useBackendActiveRunStatus(sessionId);
|
||||
const items = useSessionInputQueue(sessionId);
|
||||
if (!sessionId || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1">
|
||||
{items.map((item) => {
|
||||
const isGuidance = item.kind === "guidance";
|
||||
const canGuide = Boolean(backendRunStatus?.run_id);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 rounded-lg border bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<span className="mr-1 font-medium text-foreground">
|
||||
{isGuidance ? "待引导" : "待发送"}
|
||||
</span>
|
||||
{item.content}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
void updateSessionInputQueueItem(sessionId, item.id, {
|
||||
status: "cancelled",
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("claw-composer-restore-draft", {
|
||||
detail: { text: item.content },
|
||||
}),
|
||||
);
|
||||
dispatchInputQueueChanged();
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={!canGuide || isGuidance}
|
||||
onClick={() => {
|
||||
if (!backendRunStatus?.run_id) return;
|
||||
void updateSessionInputQueueItem(sessionId, item.id, {
|
||||
kind: "guidance",
|
||||
run_id: backendRunStatus.run_id,
|
||||
status: "pending",
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(dispatchInputQueueChanged);
|
||||
}}
|
||||
>
|
||||
引导
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
void updateSessionInputQueueItem(sessionId, item.id, {
|
||||
status: "cancelled",
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(dispatchInputQueueChanged);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerWorkspaceStatus: FC<{ sessionId: string | null }> = ({
|
||||
sessionId,
|
||||
}) => {
|
||||
@@ -1071,6 +1157,100 @@ function isActiveRunStatus(status?: ClawRunStatus | null) {
|
||||
return status?.status === "queued" || status?.status === "running";
|
||||
}
|
||||
|
||||
function dispatchInputQueueChanged() {
|
||||
window.dispatchEvent(new Event(INPUT_QUEUE_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
function useSessionInputQueue(sessionId: string | null) {
|
||||
const [items, setItems] = useState<SessionInputQueueItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const refresh = async () => {
|
||||
const nextItems = await fetchSessionInputQueue(sessionId);
|
||||
if (!cancelled) setItems(nextItems);
|
||||
};
|
||||
const handleQueueChanged = () => {
|
||||
void refresh();
|
||||
};
|
||||
void refresh();
|
||||
const interval = window.setInterval(refresh, 1000);
|
||||
window.addEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function fetchSessionInputQueue(sessionId: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
if (!response.ok) return [];
|
||||
const payload = (await response.json()) as {
|
||||
items?: SessionInputQueueItem[];
|
||||
};
|
||||
return Array.isArray(payload.items) ? payload.items : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function createSessionInputQueueItem(
|
||||
sessionId: string,
|
||||
payload: {
|
||||
content: string;
|
||||
kind?: "next_turn" | "guidance";
|
||||
run_id?: string;
|
||||
},
|
||||
) {
|
||||
const response = await fetch(
|
||||
`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`,
|
||||
{
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to queue input: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as { item?: SessionInputQueueItem };
|
||||
}
|
||||
|
||||
async function updateSessionInputQueueItem(
|
||||
sessionId: string,
|
||||
itemId: number,
|
||||
payload: Partial<
|
||||
Pick<SessionInputQueueItem, "content" | "kind" | "status" | "run_id">
|
||||
>,
|
||||
) {
|
||||
const response = await fetch(
|
||||
`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue/${itemId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
cache: "no-store",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update queued input: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as { item?: SessionInputQueueItem };
|
||||
}
|
||||
|
||||
const ReplayedRunCancelButton: FC<{
|
||||
sessionId: string | null;
|
||||
runId: string | null;
|
||||
@@ -1090,15 +1270,18 @@ const ReplayedRunCancelButton: FC<{
|
||||
setCancelling(true);
|
||||
try {
|
||||
await cancelLatestRun(sessionId, runId);
|
||||
const runStatus = await fetchLatestRunStatus(sessionId);
|
||||
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json();
|
||||
const snapshot = await fetchSessionReplaySnapshot<
|
||||
ClawStoredSession,
|
||||
ClawRunStatus
|
||||
>(sessionId);
|
||||
if (snapshot) {
|
||||
replaySession(
|
||||
sessionId,
|
||||
toReplayRepository(payload, sessionId, runStatus),
|
||||
toReplayRepository(
|
||||
snapshot.session,
|
||||
sessionId,
|
||||
snapshot.runStatus,
|
||||
),
|
||||
);
|
||||
}
|
||||
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||
@@ -2099,10 +2282,7 @@ const ActivityChainSummary: FC<{
|
||||
liveStartedAtRef.current = authoritativeStartedAt;
|
||||
} else if (liveStartedAtRef.current === null && storedElapsedMs !== null) {
|
||||
liveStartedAtRef.current = Date.now() - storedElapsedMs;
|
||||
} else if (
|
||||
liveStartedAtRef.current !== null &&
|
||||
storedElapsedMs !== null
|
||||
) {
|
||||
} else if (liveStartedAtRef.current !== null && storedElapsedMs !== null) {
|
||||
liveStartedAtRef.current = Math.min(
|
||||
liveStartedAtRef.current,
|
||||
Date.now() - storedElapsedMs,
|
||||
@@ -2422,14 +2602,25 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
const storeText = useAuiState((s) =>
|
||||
s.composer.isEditing ? s.composer.text : "",
|
||||
);
|
||||
const isEditingComposer = useAuiState((s) => s.composer.isEditing);
|
||||
const runtimeRunning = useAuiState((s) => s.thread.isRunning);
|
||||
const runtimeDisabled = useAuiState(
|
||||
(s) => s.thread.isDisabled || s.composer.dictation?.inputDisabled,
|
||||
);
|
||||
const activeSessionId = useCurrentThreadSessionId({
|
||||
includePending: true,
|
||||
includeActive: true,
|
||||
});
|
||||
const backendRunStatus = useBackendActiveRunStatus(activeSessionId);
|
||||
const [localText, setLocalText] = useState(storeText);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
const justSubmittedRef = useRef(false);
|
||||
const isDisabled = runtimeDisabled || disabled;
|
||||
const canQueueWhileRunning =
|
||||
!isEditingComposer &&
|
||||
Boolean(activeSessionId) &&
|
||||
(runtimeRunning || isActiveRunStatus(backendRunStatus));
|
||||
const isDisabled = (runtimeDisabled && !canQueueWhileRunning) || disabled;
|
||||
|
||||
// Pull store→local on external store changes (session switch, paste-from-attachment,
|
||||
// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
|
||||
@@ -2475,6 +2666,12 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
const text = detail?.text ?? "";
|
||||
justSubmittedRef.current = false;
|
||||
setLocalText(text);
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
textarea.focus({ preventScroll: true });
|
||||
textarea.setSelectionRange(text.length, text.length);
|
||||
});
|
||||
};
|
||||
window.addEventListener("claw-composer-restore-draft", handler);
|
||||
return () =>
|
||||
@@ -2523,6 +2720,27 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
|
||||
}
|
||||
|
||||
const threadState = aui.thread().getState();
|
||||
const content = localText.trim();
|
||||
if (
|
||||
(threadState.isRunning || isActiveRunStatus(backendRunStatus)) &&
|
||||
activeSessionId
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (!content) return;
|
||||
justSubmittedRef.current = true;
|
||||
setLocalText("");
|
||||
syncComposerText("");
|
||||
void createSessionInputQueueItem(activeSessionId, {
|
||||
content,
|
||||
kind: "next_turn",
|
||||
})
|
||||
.catch(() => {
|
||||
justSubmittedRef.current = false;
|
||||
setLocalText(content);
|
||||
})
|
||||
.finally(dispatchInputQueueChanged);
|
||||
return;
|
||||
}
|
||||
if (threadState.isRunning && !threadState.capabilities.queue) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
@@ -33,6 +33,12 @@ export async function fetchSessionReplaySnapshot<
|
||||
sessionId: string,
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<SessionReplaySnapshot<TSession, TRun> | null> {
|
||||
const stateSnapshot = await fetchSessionStateSnapshot<TSession, TRun>(
|
||||
sessionId,
|
||||
options.signal,
|
||||
);
|
||||
if (stateSnapshot) return stateSnapshot;
|
||||
|
||||
const [sessionResponse, runStatus] = await Promise.all([
|
||||
fetch(`/api/claw/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
cache: "no-store",
|
||||
@@ -45,6 +51,43 @@ export async function fetchSessionReplaySnapshot<
|
||||
return writeCachedSessionReplay(sessionId, session, runStatus);
|
||||
}
|
||||
|
||||
type SessionStateResponse<TRun> = {
|
||||
session?: Record<string, unknown>;
|
||||
messages?: Array<{ seq?: number; message?: unknown }>;
|
||||
run?: TRun | null;
|
||||
};
|
||||
|
||||
async function fetchSessionStateSnapshot<TSession, TRun>(
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionReplaySnapshot<TSession, TRun> | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/claw/sessions/${encodeURIComponent(sessionId)}/state?after_message_seq=0&after_event_seq=0`,
|
||||
{ cache: "no-store", signal },
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
const payload = (await response.json()) as SessionStateResponse<TRun>;
|
||||
if (!Array.isArray(payload.messages)) return null;
|
||||
const orderedMessages = payload.messages
|
||||
.slice()
|
||||
.sort((left, right) => (left.seq ?? 0) - (right.seq ?? 0))
|
||||
.map((entry) => entry.message)
|
||||
.filter((message) => Boolean(message));
|
||||
const session = {
|
||||
...(payload.session ?? {}),
|
||||
session_id: sessionId,
|
||||
messages: orderedMessages,
|
||||
} as TSession;
|
||||
return writeCachedSessionReplay(sessionId, session, payload.run ?? null);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedSessionReplay<TSession, TRun>(
|
||||
sessionId: string,
|
||||
session: TSession,
|
||||
@@ -84,15 +127,18 @@ async function fetchLatestRunStatusForCache<TRun>(
|
||||
}
|
||||
}
|
||||
|
||||
function sessionReplaySignature(session: unknown, runStatus: unknown) {
|
||||
function sessionReplaySignature(session: unknown, runStatusValue: unknown) {
|
||||
const sessionObject = isObject(session) ? session : {};
|
||||
const runObject = isObject(runStatus) ? runStatus : {};
|
||||
const runObject = isObject(runStatusValue) ? runStatusValue : {};
|
||||
const messages = Array.isArray(sessionObject.messages)
|
||||
? sessionObject.messages
|
||||
: [];
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastObject = isObject(lastMessage) ? lastMessage : {};
|
||||
const metadata = isObject(lastObject.metadata) ? lastObject.metadata : {};
|
||||
const runStatus =
|
||||
typeof runObject.status === "string" ? runObject.status : "";
|
||||
const runIsActive = runStatus === "queued" || runStatus === "running";
|
||||
return [
|
||||
sessionObject.session_id,
|
||||
sessionObject.turns,
|
||||
@@ -103,10 +149,11 @@ function sessionReplaySignature(session: unknown, runStatus: unknown) {
|
||||
typeof lastObject.content === "string" ? lastObject.content.length : "",
|
||||
metadata.elapsed_ms,
|
||||
runObject.run_id,
|
||||
runObject.status,
|
||||
runObject.updated_at,
|
||||
runStatus,
|
||||
runObject.current_stage,
|
||||
runIsActive ? "" : runObject.updated_at,
|
||||
runObject.finished_at,
|
||||
runObject.elapsed_ms,
|
||||
runIsActive ? "" : runObject.elapsed_ms,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# 运行中输入队列与 Runtime Guidance 注入
|
||||
|
||||
## 背景
|
||||
|
||||
用户在一个会话运行中继续输入,是 Agent 产品的基本能力。这个输入不能直接当成普通 user message 写入当前模型历史,否则会产生三个问题:
|
||||
|
||||
1. **串台**:前端切换 session 或 URL 状态滞后时,新输入可能被写进旧 session。
|
||||
2. **取消误伤**:新建任务或继续输入会触发新的 run,从而取消当前正在运行的 run。
|
||||
3. **上下文污染**:运行中的输入如果直接进入 `model_messages`,会破坏当前 tool_use/tool_result 顺序,甚至触发 Bedrock/Anthropic 的 tool_result 校验错误。
|
||||
|
||||
正确做法是把运行中输入先作为 UI 和 runtime 的外部事件持久化,等 Agent loop 进入安全边界时再决定如何注入。
|
||||
|
||||
## 主流方案对比
|
||||
|
||||
| 方案 | 关键机制 | 对本项目的启发 |
|
||||
|------|----------|----------------|
|
||||
| [OpenAI Codex long-horizon tasks](https://developers.openai.com/blog/run-long-horizon-tasks-with-codex) | 长任务依赖计划、验证、修复和可持续的外部状态,而不是单轮大 prompt | 会话运行态要可恢复;用户中途修正不能重置整轮任务 |
|
||||
| [Claude Code hooks](https://code.claude.com/docs/en/hooks-guide) | `UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop` 等生命周期点允许注入上下文或阻断动作 | runtime guidance 应只在明确生命周期边界注入,不直接改写当前消息流 |
|
||||
| [Building AI Coding Agents for the Terminal](https://arxiv.org/html/2603.05344v1) | Agent harness 把输入层、工具层、上下文层和执行层拆开;输入可通过线程安全队列进入执行循环 | 运行中输入应先入队,再由 Agent loop 主线程消费 |
|
||||
| [Event-driven agentic loops](https://boundaryml.com/podcast/2025-11-05-event-driven-agents) | 用户输入、LLM chunk、tool call、interrupt 都是事件;UI、LLM、持久化各自投影 | `display_messages`、`model_messages`、`run_events` 必须分离,避免一个状态源服务所有场景 |
|
||||
|
||||
## 目标设计
|
||||
|
||||
```text
|
||||
用户输入
|
||||
-> 如果当前 session idle: 正常发送,创建 run
|
||||
-> 如果当前 session running: 写入 agent_input_queue
|
||||
-> UI 展示 pending chip
|
||||
-> 用户可编辑、删除、引导
|
||||
-> 引导: kind=guidance,绑定当前 run_id
|
||||
-> Agent loop 下一轮开始前消费 guidance
|
||||
-> guidance 以 display=false 的 user message 注入 model_messages
|
||||
```
|
||||
|
||||
## 数据分层
|
||||
|
||||
| 数据 | 作用 | 是否允许 compact 覆盖 |
|
||||
|------|------|-----------------------|
|
||||
| `model_messages` | 给模型推理用,可压缩、可摘要、可隐藏注入 | 允许 |
|
||||
| `display_messages` / `agent_display_messages` | 给 UI 回放用,append-only,不因为 compact 丢历史 | 不允许 |
|
||||
| `run_states` | 当前 run 的状态、耗时、取消能力 | 不允许用前端内存替代 |
|
||||
| `run_events` | 右侧活动区事件流 | 不允许只存在 SSE 内存里 |
|
||||
| `agent_input_queue` | 运行中输入、guidance、待处理后续输入 | 不允许直接写进 display/model messages |
|
||||
|
||||
## 后端实现约定
|
||||
|
||||
### 状态读取
|
||||
|
||||
前端优先读取:
|
||||
|
||||
```text
|
||||
GET /api/sessions/{session_id}/state
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
- `messages`: DB 中 `agent_display_messages` 的增量或全量。
|
||||
- `activity_events`: DB 中 `run_events` 的增量或全量。
|
||||
- `run`: `run_states` 最新状态。
|
||||
- `input_queue`: 当前 pending 输入队列。
|
||||
|
||||
旧接口 `GET /api/sessions/{session_id}` 只作为兼容兜底,不应该再作为实时 UI 的主状态源。
|
||||
|
||||
### 输入队列
|
||||
|
||||
```text
|
||||
POST /api/sessions/{session_id}/input-queue
|
||||
GET /api/sessions/{session_id}/input-queue
|
||||
PATCH /api/sessions/{session_id}/input-queue/{item_id}
|
||||
DELETE /api/sessions/{session_id}/input-queue/{item_id}
|
||||
```
|
||||
|
||||
字段约定:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `kind=next_turn` | 运行中输入的默认状态,只展示在 composer 上方,不进入模型 |
|
||||
| `kind=guidance` | 用户显式点击“引导”后进入当前 run |
|
||||
| `run_id` | guidance 应绑定当前 active run;未绑定时由后端尝试绑定 latest active run |
|
||||
| `status=pending` | UI 可见,等待处理 |
|
||||
| `status=consumed` | 已被 runtime 注入 |
|
||||
| `status=cancelled` | 用户编辑/删除/取消 run 后不再处理 |
|
||||
|
||||
### Runtime 注入
|
||||
|
||||
`LocalCodingAgent` 提供 `runtime_guidance_provider`,在每轮模型调用前消费 pending guidance:
|
||||
|
||||
```text
|
||||
agent loop boundary
|
||||
-> consume_session_guidance(session_id, run_id)
|
||||
-> append hidden user message:
|
||||
<runtime-guidance>
|
||||
用户在任务运行中补充了以下引导...
|
||||
</runtime-guidance>
|
||||
-> display=false
|
||||
-> 继续模型调用
|
||||
```
|
||||
|
||||
这个注入点必须在 tool_result 已经写回之后、下一次模型调用之前,不能插在 tool_use 和 tool_result 中间。
|
||||
|
||||
## 前端交互
|
||||
|
||||
1. 当前 session idle:输入框 Enter 仍然正常发送。
|
||||
2. 当前 session running:输入框不禁用;Enter 写入 queue,清空输入框。
|
||||
3. pending 输入显示为 composer 上方 chip:
|
||||
- **编辑**:取消 queue item,把文本恢复到输入框。
|
||||
- **引导**:改成 `kind=guidance` 并绑定 active `run_id`。
|
||||
- **删除**:取消 queue item。
|
||||
4. 停止 run 时,后端同时取消该 session 下 pending queue item,避免下一轮误消费。
|
||||
|
||||
## 不做的事
|
||||
|
||||
- 不在运行中输入时自动创建新 run。
|
||||
- 不把 pending 输入直接写入 `display_messages`。
|
||||
- 不把 guidance 展示成普通用户消息;它是运行中的控制信号,不是对话历史。
|
||||
- 不依赖前端内存判断最终状态;刷新后必须能从 DB 完整恢复。
|
||||
|
||||
## 验收点
|
||||
|
||||
1. 同一个账号同时打开两个 session,分别运行任务,输入不会串台。
|
||||
2. A session 运行中切到 B session 输入,B 的输入只进入 B 的 queue。
|
||||
3. A session 运行中输入后刷新,pending chip 仍存在。
|
||||
4. 点击“引导”后,下一次模型调用前能消费 guidance,并在活动区记录注入事件。
|
||||
5. 点击停止后,对应 run 的进程和 pending queue 都被取消。
|
||||
6. 触发 compact 后,UI 仍能从 `display_messages` 回放完整历史;模型只使用 compact 后的 `model_messages`。
|
||||
Reference in New Issue
Block a user