mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-02 01:01:52 +03:00
feat(runtime): add user-controlled turn recovery
This commit is contained in:
@@ -1274,6 +1274,15 @@ function Shell({
|
||||
}, [activeKey, activeTabKey, activeTabState]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const recoveryChatIdList = useMemo(
|
||||
() => sessions
|
||||
.filter((session) => (
|
||||
session.recoveryState?.status === "awaiting_user"
|
||||
|| session.recoveryState?.status === "failed"
|
||||
))
|
||||
.map((session) => session.chatId),
|
||||
[sessions],
|
||||
);
|
||||
const activeChatId = activePaneSession?.chatId ?? null;
|
||||
useEffect(() => {
|
||||
activeChatIdRef.current = activeChatId;
|
||||
@@ -2552,6 +2561,7 @@ function Shell({
|
||||
collapsedGroups: sidebarState.collapsed_groups,
|
||||
runningChatIds: runningChatIdList,
|
||||
updatedChatIds: updatedChatIdList,
|
||||
recoveryChatIds: recoveryChatIdList,
|
||||
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
||||
showArchived: sidebarState.view.show_archived,
|
||||
archivedCount: sidebarArchivedTabKeys.length,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement } from
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderTree,
|
||||
@@ -260,6 +261,7 @@ interface ChatListProps {
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
runningChatIds?: string[];
|
||||
updatedChatIds?: string[];
|
||||
recoveryChatIds?: string[];
|
||||
density?: SidebarDensity;
|
||||
showPreviews?: boolean;
|
||||
showTimestamps?: boolean;
|
||||
@@ -302,6 +304,7 @@ export const ChatList = memo(function ChatList({
|
||||
collapsedGroups = {},
|
||||
runningChatIds = [],
|
||||
updatedChatIds = [],
|
||||
recoveryChatIds = [],
|
||||
density = "comfortable",
|
||||
showPreviews = false,
|
||||
showTimestamps = false,
|
||||
@@ -558,6 +561,7 @@ export const ChatList = memo(function ChatList({
|
||||
|
||||
const running = new Set(runningChatIds);
|
||||
const updated = new Set(updatedChatIds);
|
||||
const recovery = new Set(recoveryChatIds);
|
||||
const compact = density === "compact";
|
||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
|
||||
@@ -881,6 +885,7 @@ export const ChatList = memo(function ChatList({
|
||||
compact={compact}
|
||||
running={running}
|
||||
updated={updated}
|
||||
recovery={recovery}
|
||||
onSelectPane={onSelectPane}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onRequestRename={onRequestRename}
|
||||
@@ -915,9 +920,11 @@ export const ChatList = memo(function ChatList({
|
||||
: "";
|
||||
const activityState = running.has(s.chatId)
|
||||
? "running"
|
||||
: updated.has(s.chatId) && !topicActive
|
||||
? "updated"
|
||||
: null;
|
||||
: recovery.has(s.chatId)
|
||||
? "recovery"
|
||||
: updated.has(s.chatId) && !topicActive
|
||||
? "updated"
|
||||
: null;
|
||||
const hasPaneMoveTarget = Boolean(onAttachPane)
|
||||
&& paneGroupTargets.some((target) => (
|
||||
target.key !== paneGroup?.tabKey && !target.atCapacity
|
||||
@@ -1330,6 +1337,7 @@ function ActivePaneRows({
|
||||
compact,
|
||||
running,
|
||||
updated,
|
||||
recovery,
|
||||
onSelectPane,
|
||||
onRequestDelete,
|
||||
onRequestRename,
|
||||
@@ -1354,6 +1362,7 @@ function ActivePaneRows({
|
||||
compact: boolean;
|
||||
running: ReadonlySet<string>;
|
||||
updated: ReadonlySet<string>;
|
||||
recovery: ReadonlySet<string>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
@@ -1390,9 +1399,11 @@ function ActivePaneRows({
|
||||
const active = tabActive && pane.key === group.activePaneKey;
|
||||
const activityState = running.has(pane.chatId)
|
||||
? "running"
|
||||
: updated.has(pane.chatId) && !active
|
||||
? "updated"
|
||||
: null;
|
||||
: recovery.has(pane.chatId)
|
||||
? "recovery"
|
||||
: updated.has(pane.chatId) && !active
|
||||
? "updated"
|
||||
: null;
|
||||
const paneActionsLabel = t("workbench.paneActions", { title: pane.title });
|
||||
const selected = selectedDeleteKeys.has(pane.key);
|
||||
const isPinned = pinned.has(pane.key);
|
||||
@@ -1852,10 +1863,27 @@ function ChatsFoldFooter({
|
||||
function SessionActivityIndicator({
|
||||
state,
|
||||
}: {
|
||||
state: "running" | "updated" | null;
|
||||
state: "running" | "updated" | "recovery" | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state === "recovery") {
|
||||
const label = t("chat.activity.recovery", {
|
||||
defaultValue: "This conversation needs your attention",
|
||||
});
|
||||
return (
|
||||
<SidebarItemTooltip label={label}>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="grid h-4 w-4 shrink-0 place-items-center text-[#ff8a3d]"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
</SidebarItemTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "running") {
|
||||
const label = t("chat.activity.running");
|
||||
return (
|
||||
|
||||
@@ -82,6 +82,7 @@ interface SidebarProps {
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
runningChatIds?: string[];
|
||||
updatedChatIds?: string[];
|
||||
recoveryChatIds?: string[];
|
||||
viewState?: SidebarViewState;
|
||||
showArchived?: boolean;
|
||||
archivedCount?: number;
|
||||
@@ -270,6 +271,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
collapsedGroups={props.collapsedGroups}
|
||||
runningChatIds={props.runningChatIds}
|
||||
updatedChatIds={props.updatedChatIds}
|
||||
recoveryChatIds={props.recoveryChatIds}
|
||||
density={props.viewState?.density}
|
||||
showPreviews={props.viewState?.show_previews}
|
||||
showTimestamps={props.viewState?.show_timestamps}
|
||||
|
||||
@@ -421,6 +421,37 @@ export function AppearanceSettings({
|
||||
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.browserNotifications", "Task notifications")}
|
||||
description={tx(
|
||||
"settings.help.browserNotifications",
|
||||
"Notify only when this page is in the background. Off by default.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={localPrefs.browserNotifications}
|
||||
onChange={(enabled) => {
|
||||
if (!enabled) {
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: false }));
|
||||
return;
|
||||
}
|
||||
if (typeof Notification === "undefined") return;
|
||||
if (Notification.permission === "granted") {
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
|
||||
return;
|
||||
}
|
||||
void Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
|
||||
}
|
||||
});
|
||||
}}
|
||||
ariaLabel={tx("settings.rows.browserNotifications", "Task notifications")}
|
||||
label={localPrefs.browserNotifications
|
||||
? tx("settings.values.on", "On")
|
||||
: tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertTriangle, LoaderCircle, RotateCcw, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RecoveryState } from "@/lib/types";
|
||||
|
||||
interface RecoveryNoticeProps {
|
||||
state: RecoveryState;
|
||||
onContinue: () => Promise<void>;
|
||||
onDismiss: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function RecoveryNotice({ state, onContinue, onDismiss }: RecoveryNoticeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pending, setPending] = useState<"continue" | "dismiss" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hiddenRecoveryId, setHiddenRecoveryId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
// A continuation can be interrupted again with the same recovery ID.
|
||||
// Reveal the decision surface when the server returns to a waiting state.
|
||||
if (state.status === "awaiting_user" || state.status === "failed") {
|
||||
setHiddenRecoveryId(null);
|
||||
}
|
||||
}, [state.recovery_id, state.status]);
|
||||
if (state.status === "recovered" || hiddenRecoveryId === state.recovery_id) return null;
|
||||
|
||||
const waiting = state.status === "awaiting_user" || state.status === "failed";
|
||||
const contextUnavailable = state.can_continue === false;
|
||||
const title = state.status === "failed"
|
||||
? t("recovery.failed", { defaultValue: "Task recovery failed" })
|
||||
: waiting
|
||||
? t("recovery.interrupted", { defaultValue: "Task interrupted" })
|
||||
: t("recovery.resuming", { defaultValue: "Restoring interrupted task…" });
|
||||
const detail = state.status === "failed" || contextUnavailable
|
||||
? t("recovery.failedHelp", {
|
||||
defaultValue: "The saved task could not be restored safely. Review it before continuing.",
|
||||
})
|
||||
: waiting
|
||||
? t("recovery.review", { defaultValue: "Review the task before continuing. Tools will not be replayed automatically." })
|
||||
: t("recovery.safeResume", { defaultValue: "Continuing from saved conversation context." });
|
||||
const run = (action: "continue" | "dismiss") => {
|
||||
setPending(action);
|
||||
setError(null);
|
||||
// ``resuming`` is an internal transition, not another task for the user
|
||||
// to monitor. Hide the notice optimistically and only bring it back if
|
||||
// the explicit action is rejected.
|
||||
if (action === "continue") setHiddenRecoveryId(state.recovery_id);
|
||||
const operation = action === "continue" ? onContinue() : onDismiss();
|
||||
void operation.catch(() => {
|
||||
if (action === "continue") setHiddenRecoveryId(null);
|
||||
setError(t("recovery.actionFailed", { defaultValue: "Recovery action failed. Try again." }));
|
||||
}).finally(() => setPending(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role={waiting ? "alert" : "status"}
|
||||
aria-live={waiting ? "assertive" : "polite"}
|
||||
aria-busy={state.status === "resuming"}
|
||||
data-recovery-status={state.status}
|
||||
className="mx-auto mb-2 flex w-full max-w-[49.5rem] items-center gap-3 rounded-control border border-border/70 bg-muted/35 px-3 py-2 text-sm transition-[background-color,border-color,opacity,transform] duration-200 ease-out motion-reduce:transition-none animate-in fade-in-0 slide-in-from-bottom-1 duration-200 motion-reduce:animate-none"
|
||||
>
|
||||
{waiting ? (
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||
) : (
|
||||
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-primary motion-reduce:animate-none" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{title}
|
||||
</p>
|
||||
<p className={cn(
|
||||
"mt-0.5 text-xs",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{error ?? detail}
|
||||
</p>
|
||||
</div>
|
||||
{waiting ? (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending !== null}
|
||||
onClick={() => run("dismiss")}
|
||||
>
|
||||
<X className="mr-1 h-3.5 w-3.5" aria-hidden />
|
||||
{t("recovery.dismiss", { defaultValue: "Dismiss" })}
|
||||
</Button>
|
||||
{!contextUnavailable ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={pending !== null}
|
||||
onClick={() => run("continue")}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden />
|
||||
{t("recovery.continue", { defaultValue: "Continue" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailab
|
||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { RecoveryNotice } from "@/components/thread/RecoveryNotice";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import {
|
||||
ThreadComposer,
|
||||
@@ -763,6 +764,9 @@ export function ThreadShell({
|
||||
isStreaming,
|
||||
runStartedAt,
|
||||
goalState,
|
||||
recoveryState,
|
||||
continueRecovery,
|
||||
dismissRecovery,
|
||||
send,
|
||||
transcribeAudio,
|
||||
stop,
|
||||
@@ -835,8 +839,15 @@ export function ThreadShell({
|
||||
[displayMessages],
|
||||
);
|
||||
const currentGoalState = messagesReady ? goalState : undefined;
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||
// Decision states freeze the interrupted turn and hand the next action to
|
||||
// the recovery notice. ``resuming`` remains active; ``recovered`` is only
|
||||
// historical metadata and must not suppress a later normal turn.
|
||||
const recoveryNeedsDecision = recoveryState?.status === "awaiting_user"
|
||||
|| recoveryState?.status === "failed";
|
||||
const currentRunStartedAt = messagesReady && !recoveryNeedsDecision ? runStartedAt : null;
|
||||
const turnActive = messagesReady
|
||||
&& !recoveryNeedsDecision
|
||||
&& (isStreaming || currentRunStartedAt !== null);
|
||||
const restoredViewportTurnId = useMemo(
|
||||
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||
[currentRunStartedAt, displayMessages, turnActive],
|
||||
@@ -1472,6 +1483,13 @@ export function ThreadShell({
|
||||
|
||||
const composer = (
|
||||
<>
|
||||
{recoveryState ? (
|
||||
<RecoveryNotice
|
||||
state={recoveryState}
|
||||
onContinue={continueRecovery}
|
||||
onDismiss={dismissRecovery}
|
||||
/>
|
||||
) : null}
|
||||
{streamError && !hasInlineDeliveryError(messages, streamError) ? (
|
||||
<StreamErrorNotice
|
||||
error={streamError}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from "@/lib/thread-event-projection";
|
||||
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import { readLocalPreferences } from "@/lib/local-preferences";
|
||||
import type {
|
||||
InboundEvent,
|
||||
OutboundCliAppMention,
|
||||
@@ -36,6 +38,7 @@ import type {
|
||||
SessionMention,
|
||||
GoalStateWsPayload,
|
||||
MessageDeliveryStatus,
|
||||
RecoveryState,
|
||||
UIMediaAttachment,
|
||||
UIMessage,
|
||||
WorkspaceScopePayload,
|
||||
@@ -244,6 +247,9 @@ export function useNanobotStream(
|
||||
runStartedAt: number | null;
|
||||
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
||||
goalState: GoalStateWsPayload | undefined;
|
||||
recoveryState: RecoveryState | null;
|
||||
continueRecovery: () => Promise<void>;
|
||||
dismissRecovery: () => Promise<void>;
|
||||
send: (
|
||||
content: string,
|
||||
images?: SendAttachment[],
|
||||
@@ -262,6 +268,7 @@ export function useNanobotStream(
|
||||
dismissStreamError: () => void;
|
||||
} {
|
||||
const { client } = useClient();
|
||||
const { t } = useTranslation();
|
||||
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
||||
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
|
||||
@@ -273,6 +280,7 @@ export function useNanobotStream(
|
||||
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
||||
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
|
||||
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||
const [recoveryState, setRecoveryState] = useState<RecoveryState | null>(null);
|
||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||
const buffer = useRef<StreamBuffer | null>(null);
|
||||
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
|
||||
@@ -288,6 +296,16 @@ export function useNanobotStream(
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
const notifyInBackground = useCallback((body: string) => {
|
||||
if (
|
||||
typeof Notification === "undefined"
|
||||
|| Notification.permission !== "granted"
|
||||
|| document.visibilityState === "visible"
|
||||
|| !readLocalPreferences().browserNotifications
|
||||
) return;
|
||||
new Notification("nanobot", { body });
|
||||
}, []);
|
||||
|
||||
const clearPendingStreamWork = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
@@ -639,6 +657,7 @@ export function useNanobotStream(
|
||||
setStreamError(null);
|
||||
setRunStartedAt(restoredRunStartedAt);
|
||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||
setRecoveryState(null);
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
@@ -846,10 +865,71 @@ export function useNanobotStream(
|
||||
return finalized;
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
notifyInBackground(t("recovery.completed", { defaultValue: "Task completed" }));
|
||||
onTurnEnd?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "recovery_state") {
|
||||
const next: RecoveryState = {
|
||||
status: ev.status,
|
||||
recovery_id: ev.recovery_id,
|
||||
...(ev.reason ? { reason: ev.reason } : {}),
|
||||
...(typeof ev.attempts === "number" ? { attempts: ev.attempts } : {}),
|
||||
...(typeof ev.can_continue === "boolean"
|
||||
? { can_continue: ev.can_continue }
|
||||
: {}),
|
||||
};
|
||||
setRecoveryState(next);
|
||||
if (ev.status === "resuming") {
|
||||
setRunStartedAt((current) => current ?? Date.now() / 1000);
|
||||
setIsStreaming(true);
|
||||
}
|
||||
if (
|
||||
ev.status === "awaiting_user"
|
||||
|| ev.status === "recovered"
|
||||
|| ev.status === "failed"
|
||||
) {
|
||||
// Recovery is an explicit boundary. The interrupted turn is no
|
||||
// longer running, so do not let the stale start time keep the
|
||||
// activity clock (or composer stop state) alive underneath the
|
||||
// recovery notice.
|
||||
setRunStartedAt(null);
|
||||
setIsStreaming(false);
|
||||
client.finishRunLocally(chatId);
|
||||
clearPendingStreamWork();
|
||||
closeActiveAssistantStream();
|
||||
clearActivitySegment();
|
||||
if (ev.status !== "recovered") {
|
||||
notifyInBackground(
|
||||
ev.status === "failed"
|
||||
? t("recovery.failed", { defaultValue: "Task recovery failed" })
|
||||
: t("recovery.interrupted", { defaultValue: "Task interrupted" }),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "attached") {
|
||||
setRecoveryState(ev.recovery_state ?? null);
|
||||
if (ev.recovery_state?.status === "resuming") {
|
||||
setRunStartedAt((current) => current ?? Date.now() / 1000);
|
||||
setIsStreaming(true);
|
||||
} else if (
|
||||
ev.recovery_state?.status === "awaiting_user"
|
||||
|| ev.recovery_state?.status === "failed"
|
||||
) {
|
||||
setRunStartedAt(null);
|
||||
setIsStreaming(false);
|
||||
client.finishRunLocally(chatId);
|
||||
clearPendingStreamWork();
|
||||
closeActiveAssistantStream();
|
||||
clearActivitySegment();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "message") {
|
||||
if (
|
||||
suppressStreamUntilTurnEndRef.current &&
|
||||
@@ -1062,8 +1142,10 @@ export function useNanobotStream(
|
||||
ensureActivitySegmentId,
|
||||
flushPendingStreamEvents,
|
||||
isSideChannelEvent,
|
||||
notifyInBackground,
|
||||
onTurnEnd,
|
||||
schedulePendingStreamFlush,
|
||||
t,
|
||||
]);
|
||||
|
||||
const send = useCallback(
|
||||
@@ -1173,12 +1255,32 @@ export function useNanobotStream(
|
||||
[client],
|
||||
);
|
||||
|
||||
const recoveryAction = useCallback(async (action: "continue" | "dismiss") => {
|
||||
if (!chatId || !recoveryState) return;
|
||||
await client.requestMutation(`recovery.${action}`, {
|
||||
chat_id: chatId,
|
||||
recovery_id: recoveryState.recovery_id,
|
||||
});
|
||||
}, [chatId, client, recoveryState]);
|
||||
|
||||
const continueRecovery = useCallback(
|
||||
() => recoveryAction("continue"),
|
||||
[recoveryAction],
|
||||
);
|
||||
const dismissRecovery = useCallback(
|
||||
() => recoveryAction("dismiss"),
|
||||
[recoveryAction],
|
||||
);
|
||||
|
||||
return {
|
||||
messages,
|
||||
messagesReady: messageOwnerChatId === chatId,
|
||||
isStreaming,
|
||||
runStartedAt,
|
||||
goalState,
|
||||
recoveryState,
|
||||
continueRecovery,
|
||||
dismissRecovery,
|
||||
send,
|
||||
transcribeAudio,
|
||||
stop,
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"fileEditDisplay": "File edit display",
|
||||
"codeWrap": "Code wrapping",
|
||||
"brandLogos": "Brand logos",
|
||||
"browserNotifications": "Task notifications",
|
||||
"maxResults": "Max results",
|
||||
"timeout": "Timeout",
|
||||
"jinaReader": "Jina reader",
|
||||
@@ -243,6 +244,7 @@
|
||||
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
||||
"codeWrap": "Keep long code lines readable on smaller screens.",
|
||||
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
||||
"browserNotifications": "Notify only when this page is in the background. Off by default.",
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
"timeout": "Seconds before a search provider request times out.",
|
||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||
@@ -1013,7 +1015,8 @@
|
||||
"activity": {
|
||||
"running": "Agent running",
|
||||
"complete": "Agent finished",
|
||||
"updated": "New activity"
|
||||
"updated": "New activity",
|
||||
"recovery": "This conversation needs your attention"
|
||||
},
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
@@ -1436,6 +1439,18 @@
|
||||
"estimated": "Includes estimated usage"
|
||||
}
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "Recovery action failed. Try again.",
|
||||
"interrupted": "Task interrupted",
|
||||
"completed": "Task completed",
|
||||
"failed": "Task recovery failed",
|
||||
"failedHelp": "The saved task could not be restored safely. Review it before continuing.",
|
||||
"resuming": "Restoring interrupted task…",
|
||||
"review": "Review the task before continuing. Tools will not be replayed automatically.",
|
||||
"safeResume": "Continuing from saved conversation context.",
|
||||
"dismiss": "Dismiss",
|
||||
"continue": "Continue"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Image preview",
|
||||
"open": "View image",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"webuiDefaultAccess": "Acceso predeterminado",
|
||||
"currentModel": "Configuración actual",
|
||||
"brandLogos": "Logos de marca",
|
||||
"browserNotifications": "Notificaciones de tareas",
|
||||
"cliAppsCatalog": "Catálogo",
|
||||
"cliAppsFilter": "Filtro",
|
||||
"engine": "Motor",
|
||||
@@ -204,6 +205,7 @@
|
||||
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
||||
"selectedModelValue": "Definido por el modelo seleccionado.",
|
||||
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
|
||||
"browserNotifications": "Notifica solo cuando esta página está en segundo plano. Desactivado de forma predeterminada.",
|
||||
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
|
||||
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
|
||||
"logs": "Abre la carpeta de registros del motor nativo.",
|
||||
@@ -1000,7 +1002,8 @@
|
||||
"activity": {
|
||||
"running": "Agente en ejecución",
|
||||
"complete": "Agente terminado",
|
||||
"updated": "Nueva actividad"
|
||||
"updated": "Nueva actividad",
|
||||
"recovery": "Esta conversación requiere tu atención"
|
||||
},
|
||||
"pin": "Fijar",
|
||||
"unpin": "Desfijar",
|
||||
@@ -1423,6 +1426,18 @@
|
||||
"automationSourceFallback": "Automatización",
|
||||
"automationTriggered": "Activada automáticamente"
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "La recuperación falló. Inténtalo de nuevo.",
|
||||
"interrupted": "Tarea interrumpida",
|
||||
"completed": "Tarea completada",
|
||||
"failed": "La recuperación de la tarea falló",
|
||||
"failedHelp": "La tarea guardada no se pudo restaurar de forma segura. Revísala antes de continuar.",
|
||||
"resuming": "Restaurando la tarea interrumpida…",
|
||||
"review": "Revisa la tarea antes de continuar. Las herramientas no se repetirán automáticamente.",
|
||||
"safeResume": "Continuando desde el contexto guardado de la conversación.",
|
||||
"dismiss": "Descartar",
|
||||
"continue": "Continuar"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Vista previa de imagen",
|
||||
"open": "Ver imagen",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"webuiDefaultAccess": "Accès par défaut",
|
||||
"currentModel": "Configuration actuelle",
|
||||
"brandLogos": "Logos de marque",
|
||||
"browserNotifications": "Notifications de tâches",
|
||||
"cliAppsCatalog": "Catalogue",
|
||||
"cliAppsFilter": "Filtre",
|
||||
"engine": "Moteur",
|
||||
@@ -204,6 +205,7 @@
|
||||
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
||||
"selectedModelValue": "Défini par le modèle sélectionné.",
|
||||
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
|
||||
"browserNotifications": "Notifier uniquement lorsque cette page est en arrière-plan. Désactivé par défaut.",
|
||||
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’applications que nanobot peut exécuter localement ; les applications natives restent inchangées.",
|
||||
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
|
||||
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
||||
@@ -999,7 +1001,8 @@
|
||||
"activity": {
|
||||
"running": "Agent en cours",
|
||||
"complete": "Agent terminé",
|
||||
"updated": "Nouvelle activité"
|
||||
"updated": "Nouvelle activité",
|
||||
"recovery": "Cette conversation nécessite votre attention"
|
||||
},
|
||||
"pin": "Épingler",
|
||||
"unpin": "Désépingler",
|
||||
@@ -1422,6 +1425,18 @@
|
||||
"automationSourceFallback": "Automatisation",
|
||||
"automationTriggered": "Déclenché automatiquement"
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "La récupération a échoué. Réessayez.",
|
||||
"interrupted": "Tâche interrompue",
|
||||
"completed": "Tâche terminée",
|
||||
"failed": "Échec de la récupération de la tâche",
|
||||
"failedHelp": "La tâche enregistrée n’a pas pu être restaurée en toute sécurité. Vérifiez-la avant de continuer.",
|
||||
"resuming": "Restauration de la tâche interrompue…",
|
||||
"review": "Vérifiez la tâche avant de continuer. Les outils ne seront pas relancés automatiquement.",
|
||||
"safeResume": "Reprise depuis le contexte de conversation enregistré.",
|
||||
"dismiss": "Ignorer",
|
||||
"continue": "Continuer"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Aperçu de l’image",
|
||||
"open": "Voir l’image",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"webuiDefaultAccess": "Akses bawaan",
|
||||
"currentModel": "Konfigurasi saat ini",
|
||||
"brandLogos": "Logo merek",
|
||||
"browserNotifications": "Notifikasi tugas",
|
||||
"cliAppsCatalog": "Katalog",
|
||||
"cliAppsFilter": "Saring",
|
||||
"engine": "Mesin",
|
||||
@@ -204,6 +205,7 @@
|
||||
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
||||
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
||||
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
|
||||
"browserNotifications": "Beri tahu hanya saat halaman ini di latar belakang. Nonaktif secara bawaan.",
|
||||
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
|
||||
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
||||
"logs": "Buka folder log mesin asli.",
|
||||
@@ -999,7 +1001,8 @@
|
||||
"activity": {
|
||||
"running": "Agen sedang berjalan",
|
||||
"complete": "Agen selesai",
|
||||
"updated": "Aktivitas baru"
|
||||
"updated": "Aktivitas baru",
|
||||
"recovery": "Percakapan ini memerlukan perhatian Anda"
|
||||
},
|
||||
"pin": "Sematkan",
|
||||
"unpin": "Lepas sematan",
|
||||
@@ -1422,6 +1425,18 @@
|
||||
"automationSourceFallback": "Otomatisasi",
|
||||
"automationTriggered": "Dipicu otomatis"
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "Pemulihan gagal. Coba lagi.",
|
||||
"interrupted": "Tugas terputus",
|
||||
"completed": "Tugas selesai",
|
||||
"failed": "Pemulihan tugas gagal",
|
||||
"failedHelp": "Tugas tersimpan tidak dapat dipulihkan dengan aman. Tinjau sebelum melanjutkan.",
|
||||
"resuming": "Memulihkan tugas yang terputus…",
|
||||
"review": "Tinjau tugas sebelum melanjutkan. Alat tidak akan dijalankan ulang secara otomatis.",
|
||||
"safeResume": "Melanjutkan dari konteks percakapan yang tersimpan.",
|
||||
"dismiss": "Abaikan",
|
||||
"continue": "Lanjutkan"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Pratinjau gambar",
|
||||
"open": "Lihat gambar",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"webuiDefaultAccess": "既定の権限",
|
||||
"currentModel": "現在の設定",
|
||||
"brandLogos": "ブランドロゴ",
|
||||
"browserNotifications": "タスク通知",
|
||||
"cliAppsCatalog": "カタログ",
|
||||
"cliAppsFilter": "フィルター",
|
||||
"engine": "エンジン",
|
||||
@@ -204,6 +205,7 @@
|
||||
"selectedModelProvider": "選択したモデルによって設定されます。",
|
||||
"selectedModelValue": "選択したモデルによって設定されます。",
|
||||
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
|
||||
"browserNotifications": "このページがバックグラウンドにある場合のみ通知します。既定ではオフです。",
|
||||
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
|
||||
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
||||
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
||||
@@ -999,7 +1001,8 @@
|
||||
"activity": {
|
||||
"running": "エージェント実行中",
|
||||
"complete": "エージェント完了",
|
||||
"updated": "新しいアクティビティ"
|
||||
"updated": "新しいアクティビティ",
|
||||
"recovery": "この会話には対応が必要です"
|
||||
},
|
||||
"pin": "ピン留め",
|
||||
"unpin": "ピン留めを解除",
|
||||
@@ -1422,6 +1425,18 @@
|
||||
"automationSourceFallback": "自動化",
|
||||
"automationTriggered": "自動実行"
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "復元操作に失敗しました。もう一度お試しください。",
|
||||
"interrupted": "タスクが中断されました",
|
||||
"completed": "タスクが完了しました",
|
||||
"failed": "タスクの復元に失敗しました",
|
||||
"failedHelp": "保存されたタスクを安全に復元できませんでした。続行前に確認してください。",
|
||||
"resuming": "中断されたタスクを復元しています…",
|
||||
"review": "続行する前にタスクを確認してください。ツールは自動的に再実行されません。",
|
||||
"safeResume": "保存された会話コンテキストから続行しています。",
|
||||
"dismiss": "閉じる",
|
||||
"continue": "続行"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "画像プレビュー",
|
||||
"open": "画像を表示",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"webuiDefaultAccess": "기본 권한",
|
||||
"currentModel": "현재 구성",
|
||||
"brandLogos": "브랜드 로고",
|
||||
"browserNotifications": "작업 알림",
|
||||
"cliAppsCatalog": "카탈로그",
|
||||
"cliAppsFilter": "필터",
|
||||
"engine": "엔진",
|
||||
@@ -204,6 +205,7 @@
|
||||
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
||||
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
|
||||
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
|
||||
"browserNotifications": "이 페이지가 백그라운드에 있을 때만 알립니다. 기본값은 꺼짐입니다.",
|
||||
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
|
||||
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
||||
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
||||
@@ -999,7 +1001,8 @@
|
||||
"activity": {
|
||||
"running": "에이전트 실행 중",
|
||||
"complete": "에이전트 완료",
|
||||
"updated": "새 활동"
|
||||
"updated": "새 활동",
|
||||
"recovery": "이 대화에는 확인이 필요합니다"
|
||||
},
|
||||
"pin": "고정",
|
||||
"unpin": "고정 해제",
|
||||
@@ -1422,6 +1425,18 @@
|
||||
"automationSourceFallback": "자동화",
|
||||
"automationTriggered": "자동 실행됨"
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "복구 작업에 실패했습니다. 다시 시도하세요.",
|
||||
"interrupted": "작업이 중단됨",
|
||||
"completed": "작업 완료",
|
||||
"failed": "작업 복구 실패",
|
||||
"failedHelp": "저장된 작업을 안전하게 복구할 수 없습니다. 계속하기 전에 검토하세요.",
|
||||
"resuming": "중단된 작업을 복구하는 중…",
|
||||
"review": "계속하기 전에 작업을 검토하세요. 도구는 자동으로 다시 실행되지 않습니다.",
|
||||
"safeResume": "저장된 대화 컨텍스트에서 계속합니다.",
|
||||
"dismiss": "닫기",
|
||||
"continue": "계속"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "이미지 미리보기",
|
||||
"open": "이미지 보기",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"fileEditDisplay": "Exibição de edição de arquivo",
|
||||
"codeWrap": "Quebra de linha no código",
|
||||
"brandLogos": "Logos de marca",
|
||||
"browserNotifications": "Notificações de tarefas",
|
||||
"maxResults": "Máx. de resultados",
|
||||
"timeout": "Tempo limite",
|
||||
"jinaReader": "Leitor Jina",
|
||||
@@ -243,6 +244,7 @@
|
||||
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
|
||||
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
|
||||
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
|
||||
"browserNotifications": "Notifica somente quando esta página está em segundo plano. Desativado por padrão.",
|
||||
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
||||
"timeout": "Segundos antes de uma requisição de busca expirar.",
|
||||
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
||||
@@ -1013,7 +1015,8 @@
|
||||
"activity": {
|
||||
"running": "Agente em execução",
|
||||
"complete": "Agente finalizado",
|
||||
"updated": "Nova atividade"
|
||||
"updated": "Nova atividade",
|
||||
"recovery": "Esta conversa precisa da sua atenção"
|
||||
},
|
||||
"pin": "Fixar",
|
||||
"unpin": "Desafixar",
|
||||
@@ -1436,6 +1439,18 @@
|
||||
"estimated": "Inclui uso estimado"
|
||||
}
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "A recuperação falhou. Tente novamente.",
|
||||
"interrupted": "Tarefa interrompida",
|
||||
"completed": "Tarefa concluída",
|
||||
"failed": "Falha ao recuperar a tarefa",
|
||||
"failedHelp": "Não foi possível restaurar a tarefa salva com segurança. Revise-a antes de continuar.",
|
||||
"resuming": "Restaurando a tarefa interrompida…",
|
||||
"review": "Revise a tarefa antes de continuar. As ferramentas não serão executadas novamente de forma automática.",
|
||||
"safeResume": "Continuando a partir do contexto de conversa salvo.",
|
||||
"dismiss": "Descartar",
|
||||
"continue": "Continuar"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Pré-visualização de imagem",
|
||||
"open": "Ver imagem",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"webuiDefaultAccess": "Quyền mặc định",
|
||||
"currentModel": "Cấu hình hiện tại",
|
||||
"brandLogos": "Logo thương hiệu",
|
||||
"browserNotifications": "Thông báo tác vụ",
|
||||
"cliAppsCatalog": "Danh mục",
|
||||
"cliAppsFilter": "Bộ lọc",
|
||||
"engine": "Bộ máy",
|
||||
@@ -204,6 +205,7 @@
|
||||
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
||||
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
|
||||
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
|
||||
"browserNotifications": "Chỉ thông báo khi trang này ở nền. Mặc định tắt.",
|
||||
"cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.",
|
||||
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
|
||||
"logs": "Mở thư mục nhật ký của bộ máy gốc.",
|
||||
@@ -999,7 +1001,8 @@
|
||||
"activity": {
|
||||
"running": "Tác nhân đang chạy",
|
||||
"complete": "Tác nhân đã hoàn tất",
|
||||
"updated": "Hoạt động mới"
|
||||
"updated": "Hoạt động mới",
|
||||
"recovery": "Cuộc trò chuyện này cần bạn xử lý"
|
||||
},
|
||||
"pin": "Ghim",
|
||||
"unpin": "Bỏ ghim",
|
||||
@@ -1422,6 +1425,18 @@
|
||||
"automationSourceFallback": "Tự động hóa",
|
||||
"automationTriggered": "Tự động kích hoạt"
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "Khôi phục thất bại. Hãy thử lại.",
|
||||
"interrupted": "Tác vụ bị gián đoạn",
|
||||
"completed": "Tác vụ đã hoàn tất",
|
||||
"failed": "Khôi phục tác vụ thất bại",
|
||||
"failedHelp": "Không thể khôi phục an toàn tác vụ đã lưu. Hãy xem lại trước khi tiếp tục.",
|
||||
"resuming": "Đang khôi phục tác vụ bị gián đoạn…",
|
||||
"review": "Hãy xem lại tác vụ trước khi tiếp tục. Công cụ sẽ không tự động chạy lại.",
|
||||
"safeResume": "Đang tiếp tục từ ngữ cảnh hội thoại đã lưu.",
|
||||
"dismiss": "Bỏ qua",
|
||||
"continue": "Tiếp tục"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "Xem trước ảnh",
|
||||
"open": "Xem ảnh",
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
"fileEditDisplay": "文件编辑显示",
|
||||
"codeWrap": "代码换行",
|
||||
"brandLogos": "品牌 Logo",
|
||||
"browserNotifications": "任务通知",
|
||||
"maxResults": "最大结果数",
|
||||
"timeout": "超时",
|
||||
"jinaReader": "Jina 阅读器",
|
||||
@@ -243,6 +244,7 @@
|
||||
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
||||
"codeWrap": "让长代码行在小屏幕上也易读。",
|
||||
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
||||
"browserNotifications": "仅在页面位于后台时通知,默认关闭。",
|
||||
"maxResults": "每次 web_search 调用返回的结果数。",
|
||||
"timeout": "搜索提供商请求超时前等待的秒数。",
|
||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||
@@ -1013,7 +1015,8 @@
|
||||
"activity": {
|
||||
"running": "智能体正在运行",
|
||||
"complete": "智能体已完成",
|
||||
"updated": "有新内容"
|
||||
"updated": "有新内容",
|
||||
"recovery": "此对话需要你的处理"
|
||||
},
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
@@ -1436,6 +1439,18 @@
|
||||
"estimated": "包含估算用量"
|
||||
}
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "恢复操作失败,请重试。",
|
||||
"interrupted": "任务已中断",
|
||||
"completed": "任务已完成",
|
||||
"failed": "任务恢复失败",
|
||||
"failedHelp": "无法安全恢复已保存的任务,继续前请先检查。",
|
||||
"resuming": "正在恢复中断的任务…",
|
||||
"review": "继续前请检查任务。工具不会被自动重放。",
|
||||
"safeResume": "正在从已保存的对话上下文继续。",
|
||||
"dismiss": "忽略",
|
||||
"continue": "继续"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "图片预览",
|
||||
"open": "查看图片",
|
||||
|
||||
@@ -161,6 +161,7 @@
|
||||
"webuiDefaultAccess": "預設存取權",
|
||||
"currentModel": "目前設定",
|
||||
"brandLogos": "品牌 Logo",
|
||||
"browserNotifications": "任務通知",
|
||||
"cliAppsCatalog": "目錄",
|
||||
"cliAppsFilter": "篩選",
|
||||
"engine": "引擎",
|
||||
@@ -204,6 +205,7 @@
|
||||
"selectedModelProvider": "由選取的模型決定。",
|
||||
"selectedModelValue": "由選取的模型決定。",
|
||||
"brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。",
|
||||
"browserNotifications": "僅在頁面位於背景時通知,預設關閉。",
|
||||
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
|
||||
"cliAppsFilter": "依應用程式、類別或功能搜尋。",
|
||||
"logs": "開啟原生引擎日誌資料夾。",
|
||||
@@ -999,7 +1001,8 @@
|
||||
"activity": {
|
||||
"running": "智能體正在執行",
|
||||
"complete": "智能體已完成",
|
||||
"updated": "有新內容"
|
||||
"updated": "有新內容",
|
||||
"recovery": "此對話需要你的處理"
|
||||
},
|
||||
"pin": "置頂",
|
||||
"unpin": "取消置頂",
|
||||
@@ -1422,6 +1425,18 @@
|
||||
"automationTriggered": "已自動觸發",
|
||||
"askAboutSelection": "繼續提問"
|
||||
},
|
||||
"recovery": {
|
||||
"actionFailed": "復原操作失敗,請再試一次。",
|
||||
"interrupted": "任務已中斷",
|
||||
"completed": "任務已完成",
|
||||
"failed": "任務復原失敗",
|
||||
"failedHelp": "無法安全復原已儲存的任務,繼續前請先檢查。",
|
||||
"resuming": "正在復原中斷的任務…",
|
||||
"review": "繼續前請檢查任務。工具不會自動重播。",
|
||||
"safeResume": "正在從已儲存的對話上下文繼續。",
|
||||
"dismiss": "略過",
|
||||
"continue": "繼續"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "圖片預覽",
|
||||
"open": "檢視圖片",
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
ProviderOAuthCompletionResult,
|
||||
ProviderOAuthLoginResult,
|
||||
ProviderSettingsUpdate,
|
||||
RecoveryState,
|
||||
SessionDeleteResult,
|
||||
SessionHandle,
|
||||
SessionAutomationsPayload,
|
||||
@@ -192,6 +193,7 @@ export async function listSessions(
|
||||
preview?: string;
|
||||
model_preset?: string | null;
|
||||
run_started_at?: number | null;
|
||||
recovery_state?: RecoveryState | null;
|
||||
workspace_scope?: WorkspaceScopePayload | null;
|
||||
handle?: SessionHandle | null;
|
||||
};
|
||||
@@ -212,6 +214,7 @@ export async function listSessions(
|
||||
preview: s.preview ?? "",
|
||||
modelPreset: s.model_preset ?? null,
|
||||
runStartedAt: s.run_started_at ?? null,
|
||||
recoveryState: s.recovery_state ?? null,
|
||||
workspaceScope: s.workspace_scope ?? null,
|
||||
handle,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface LocalPreferences {
|
||||
activityMode: LocalActivityMode;
|
||||
codeWrap: boolean;
|
||||
brandLogos: boolean;
|
||||
browserNotifications: boolean;
|
||||
fileEditDisplayMode: FileEditDisplayMode;
|
||||
}
|
||||
|
||||
@@ -18,6 +19,7 @@ export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
|
||||
activityMode: "auto",
|
||||
codeWrap: true,
|
||||
brandLogos: false,
|
||||
browserNotifications: false,
|
||||
fileEditDisplayMode: "summary",
|
||||
};
|
||||
|
||||
@@ -35,6 +37,7 @@ export function readLocalPreferences(): LocalPreferences {
|
||||
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
|
||||
codeWrap: parsed.codeWrap !== false,
|
||||
brandLogos: parsed.brandLogos === true,
|
||||
browserNotifications: parsed.browserNotifications === true,
|
||||
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -49,6 +49,16 @@ export interface TurnUsage {
|
||||
[key: string]: number | undefined;
|
||||
}
|
||||
|
||||
export type RecoveryStatus = "resuming" | "awaiting_user" | "recovered" | "failed";
|
||||
|
||||
export interface RecoveryState {
|
||||
status: RecoveryStatus;
|
||||
recovery_id: string;
|
||||
reason?: string;
|
||||
attempts?: number;
|
||||
can_continue?: boolean;
|
||||
}
|
||||
|
||||
export interface UIMessage {
|
||||
id: string;
|
||||
role: Role;
|
||||
@@ -368,6 +378,8 @@ export interface ChatSummary {
|
||||
modelPreset?: string | null;
|
||||
/** Unix epoch seconds when this session currently has a turn in flight. */
|
||||
runStartedAt?: number | null;
|
||||
/** Durable recovery state that needs attention after an interrupted turn. */
|
||||
recoveryState?: RecoveryState | null;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
/** Stable, server-owned @handle for this session. */
|
||||
handle?: SessionHandle | null;
|
||||
@@ -1246,6 +1258,7 @@ export type InboundEvent =
|
||||
event: "attached";
|
||||
chat_id: string;
|
||||
temporary?: boolean;
|
||||
recovery_state?: RecoveryState;
|
||||
usage?: TurnUsage;
|
||||
}
|
||||
| {
|
||||
@@ -1289,6 +1302,10 @@ export type InboundEvent =
|
||||
/** Optional structured payload on progress frames (channel-specific). */
|
||||
agent_ui?: AgentUIBlob;
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "recovery_state";
|
||||
chat_id: string;
|
||||
} & RecoveryState)
|
||||
| ({
|
||||
event: "file_edit";
|
||||
chat_id: string;
|
||||
|
||||
@@ -103,6 +103,24 @@ describe("ChatList", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a conversation that needs recovery attention with a warning indicator", () => {
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[session({ chatId: "recovery", title: "Interrupted task" })]}
|
||||
recoveryChatIds={["recovery"]}
|
||||
activeKey="websocket:other"
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("img", { name: "This conversation needs your attention" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps handle columns intact inside grouped panes", () => {
|
||||
render(
|
||||
<ChatList
|
||||
@@ -151,6 +169,26 @@ describe("ChatList", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the running indicator while a recovery continuation is active", () => {
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[session({ chatId: "recovery", title: "Interrupted task" })]}
|
||||
runningChatIds={["recovery"]}
|
||||
recoveryChatIds={["recovery"]}
|
||||
activeKey="websocket:other"
|
||||
onSelect={vi.fn()}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("img", { name: "Agent running" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "This conversation needs your attention" }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps tab grouping out of drag protocols while exposing inactive panes as mention sources", () => {
|
||||
render(
|
||||
<ChatList
|
||||
|
||||
@@ -161,6 +161,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.rows.fileEditDisplay",
|
||||
"settings.rows.codeWrap",
|
||||
"settings.rows.brandLogos",
|
||||
"settings.rows.browserNotifications",
|
||||
"settings.rows.currentModel",
|
||||
"settings.rows.localServiceAccess",
|
||||
"settings.rows.webuiDefaultAccess",
|
||||
@@ -172,6 +173,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.help.fileEditDisplay",
|
||||
"settings.help.codeWrap",
|
||||
"settings.help.brandLogos",
|
||||
"settings.help.browserNotifications",
|
||||
"settings.help.currentModel",
|
||||
"settings.help.localServiceAccess",
|
||||
"settings.help.webuiDefaultAccess",
|
||||
@@ -252,6 +254,7 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
|
||||
"chat.activity.running",
|
||||
"chat.activity.complete",
|
||||
"chat.activity.updated",
|
||||
"chat.activity.recovery",
|
||||
"chat.pin",
|
||||
"chat.unpin",
|
||||
"chat.rename",
|
||||
@@ -293,6 +296,16 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
|
||||
"message.skill",
|
||||
"settings.channels.connectionChecks",
|
||||
"settings.channels.open",
|
||||
"recovery.actionFailed",
|
||||
"recovery.interrupted",
|
||||
"recovery.completed",
|
||||
"recovery.failed",
|
||||
"recovery.failedHelp",
|
||||
"recovery.resuming",
|
||||
"recovery.review",
|
||||
"recovery.safeResume",
|
||||
"recovery.dismiss",
|
||||
"recovery.continue",
|
||||
];
|
||||
const ACCIDENTALLY_SPANISH_SETTINGS_KEYS = [
|
||||
"settings.help.provider",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCAL_PREFS,
|
||||
readLocalPreferences,
|
||||
writeLocalPreferences,
|
||||
} from "@/lib/local-preferences";
|
||||
|
||||
describe("local preferences", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("keeps browser notifications opt-in", () => {
|
||||
expect(DEFAULT_LOCAL_PREFS.browserNotifications).toBe(false);
|
||||
expect(readLocalPreferences().browserNotifications).toBe(false);
|
||||
|
||||
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, browserNotifications: true });
|
||||
expect(readLocalPreferences().browserNotifications).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RecoveryNotice } from "@/components/thread/RecoveryNotice";
|
||||
|
||||
const INTERRUPTED = {
|
||||
status: "awaiting_user" as const,
|
||||
recovery_id: "recovery-1",
|
||||
reason: "tool_state_uncertain",
|
||||
};
|
||||
|
||||
describe("RecoveryNotice", () => {
|
||||
it("hides the internal resuming state after Continue is accepted", async () => {
|
||||
const onContinue = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<RecoveryNotice
|
||||
state={INTERRUPTED}
|
||||
onContinue={onContinue}
|
||||
onDismiss={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||
expect(onContinue).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the shared status surface and motion treatment", () => {
|
||||
render(
|
||||
<RecoveryNotice
|
||||
state={{ status: "resuming", recovery_id: "recovery-1" }}
|
||||
onContinue={vi.fn().mockResolvedValue(undefined)}
|
||||
onDismiss={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const notice = screen.getByRole("status");
|
||||
expect(notice).toHaveAttribute("data-recovery-status", "resuming");
|
||||
expect(notice).toHaveAttribute("aria-live", "polite");
|
||||
expect(notice).toHaveClass(
|
||||
"max-w-[49.5rem]",
|
||||
"rounded-control",
|
||||
"animate-in",
|
||||
"fade-in-0",
|
||||
"slide-in-from-bottom-1",
|
||||
"duration-200",
|
||||
"motion-reduce:animate-none",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the notice visible when Continue fails", async () => {
|
||||
const onContinue = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
|
||||
render(
|
||||
<RecoveryNotice
|
||||
state={INTERRUPTED}
|
||||
onContinue={onContinue}
|
||||
onDismiss={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Recovery action failed");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the decision surface again when a continuation is interrupted", async () => {
|
||||
const onContinue = vi.fn().mockResolvedValue(undefined);
|
||||
const { rerender } = render(
|
||||
<RecoveryNotice
|
||||
state={INTERRUPTED}
|
||||
onContinue={onContinue}
|
||||
onDismiss={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
|
||||
|
||||
rerender(
|
||||
<RecoveryNotice
|
||||
state={{ status: "resuming", recovery_id: "recovery-1" }}
|
||||
onContinue={onContinue}
|
||||
onDismiss={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<RecoveryNotice
|
||||
state={{ ...INTERRUPTED, reason: "loop_guard" }}
|
||||
onContinue={onContinue}
|
||||
onDismiss={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("does not offer Continue when saved conversation context is unavailable", () => {
|
||||
render(
|
||||
<RecoveryNotice
|
||||
state={{ ...INTERRUPTED, can_continue: false }}
|
||||
onContinue={vi.fn().mockResolvedValue(undefined)}
|
||||
onDismiss={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Continue" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4007,4 +4007,32 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByRole("option", { name: /Other project/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows a new turn after a completed recovery state", async () => {
|
||||
const client = makeClient();
|
||||
render(wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("recovered-chat")}
|
||||
title="Recovered chat"
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
act(() => {
|
||||
client._emitChat("recovered-chat", {
|
||||
event: "recovery_state",
|
||||
chat_id: "recovered-chat",
|
||||
recovery_id: "recovery-1",
|
||||
status: "recovered",
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.change(input, { target: { value: "start the next task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ function fakeClient() {
|
||||
const runStartedAtByChatId = new Map<string, number>();
|
||||
const unsettledRunByChatId = new Map<string, boolean>();
|
||||
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
const requestMutation = vi.fn().mockResolvedValue({});
|
||||
let status: ConnectionStatus = "open";
|
||||
|
||||
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
|
||||
@@ -133,6 +134,7 @@ function fakeClient() {
|
||||
return () => set!.delete(h);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
requestMutation,
|
||||
finishRunLocally: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
forkChat: vi.fn(),
|
||||
@@ -157,6 +159,7 @@ function fakeClient() {
|
||||
setUnsettled(chatId: string, unsettled: boolean) {
|
||||
unsettledRunByChatId.set(chatId, unsettled);
|
||||
},
|
||||
requestMutation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,6 +398,101 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes typed recovery state and validates actions with its recovery id", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-recovery", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-recovery", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-recovery",
|
||||
status: "running",
|
||||
started_at: 1_700,
|
||||
});
|
||||
});
|
||||
expect(result.current.runStartedAt).toBe(1_700);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-recovery", {
|
||||
event: "recovery_state",
|
||||
chat_id: "chat-recovery",
|
||||
recovery_id: "recovery-1",
|
||||
status: "awaiting_user",
|
||||
reason: "tool_state_uncertain",
|
||||
can_continue: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.recoveryState).toEqual({
|
||||
recovery_id: "recovery-1",
|
||||
status: "awaiting_user",
|
||||
reason: "tool_state_uncertain",
|
||||
can_continue: false,
|
||||
});
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-recovery");
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-recovery", {
|
||||
event: "recovery_state",
|
||||
chat_id: "chat-recovery",
|
||||
recovery_id: "recovery-1",
|
||||
status: "awaiting_user",
|
||||
reason: "tool_state_uncertain",
|
||||
can_continue: true,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => result.current.continueRecovery());
|
||||
expect(fake.requestMutation).toHaveBeenCalledWith("recovery.continue", {
|
||||
chat_id: "chat-recovery",
|
||||
recovery_id: "recovery-1",
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-recovery", {
|
||||
event: "recovery_state",
|
||||
chat_id: "chat-recovery",
|
||||
recovery_id: "recovery-1",
|
||||
status: "recovered",
|
||||
});
|
||||
});
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-recovery");
|
||||
});
|
||||
|
||||
it("does not let historical recovered state clear a later active turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-recovered-history", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-recovered-history", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-recovered-history",
|
||||
status: "running",
|
||||
started_at: 1_700,
|
||||
});
|
||||
fake.emit("chat-recovered-history", {
|
||||
event: "attached",
|
||||
chat_id: "chat-recovered-history",
|
||||
recovery_state: {
|
||||
recovery_id: "old-recovery",
|
||||
status: "recovered",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.runStartedAt).toBe(1_700);
|
||||
expect(fake.client.finishRunLocally).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves proactive automation source metadata on complete assistant messages", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), {
|
||||
|
||||
Reference in New Issue
Block a user