mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-21 18:55:16 +02:00
Merge branch 'dev' of https://github.com/MODSetter/SurfSense into dev
This commit is contained in:
commit
dc88ce0277
193 changed files with 6934 additions and 2192 deletions
|
|
@ -49,7 +49,11 @@ import {
|
|||
type TokenUsageData,
|
||||
TokenUsageProvider,
|
||||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { type HitlDecision, PendingInterruptProvider } from "@/features/chat-messages/hitl";
|
||||
import {
|
||||
type HitlDecision,
|
||||
PendingInterruptProvider,
|
||||
type PendingInterruptState,
|
||||
} from "@/features/chat-messages/hitl";
|
||||
import { TimelineDataUI } from "@/features/chat-messages/timeline";
|
||||
import {
|
||||
applyActionLogSse,
|
||||
|
|
@ -272,12 +276,16 @@ export default function NewChatPage() {
|
|||
const [tokenUsageStore] = useState(() => createTokenUsageStore());
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const recentCancelRequestedAtRef = useRef(0);
|
||||
const [pendingInterrupt, setPendingInterrupt] = useState<{
|
||||
threadId: number;
|
||||
assistantMsgId: string;
|
||||
interruptData: Record<string, unknown>;
|
||||
bundleToolCallIds: string[];
|
||||
} | null>(null);
|
||||
// One entry per paused subagent, in receipt order (which matches the
|
||||
// backend's ``state.interrupts`` traversal — and therefore the order
|
||||
// ``slice_decisions_by_tool_call`` consumes on resume). Cleared on submit
|
||||
// or on a fresh user turn.
|
||||
const [pendingInterrupts, setPendingInterrupts] = useState<PendingInterruptState[]>([]);
|
||||
// Per-card staged decisions held until every pending card has submitted,
|
||||
// at which point we batch them into one ``hitl-decision`` event in the
|
||||
// same order as ``pendingInterrupts``. Using a ref because partial
|
||||
// progress should not re-render the page.
|
||||
const stagedDecisionsByInterruptIdRef = useRef<Map<string, HitlDecision[]>>(new Map());
|
||||
const toolsWithUI = TOOLS_WITH_UI_ALL;
|
||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||
|
||||
|
|
@ -1194,12 +1202,24 @@ export default function NewChatPage() {
|
|||
)
|
||||
);
|
||||
if (currentThreadId) {
|
||||
setPendingInterrupt({
|
||||
threadId: currentThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
});
|
||||
// ``tool_call_id`` is stamped on the backend by
|
||||
// ``checkpointed_subagent_middleware``. Without it we
|
||||
// can't address the paused subagent on resume — skip
|
||||
// rather than fabricate a synthetic key.
|
||||
const interruptId = String(interruptData.tool_call_id ?? "");
|
||||
if (interruptId) {
|
||||
const incoming: PendingInterruptState = {
|
||||
interruptId,
|
||||
threadId: currentThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
};
|
||||
setPendingInterrupts((prev) => {
|
||||
const without = prev.filter((p) => p.interruptId !== interruptId);
|
||||
return [...without, incoming];
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -1275,7 +1295,7 @@ export default function NewChatPage() {
|
|||
// by ``persist_assistant_shell``. Rename the optimistic
|
||||
// id, migrate ``tokenUsageStore`` so any pending
|
||||
// ``data-token-usage`` payload binds to the new id,
|
||||
// remap any in-flight ``pendingInterrupt`` reference,
|
||||
// remap any in-flight ``pendingInterrupts`` entries,
|
||||
// and reassign the closure variable so the in-stream
|
||||
// flush callback (line ~1074) keeps writing to the
|
||||
// renamed message.
|
||||
|
|
@ -1291,10 +1311,12 @@ export default function NewChatPage() {
|
|||
: m
|
||||
)
|
||||
);
|
||||
setPendingInterrupt((prev) =>
|
||||
prev && prev.assistantMsgId === oldAssistantMsgId
|
||||
? { ...prev, assistantMsgId: newAssistantMsgId }
|
||||
: prev
|
||||
setPendingInterrupts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.assistantMsgId === oldAssistantMsgId
|
||||
? { ...p, assistantMsgId: newAssistantMsgId }
|
||||
: p
|
||||
)
|
||||
);
|
||||
assistantMsgId = newAssistantMsgId;
|
||||
break;
|
||||
|
|
@ -1381,14 +1403,23 @@ export default function NewChatPage() {
|
|||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}>
|
||||
) => {
|
||||
if (!pendingInterrupt) return;
|
||||
const { threadId: resumeThreadId } = pendingInterrupt;
|
||||
if (pendingInterrupts.length === 0) return;
|
||||
// All cards in this turn share the same threadId/assistantMsgId
|
||||
// (they're siblings of one parent agent step), so reading from
|
||||
// the first entry is safe.
|
||||
const resumeThreadId = pendingInterrupts[0].threadId;
|
||||
// Destructured separately as ``let`` so the SSE
|
||||
// ``data-assistant-message-id`` handler (resume always
|
||||
// allocates a fresh server-side row) can rename it to
|
||||
// the canonical ``msg-{db_id}`` mid-stream.
|
||||
let assistantMsgId = pendingInterrupt.assistantMsgId;
|
||||
setPendingInterrupt(null);
|
||||
let assistantMsgId = pendingInterrupts[0].assistantMsgId;
|
||||
// Concatenate every card's tool-call ids in pendingInterrupts order;
|
||||
// this matches the ``decisions`` ordering produced by
|
||||
// ``handleApprovalSubmit`` and the backend slicer's traversal of
|
||||
// ``state.interrupts``.
|
||||
const allBundleToolCallIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
|
||||
setPendingInterrupts([]);
|
||||
stagedDecisionsByInterruptIdRef.current.clear();
|
||||
setIsRunning(true);
|
||||
|
||||
const token = getBearerToken();
|
||||
|
|
@ -1465,7 +1496,7 @@ export default function NewChatPage() {
|
|||
// collapse onto ``decisions[0]``. Cards outside the bundle are
|
||||
// untouched. Mirrors the host ``hitl-decision`` handler.
|
||||
const decisionByTcId = new Map<string, (typeof decisions)[number]>();
|
||||
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||
const tcIds = allBundleToolCallIds;
|
||||
if (decisions.length === tcIds.length) {
|
||||
for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]);
|
||||
}
|
||||
|
|
@ -1477,7 +1508,7 @@ export default function NewChatPage() {
|
|||
if (!d) continue;
|
||||
if (typeof part.result !== "object" || part.result === null) continue;
|
||||
if (!("__interrupt__" in (part.result as Record<string, unknown>))) continue;
|
||||
const decided = d.type as "approve" | "reject" | "edit";
|
||||
const decided = d.type;
|
||||
if (decided === "edit" && d.edited_action) {
|
||||
const mergedArgs = { ...part.args, ...d.edited_action.args };
|
||||
part.args = mergedArgs;
|
||||
|
|
@ -1597,12 +1628,22 @@ export default function NewChatPage() {
|
|||
: m
|
||||
)
|
||||
);
|
||||
setPendingInterrupt({
|
||||
threadId: resumeThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
});
|
||||
{
|
||||
const interruptId = String(interruptData.tool_call_id ?? "");
|
||||
if (interruptId) {
|
||||
const incoming: PendingInterruptState = {
|
||||
interruptId,
|
||||
threadId: resumeThreadId,
|
||||
assistantMsgId,
|
||||
interruptData,
|
||||
bundleToolCallIds,
|
||||
};
|
||||
setPendingInterrupts((prev) => {
|
||||
const without = prev.filter((p) => p.interruptId !== interruptId);
|
||||
return [...without, incoming];
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -1680,7 +1721,7 @@ export default function NewChatPage() {
|
|||
}
|
||||
},
|
||||
[
|
||||
pendingInterrupt,
|
||||
pendingInterrupts,
|
||||
messages,
|
||||
searchSpaceId,
|
||||
localFilesystemEnabled,
|
||||
|
|
@ -1701,17 +1742,19 @@ export default function NewChatPage() {
|
|||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}>;
|
||||
};
|
||||
if (!detail?.decisions || !pendingInterrupt) return;
|
||||
if (!detail?.decisions || pendingInterrupts.length === 0) return;
|
||||
const incoming = detail.decisions;
|
||||
if (incoming.length === 0) return;
|
||||
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||
// Concatenated tool-call ids across every pending card, in the
|
||||
// order ``handleApprovalSubmit`` produced ``incoming``.
|
||||
const tcIds = pendingInterrupts.flatMap((p) => p.bundleToolCallIds);
|
||||
const N = tcIds.length;
|
||||
|
||||
// Bundles must submit exactly one decision per action_request.
|
||||
// Refuse rather than silently broadcast a single decision across
|
||||
// the bundle (would mis-apply rejects/edits and diverge from
|
||||
// what handleResume sends to /resume).
|
||||
if (N > 1 && incoming.length !== N) {
|
||||
// Refuse rather than silently broadcast or drop. The orchestrator
|
||||
// only fires ``hitl-decision`` once every pending card has
|
||||
// submitted, so a count mismatch indicates a contract drift
|
||||
// (and would later make the backend slicer raise).
|
||||
if (incoming.length !== N) {
|
||||
toast.error(
|
||||
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
|
||||
);
|
||||
|
|
@ -1722,9 +1765,12 @@ export default function NewChatPage() {
|
|||
for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]);
|
||||
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
|
||||
|
||||
// All pending cards belong to the same assistant message, so a
|
||||
// single content-update pass suffices.
|
||||
const targetAssistantMsgId = pendingInterrupts[0].assistantMsgId;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.id !== pendingInterrupt.assistantMsgId) return m;
|
||||
if (m.id !== targetAssistantMsgId) return m;
|
||||
const parts = m.content as unknown as Array<Record<string, unknown>>;
|
||||
const newContent = parts.map((part) => {
|
||||
const tcId = part.toolCallId as string | undefined;
|
||||
|
|
@ -1732,7 +1778,7 @@ export default function NewChatPage() {
|
|||
if (!d || part.type !== "tool-call") return part;
|
||||
if (typeof part.result !== "object" || part.result === null) return part;
|
||||
if (!("__interrupt__" in (part.result as Record<string, unknown>))) return part;
|
||||
const decided = d.type as "approve" | "reject" | "edit";
|
||||
const decided = d.type;
|
||||
if (decided === "edit" && d.edited_action) {
|
||||
return {
|
||||
...part,
|
||||
|
|
@ -1761,7 +1807,7 @@ export default function NewChatPage() {
|
|||
};
|
||||
window.addEventListener("hitl-decision", handler);
|
||||
return () => window.removeEventListener("hitl-decision", handler);
|
||||
}, [handleResume, pendingInterrupt]);
|
||||
}, [handleResume, pendingInterrupts]);
|
||||
|
||||
// Convert message (pass through since already in correct format)
|
||||
const convertMessage = useCallback(
|
||||
|
|
@ -2283,11 +2329,32 @@ export default function NewChatPage() {
|
|||
[handleRegenerate, messages, agentActionItems]
|
||||
);
|
||||
|
||||
const handleApprovalSubmit = useCallback((orderedDecisions: HitlDecision[]) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: orderedDecisions } })
|
||||
);
|
||||
}, []);
|
||||
const handleApprovalSubmit = useCallback(
|
||||
(interruptId: string, decisions: HitlDecision[]) => {
|
||||
// Stage this card's decisions; only fire the resume once every
|
||||
// pending card in the current turn has submitted, so the
|
||||
// backend slicer sees a single concatenated decisions list
|
||||
// whose total matches the parent state's pending action count.
|
||||
stagedDecisionsByInterruptIdRef.current.set(interruptId, decisions);
|
||||
if (stagedDecisionsByInterruptIdRef.current.size < pendingInterrupts.length) {
|
||||
return;
|
||||
}
|
||||
const ordered: HitlDecision[] = [];
|
||||
for (const pi of pendingInterrupts) {
|
||||
const staged = stagedDecisionsByInterruptIdRef.current.get(pi.interruptId);
|
||||
if (!staged) {
|
||||
// Defensive: a missing entry means the staging map and
|
||||
// the pending list disagreed for one cycle. Bail rather
|
||||
// than dispatch a count-mismatched batch.
|
||||
return;
|
||||
}
|
||||
ordered.push(...staged);
|
||||
}
|
||||
stagedDecisionsByInterruptIdRef.current.clear();
|
||||
window.dispatchEvent(new CustomEvent("hitl-decision", { detail: { decisions: ordered } }));
|
||||
},
|
||||
[pendingInterrupts]
|
||||
);
|
||||
|
||||
const handleEditDialogChoice = useCallback(
|
||||
async (choice: EditMessageDialogChoice) => {
|
||||
|
|
@ -2360,7 +2427,7 @@ export default function NewChatPage() {
|
|||
<TimelineDataUI />
|
||||
<StepSeparatorDataUI />
|
||||
<PendingInterruptProvider
|
||||
pendingInterrupt={pendingInterrupt}
|
||||
pendingInterrupts={pendingInterrupts}
|
||||
onSubmit={handleApprovalSubmit}
|
||||
>
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
export const createCommentMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: CreateCommentRequest) => {
|
||||
return chatCommentsApiService.createComment(request);
|
||||
},
|
||||
|
|
@ -26,6 +27,7 @@ export const createCommentMutationAtom = atomWithMutation(() => ({
|
|||
}));
|
||||
|
||||
export const createReplyMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: CreateReplyRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.createReply(request);
|
||||
},
|
||||
|
|
@ -41,6 +43,7 @@ export const createReplyMutationAtom = atomWithMutation(() => ({
|
|||
}));
|
||||
|
||||
export const updateCommentMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: UpdateCommentRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.updateComment(request);
|
||||
},
|
||||
|
|
@ -56,6 +59,7 @@ export const updateCommentMutationAtom = atomWithMutation(() => ({
|
|||
}));
|
||||
|
||||
export const deleteCommentMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: DeleteCommentRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.deleteComment(request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const createImageGenConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["image-gen-configs", "create"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateImageGenConfigRequest) => {
|
||||
return imageGenConfigApiService.createConfig(request);
|
||||
|
|
@ -45,6 +46,7 @@ export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["image-gen-configs", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateImageGenConfigRequest) => {
|
||||
return imageGenConfigApiService.updateConfig(request);
|
||||
|
|
@ -72,6 +74,7 @@ export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["image-gen-configs", "delete"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: { id: number; name: string }) => {
|
||||
return imageGenConfigApiService.deleteConfig(request.id);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { queryClient } from "@/lib/query-client/client";
|
|||
* Mutation atom for creating an invite
|
||||
*/
|
||||
export const createInviteMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: CreateInviteRequest) => {
|
||||
return invitesApiService.createInvite(request);
|
||||
},
|
||||
|
|
@ -33,6 +34,7 @@ export const createInviteMutationAtom = atomWithMutation(() => ({
|
|||
* Mutation atom for updating an invite
|
||||
*/
|
||||
export const updateInviteMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: UpdateInviteRequest) => {
|
||||
return invitesApiService.updateInvite(request);
|
||||
},
|
||||
|
|
@ -52,6 +54,7 @@ export const updateInviteMutationAtom = atomWithMutation(() => ({
|
|||
* Mutation atom for deleting an invite
|
||||
*/
|
||||
export const deleteInviteMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: DeleteInviteRequest) => {
|
||||
return invitesApiService.deleteInvite(request);
|
||||
},
|
||||
|
|
@ -71,6 +74,7 @@ export const deleteInviteMutationAtom = atomWithMutation(() => ({
|
|||
* Mutation atom for accepting an invite
|
||||
*/
|
||||
export const acceptInviteMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: AcceptInviteRequest) => {
|
||||
return invitesApiService.acceptInvite(request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { queryClient } from "@/lib/query-client/client";
|
|||
|
||||
export const updateMemberMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: UpdateMembershipRequest) => {
|
||||
return membersApiService.updateMember(request);
|
||||
},
|
||||
|
|
@ -31,6 +32,7 @@ export const updateMemberMutationAtom = atomWithMutation(() => {
|
|||
|
||||
export const deleteMemberMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: DeleteMembershipRequest) => {
|
||||
return membersApiService.deleteMember(request);
|
||||
},
|
||||
|
|
@ -48,6 +50,7 @@ export const deleteMemberMutationAtom = atomWithMutation(() => {
|
|||
|
||||
export const leaveSearchSpaceMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: LeaveSearchSpaceRequest) => {
|
||||
return membersApiService.leaveSearchSpace(request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["new-llm-configs", "create"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateNewLLMConfigRequest) => {
|
||||
return newLLMConfigApiService.createConfig(request);
|
||||
|
|
@ -47,6 +48,7 @@ export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["new-llm-configs", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateNewLLMConfigRequest) => {
|
||||
return newLLMConfigApiService.updateConfig(request);
|
||||
|
|
@ -74,6 +76,7 @@ export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["new-llm-configs", "delete"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
|
||||
return newLLMConfigApiService.deleteConfig({ id: request.id });
|
||||
|
|
@ -105,6 +108,7 @@ export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["llm-preferences", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateLLMPreferencesRequest) => {
|
||||
return newLLMConfigApiService.updateLLMPreferences(request);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { queryClient } from "@/lib/query-client/client";
|
|||
|
||||
export const createPromptMutationAtom = atomWithMutation(() => ({
|
||||
mutationKey: ["prompts", "create"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: PromptCreateRequest) => {
|
||||
return promptsApiService.create(request);
|
||||
},
|
||||
|
|
@ -25,6 +26,7 @@ export const createPromptMutationAtom = atomWithMutation(() => ({
|
|||
|
||||
export const updatePromptMutationAtom = atomWithMutation(() => ({
|
||||
mutationKey: ["prompts", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async ({ id, ...data }: PromptUpdateRequest & { id: number }) => {
|
||||
return promptsApiService.update(id, data);
|
||||
},
|
||||
|
|
@ -39,6 +41,7 @@ export const updatePromptMutationAtom = atomWithMutation(() => ({
|
|||
|
||||
export const deletePromptMutationAtom = atomWithMutation(() => ({
|
||||
mutationKey: ["prompts", "delete"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (id: number) => {
|
||||
return promptsApiService.delete(id);
|
||||
},
|
||||
|
|
@ -57,6 +60,7 @@ export const deletePromptMutationAtom = atomWithMutation(() => ({
|
|||
|
||||
export const copyPromptMutationAtom = atomWithMutation(() => ({
|
||||
mutationKey: ["prompts", "copy"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (promptId: number) => {
|
||||
return promptsApiService.copy(promptId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: PublicChatSnapshotCreateRequest) => {
|
||||
return chatThreadsApiService.createPublicChatSnapshot(request);
|
||||
},
|
||||
|
|
@ -37,6 +38,7 @@ export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({
|
|||
}));
|
||||
|
||||
export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: PublicChatSnapshotDeleteRequest) => {
|
||||
return chatThreadsApiService.deletePublicChatSnapshot(request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { queryClient } from "@/lib/query-client/client";
|
|||
|
||||
export const createRoleMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: CreateRoleRequest) => {
|
||||
return rolesApiService.createRole(request);
|
||||
},
|
||||
|
|
@ -31,6 +32,7 @@ export const createRoleMutationAtom = atomWithMutation(() => {
|
|||
|
||||
export const updateRoleMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: UpdateRoleRequest) => {
|
||||
return rolesApiService.updateRole(request);
|
||||
},
|
||||
|
|
@ -54,6 +56,7 @@ export const updateRoleMutationAtom = atomWithMutation(() => {
|
|||
|
||||
export const deleteRoleMutationAtom = atomWithMutation(() => {
|
||||
return {
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
mutationFn: async (request: DeleteRoleRequest) => {
|
||||
return rolesApiService.deleteRole(request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const createVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "create"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateVisionLLMConfigRequest) => {
|
||||
return visionLLMConfigApiService.createConfig(request);
|
||||
|
|
@ -39,6 +40,7 @@ export const updateVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateVisionLLMConfigRequest) => {
|
||||
return visionLLMConfigApiService.updateConfig(request);
|
||||
|
|
@ -63,6 +65,7 @@ export const deleteVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
|||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "delete"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: { id: number; name: string }) => {
|
||||
return visionLLMConfigApiService.deleteConfig(request.id);
|
||||
|
|
|
|||
|
|
@ -124,7 +124,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleDisconnectFromList,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
|
|
@ -233,9 +232,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onDisconnect={(connector) =>
|
||||
handleDisconnectFromList(connector, () => refreshConnectors())
|
||||
}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
|
|
@ -247,9 +243,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onDisconnect={(connector) =>
|
||||
handleDisconnectFromList(connector, () => refreshConnectors())
|
||||
}
|
||||
onAddAccount={() => {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { CheckCircle2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
import { MCPTrustedTools } from "./mcp-trusted-tools";
|
||||
|
||||
export const MCPServiceConfig: FC<ConnectorConfigProps> = ({ connector }) => {
|
||||
const serviceName = connector.config?.mcp_service as string | undefined;
|
||||
|
|
@ -11,7 +12,7 @@ export const MCPServiceConfig: FC<ConnectorConfigProps> = ({ connector }) => {
|
|||
: "this service";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-emerald-500/5 p-4 flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0 mt-0.5">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||
|
|
@ -23,6 +24,8 @@ export const MCPServiceConfig: FC<ConnectorConfigProps> = ({ connector }) => {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connector.id > 0 && <MCPTrustedTools connector={connector} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { ShieldCheck, Trash2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
|
||||
interface MCPTrustedToolsProps {
|
||||
connector: SearchSourceConnector;
|
||||
}
|
||||
|
||||
/** Audit + revoke surface for tools promoted via in-chat "Always Allow". */
|
||||
export const MCPTrustedTools: FC<MCPTrustedToolsProps> = ({ connector }) => {
|
||||
const trustedTools = readTrustedTools(connector.config);
|
||||
const [pending, setPending] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleRevoke = async (toolName: string) => {
|
||||
setPending((prev) => new Set(prev).add(toolName));
|
||||
try {
|
||||
await connectorsApiService.untrustMCPTool(connector.id, toolName);
|
||||
toast.success(`Removed ${toolName} from trusted tools`);
|
||||
} catch {
|
||||
toast.error(`Failed to remove ${toolName} from trusted tools`);
|
||||
} finally {
|
||||
setPending((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(toolName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Trusted Tools
|
||||
</h3>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Tools listed here skip the approval prompt during chat. Trust is granted by clicking
|
||||
"Always Allow" on an approval card; revoke it here to require approval again.
|
||||
</p>
|
||||
|
||||
{trustedTools.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground/70 italic">
|
||||
No trusted tools yet for this connector.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{trustedTools.map((toolName) => {
|
||||
const isPending = pending.has(toolName);
|
||||
return (
|
||||
<li
|
||||
key={toolName}
|
||||
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<span className="text-xs font-mono break-all">{toolName}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => handleRevoke(toolName)}
|
||||
disabled={isPending}
|
||||
aria-label={`Revoke trust for ${toolName}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="ml-1 hidden sm:inline">Revoke</span>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function readTrustedTools(config: Record<string, unknown> | undefined | null): string[] {
|
||||
const raw = config?.trusted_tools;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
|
|
@ -1288,25 +1288,6 @@ export const useConnectorDialog = () => {
|
|||
[editingConnector, searchSpaceId, deleteConnector, cameFromMCPList, setIsOpen]
|
||||
);
|
||||
|
||||
const handleDisconnectFromList = useCallback(
|
||||
async (connector: SearchSourceConnector, refreshConnectors: () => void) => {
|
||||
if (!searchSpaceId) return;
|
||||
try {
|
||||
await deleteConnector({ id: connector.id });
|
||||
trackConnectorDeleted(Number(searchSpaceId), connector.connector_type, connector.id);
|
||||
toast.success(`${connector.name} disconnected successfully`);
|
||||
refreshConnectors();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error disconnecting connector:", error);
|
||||
toast.error("Failed to disconnect connector");
|
||||
}
|
||||
},
|
||||
[searchSpaceId, deleteConnector]
|
||||
);
|
||||
|
||||
// Handle quick index (index with selected date range, or backend defaults if none selected)
|
||||
const handleQuickIndexConnector = useCallback(
|
||||
async (
|
||||
|
|
@ -1480,7 +1461,6 @@ export const useConnectorDialog = () => {
|
|||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleDisconnectFromList,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Plus, RefreshCw, Server } from "lucide-react";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
|
|
@ -24,7 +24,6 @@ interface ConnectorAccountsListViewProps {
|
|||
indexingConnectorIds: Set<number>;
|
||||
onBack: () => void;
|
||||
onManage: (connector: SearchSourceConnector) => void;
|
||||
onDisconnect?: (connector: SearchSourceConnector) => Promise<void> | void;
|
||||
onAddAccount: () => void;
|
||||
isConnecting?: boolean;
|
||||
addButtonText?: string;
|
||||
|
|
@ -37,15 +36,12 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
indexingConnectorIds,
|
||||
onBack,
|
||||
onManage,
|
||||
onDisconnect,
|
||||
onAddAccount,
|
||||
isConnecting = false,
|
||||
addButtonText,
|
||||
}) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const [reauthingId, setReauthingId] = useState<number | null>(null);
|
||||
const [confirmDisconnectId, setConfirmDisconnectId] = useState<number | null>(null);
|
||||
const [disconnectingId, setDisconnectingId] = useState<number | null>(null);
|
||||
|
||||
// Get connector status
|
||||
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
|
||||
|
|
@ -240,51 +236,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
/>
|
||||
Re-authenticate
|
||||
</Button>
|
||||
) : isLive && onDisconnect ? (
|
||||
confirmDisconnectId === connector.id ? (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium shadow-xs"
|
||||
onClick={async () => {
|
||||
setDisconnectingId(connector.id);
|
||||
setConfirmDisconnectId(null);
|
||||
try {
|
||||
await onDisconnect(connector);
|
||||
} finally {
|
||||
setDisconnectingId(null);
|
||||
}
|
||||
}}
|
||||
disabled={disconnectingId === connector.id}
|
||||
>
|
||||
{disconnectingId === connector.id ? (
|
||||
<RefreshCw className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
"Confirm"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-2 rounded-lg"
|
||||
onClick={() => setConfirmDisconnectId(null)}
|
||||
disabled={disconnectingId === connector.id}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-red-50 hover:text-red-700 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-red-950 dark:hover:text-red-400 shrink-0"
|
||||
onClick={() => setConfirmDisconnectId(connector.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Disconnect
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export interface PlateEditorProps {
|
|||
editorVariant?: "default" | "demo" | "fullWidth" | "none";
|
||||
/** Additional className for the container */
|
||||
className?: string;
|
||||
/** Save callback. When provided, ⌘+S / Ctrl+S shortcut is registered and save button appears. */
|
||||
/** Save callback. When provided, ⌘+Shift+S / Ctrl+Shift+S shortcut is registered (avoiding the browser's ⌘+S / Ctrl+S "Save Page As" conflict) and a save button appears in the toolbar. */
|
||||
onSave?: () => void;
|
||||
/** Whether there are unsaved changes */
|
||||
hasUnsavedChanges?: boolean;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,11 @@
|
|||
|
||||
import { CornerDownLeftIcon, Pencil } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import type { HitlDecision, InterruptResult, PerToolApprovalCard } from "../types";
|
||||
import { useHitlDecision } from "../use-hitl-decision";
|
||||
import { useHitlPhase } from "../use-hitl-phase";
|
||||
|
|
@ -81,12 +79,11 @@ function GenericApprovalCardView({
|
|||
|
||||
const mcpServer = interruptData.context?.mcp_server as string | undefined;
|
||||
const toolDescription = interruptData.context?.tool_description as string | undefined;
|
||||
const mcpConnectorId = interruptData.context?.mcp_connector_id as number | undefined;
|
||||
const isMCPTool = mcpConnectorId != null;
|
||||
|
||||
const reviewConfig = interruptData.review_configs?.[0];
|
||||
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
|
||||
const canEdit = allowedDecisions.includes("edit");
|
||||
const canApproveAlways = allowedDecisions.includes("approve_always");
|
||||
|
||||
const hasChanged = useMemo(() => {
|
||||
return JSON.stringify(editedParams) !== JSON.stringify(args);
|
||||
|
|
@ -113,16 +110,11 @@ function GenericApprovalCardView({
|
|||
editedParams,
|
||||
]);
|
||||
|
||||
const handleAlwaysAllow = useCallback(() => {
|
||||
if (phase !== "pending" || !isMCPTool) return;
|
||||
const handleApproveAlways = useCallback(() => {
|
||||
if (phase !== "pending" || !canApproveAlways) return;
|
||||
setProcessing();
|
||||
onDecision({ type: "approve" });
|
||||
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
|
||||
toast.error(
|
||||
"Failed to save 'Always Allow' preference. The tool will still require approval next time."
|
||||
);
|
||||
});
|
||||
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
|
||||
onDecision({ type: "approve_always" });
|
||||
}, [phase, setProcessing, onDecision, canApproveAlways]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
|
|
@ -214,8 +206,8 @@ function GenericApprovalCardView({
|
|||
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||
</Button>
|
||||
)}
|
||||
{isMCPTool && (
|
||||
<Button size="sm" className="rounded-lg" onClick={handleAlwaysAllow}>
|
||||
{canApproveAlways && (
|
||||
<Button size="sm" className="rounded-lg" onClick={handleApproveAlways}>
|
||||
Always Allow
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type { HitlDecision } from "../types";
|
||||
|
||||
/** Snapshot of one in-flight HITL interrupt; ``null`` when nothing is pending. */
|
||||
/** One in-flight HITL interrupt (one paused subagent). */
|
||||
export interface PendingInterruptState {
|
||||
/** Stable id keyed by the parent ``tool_call_id`` stamped on the interrupt. */
|
||||
interruptId: string;
|
||||
threadId: number;
|
||||
assistantMsgId: string;
|
||||
interruptData: Record<string, unknown>;
|
||||
|
|
@ -12,8 +14,19 @@ export interface PendingInterruptState {
|
|||
}
|
||||
|
||||
export interface PendingInterruptValue {
|
||||
pendingInterrupt: PendingInterruptState | null;
|
||||
onSubmit: (decisions: HitlDecision[]) => void;
|
||||
/**
|
||||
* Every paused subagent for the current turn, in the order the SSE stream
|
||||
* delivered them — which matches ``state.interrupts`` traversal on the
|
||||
* backend, which is the order ``slice_decisions_by_tool_call`` consumes.
|
||||
*/
|
||||
pendingInterrupts: PendingInterruptState[];
|
||||
/**
|
||||
* Stage one card's decisions. The orchestrator (page-level) batches across
|
||||
* cards and dispatches the resume only once every pending interrupt has
|
||||
* submitted, so the backend slicer sees a single concatenated decisions
|
||||
* list whose total matches the parent state's pending action count.
|
||||
*/
|
||||
onSubmit: (interruptId: string, decisions: HitlDecision[]) => void;
|
||||
}
|
||||
|
||||
const PendingInterruptContext = createContext<PendingInterruptValue | null>(null);
|
||||
|
|
@ -24,16 +37,16 @@ const PendingInterruptContext = createContext<PendingInterruptValue | null>(null
|
|||
* page root.
|
||||
*/
|
||||
export function PendingInterruptProvider({
|
||||
pendingInterrupt,
|
||||
pendingInterrupts,
|
||||
onSubmit,
|
||||
children,
|
||||
}: {
|
||||
pendingInterrupt: PendingInterruptState | null;
|
||||
onSubmit: (decisions: HitlDecision[]) => void;
|
||||
pendingInterrupts: PendingInterruptState[];
|
||||
onSubmit: (interruptId: string, decisions: HitlDecision[]) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PendingInterruptContext.Provider value={{ pendingInterrupt, onSubmit }}>
|
||||
<PendingInterruptContext.Provider value={{ pendingInterrupts, onSubmit }}>
|
||||
{children}
|
||||
</PendingInterruptContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ export interface InterruptActionRequest {
|
|||
|
||||
export interface InterruptReviewConfig {
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject" | "approve_always">;
|
||||
}
|
||||
|
||||
export interface InterruptResult<C extends Record<string, unknown> = Record<string, unknown>> {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__decided__?: "approve" | "reject" | "edit" | "approve_always";
|
||||
__completed__?: boolean;
|
||||
action_requests: InterruptActionRequest[];
|
||||
review_configs: InterruptReviewConfig[];
|
||||
|
|
@ -31,7 +31,7 @@ export function isInterruptResult(result: unknown): result is InterruptResult {
|
|||
}
|
||||
|
||||
export interface HitlDecision {
|
||||
type: "approve" | "reject" | "edit";
|
||||
type: "approve" | "reject" | "edit" | "approve_always";
|
||||
message?: string;
|
||||
edited_action?: {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@ const noopSubmit = () => {};
|
|||
/**
|
||||
* assistant-ui data UI for the ``thinking-steps`` data-part.
|
||||
*
|
||||
* Re-scopes the global ``PendingInterruptProvider`` per message: the
|
||||
* approval card only mounts under the assistant message that owns
|
||||
* the interrupt (otherwise every message in scrollback would render
|
||||
* its own card).
|
||||
* Re-scopes the global ``PendingInterruptProvider`` per message: approval
|
||||
* cards only mount under the assistant message that owns the interrupt
|
||||
* (otherwise every message in scrollback would render its own cards).
|
||||
*/
|
||||
function TimelineDataRenderer({ data }: { name: string; data: unknown }) {
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
|
|
@ -23,10 +22,10 @@ function TimelineDataRenderer({ data }: { name: string; data: unknown }) {
|
|||
const content = useAuiState(({ message }) => message?.content);
|
||||
const messageId = useAuiState(({ message }) => message?.id);
|
||||
const pendingValue = usePendingInterrupt();
|
||||
const pendingForThisMessage =
|
||||
pendingValue?.pendingInterrupt && pendingValue.pendingInterrupt.assistantMsgId === messageId
|
||||
? pendingValue.pendingInterrupt
|
||||
: null;
|
||||
const pendingForThisMessage = useMemo(
|
||||
() => (pendingValue?.pendingInterrupts ?? []).filter((p) => p.assistantMsgId === messageId),
|
||||
[pendingValue?.pendingInterrupts, messageId]
|
||||
);
|
||||
const onSubmit = pendingValue?.onSubmit ?? noopSubmit;
|
||||
|
||||
const steps = useMemo<ThinkingStepInput[]>(
|
||||
|
|
@ -39,11 +38,11 @@ function TimelineDataRenderer({ data }: { name: string; data: unknown }) {
|
|||
[steps, content]
|
||||
);
|
||||
|
||||
if (items.length === 0 && !pendingForThisMessage) return null;
|
||||
if (items.length === 0 && pendingForThisMessage.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3 -mx-2 leading-normal">
|
||||
<PendingInterruptProvider pendingInterrupt={pendingForThisMessage} onSubmit={onSubmit}>
|
||||
<PendingInterruptProvider pendingInterrupts={pendingForThisMessage} onSubmit={onSubmit}>
|
||||
<Timeline items={items} isThreadRunning={isMessageStreaming} />
|
||||
</PendingInterruptProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ export const Timeline: FC<{
|
|||
isThreadRunning?: boolean;
|
||||
}> = ({ items, isThreadRunning = true }) => {
|
||||
const pendingValue = usePendingInterrupt();
|
||||
const pendingInterrupt = pendingValue?.pendingInterrupt ?? null;
|
||||
const pendingInterrupts = pendingValue?.pendingInterrupts ?? [];
|
||||
const onSubmit = pendingValue?.onSubmit;
|
||||
const hasPending = pendingInterrupt !== null;
|
||||
const hasPending = pendingInterrupts.length > 0;
|
||||
|
||||
// Apply the override here so downstream (grouping, headers, dots)
|
||||
// sees the corrected status without threading a callback. Keeps
|
||||
|
|
@ -135,9 +135,15 @@ export const Timeline: FC<{
|
|||
/>
|
||||
);
|
||||
})}
|
||||
{pendingInterrupt && onSubmit && (
|
||||
<div className="pl-5">
|
||||
<HitlApprovalCard pendingInterrupt={pendingInterrupt} onSubmit={onSubmit} />
|
||||
{hasPending && onSubmit && (
|
||||
<div className="pl-5 space-y-3">
|
||||
{pendingInterrupts.map((pi) => (
|
||||
<HitlApprovalCard
|
||||
key={pi.interruptId}
|
||||
pendingInterrupt={pi}
|
||||
onSubmit={(decisions) => onSubmit(pi.interruptId, decisions)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -405,35 +405,19 @@ class ConnectorsApiService {
|
|||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MCP Tool Trust (Allow-List) Methods
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Add a tool to the MCP connector's "Always Allow" list.
|
||||
* Subsequent calls to this tool will skip HITL approval.
|
||||
*/
|
||||
trustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
|
||||
await baseApiService.post(`/api/v1/connectors/mcp/${connectorId}/trust-tool`, undefined, {
|
||||
body: { tool_name: toolName },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a tool from the MCP connector's "Always Allow" list.
|
||||
*/
|
||||
untrustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
|
||||
await baseApiService.post(`/api/v1/connectors/mcp/${connectorId}/untrust-tool`, undefined, {
|
||||
body: { tool_name: toolName },
|
||||
});
|
||||
};
|
||||
|
||||
/** Live stats for the Obsidian connector tile. */
|
||||
getObsidianStats = async (vaultId: string): Promise<ObsidianStats> => {
|
||||
return baseApiService.get<ObsidianStats>(
|
||||
`/api/v1/obsidian/stats?vault_id=${encodeURIComponent(vaultId)}`
|
||||
);
|
||||
};
|
||||
|
||||
/** Revoke a previously-trusted MCP tool so the next call asks again. */
|
||||
untrustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
|
||||
await baseApiService.post(`/api/v1/connectors/mcp/${connectorId}/untrust-tool`, undefined, {
|
||||
body: { tool_name: toolName },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface ObsidianStats {
|
||||
|
|
|
|||
|
|
@ -565,7 +565,7 @@ export type SSEEvent =
|
|||
* the assistant-side row of the current turn. The frontend
|
||||
* renames its optimistic ``msg-assistant-XXX`` placeholder
|
||||
* id, migrates the local ``tokenUsageStore`` and
|
||||
* ``pendingInterrupt`` references, and binds the running
|
||||
* ``pendingInterrupts`` entries, and binds the running
|
||||
* mutable ``assistantMsgId`` closure variable to the
|
||||
* canonical id for the rest of the stream.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue