Add runtime guidance input queue
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user