mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge commit 'e1e4bb4706' into dev_mod
This commit is contained in:
commit
5d3142332b
85 changed files with 2357 additions and 3132 deletions
|
|
@ -798,7 +798,7 @@ export default function NewChatPage() {
|
|||
});
|
||||
} else {
|
||||
const tcId = `interrupt-${action.name}`;
|
||||
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args);
|
||||
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true);
|
||||
updateToolCall(contentPartsState, tcId, {
|
||||
result: { __interrupt__: true, ...interruptData },
|
||||
});
|
||||
|
|
@ -1125,7 +1125,7 @@ export default function NewChatPage() {
|
|||
});
|
||||
} else {
|
||||
const tcId = `interrupt-${action.name}`;
|
||||
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args);
|
||||
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true);
|
||||
updateToolCall(contentPartsState, tcId, {
|
||||
result: {
|
||||
__interrupt__: true,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export function ProfileContent() {
|
|||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<div className="rounded-lg bg-card">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("profile_avatar")}</Label>
|
||||
|
|
|
|||
|
|
@ -499,10 +499,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const empty = text.length === 0 && mentionedDocs.size === 0;
|
||||
setIsEmpty(empty);
|
||||
|
||||
// Check for @ mentions
|
||||
// Unified trigger scan: find the leftmost @ or / in the current word.
|
||||
// Whichever trigger was typed first owns the token — the other character
|
||||
// is treated as part of the query, not as a separate trigger.
|
||||
const selection = window.getSelection();
|
||||
let shouldTriggerMention = false;
|
||||
let mentionQuery = "";
|
||||
let shouldTriggerAction = false;
|
||||
let actionQuery = "";
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
|
|
@ -512,63 +516,41 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const textContent = textNode.textContent || "";
|
||||
const cursorPos = range.startOffset;
|
||||
|
||||
// Look for @ before cursor
|
||||
let atIndex = -1;
|
||||
let wordStart = 0;
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
if (textContent[i] === "@") {
|
||||
atIndex = i;
|
||||
break;
|
||||
}
|
||||
// Stop if we hit a space (@ must be at word boundary)
|
||||
if (textContent[i] === " " || textContent[i] === "\n") {
|
||||
wordStart = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atIndex !== -1) {
|
||||
const query = textContent.slice(atIndex + 1, cursorPos);
|
||||
// Only trigger if query doesn't start with space
|
||||
let triggerChar: "@" | "/" | null = null;
|
||||
let triggerIndex = -1;
|
||||
for (let i = wordStart; i < cursorPos; i++) {
|
||||
if (textContent[i] === "@" || textContent[i] === "/") {
|
||||
triggerChar = textContent[i] as "@" | "/";
|
||||
triggerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerChar === "@" && triggerIndex !== -1) {
|
||||
const query = textContent.slice(triggerIndex + 1, cursorPos);
|
||||
if (!query.startsWith(" ")) {
|
||||
shouldTriggerMention = true;
|
||||
mentionQuery = query;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for / actions (same pattern as @)
|
||||
let shouldTriggerAction = false;
|
||||
let actionQuery = "";
|
||||
|
||||
if (!shouldTriggerMention && selection && selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const textNode = range.startContainer;
|
||||
|
||||
if (textNode.nodeType === Node.TEXT_NODE) {
|
||||
const textContent = textNode.textContent || "";
|
||||
const cursorPos = range.startOffset;
|
||||
|
||||
let slashIndex = -1;
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
if (textContent[i] === "/") {
|
||||
slashIndex = i;
|
||||
break;
|
||||
}
|
||||
if (textContent[i] === " " || textContent[i] === "\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
slashIndex !== -1 &&
|
||||
(slashIndex === 0 ||
|
||||
textContent[slashIndex - 1] === " " ||
|
||||
textContent[slashIndex - 1] === "\n")
|
||||
) {
|
||||
const query = textContent.slice(slashIndex + 1, cursorPos);
|
||||
if (!query.startsWith(" ")) {
|
||||
shouldTriggerAction = true;
|
||||
actionQuery = query;
|
||||
} else if (triggerChar === "/" && triggerIndex !== -1) {
|
||||
if (
|
||||
triggerIndex === 0 ||
|
||||
textContent[triggerIndex - 1] === " " ||
|
||||
textContent[triggerIndex - 1] === "\n"
|
||||
) {
|
||||
const query = textContent.slice(triggerIndex + 1, cursorPos);
|
||||
if (!query.startsWith(" ")) {
|
||||
shouldTriggerAction = true;
|
||||
actionQuery = query;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@ import {
|
|||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
agentToolsAtom,
|
||||
disabledToolsAtom,
|
||||
|
|
@ -124,16 +123,18 @@ const ThreadContent: FC = () => {
|
|||
}}
|
||||
/>
|
||||
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="grow" />
|
||||
</AuiIf>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
|
||||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<ThreadScrollToBottom />
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||
<Composer />
|
||||
</div>
|
||||
</AuiIf>
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<Composer />
|
||||
</AuiIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
|
|
@ -339,10 +340,7 @@ const Composer: FC = () => {
|
|||
const [showPromptPicker, setShowPromptPicker] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState("");
|
||||
const [actionQuery, setActionQuery] = useState("");
|
||||
const [containerPos, setContainerPos] = useState({ bottom: "200px", left: "50%", top: "auto" });
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const composerBoxRef = useRef<HTMLDivElement>(null);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||
const viewportRef = useRef<Element | null>(null);
|
||||
|
|
@ -363,38 +361,13 @@ const Composer: FC = () => {
|
|||
viewportRef.current = document.querySelector(".aui-thread-viewport");
|
||||
}, []);
|
||||
|
||||
// Compute picker positions using ResizeObserver to avoid layout reads during render
|
||||
useLayoutEffect(() => {
|
||||
if (!editorContainerRef.current) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!editorContainerRef.current) return;
|
||||
const rect = editorContainerRef.current.getBoundingClientRect();
|
||||
const composerRect = composerBoxRef.current?.getBoundingClientRect();
|
||||
setContainerPos({
|
||||
bottom: `${window.innerHeight - rect.top + 8}px`,
|
||||
left: `${rect.left}px`,
|
||||
top: composerRect ? `${composerRect.bottom + 8}px` : "auto",
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
const ro = new ResizeObserver(updatePosition);
|
||||
ro.observe(editorContainerRef.current);
|
||||
if (composerBoxRef.current) {
|
||||
ro.observe(composerBoxRef.current);
|
||||
}
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const electronAPI = useElectronAPI();
|
||||
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
||||
const clipboardLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!electronAPI || clipboardLoadedRef.current) return;
|
||||
clipboardLoadedRef.current = true;
|
||||
electronAPI.getQuickAskText().then((text) => {
|
||||
electronAPI.getQuickAskText().then((text: string) => {
|
||||
if (text) {
|
||||
setClipboardInitialText(text);
|
||||
}
|
||||
|
|
@ -587,23 +560,15 @@ const Composer: FC = () => {
|
|||
|
||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isThreadRunning || isBlockedByOtherUser) {
|
||||
return;
|
||||
}
|
||||
if (!showDocumentPopover && !showPromptPicker) {
|
||||
if (clipboardInitialText) {
|
||||
const userText = editorRef.current?.getText() ?? "";
|
||||
const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText;
|
||||
aui.composer().setText(combined);
|
||||
setClipboardInitialText(undefined);
|
||||
}
|
||||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocs([]);
|
||||
}
|
||||
if (isThreadRunning || isBlockedByOtherUser) return;
|
||||
if (showDocumentPopover) return;
|
||||
if (showDocumentPopover || showPromptPicker) return;
|
||||
|
||||
if (clipboardInitialText) {
|
||||
const userText = editorRef.current?.getText() ?? "";
|
||||
const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText;
|
||||
aui.composer().setText(combined);
|
||||
setClipboardInitialText(undefined);
|
||||
}
|
||||
|
||||
const viewportEl = viewportRef.current;
|
||||
const heightBefore = viewportEl?.scrollHeight ?? 0;
|
||||
|
|
@ -617,18 +582,14 @@ const Composer: FC = () => {
|
|||
// assistant message so that scrolling-to-bottom actually positions the
|
||||
// user message at the TOP of the viewport. That slack height is
|
||||
// calculated asynchronously (ResizeObserver → style → layout).
|
||||
//
|
||||
// We poll via rAF for ~2 s, re-scrolling whenever scrollHeight changes
|
||||
// (user msg render → assistant placeholder → ViewportSlack min-height →
|
||||
// first streamed content). Backup setTimeout calls cover cases where
|
||||
// the batcher's 50 ms throttle delays the DOM update past the rAF.
|
||||
// Poll via rAF for ~500ms, re-scrolling whenever scrollHeight changes.
|
||||
const scrollToBottom = () =>
|
||||
threadViewportStore.getState().scrollToBottom({ behavior: "instant" });
|
||||
|
||||
let lastHeight = heightBefore;
|
||||
let frames = 0;
|
||||
let cancelled = false;
|
||||
const POLL_FRAMES = 120;
|
||||
const POLL_FRAMES = 30;
|
||||
|
||||
const pollAndScroll = () => {
|
||||
if (cancelled) return;
|
||||
|
|
@ -648,16 +609,11 @@ const Composer: FC = () => {
|
|||
|
||||
const t1 = setTimeout(scrollToBottom, 100);
|
||||
const t2 = setTimeout(scrollToBottom, 300);
|
||||
const t3 = setTimeout(scrollToBottom, 600);
|
||||
|
||||
// Cleanup if component unmounts during the polling window. The ref is
|
||||
// checked inside pollAndScroll; timeouts are cleared in the return below.
|
||||
// Store cleanup fn so it can be called from a useEffect cleanup if needed.
|
||||
submitCleanupRef.current = () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
clearTimeout(t3);
|
||||
};
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
|
|
@ -705,28 +661,54 @@ const Composer: FC = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<ComposerPrimitive.Root
|
||||
className="aui-composer-root relative flex w-full flex-col gap-2"
|
||||
style={showPromptPicker && clipboardInitialText ? { marginBottom: 220 } : undefined}
|
||||
>
|
||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||
<ChatSessionStatus
|
||||
isAiResponding={isAiResponding}
|
||||
respondingToUserId={respondingToUserId}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
members={members ?? []}
|
||||
/>
|
||||
<div
|
||||
ref={composerBoxRef}
|
||||
className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow"
|
||||
>
|
||||
{showDocumentPopover && (
|
||||
<div className="absolute bottom-full left-0 z-[9999] mb-2">
|
||||
<DocumentMentionPicker
|
||||
ref={documentPickerRef}
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
onSelectionChange={handleDocumentsMention}
|
||||
onDone={() => {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
}}
|
||||
initialSelectedDocuments={mentionedDocuments}
|
||||
externalSearch={mentionQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPromptPicker && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 z-[9999]",
|
||||
clipboardInitialText ? "top-full mt-2" : "bottom-full mb-2"
|
||||
)}
|
||||
>
|
||||
<PromptPicker
|
||||
ref={promptPickerRef}
|
||||
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||
onDone={() => {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
}}
|
||||
externalSearch={actionQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
|
||||
{clipboardInitialText && (
|
||||
<ClipboardChip
|
||||
text={clipboardInitialText}
|
||||
onDismiss={() => setClipboardInitialText(undefined)}
|
||||
/>
|
||||
)}
|
||||
{/* Inline editor with @mention support */}
|
||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
||||
<div className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
placeholder={currentPlaceholder}
|
||||
|
|
@ -741,49 +723,6 @@ const Composer: FC = () => {
|
|||
className="min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
||||
{showDocumentPopover &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<DocumentMentionPicker
|
||||
ref={documentPickerRef}
|
||||
searchSpaceId={Number(search_space_id)}
|
||||
onSelectionChange={handleDocumentsMention}
|
||||
onDone={() => {
|
||||
setShowDocumentPopover(false);
|
||||
setMentionQuery("");
|
||||
}}
|
||||
initialSelectedDocuments={mentionedDocuments}
|
||||
externalSearch={mentionQuery}
|
||||
containerStyle={{
|
||||
bottom: containerPos.bottom,
|
||||
left: containerPos.left,
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
{showPromptPicker &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<PromptPicker
|
||||
ref={promptPickerRef}
|
||||
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
|
||||
onDone={() => {
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
}}
|
||||
externalSearch={actionQuery}
|
||||
containerStyle={{
|
||||
position: "fixed",
|
||||
...(clipboardInitialText
|
||||
? { top: containerPos.top }
|
||||
: { bottom: containerPos.bottom }),
|
||||
left: containerPos.left,
|
||||
zIndex: 50,
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
<ConnectorIndicator showTrigger={false} />
|
||||
<ConnectToolsBanner isThreadEmpty={isThreadEmpty} />
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
|
||||
import { getToolIcon } from "@/contracts/enums/toolIcons";
|
||||
import { isInterruptResult } from "@/lib/hitl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatToolName(name: string): string {
|
||||
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export const ToolFallback: ToolCallMessagePartComponent = ({
|
||||
const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
argsText,
|
||||
result,
|
||||
|
|
@ -145,3 +147,10 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
|
||||
if (isInterruptResult(props.result)) {
|
||||
return <GenericHitlApprovalToolUI {...props} />;
|
||||
}
|
||||
return <DefaultToolFallbackInner {...props} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -82,11 +82,12 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
onContextMenuOpenChange,
|
||||
}: DocumentNodeProps) {
|
||||
const statusState = doc.status?.state ?? "ready";
|
||||
const isSelectable = statusState !== "pending" && statusState !== "processing";
|
||||
const isFailed = statusState === "failed";
|
||||
const isProcessing = statusState === "pending" || statusState === "processing";
|
||||
const isUnavailable = isProcessing || isFailed;
|
||||
const isSelectable = !isUnavailable;
|
||||
const isEditable =
|
||||
EDITABLE_DOCUMENT_TYPES.has(doc.document_type) &&
|
||||
statusState !== "pending" &&
|
||||
statusState !== "processing";
|
||||
EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
|
||||
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (isSelectable) {
|
||||
|
|
@ -103,7 +104,6 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
[doc.id]
|
||||
);
|
||||
|
||||
const isProcessing = statusState === "pending" || statusState === "processing";
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState<string | null>(null);
|
||||
const [titleTooltipOpen, setTitleTooltipOpen] = useState(false);
|
||||
|
|
@ -261,38 +261,38 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
className="w-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
{isEditable && (
|
||||
<DropdownMenuItem onClick={() => onEdit(doc)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{isEditable && (
|
||||
<DropdownMenuItem onClick={() => onEdit(doc)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</DropdownMenuItem>
|
||||
{onExport && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={isUnavailable}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</DropdownMenuItem>
|
||||
{onExport && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger disabled={isProcessing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
)}
|
||||
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -304,38 +304,38 @@ export const DocumentNode = React.memo(function DocumentNode({
|
|||
|
||||
{contextMenuOpen && (
|
||||
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
{isEditable && (
|
||||
<ContextMenuItem onClick={() => onEdit(doc)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
{isEditable && (
|
||||
<ContextMenuItem onClick={() => onEdit(doc)}>
|
||||
<PenLine className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
)}
|
||||
<ContextMenuItem onClick={() => onMove(doc)}>
|
||||
<Move className="mr-2 h-4 w-4" />
|
||||
Move to...
|
||||
</ContextMenuItem>
|
||||
{onExport && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={isUnavailable}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[180px]">
|
||||
<ExportContextItems onExport={handleExport} exporting={exporting} />
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</ContextMenuItem>
|
||||
{onExport && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger disabled={isProcessing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="min-w-[180px]">
|
||||
<ExportContextItems onExport={handleExport} exporting={exporting} />
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
{onVersionHistory && isVersionableType(doc.document_type) && (
|
||||
<ContextMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Versions
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
)}
|
||||
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Download, FolderPlus, ListFilter, Loader2, Search, Upload, X } from "lucide-react";
|
||||
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
|
|
@ -20,8 +20,6 @@ export function DocumentsFilters({
|
|||
onToggleType,
|
||||
activeTypes,
|
||||
onCreateFolder,
|
||||
onExportKB,
|
||||
isExporting,
|
||||
}: {
|
||||
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
|
||||
onSearch: (v: string) => void;
|
||||
|
|
@ -29,8 +27,6 @@ export function DocumentsFilters({
|
|||
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
|
||||
activeTypes: DocumentTypeEnum[];
|
||||
onCreateFolder?: () => void;
|
||||
onExportKB?: () => void;
|
||||
isExporting?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("documents");
|
||||
const id = React.useId();
|
||||
|
|
@ -85,33 +81,8 @@ export function DocumentsFilters({
|
|||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New folder</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{onExportKB && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem
|
||||
value="export"
|
||||
disabled={isExporting}
|
||||
className="h-9 w-9 shrink-0 border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExportKB();
|
||||
}}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isExporting ? "Exporting…" : "Export knowledge base"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -532,16 +532,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
||||
|
||||
if (isOutOfSync) {
|
||||
// First sync Next.js router by navigating to the current chat's actual URL
|
||||
// This updates the router's internal state to match the browser URL
|
||||
resetCurrentThread();
|
||||
router.replace(`/dashboard/${searchSpaceId}/new-chat/${currentThreadState.id}`);
|
||||
// Allow router to sync, then navigate to fresh new-chat
|
||||
setTimeout(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}, 0);
|
||||
// Immediately set the browser URL so the page remounts with a clean /new-chat path
|
||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||
// Force-remount the page component to reset all React state synchronously
|
||||
setChatResetKey((k) => k + 1);
|
||||
// Sync Next.js router internals so useParams/usePathname stay correct going forward
|
||||
router.replace(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
} else {
|
||||
// Normal navigation - router is in sync
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
}, [router, searchSpaceId, currentThreadState.id, params?.chat_id, resetCurrentThread]);
|
||||
|
|
|
|||
|
|
@ -406,22 +406,13 @@ export function DocumentsSidebar({
|
|||
setFolderPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const [isExportingKB, setIsExportingKB] = useState(false);
|
||||
const [, setIsExportingKB] = useState(false);
|
||||
const [exportWarningOpen, setExportWarningOpen] = useState(false);
|
||||
const [exportWarningContext, setExportWarningContext] = useState<{
|
||||
type: "kb" | "folder";
|
||||
folder?: FolderDisplay;
|
||||
folder: FolderDisplay;
|
||||
pendingCount: number;
|
||||
} | null>(null);
|
||||
|
||||
const pendingDocuments = useMemo(
|
||||
() =>
|
||||
treeDocuments.filter(
|
||||
(d) => d.status?.state === "pending" || d.status?.state === "processing"
|
||||
),
|
||||
[treeDocuments]
|
||||
);
|
||||
|
||||
const doExport = useCallback(async (url: string, downloadName: string) => {
|
||||
const response = await authenticatedFetch(url, { method: "GET" });
|
||||
if (!response.ok) {
|
||||
|
|
@ -440,68 +431,28 @@ export function DocumentsSidebar({
|
|||
URL.revokeObjectURL(blobUrl);
|
||||
}, []);
|
||||
|
||||
const handleExportKB = useCallback(async () => {
|
||||
if (isExportingKB) return;
|
||||
|
||||
if (pendingDocuments.length > 0) {
|
||||
setExportWarningContext({ type: "kb", pendingCount: pendingDocuments.length });
|
||||
setExportWarningOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExportingKB(true);
|
||||
try {
|
||||
await doExport(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
|
||||
"knowledge-base.zip"
|
||||
);
|
||||
toast.success("Knowledge base exported");
|
||||
} catch (err) {
|
||||
console.error("KB export failed:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setIsExportingKB(false);
|
||||
}
|
||||
}, [searchSpaceId, isExportingKB, pendingDocuments.length, doExport]);
|
||||
|
||||
const handleExportWarningConfirm = useCallback(async () => {
|
||||
setExportWarningOpen(false);
|
||||
const ctx = exportWarningContext;
|
||||
if (!ctx) return;
|
||||
if (!ctx?.folder) return;
|
||||
|
||||
if (ctx.type === "kb") {
|
||||
setIsExportingKB(true);
|
||||
try {
|
||||
await doExport(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
|
||||
"knowledge-base.zip"
|
||||
);
|
||||
toast.success("Knowledge base exported");
|
||||
} catch (err) {
|
||||
console.error("KB export failed:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setIsExportingKB(false);
|
||||
}
|
||||
} else if (ctx.type === "folder" && ctx.folder) {
|
||||
setIsExportingKB(true);
|
||||
try {
|
||||
const safeName =
|
||||
ctx.folder.name
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
||||
.trim()
|
||||
.slice(0, 80) || "folder";
|
||||
await doExport(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`,
|
||||
`${safeName}.zip`
|
||||
);
|
||||
toast.success(`Folder "${ctx.folder.name}" exported`);
|
||||
} catch (err) {
|
||||
console.error("Folder export failed:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setIsExportingKB(false);
|
||||
}
|
||||
setIsExportingKB(true);
|
||||
try {
|
||||
const safeName =
|
||||
ctx.folder.name
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, "_")
|
||||
.trim()
|
||||
.slice(0, 80) || "folder";
|
||||
await doExport(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`,
|
||||
`${safeName}.zip`
|
||||
);
|
||||
toast.success(`Folder "${ctx.folder.name}" exported`);
|
||||
} catch (err) {
|
||||
console.error("Folder export failed:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setIsExportingKB(false);
|
||||
}
|
||||
setExportWarningContext(null);
|
||||
}, [exportWarningContext, searchSpaceId, doExport]);
|
||||
|
|
@ -530,7 +481,6 @@ export function DocumentsSidebar({
|
|||
const folderPendingCount = getPendingCountInSubtree(folder.id);
|
||||
if (folderPendingCount > 0) {
|
||||
setExportWarningContext({
|
||||
type: "folder",
|
||||
folder,
|
||||
pendingCount: folderPendingCount,
|
||||
});
|
||||
|
|
@ -677,9 +627,10 @@ export function DocumentsSidebar({
|
|||
function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] {
|
||||
const directDocs = (treeDocuments ?? []).filter(
|
||||
(d) =>
|
||||
d.folderId === parentId &&
|
||||
d.status?.state !== "pending" &&
|
||||
d.status?.state !== "processing"
|
||||
d.folderId === parentId &&
|
||||
d.status?.state !== "pending" &&
|
||||
d.status?.state !== "processing" &&
|
||||
d.status?.state !== "failed"
|
||||
);
|
||||
const childFolders = foldersByParent[String(parentId)] ?? [];
|
||||
const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id));
|
||||
|
|
@ -954,8 +905,6 @@ export function DocumentsSidebar({
|
|||
onToggleType={onToggleType}
|
||||
activeTypes={activeTypes}
|
||||
onCreateFolder={() => handleCreateFolder(null)}
|
||||
onExportKB={handleExportKB}
|
||||
isExporting={isExportingKB}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ interface DocumentMentionPickerProps {
|
|||
onDone: () => void;
|
||||
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
|
||||
externalSearch?: string;
|
||||
/** Positioning styles for the container */
|
||||
containerStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
|
@ -75,7 +73,6 @@ export const DocumentMentionPicker = forwardRef<
|
|||
onDone,
|
||||
initialSelectedDocuments = [],
|
||||
externalSearch = "",
|
||||
containerStyle,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
|
|
@ -394,19 +391,9 @@ export const DocumentMentionPicker = forwardRef<
|
|||
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
|
||||
);
|
||||
|
||||
// Hide popup when there are no documents to display (regardless of fetch state)
|
||||
// Search continues in background; popup reappears when results arrive
|
||||
if (!actualLoading && actualDocuments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
|
||||
style={{
|
||||
zIndex: 9999,
|
||||
...containerStyle,
|
||||
}}
|
||||
className="shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
|
||||
onKeyDown={handleKeyDown}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
|
|
@ -547,7 +534,11 @@ export const DocumentMentionPicker = forwardRef<
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
) : (
|
||||
<div className="py-1 px-2">
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching documents</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
|
||||
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
|
||||
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PromptPickerRef {
|
||||
|
|
@ -28,11 +28,10 @@ interface PromptPickerProps {
|
|||
onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void;
|
||||
onDone: () => void;
|
||||
externalSearch?: string;
|
||||
containerStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(function PromptPicker(
|
||||
{ onSelect, onDone, externalSearch = "", containerStyle },
|
||||
{ onSelect, onDone, externalSearch = "" },
|
||||
ref
|
||||
) {
|
||||
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
|
||||
|
|
@ -60,13 +59,21 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
|||
}
|
||||
}
|
||||
|
||||
const createPromptIndex = filtered.length;
|
||||
const totalItems = filtered.length + 1;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(index: number) => {
|
||||
if (index === createPromptIndex) {
|
||||
onDone();
|
||||
setUserSettingsDialog({ open: true, initialTab: "prompts" });
|
||||
return;
|
||||
}
|
||||
const action = filtered[index];
|
||||
if (!action) return;
|
||||
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
|
||||
},
|
||||
[filtered, onSelect]
|
||||
[filtered, onSelect, createPromptIndex, onDone, setUserSettingsDialog]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -93,69 +100,98 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
|
|||
() => ({
|
||||
selectHighlighted: () => handleSelect(highlightedIndex),
|
||||
moveUp: () => {
|
||||
if (filtered.length === 0) return;
|
||||
shouldScrollRef.current = true;
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
|
||||
},
|
||||
moveDown: () => {
|
||||
if (filtered.length === 0) return;
|
||||
shouldScrollRef.current = true;
|
||||
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
|
||||
setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
|
||||
},
|
||||
}),
|
||||
[filtered.length, highlightedIndex, handleSelect]
|
||||
[totalItems, highlightedIndex, handleSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
|
||||
style={containerStyle}
|
||||
>
|
||||
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
|
||||
<div className="shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none">
|
||||
<div ref={scrollContainerRef} className="max-h-[180px] sm:max-h-[280px] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Spinner className="size-4" />
|
||||
<div className="py-1 px-2">
|
||||
<div className="px-3 py-2">
|
||||
<Skeleton className="h-[16px] w-24" />
|
||||
</div>
|
||||
{["a", "b", "c", "d", "e"].map((id, i) => (
|
||||
<div
|
||||
key={id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
|
||||
i >= 3 && "hidden sm:flex"
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1 text-sm">
|
||||
<Skeleton className="h-[20px]" style={{ width: `${60 + ((i * 7) % 30)}%` }} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
|
||||
<div className="py-1 px-2">
|
||||
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
|
||||
<div className="py-1 px-2">
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((action, index) => (
|
||||
<div className="py-1 px-2">
|
||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
||||
Saved Prompts
|
||||
</div>
|
||||
{filtered.map((action, index) => (
|
||||
<button
|
||||
key={action.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(index, el);
|
||||
else itemRefs.current.delete(index);
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => handleSelect(index)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer",
|
||||
index === highlightedIndex && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
<Zap className="size-4" />
|
||||
</span>
|
||||
<span className="flex-1 text-sm truncate">{action.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="mx-2 my-1 border-t border-border dark:border-white/5" />
|
||||
<button
|
||||
key={action.id}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(index, el);
|
||||
else itemRefs.current.delete(index);
|
||||
if (el) itemRefs.current.set(createPromptIndex, el);
|
||||
else itemRefs.current.delete(createPromptIndex);
|
||||
}}
|
||||
type="button"
|
||||
onClick={() => handleSelect(index)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onClick={() => handleSelect(createPromptIndex)}
|
||||
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
|
||||
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer text-muted-foreground",
|
||||
highlightedIndex === createPromptIndex ? "bg-accent text-foreground" : "hover:text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
<Zap className="size-3.5" />
|
||||
<span className="shrink-0">
|
||||
<Plus className="size-4" />
|
||||
</span>
|
||||
<span className="truncate">{action.name}</span>
|
||||
<span>Create prompt</span>
|
||||
</button>
|
||||
))
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="my-1 h-px bg-border mx-2" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDone();
|
||||
setUserSettingsDialog({ open: true, initialTab: "prompts" });
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<span>Create prompt</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
MessageSquareQuote,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
|
|
@ -43,7 +42,7 @@ import { useMediaQuery } from "@/hooks/use-media-query";
|
|||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModelConfigManagerProps {
|
||||
interface AgentModelManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +54,7 @@ function getInitials(name: string): string {
|
|||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||
export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
// Mutations
|
||||
const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
|
||||
|
|
@ -208,28 +207,26 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-border/60">
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* Header: Icon + Name */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Skeleton className="size-4 rounded-full shrink-0 mt-0.5" />
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<Skeleton className="h-4 w-28 md:w-32" />
|
||||
<Skeleton className="h-3 w-40 md:w-48" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Provider + Model */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
</div>
|
||||
{/* Feature badges */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<div className="flex items-center pt-2 border-t border-border/40">
|
||||
<Skeleton className="h-3 w-20 flex-1" />
|
||||
<Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
|
||||
<div className="flex-1 flex items-center justify-end gap-1.5">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -262,20 +259,25 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
<div key={config.id}>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
{/* Header: Name + Actions */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Header: Icon + Name + Actions */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && (
|
||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
|
||||
{canUpdate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
|
|
@ -284,7 +286,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -301,7 +303,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -314,20 +316,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider + Model */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{getProviderIcon(config.provider, { className: "size-3.5 shrink-0" })}
|
||||
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
|
||||
{config.model_name}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Feature badges */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0.5 border-emerald-500/30 text-emerald-700 dark:text-emerald-300 bg-emerald-500/5"
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
<MessageSquareQuote className="h-2.5 w-2.5 mr-1" />
|
||||
Citations
|
||||
|
|
@ -336,8 +330,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
{!config.use_default_system_instructions &&
|
||||
config.system_instructions && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0.5 border-blue-500/30 text-blue-700 dark:text-blue-300 bg-blue-500/5"
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5 mr-1" />
|
||||
Custom
|
||||
|
|
@ -346,8 +340,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
</div>
|
||||
|
||||
{/* Footer: Date + Creator */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60">
|
||||
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
|
||||
{new Date(config.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
|
|
@ -356,11 +350,11 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30" />
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 cursor-default">
|
||||
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
|
||||
<Avatar className="size-4.5 shrink-0">
|
||||
{member.avatarUrl && (
|
||||
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||
|
|
@ -369,7 +363,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
{getInitials(member.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -2,18 +2,18 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Info } from "lucide-react";
|
||||
import { FolderArchive, Info } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { Spinner } from "../ui/spinner";
|
||||
|
||||
|
|
@ -40,6 +40,37 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const handleExportKB = useCallback(async () => {
|
||||
if (isExporting) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
|
||||
throw new Error(errorData.detail || "Export failed");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "knowledge-base.zip";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Knowledge base exported");
|
||||
} catch (err) {
|
||||
console.error("KB export failed:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [searchSpaceId, isExporting]);
|
||||
|
||||
// Initialize state from fetched search space
|
||||
useEffect(() => {
|
||||
|
|
@ -83,16 +114,10 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
|
||||
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
|
||||
<Skeleton className="h-10 md:h-12 w-full" />
|
||||
<Skeleton className="h-10 md:h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Skeleton className="h-10 md:h-12 w-full" />
|
||||
<Skeleton className="h-10 md:h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,61 +138,45 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
Update your search space name and description. These details help identify and organize
|
||||
your workspace.
|
||||
Update your search space name and description.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Search Space Details Card */}
|
||||
<form onSubmit={onSubmit} className="space-y-4 md:space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Search Space Details</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Manage the basic information for this search space.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="search-space-name" className="text-sm md:text-base font-medium">
|
||||
{t("general_name_label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="search-space-name"
|
||||
placeholder={t("general_name_placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-sm md:text-base h-9 md:h-10"
|
||||
/>
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground">
|
||||
{t("general_name_description")}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-space-name">
|
||||
{t("general_name_label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="search-space-name"
|
||||
placeholder={t("general_name_placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general_name_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label
|
||||
htmlFor="search-space-description"
|
||||
className="text-sm md:text-base font-medium"
|
||||
>
|
||||
{t("general_description_label")}{" "}
|
||||
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="search-space-description"
|
||||
placeholder={t("general_description_placeholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="text-sm md:text-base h-9 md:h-10"
|
||||
/>
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground">
|
||||
{t("general_description_description")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-space-description">
|
||||
{t("general_description_label")}{" "}
|
||||
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="search-space-description"
|
||||
placeholder={t("general_description_placeholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general_description_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end pt-3 md:pt-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
|
|
@ -179,6 +188,29 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
|
|||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Export knowledge base</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Download all documents in this search space as a ZIP of markdown files.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
onClick={handleExportKB}
|
||||
className="relative w-fit shrink-0"
|
||||
>
|
||||
<span className={isExporting ? "opacity-0" : ""}>
|
||||
<FolderArchive className="h-3 w-3 opacity-60" />
|
||||
</span>
|
||||
<span className={isExporting ? "opacity-0" : ""}>Export</span>
|
||||
{isExporting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2, Wand2 } from "lucide-react";
|
||||
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import {
|
||||
|
|
@ -209,20 +209,20 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-border/60">
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Skeleton className="size-4 rounded-full shrink-0" />
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<Skeleton className="h-4 w-28 md:w-32" />
|
||||
<Skeleton className="h-3 w-40 md:w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<div className="flex items-center pt-2 border-t border-border/40">
|
||||
<Skeleton className="h-3 w-20 flex-1" />
|
||||
<Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
|
||||
<div className="flex-1 flex items-center justify-end gap-1.5">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -255,20 +255,25 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
<div key={config.id}>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
{/* Header: Name + Actions */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Header: Icon + Name + Actions */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && (
|
||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
|
||||
{canUpdate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
|
|
@ -277,7 +282,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -294,7 +299,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -307,17 +312,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider + Model */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{getProviderIcon(config.provider, { className: "size-3.5 shrink-0" })}
|
||||
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
|
||||
{config.model_name}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Footer: Date + Creator */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60">
|
||||
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
|
||||
{new Date(config.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
|
|
@ -326,11 +323,11 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30" />
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 cursor-default">
|
||||
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
|
||||
<Avatar className="size-4.5 shrink-0">
|
||||
{member.avatarUrl && (
|
||||
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||
|
|
@ -339,7 +336,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
{getInitials(member.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
Bot,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
Eye,
|
||||
ScanEye,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
|
|
@ -74,7 +74,7 @@ const ROLE_DESCRIPTIONS = {
|
|||
configType: "image" as const,
|
||||
},
|
||||
vision: {
|
||||
icon: Eye,
|
||||
icon: ScanEye,
|
||||
title: "Vision LLM",
|
||||
description: "Vision-capable model for screenshot analysis and context extraction",
|
||||
color: "text-muted-foreground",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Edit2,
|
||||
FileText,
|
||||
Globe,
|
||||
|
|
@ -47,7 +48,6 @@ import {
|
|||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -58,7 +58,6 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { PermissionInfo } from "@/contracts/types/permissions.types";
|
||||
import type {
|
||||
|
|
@ -319,100 +318,6 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ============ Role Permissions Display ============
|
||||
|
||||
function RolePermissionsDialog({
|
||||
permissions,
|
||||
roleName,
|
||||
children,
|
||||
}: {
|
||||
permissions: string[];
|
||||
roleName: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const isFullAccess = permissions.includes("*");
|
||||
|
||||
const grouped: Record<string, string[]> = {};
|
||||
if (!isFullAccess) {
|
||||
for (const perm of permissions) {
|
||||
const [category, action] = perm.split(":");
|
||||
if (!grouped[category]) grouped[category] = [];
|
||||
grouped[category].push(action);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
|
||||
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const categoryCount = sortedCategories.length;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
|
||||
<DialogHeader className="p-4 md:p-5">
|
||||
<DialogTitle className="text-base">{roleName} — Permissions</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{isFullAccess
|
||||
? "This role has unrestricted access to all resources"
|
||||
: `${permissions.length} permissions across ${categoryCount} categories`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isFullAccess ? (
|
||||
<div className="flex items-center gap-3 px-4 md:px-5 py-6">
|
||||
<div className="h-9 w-9 rounded-lg bg-muted/60 flex items-center justify-center shrink-0">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Full access</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All permissions granted across every category
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[55vh]">
|
||||
<div className="divide-y divide-border/50">
|
||||
{sortedCategories.map((category) => {
|
||||
const actions = grouped[category];
|
||||
const config = CATEGORY_CONFIG[category] || {
|
||||
label: category,
|
||||
icon: FileText,
|
||||
};
|
||||
const IconComponent = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{config.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => (
|
||||
<span
|
||||
key={action}
|
||||
className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
|
||||
>
|
||||
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionsBadge({ permissions }: { permissions: string[] }) {
|
||||
if (permissions.includes("*")) {
|
||||
return (
|
||||
|
|
@ -463,6 +368,7 @@ function RolesContent({
|
|||
}) {
|
||||
const [showCreateRole, setShowCreateRole] = useState(false);
|
||||
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||
const [expandedRoleId, setExpandedRoleId] = useState<number | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -508,91 +414,170 @@ function RolesContent({
|
|||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{roles.map((role) => (
|
||||
<div key={role.id}>
|
||||
<div className="w-full text-left relative flex items-center gap-4 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/30">
|
||||
<div className="flex-1 min-w-0">
|
||||
<RolePermissionsDialog permissions={role.permissions} roleName={role.name}>
|
||||
<button type="button" className="w-full text-left cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{role.name}</span>
|
||||
{role.is_system_role && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
{role.is_default && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{role.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{role.description}
|
||||
</p>
|
||||
{roles.map((role) => {
|
||||
const isExpanded = expandedRoleId === role.id;
|
||||
const isFullAccess = role.permissions.includes("*");
|
||||
|
||||
const grouped: Record<string, string[]> = {};
|
||||
if (!isFullAccess) {
|
||||
for (const perm of role.permissions) {
|
||||
const [category, action] = perm.split(":");
|
||||
if (!grouped[category]) grouped[category] = [];
|
||||
grouped[category].push(action);
|
||||
}
|
||||
}
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
|
||||
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
|
||||
<div className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{role.name}</span>
|
||||
{role.is_system_role && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</RolePermissionsDialog>
|
||||
{role.is_default && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{role.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{role.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="shrink-0">
|
||||
<PermissionsBadge permissions={role.permissions} />
|
||||
</div>
|
||||
|
||||
{!role.is_system_role && (
|
||||
<div className="shrink-0" role="none">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
{canUpdate && (
|
||||
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
Edit Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete role?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the "{role.name}" role.
|
||||
Members with this role will lose their permissions.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDeleteRole(role.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-1 cursor-pointer"
|
||||
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||
isExpanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<PermissionsBadge permissions={role.permissions} />
|
||||
</div>
|
||||
|
||||
{!role.is_system_role && (
|
||||
<div className="shrink-0" role="none">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
{canUpdate && (
|
||||
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
Edit Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete role?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the "{role.name}" role.
|
||||
Members with this role will lose their permissions.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDeleteRole(role.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/40 px-4 py-3">
|
||||
{isFullAccess ? (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Shield className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Full access — all permissions granted across every category
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{sortedCategories.map((category) => {
|
||||
const actions = grouped[category];
|
||||
const config = CATEGORY_CONFIG[category] || {
|
||||
label: category,
|
||||
icon: FileText,
|
||||
};
|
||||
const IconComponent = config.icon;
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="flex items-center justify-between gap-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{actions.map((action) => (
|
||||
<span
|
||||
key={action}
|
||||
className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -676,46 +661,51 @@ function PermissionsEditor({
|
|||
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
|
||||
onClick={() => toggleCategoryExpanded(category)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-2.5 cursor-pointer"
|
||||
onClick={() => toggleCategoryExpanded(category)}
|
||||
>
|
||||
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium text-sm">{config.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{stats.selected}/{stats.total}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={stats.allSelected}
|
||||
onCheckedChange={() => onToggleCategory(category)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select all ${config.label} permissions`}
|
||||
/>
|
||||
<div
|
||||
className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleCategoryExpanded(category)}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
<div
|
||||
className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
|
||||
>
|
||||
<title>Toggle</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<title>Toggle</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/60">
|
||||
|
|
@ -726,28 +716,29 @@ function PermissionsEditor({
|
|||
const isSelected = selectedPermissions.includes(perm.value);
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={perm.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors",
|
||||
"flex items-center justify-between gap-3 px-2.5 py-2 rounded-md transition-colors",
|
||||
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
|
||||
)}
|
||||
onClick={() => onTogglePermission(perm.value)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
onClick={() => onTogglePermission(perm.value)}
|
||||
>
|
||||
<span className="text-sm font-medium">{actionLabel}</span>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{perm.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onTogglePermission(perm.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
Brain,
|
||||
CircleUser,
|
||||
Earth,
|
||||
Eye,
|
||||
ScanEye,
|
||||
ImageIcon,
|
||||
ListChecks,
|
||||
UserKey,
|
||||
|
|
@ -25,10 +25,10 @@ const GeneralSettingsManager = dynamic(
|
|||
})),
|
||||
{ ssr: false }
|
||||
);
|
||||
const ModelConfigManager = dynamic(
|
||||
const AgentModelManager = dynamic(
|
||||
() =>
|
||||
import("@/components/settings/model-config-manager").then((m) => ({
|
||||
default: m.ModelConfigManager,
|
||||
import("@/components/settings/agent-model-manager").then((m) => ({
|
||||
default: m.AgentModelManager,
|
||||
})),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
|
@ -88,7 +88,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
|
|||
const navItems = [
|
||||
{ value: "general", label: t("nav_general"), icon: <CircleUser className="h-4 w-4" /> },
|
||||
{ value: "roles", label: t("nav_role_assignments"), icon: <ListChecks className="h-4 w-4" /> },
|
||||
{ value: "models", label: t("nav_agent_configs"), icon: <Bot className="h-4 w-4" /> },
|
||||
{ value: "models", label: t("nav_agent_models"), icon: <Bot className="h-4 w-4" /> },
|
||||
{
|
||||
value: "image-models",
|
||||
label: t("nav_image_models"),
|
||||
|
|
@ -97,7 +97,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
|
|||
{
|
||||
value: "vision-models",
|
||||
label: t("nav_vision_models"),
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
icon: <ScanEye className="h-4 w-4" />,
|
||||
},
|
||||
{ value: "team-roles", label: t("nav_team_roles"), icon: <UserKey className="h-4 w-4" /> },
|
||||
{
|
||||
|
|
@ -115,7 +115,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
|
|||
|
||||
const content: Record<string, React.ReactNode> = {
|
||||
general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />,
|
||||
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
|
||||
models: <AgentModelManager searchSpaceId={searchSpaceId} />,
|
||||
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
|
||||
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
|
||||
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,
|
||||
|
|
|
|||
|
|
@ -208,20 +208,20 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-border/60">
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Skeleton className="size-4 rounded-full shrink-0" />
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<Skeleton className="h-4 w-28 md:w-32" />
|
||||
<Skeleton className="h-3 w-40 md:w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<div className="flex items-center pt-2 border-t border-border/40">
|
||||
<Skeleton className="h-3 w-20 flex-1" />
|
||||
<Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
|
||||
<div className="flex-1 flex items-center justify-end gap-1.5">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -253,19 +253,25 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
<div key={config.id}>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Header: Icon + Name + Actions */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && (
|
||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
|
||||
{canUpdate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
|
|
@ -274,7 +280,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -291,7 +297,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
@ -304,17 +310,9 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{getProviderIcon(config.provider, {
|
||||
className: "size-3.5 shrink-0",
|
||||
})}
|
||||
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
|
||||
{config.model_name}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60">
|
||||
{/* Footer: Date + Creator */}
|
||||
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
|
||||
{new Date(config.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
|
|
@ -323,11 +321,11 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30" />
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 cursor-default">
|
||||
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
|
||||
<Avatar className="size-4.5 shrink-0">
|
||||
{member.avatarUrl && (
|
||||
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||
|
|
@ -336,7 +334,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
{getInitials(member.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface ConfluenceAccount {
|
||||
|
|
@ -30,24 +32,10 @@ interface ConfluenceSpace {
|
|||
name: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
accounts?: ConfluenceAccount[];
|
||||
spaces?: ConfluenceSpace[];
|
||||
error?: string;
|
||||
};
|
||||
type CreateConfluencePageInterruptContext = {
|
||||
accounts?: ConfluenceAccount[];
|
||||
spaces?: ConfluenceSpace[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -76,21 +64,12 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type CreateConfluencePageResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<CreateConfluencePageInterruptContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| AuthErrorResult
|
||||
| InsufficientPermissionsResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -124,12 +103,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: { title: string; content?: string; space_id?: string };
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<CreateConfluencePageInterruptContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -464,18 +439,16 @@ export const CreateConfluencePageToolUI = ({
|
|||
{ title: string; content?: string; space_id?: string },
|
||||
CreateConfluencePageResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<CreateConfluencePageInterruptContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,38 +6,26 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
type DeleteConfluencePageInterruptContext = {
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
auth_expired?: boolean;
|
||||
};
|
||||
page?: {
|
||||
page_id: string;
|
||||
page_title: string;
|
||||
space_id: string;
|
||||
connector_id?: number;
|
||||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
base_url: string;
|
||||
auth_expired?: boolean;
|
||||
};
|
||||
page?: {
|
||||
page_id: string;
|
||||
page_title: string;
|
||||
space_id: string;
|
||||
connector_id?: number;
|
||||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -77,7 +65,7 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type DeleteConfluencePageResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<DeleteConfluencePageInterruptContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
|
|
@ -85,15 +73,6 @@ type DeleteConfluencePageResult =
|
|||
| AuthErrorResult
|
||||
| InsufficientPermissionsResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -145,12 +124,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<DeleteConfluencePageInterruptContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -402,18 +377,15 @@ export const DeleteConfluencePageToolUI = ({
|
|||
{ page_title_or_id: string; delete_from_kb?: boolean },
|
||||
DeleteConfluencePageResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<DeleteConfluencePageInterruptContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,39 +8,27 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
type UpdateConfluencePageInterruptContext = {
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
auth_expired?: boolean;
|
||||
};
|
||||
page?: {
|
||||
page_id: string;
|
||||
page_title: string;
|
||||
space_id: string;
|
||||
body: string;
|
||||
version: number;
|
||||
document_id: number;
|
||||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
base_url: string;
|
||||
auth_expired?: boolean;
|
||||
};
|
||||
page?: {
|
||||
page_id: string;
|
||||
page_title: string;
|
||||
space_id: string;
|
||||
body: string;
|
||||
version: number;
|
||||
document_id: number;
|
||||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -74,22 +62,13 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type UpdateConfluencePageResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<UpdateConfluencePageInterruptContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| AuthErrorResult
|
||||
| InsufficientPermissionsResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -136,12 +115,8 @@ function ApprovalCard({
|
|||
new_title?: string;
|
||||
new_content?: string;
|
||||
};
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<UpdateConfluencePageInterruptContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
|
||||
|
|
@ -502,18 +477,16 @@ export const UpdateConfluencePageToolUI = ({
|
|||
},
|
||||
UpdateConfluencePageResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<UpdateConfluencePageInterruptContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface DropboxAccount {
|
||||
id: number;
|
||||
|
|
@ -29,21 +31,11 @@ interface SupportedType {
|
|||
label: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
accounts?: DropboxAccount[];
|
||||
parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>;
|
||||
supported_types?: SupportedType[];
|
||||
error?: string;
|
||||
};
|
||||
type DropboxCreateFileContext = {
|
||||
accounts?: DropboxAccount[];
|
||||
parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>;
|
||||
supported_types?: SupportedType[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -65,16 +57,7 @@ interface AuthErrorResult {
|
|||
connector_type?: string;
|
||||
}
|
||||
|
||||
type CreateDropboxFileResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
type CreateDropboxFileResult = InterruptResult<DropboxCreateFileContext> | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
|
|
@ -100,12 +83,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: { name: string; file_type?: string; content?: string };
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<DropboxCreateFileContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -455,17 +434,14 @@ export const CreateDropboxFileToolUI = ({
|
|||
{ name: string; file_type?: string; content?: string },
|
||||
CreateDropboxFileResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
if (!result) return null;
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<DropboxCreateFileContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface DropboxAccount {
|
||||
id: number;
|
||||
|
|
@ -22,13 +24,10 @@ interface DropboxFile {
|
|||
document_id?: number;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
|
||||
review_configs: Array<{ action_name: string; allowed_decisions: Array<"approve" | "reject"> }>;
|
||||
context?: { account?: DropboxAccount; file?: DropboxFile; error?: string };
|
||||
type DropboxTrashFileContext = {
|
||||
account?: DropboxAccount;
|
||||
file?: DropboxFile;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -52,20 +51,12 @@ interface AuthErrorResult {
|
|||
}
|
||||
|
||||
type DeleteDropboxFileResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<DropboxTrashFileContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -95,12 +86,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<DropboxTrashFileContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -308,16 +295,13 @@ export const DeleteDropboxFileToolUI = ({
|
|||
{ file_name: string; delete_from_kb?: boolean },
|
||||
DeleteDropboxFileResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
if (!result) return null;
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<DropboxTrashFileContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
264
surfsense_web/components/tool-ui/generic-hitl-approval.tsx
Normal file
264
surfsense_web/components/tool-ui/generic-hitl-approval.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
|
||||
import { CornerDownLeftIcon, Pen } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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 { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
|
||||
function ParamEditor({
|
||||
params,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
params: Record<string, unknown>;
|
||||
onChange: (updated: Record<string, unknown>) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const entries = Object.entries(params);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, value]) => {
|
||||
const strValue = value == null ? "" : String(value);
|
||||
const isLong = strValue.length > 120;
|
||||
const fieldId = `hitl-param-${key}`;
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-1">
|
||||
<label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">
|
||||
{key}
|
||||
</label>
|
||||
{isLong ? (
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={strValue}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
|
||||
className="text-xs"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={strValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericApprovalCard({
|
||||
toolName,
|
||||
args,
|
||||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
toolName: string;
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const displayName = toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
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 hasChanged = useMemo(() => {
|
||||
return JSON.stringify(editedParams) !== JSON.stringify(args);
|
||||
}, [editedParams, args]);
|
||||
|
||||
const handleApprove = useCallback(() => {
|
||||
if (phase !== "pending") return;
|
||||
const isEdited = isEditing && hasChanged;
|
||||
setProcessing();
|
||||
onDecision({
|
||||
type: isEdited ? "edit" : "approve",
|
||||
edited_action: isEdited
|
||||
? { name: interruptData.action_requests[0]?.name ?? toolName, args: editedParams }
|
||||
: undefined,
|
||||
});
|
||||
}, [
|
||||
phase,
|
||||
setProcessing,
|
||||
isEditing,
|
||||
hasChanged,
|
||||
onDecision,
|
||||
interruptData,
|
||||
toolName,
|
||||
editedParams,
|
||||
]);
|
||||
|
||||
const handleAlwaysAllow = useCallback(() => {
|
||||
if (phase !== "pending" || !isMCPTool) return;
|
||||
setProcessing();
|
||||
onDecision({ type: "approve" });
|
||||
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch((err) => {
|
||||
console.error("Failed to trust MCP tool:", err);
|
||||
});
|
||||
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && phase === "pending") {
|
||||
handleApprove();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleApprove, phase]);
|
||||
|
||||
return (
|
||||
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{phase === "rejected"
|
||||
? `${displayName} — Rejected`
|
||||
: phase === "processing" || phase === "complete"
|
||||
? `${displayName} — Approved`
|
||||
: displayName}
|
||||
</p>
|
||||
{phase === "processing" ? (
|
||||
<TextShimmerLoader text="Executing..." size="sm" />
|
||||
) : phase === "complete" ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Action completed</p>
|
||||
) : phase === "rejected" ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Action was cancelled</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Requires your approval to proceed
|
||||
</p>
|
||||
)}
|
||||
{mcpServer && (
|
||||
<p className="text-[10px] text-muted-foreground/70 mt-1">
|
||||
via <span className="font-medium">{mcpServer}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{phase === "pending" && canEdit && !isEditing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Pen className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{toolDescription && phase === "pending" && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-3">
|
||||
<p className="text-xs text-muted-foreground">{toolDescription}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Parameters */}
|
||||
{Object.keys(args).length > 0 && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Parameters</p>
|
||||
{phase === "pending" && isEditing ? (
|
||||
<ParamEditor
|
||||
params={editedParams}
|
||||
onChange={setEditedParams}
|
||||
disabled={phase !== "pending"}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all bg-muted/50 rounded-lg p-3">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{phase === "pending" && (
|
||||
<>
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
<div className="px-5 py-4 flex items-center gap-2 select-none">
|
||||
{allowedDecisions.includes("approve") && (
|
||||
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
|
||||
{isEditing && hasChanged ? "Approve with edits" : "Approve"}
|
||||
<CornerDownLeftIcon className="size-3 opacity-60" />
|
||||
</Button>
|
||||
)}
|
||||
{isMCPTool && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={handleAlwaysAllow}
|
||||
>
|
||||
Always Allow
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("reject") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg text-muted-foreground"
|
||||
onClick={() => {
|
||||
setRejected();
|
||||
onDecision({ type: "reject", message: "User rejected the action." });
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GenericHitlApprovalToolUI: ToolCallMessagePartComponent = ({
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
}) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result || !isInterruptResult(result)) return null;
|
||||
|
||||
return (
|
||||
<GenericApprovalCard
|
||||
toolName={toolName}
|
||||
args={args as Record<string, unknown>}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -16,6 +16,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface GmailAccount {
|
||||
|
|
@ -25,22 +27,9 @@ interface GmailAccount {
|
|||
auth_expired?: boolean;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
accounts?: GmailAccount[];
|
||||
error?: string;
|
||||
};
|
||||
type GmailCreateDraftContext = {
|
||||
accounts?: GmailAccount[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -68,21 +57,12 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type CreateGmailDraftResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<GmailCreateDraftContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -116,12 +96,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: { to: string; subject: string; body: string; cc?: string; bcc?: string };
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<GmailCreateDraftContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -473,18 +449,16 @@ export const CreateGmailDraftToolUI = ({
|
|||
{ to: string; subject: string; body: string; cc?: string; bcc?: string },
|
||||
CreateGmailDraftResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<GmailCreateDraftContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface GmailAccount {
|
||||
|
|
@ -25,22 +27,9 @@ interface GmailAccount {
|
|||
auth_expired?: boolean;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
accounts?: GmailAccount[];
|
||||
error?: string;
|
||||
};
|
||||
type GmailSendEmailContext = {
|
||||
accounts?: GmailAccount[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -67,21 +56,12 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type SendGmailEmailResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<GmailSendEmailContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -115,12 +95,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: { to: string; subject: string; body: string; cc?: string; bcc?: string };
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<GmailSendEmailContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -471,18 +447,16 @@ export const SendGmailEmailToolUI = ({
|
|||
{ to: string; subject: string; body: string; cc?: string; bcc?: string },
|
||||
SendGmailEmailResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<GmailSendEmailContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface GmailAccount {
|
||||
|
|
@ -25,23 +27,10 @@ interface GmailMessage {
|
|||
document_id: number;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
account?: GmailAccount;
|
||||
email?: GmailMessage;
|
||||
error?: string;
|
||||
};
|
||||
type GmailTrashEmailContext = {
|
||||
account?: GmailAccount;
|
||||
email?: GmailMessage;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -74,22 +63,13 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type TrashGmailEmailResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<GmailTrashEmailContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -134,12 +114,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<GmailTrashEmailContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -385,18 +361,15 @@ export const TrashGmailEmailToolUI = ({
|
|||
{ email_subject_or_id: string; delete_from_kb?: boolean },
|
||||
TrashGmailEmailResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<GmailTrashEmailContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface GmailAccount {
|
||||
|
|
@ -28,25 +30,12 @@ interface GmailMessage {
|
|||
document_id: number;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
account?: GmailAccount;
|
||||
email?: GmailMessage;
|
||||
draft_id?: string;
|
||||
existing_body?: string;
|
||||
error?: string;
|
||||
};
|
||||
type GmailUpdateDraftContext = {
|
||||
account?: GmailAccount;
|
||||
email?: GmailMessage;
|
||||
draft_id?: string;
|
||||
existing_body?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -78,22 +67,13 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type UpdateGmailDraftResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<GmailUpdateDraftContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -143,12 +123,8 @@ function ApprovalCard({
|
|||
cc?: string;
|
||||
bcc?: string;
|
||||
};
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<GmailUpdateDraftContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -522,20 +498,16 @@ export const UpdateGmailDraftToolUI = ({
|
|||
},
|
||||
UpdateGmailDraftResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
})
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<GmailUpdateDraftContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface GoogleCalendarAccount {
|
||||
|
|
@ -30,24 +32,11 @@ interface CalendarEntry {
|
|||
primary?: boolean;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
accounts?: GoogleCalendarAccount[];
|
||||
calendars?: CalendarEntry[];
|
||||
timezone?: string;
|
||||
error?: string;
|
||||
};
|
||||
type CalendarCreateEventContext = {
|
||||
accounts?: GoogleCalendarAccount[];
|
||||
calendars?: CalendarEntry[];
|
||||
timezone?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -75,21 +64,12 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type CreateCalendarEventResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<CalendarCreateEventContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -141,12 +121,8 @@ function ApprovalCard({
|
|||
location?: string;
|
||||
attendees?: string[];
|
||||
};
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<CalendarCreateEventContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -620,18 +596,16 @@ export const CreateCalendarEventToolUI = ({
|
|||
},
|
||||
CreateCalendarEventResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<CalendarCreateEventContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface GoogleCalendarAccount {
|
||||
|
|
@ -27,23 +29,10 @@ interface CalendarEvent {
|
|||
indexed_at?: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
account?: GoogleCalendarAccount;
|
||||
event?: CalendarEvent;
|
||||
error?: string;
|
||||
};
|
||||
type CalendarDeleteEventContext = {
|
||||
account?: GoogleCalendarAccount;
|
||||
event?: CalendarEvent;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -83,7 +72,7 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type DeleteCalendarEventResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<CalendarDeleteEventContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
|
|
@ -91,15 +80,6 @@ type DeleteCalendarEventResult =
|
|||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -162,12 +142,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<CalendarDeleteEventContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -437,18 +413,15 @@ export const DeleteCalendarEventToolUI = ({
|
|||
{ event_title_or_id: string; delete_from_kb?: boolean },
|
||||
DeleteCalendarEventResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<CalendarDeleteEventContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
|||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface GoogleCalendarAccount {
|
||||
|
|
@ -37,23 +39,10 @@ interface CalendarEvent {
|
|||
indexed_at?: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
account?: GoogleCalendarAccount;
|
||||
event?: CalendarEvent;
|
||||
error?: string;
|
||||
};
|
||||
type CalendarUpdateEventContext = {
|
||||
account?: GoogleCalendarAccount;
|
||||
event?: CalendarEvent;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -86,22 +75,13 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type UpdateCalendarEventResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<CalendarUpdateEventContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -163,12 +143,8 @@ function ApprovalCard({
|
|||
new_location?: string;
|
||||
new_attendees?: string[];
|
||||
};
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<CalendarUpdateEventContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const actionArgs = interruptData.action_requests[0]?.args ?? {};
|
||||
|
|
@ -686,18 +662,16 @@ export const UpdateCalendarEventToolUI = ({
|
|||
},
|
||||
UpdateCalendarEventResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<CalendarUpdateEventContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GoogleDriveAccount {
|
||||
id: number;
|
||||
|
|
@ -23,24 +25,11 @@ interface GoogleDriveAccount {
|
|||
auth_expired?: boolean;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
accounts?: GoogleDriveAccount[];
|
||||
supported_types?: string[];
|
||||
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
|
||||
error?: string;
|
||||
};
|
||||
type DriveCreateFileContext = {
|
||||
accounts?: GoogleDriveAccount[];
|
||||
supported_types?: string[];
|
||||
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -69,21 +58,12 @@ interface AuthErrorResult {
|
|||
}
|
||||
|
||||
type CreateGoogleDriveFileResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<DriveCreateFileContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -122,12 +102,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: { name: string; file_type: string; content?: string };
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<DriveCreateFileContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -499,18 +475,15 @@ export const CreateGoogleDriveFileToolUI = ({
|
|||
{ name: string; file_type: string; content?: string },
|
||||
CreateGoogleDriveFileResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<DriveCreateFileContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface GoogleDriveAccount {
|
||||
id: number;
|
||||
|
|
@ -21,23 +23,10 @@ interface GoogleDriveFile {
|
|||
web_view_link: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
account?: GoogleDriveAccount;
|
||||
file?: GoogleDriveFile;
|
||||
error?: string;
|
||||
};
|
||||
type DriveTrashFileContext = {
|
||||
account?: GoogleDriveAccount;
|
||||
file?: GoogleDriveFile;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -77,7 +66,7 @@ interface AuthErrorResult {
|
|||
}
|
||||
|
||||
type DeleteGoogleDriveFileResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<DriveTrashFileContext>
|
||||
| SuccessResult
|
||||
| WarningResult
|
||||
| ErrorResult
|
||||
|
|
@ -85,15 +74,6 @@ type DeleteGoogleDriveFileResult =
|
|||
| InsufficientPermissionsResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -151,12 +131,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<DriveTrashFileContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -416,18 +392,14 @@ export const DeleteGoogleDriveFileToolUI = ({
|
|||
{ file_name: string; delete_from_kb?: boolean },
|
||||
DeleteGoogleDriveFileResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<DriveTrashFileContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface JiraAccount {
|
||||
|
|
@ -40,26 +42,12 @@ interface JiraPriority {
|
|||
name: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
accounts?: JiraAccount[];
|
||||
projects?: JiraProject[];
|
||||
issue_types?: JiraIssueType[];
|
||||
priorities?: JiraPriority[];
|
||||
error?: string;
|
||||
};
|
||||
type CreateJiraIssueInterruptContext = {
|
||||
accounts?: JiraAccount[];
|
||||
projects?: JiraProject[];
|
||||
issue_types?: JiraIssueType[];
|
||||
priorities?: JiraPriority[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -88,21 +76,12 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type CreateJiraIssueResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<CreateJiraIssueInterruptContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| AuthErrorResult
|
||||
| InsufficientPermissionsResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -142,12 +121,8 @@ function ApprovalCard({
|
|||
description?: string;
|
||||
priority?: string;
|
||||
};
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<CreateJiraIssueInterruptContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -549,18 +524,16 @@ export const CreateJiraIssueToolUI = ({
|
|||
},
|
||||
CreateJiraIssueResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<CreateJiraIssueInterruptContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface JiraAccount {
|
||||
|
|
@ -23,24 +25,10 @@ interface JiraIssue {
|
|||
document_id?: number;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
account?: JiraAccount;
|
||||
issue?: JiraIssue;
|
||||
error?: string;
|
||||
};
|
||||
type DeleteJiraIssueInterruptContext = {
|
||||
account?: JiraAccount;
|
||||
issue?: JiraIssue;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -79,7 +67,7 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type DeleteJiraIssueResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<DeleteJiraIssueInterruptContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
|
|
@ -87,15 +75,6 @@ type DeleteJiraIssueResult =
|
|||
| AuthErrorResult
|
||||
| InsufficientPermissionsResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -147,12 +126,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<DeleteJiraIssueInterruptContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -399,18 +374,15 @@ export const DeleteJiraIssueToolUI = ({
|
|||
{ issue_title_or_key: string; delete_from_kb?: boolean },
|
||||
DeleteJiraIssueResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<DeleteJiraIssueInterruptContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
|
||||
interface JiraIssue {
|
||||
|
|
@ -43,25 +45,11 @@ interface JiraPriority {
|
|||
name: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
account?: JiraAccount;
|
||||
issue?: JiraIssue;
|
||||
priorities?: JiraPriority[];
|
||||
error?: string;
|
||||
};
|
||||
type UpdateJiraIssueInterruptContext = {
|
||||
account?: JiraAccount;
|
||||
issue?: JiraIssue;
|
||||
priorities?: JiraPriority[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -95,22 +83,13 @@ interface InsufficientPermissionsResult {
|
|||
}
|
||||
|
||||
type UpdateJiraIssueResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<UpdateJiraIssueInterruptContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| AuthErrorResult
|
||||
| InsufficientPermissionsResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -158,12 +137,8 @@ function ApprovalCard({
|
|||
new_description?: string;
|
||||
new_priority?: string;
|
||||
};
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<UpdateJiraIssueInterruptContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
|
||||
|
|
@ -563,18 +538,16 @@ export const UpdateJiraIssueToolUI = ({
|
|||
},
|
||||
UpdateJiraIssueResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<UpdateJiraIssueInterruptContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface LinearLabel {
|
||||
id: string;
|
||||
|
|
@ -64,23 +66,9 @@ interface LinearWorkspace {
|
|||
auth_expired?: boolean;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
workspaces?: LinearWorkspace[];
|
||||
error?: string;
|
||||
};
|
||||
type LinearCreateIssueContext = {
|
||||
workspaces?: LinearWorkspace[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -103,16 +91,7 @@ interface AuthErrorResult {
|
|||
connector_type: string;
|
||||
}
|
||||
|
||||
type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
type CreateLinearIssueResult = InterruptResult<LinearCreateIssueContext> | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
|
|
@ -138,12 +117,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: { title: string; description?: string };
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<LinearCreateIssueContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -609,18 +584,16 @@ export const CreateLinearIssueToolUI = ({
|
|||
args,
|
||||
result,
|
||||
}: ToolCallMessagePartProps<{ title: string; description?: string }, CreateLinearIssueResult>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<LinearCreateIssueContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,32 +7,20 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
workspace?: { id: number; organization_name: string };
|
||||
issue?: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
state?: string;
|
||||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
type LinearDeleteIssueContext = {
|
||||
workspace?: { id: number; organization_name: string };
|
||||
issue?: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
state?: string;
|
||||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -65,22 +53,13 @@ interface AuthErrorResult {
|
|||
}
|
||||
|
||||
type DeleteLinearIssueResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<LinearDeleteIssueContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| WarningResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -123,12 +102,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<LinearDeleteIssueContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -366,18 +341,15 @@ export const DeleteLinearIssueToolUI = ({
|
|||
{ issue_ref: string; delete_from_kb?: boolean },
|
||||
DeleteLinearIssueResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<LinearDeleteIssueContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface LinearLabel {
|
||||
id: string;
|
||||
|
|
@ -45,45 +47,31 @@ interface LinearPriority {
|
|||
label: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
context?: {
|
||||
workspace?: { id: number; organization_name: string };
|
||||
priorities?: LinearPriority[];
|
||||
issue?: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority: number;
|
||||
url: string;
|
||||
current_state?: LinearState;
|
||||
current_assignee?: { id: string; name: string; email: string } | null;
|
||||
current_labels?: LinearLabel[];
|
||||
team_id: string;
|
||||
document_id: number;
|
||||
};
|
||||
team?: {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
states: LinearState[];
|
||||
members: LinearMember[];
|
||||
labels: LinearLabel[];
|
||||
};
|
||||
error?: string;
|
||||
type LinearUpdateIssueContext = {
|
||||
workspace?: { id: number; organization_name: string };
|
||||
priorities?: LinearPriority[];
|
||||
issue?: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority: number;
|
||||
url: string;
|
||||
current_state?: LinearState;
|
||||
current_assignee?: { id: string; name: string; email: string } | null;
|
||||
current_labels?: LinearLabel[];
|
||||
team_id: string;
|
||||
document_id: number;
|
||||
};
|
||||
team?: {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
states: LinearState[];
|
||||
members: LinearMember[];
|
||||
labels: LinearLabel[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -111,21 +99,12 @@ interface AuthErrorResult {
|
|||
}
|
||||
|
||||
type UpdateLinearIssueResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<LinearUpdateIssueContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -167,12 +146,8 @@ function ApprovalCard({
|
|||
new_priority?: number;
|
||||
new_label_names?: string[];
|
||||
};
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<LinearUpdateIssueContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
|
||||
|
|
@ -752,18 +727,16 @@ export const UpdateLinearIssueToolUI = ({
|
|||
},
|
||||
UpdateLinearIssueResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<LinearUpdateIssueContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,41 +16,27 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
type NotionCreatePageContext = {
|
||||
accounts?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
workspace_id: string | null;
|
||||
workspace_name: string;
|
||||
workspace_icon: string;
|
||||
auth_expired?: boolean;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
message?: string;
|
||||
context?: {
|
||||
accounts?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
workspace_id: string | null;
|
||||
workspace_name: string;
|
||||
workspace_icon: string;
|
||||
auth_expired?: boolean;
|
||||
}>;
|
||||
parent_pages?: Record<
|
||||
number,
|
||||
Array<{
|
||||
page_id: string;
|
||||
title: string;
|
||||
document_id: number;
|
||||
}>
|
||||
>;
|
||||
error?: string;
|
||||
};
|
||||
parent_pages?: Record<
|
||||
number,
|
||||
Array<{
|
||||
page_id: string;
|
||||
title: string;
|
||||
document_id: number;
|
||||
}>
|
||||
>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -75,16 +61,7 @@ interface AuthErrorResult {
|
|||
connector_type: string;
|
||||
}
|
||||
|
||||
type CreateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
type CreateNotionPageResult = InterruptResult<NotionCreatePageContext> | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
|
||||
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
|
||||
return (
|
||||
|
|
@ -110,12 +87,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<NotionCreatePageContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -449,19 +422,16 @@ export const CreateNotionPageToolUI = ({
|
|||
args,
|
||||
result,
|
||||
}: ToolCallMessagePartProps<{ title: string; content: string }, CreateNotionPageResult>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<NotionCreatePageContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,36 +7,22 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
type NotionDeletePageContext = {
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
message?: string;
|
||||
context?: {
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
workspace_id: string | null;
|
||||
workspace_name: string;
|
||||
workspace_icon: string;
|
||||
};
|
||||
page_id?: string;
|
||||
current_title?: string;
|
||||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
error?: string;
|
||||
workspace_id: string | null;
|
||||
workspace_name: string;
|
||||
workspace_icon: string;
|
||||
};
|
||||
page_id?: string;
|
||||
current_title?: string;
|
||||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -73,22 +59,13 @@ interface AuthErrorResult {
|
|||
}
|
||||
|
||||
type DeleteNotionPageResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<NotionDeletePageContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| InfoResult
|
||||
| WarningResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -131,12 +108,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<NotionDeletePageContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -378,18 +351,15 @@ export const DeleteNotionPageToolUI = ({
|
|||
{ page_title: string; delete_from_kb?: boolean },
|
||||
DeleteNotionPageResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<NotionDeletePageContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,36 +9,22 @@ import { PlateEditor } from "@/components/editor/plate-editor";
|
|||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{
|
||||
type NotionUpdatePageContext = {
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
}>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
interrupt_type?: string;
|
||||
message?: string;
|
||||
context?: {
|
||||
account?: {
|
||||
id: number;
|
||||
name: string;
|
||||
workspace_id: string | null;
|
||||
workspace_name: string;
|
||||
workspace_icon: string;
|
||||
};
|
||||
page_id?: string;
|
||||
current_title?: string;
|
||||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
error?: string;
|
||||
workspace_id: string | null;
|
||||
workspace_name: string;
|
||||
workspace_icon: string;
|
||||
};
|
||||
page_id?: string;
|
||||
current_title?: string;
|
||||
document_id?: number;
|
||||
indexed_at?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -69,21 +55,12 @@ interface AuthErrorResult {
|
|||
}
|
||||
|
||||
type UpdateNotionPageResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<NotionUpdatePageContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| InfoResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -117,12 +94,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: Record<string, unknown>;
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<NotionUpdatePageContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -399,19 +372,16 @@ export const UpdateNotionPageToolUI = ({
|
|||
args,
|
||||
result,
|
||||
}: ToolCallMessagePartProps<{ page_title: string; content: string }, UpdateNotionPageResult>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
const event = new CustomEvent("hitl-decision", {
|
||||
detail: { decisions: [decision] },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
interruptData={result as InterruptResult<NotionUpdatePageContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface OneDriveAccount {
|
||||
id: number;
|
||||
|
|
@ -24,20 +26,10 @@ interface OneDriveAccount {
|
|||
auth_expired?: boolean;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
|
||||
review_configs: Array<{
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}>;
|
||||
context?: {
|
||||
accounts?: OneDriveAccount[];
|
||||
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
|
||||
error?: string;
|
||||
};
|
||||
type OneDriveCreateFileContext = {
|
||||
accounts?: OneDriveAccount[];
|
||||
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -59,16 +51,7 @@ interface AuthErrorResult {
|
|||
connector_type?: string;
|
||||
}
|
||||
|
||||
type CreateOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
type CreateOneDriveFileResult = InterruptResult<OneDriveCreateFileContext> | SuccessResult | ErrorResult | AuthErrorResult;
|
||||
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
|
|
@ -94,12 +77,8 @@ function ApprovalCard({
|
|||
onDecision,
|
||||
}: {
|
||||
args: { name: string; content?: string };
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<OneDriveCreateFileContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||
|
|
@ -434,17 +413,14 @@ export const CreateOneDriveFileToolUI = ({
|
|||
args,
|
||||
result,
|
||||
}: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
if (!result) return null;
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
args={args}
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<OneDriveCreateFileContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useHitlPhase } from "@/hooks/use-hitl-phase";
|
||||
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
|
||||
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
|
||||
|
||||
interface OneDriveAccount {
|
||||
id: number;
|
||||
|
|
@ -22,13 +24,10 @@ interface OneDriveFile {
|
|||
web_url?: string;
|
||||
}
|
||||
|
||||
interface InterruptResult {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject";
|
||||
__completed__?: boolean;
|
||||
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
|
||||
review_configs: Array<{ action_name: string; allowed_decisions: Array<"approve" | "reject"> }>;
|
||||
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
|
||||
type OneDriveTrashFileContext = {
|
||||
account?: OneDriveAccount;
|
||||
file?: OneDriveFile;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SuccessResult {
|
||||
|
|
@ -52,20 +51,11 @@ interface AuthErrorResult {
|
|||
}
|
||||
|
||||
type DeleteOneDriveFileResult =
|
||||
| InterruptResult
|
||||
| InterruptResult<OneDriveTrashFileContext>
|
||||
| SuccessResult
|
||||
| ErrorResult
|
||||
| NotFoundResult
|
||||
| AuthErrorResult;
|
||||
|
||||
function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
function isErrorResult(result: unknown): result is ErrorResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
|
|
@ -95,12 +85,8 @@ function ApprovalCard({
|
|||
interruptData,
|
||||
onDecision,
|
||||
}: {
|
||||
interruptData: InterruptResult;
|
||||
onDecision: (decision: {
|
||||
type: "approve" | "reject";
|
||||
message?: string;
|
||||
edited_action?: { name: string; args: Record<string, unknown> };
|
||||
}) => void;
|
||||
interruptData: InterruptResult<OneDriveTrashFileContext>;
|
||||
onDecision: (decision: HitlDecision) => void;
|
||||
}) {
|
||||
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
|
||||
const [deleteFromKb, setDeleteFromKb] = useState(false);
|
||||
|
|
@ -311,16 +297,13 @@ export const DeleteOneDriveFileToolUI = ({
|
|||
{ file_name: string; delete_from_kb?: boolean },
|
||||
DeleteOneDriveFileResult
|
||||
>) => {
|
||||
const { dispatch } = useHitlDecision();
|
||||
if (!result) return null;
|
||||
if (isInterruptResult(result)) {
|
||||
return (
|
||||
<ApprovalCard
|
||||
interruptData={result}
|
||||
onDecision={(decision) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
|
||||
);
|
||||
}}
|
||||
interruptData={result as InterruptResult<OneDriveTrashFileContext>}
|
||||
onDecision={(decision) => dispatch([decision])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -404,6 +404,45 @@ class ConnectorsApiService {
|
|||
listDiscordChannelsResponse
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// 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> => {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const token =
|
||||
typeof window !== "undefined" ? document.cookie.match(/fapiToken=([^;]+)/)?.[1] : undefined;
|
||||
await fetch(`${backendUrl}/api/v1/connectors/mcp/${connectorId}/trust-tool`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ tool_name: toolName }),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a tool from the MCP connector's "Always Allow" list.
|
||||
*/
|
||||
untrustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const token =
|
||||
typeof window !== "undefined" ? document.cookie.match(/fapiToken=([^;]+)/)?.[1] : undefined;
|
||||
await fetch(`${backendUrl}/api/v1/connectors/mcp/${connectorId}/untrust-tool`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ tool_name: toolName }),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type { SlackChannel, DiscordChannel };
|
||||
|
|
|
|||
|
|
@ -148,9 +148,10 @@ export function addToolCall(
|
|||
toolsWithUI: Set<string>,
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>
|
||||
args: Record<string, unknown>,
|
||||
force = false,
|
||||
): void {
|
||||
if (toolsWithUI.has(toolName)) {
|
||||
if (force || toolsWithUI.has(toolName)) {
|
||||
state.contentParts.push({
|
||||
type: "tool-call",
|
||||
toolCallId,
|
||||
|
|
@ -175,13 +176,20 @@ export function updateToolCall(
|
|||
}
|
||||
}
|
||||
|
||||
function _hasInterruptResult(part: ContentPart): boolean {
|
||||
if (part.type !== "tool-call") return false;
|
||||
const r = (part as { result?: unknown }).result;
|
||||
return typeof r === "object" && r !== null && "__interrupt__" in r;
|
||||
}
|
||||
|
||||
export function buildContentForUI(
|
||||
state: ContentPartsState,
|
||||
toolsWithUI: Set<string>
|
||||
): ThreadMessageLike["content"] {
|
||||
const filtered = state.contentParts.filter((part) => {
|
||||
if (part.type === "text") return part.text.length > 0;
|
||||
if (part.type === "tool-call") return toolsWithUI.has(part.toolName);
|
||||
if (part.type === "tool-call")
|
||||
return toolsWithUI.has(part.toolName) || _hasInterruptResult(part);
|
||||
if (part.type === "data-thinking-steps") return true;
|
||||
return false;
|
||||
});
|
||||
|
|
@ -199,7 +207,10 @@ export function buildContentForPersistence(
|
|||
for (const part of state.contentParts) {
|
||||
if (part.type === "text" && part.text.length > 0) {
|
||||
parts.push(part);
|
||||
} else if (part.type === "tool-call" && toolsWithUI.has(part.toolName)) {
|
||||
} else if (
|
||||
part.type === "tool-call" &&
|
||||
(toolsWithUI.has(part.toolName) || _hasInterruptResult(part))
|
||||
) {
|
||||
parts.push(part);
|
||||
} else if (part.type === "data-thinking-steps") {
|
||||
parts.push(part);
|
||||
|
|
|
|||
8
surfsense_web/lib/hitl/index.ts
Normal file
8
surfsense_web/lib/hitl/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export { isInterruptResult } from "./types";
|
||||
export type {
|
||||
HitlDecision,
|
||||
InterruptActionRequest,
|
||||
InterruptResult,
|
||||
InterruptReviewConfig,
|
||||
} from "./types";
|
||||
export { useHitlDecision } from "./use-hitl-decision";
|
||||
45
surfsense_web/lib/hitl/types.ts
Normal file
45
surfsense_web/lib/hitl/types.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Shared types for Human-in-the-Loop (HITL) approval across all tools.
|
||||
*
|
||||
* Every tool-ui component that handles interrupts should import from here
|
||||
* instead of defining its own `InterruptResult` / `isInterruptResult`.
|
||||
*/
|
||||
|
||||
export interface InterruptActionRequest {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface InterruptReviewConfig {
|
||||
action_name: string;
|
||||
allowed_decisions: Array<"approve" | "edit" | "reject">;
|
||||
}
|
||||
|
||||
export interface InterruptResult<C extends Record<string, unknown> = Record<string, unknown>> {
|
||||
__interrupt__: true;
|
||||
__decided__?: "approve" | "reject" | "edit";
|
||||
__completed__?: boolean;
|
||||
action_requests: InterruptActionRequest[];
|
||||
review_configs: InterruptReviewConfig[];
|
||||
interrupt_type?: string;
|
||||
context?: C;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function isInterruptResult(result: unknown): result is InterruptResult {
|
||||
return (
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
"__interrupt__" in result &&
|
||||
(result as InterruptResult).__interrupt__ === true
|
||||
);
|
||||
}
|
||||
|
||||
export interface HitlDecision {
|
||||
type: "approve" | "reject" | "edit";
|
||||
message?: string;
|
||||
edited_action?: {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
19
surfsense_web/lib/hitl/use-hitl-decision.ts
Normal file
19
surfsense_web/lib/hitl/use-hitl-decision.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Shared hook for dispatching HITL decisions.
|
||||
*
|
||||
* All tool-ui components that handle approve/reject/edit should use this
|
||||
* instead of manually constructing `CustomEvent("hitl-decision", ...)`.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { HitlDecision } from "./types";
|
||||
|
||||
export function useHitlDecision() {
|
||||
const dispatch = useCallback((decisions: HitlDecision[]) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hitl-decision", { detail: { decisions } }),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return { dispatch };
|
||||
}
|
||||
|
|
@ -737,8 +737,8 @@
|
|||
"back_to_app": "Back to app",
|
||||
"nav_general": "General",
|
||||
"nav_general_desc": "Name, description & basic info",
|
||||
"nav_agent_configs": "Agent Configs",
|
||||
"nav_agent_configs_desc": "Models with prompts & citations",
|
||||
"nav_agent_models": "Agent Models",
|
||||
"nav_agent_models_desc": "Models with prompts & citations",
|
||||
"nav_role_assignments": "Role Assignments",
|
||||
"nav_role_assignments_desc": "Assign configs to agent roles",
|
||||
"nav_image_models": "Image Models",
|
||||
|
|
|
|||
|
|
@ -737,8 +737,8 @@
|
|||
"back_to_app": "Volver a la app",
|
||||
"nav_general": "General",
|
||||
"nav_general_desc": "Nombre, descripción e información básica",
|
||||
"nav_agent_configs": "Configuraciones de agente",
|
||||
"nav_agent_configs_desc": "Modelos LLM con prompts y citas",
|
||||
"nav_agent_models": "Modelos de agente",
|
||||
"nav_agent_models_desc": "Modelos LLM con prompts y citas",
|
||||
"nav_role_assignments": "Asignaciones de roles",
|
||||
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
|
||||
"nav_image_models": "Modelos de imagen",
|
||||
|
|
|
|||
|
|
@ -737,8 +737,8 @@
|
|||
"back_to_app": "ऐप पर वापस जाएं",
|
||||
"nav_general": "सामान्य",
|
||||
"nav_general_desc": "नाम, विवरण और बुनियादी जानकारी",
|
||||
"nav_agent_configs": "एजेंट कॉन्फ़िगरेशन",
|
||||
"nav_agent_configs_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
|
||||
"nav_agent_models": "एजेंट मॉडल",
|
||||
"nav_agent_models_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
|
||||
"nav_role_assignments": "भूमिका असाइनमेंट",
|
||||
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
|
||||
"nav_image_models": "इमेज मॉडल",
|
||||
|
|
|
|||
|
|
@ -737,8 +737,8 @@
|
|||
"back_to_app": "Voltar ao app",
|
||||
"nav_general": "Geral",
|
||||
"nav_general_desc": "Nome, descrição e informações básicas",
|
||||
"nav_agent_configs": "Configurações do agente",
|
||||
"nav_agent_configs_desc": "Modelos LLM com prompts e citações",
|
||||
"nav_agent_models": "Modelos do agente",
|
||||
"nav_agent_models_desc": "Modelos LLM com prompts e citações",
|
||||
"nav_role_assignments": "Atribuições de funções",
|
||||
"nav_role_assignments_desc": "Atribuir configurações a funções do agente",
|
||||
"nav_image_models": "Modelos de imagem",
|
||||
|
|
|
|||
|
|
@ -721,8 +721,8 @@
|
|||
"back_to_app": "返回应用",
|
||||
"nav_general": "常规",
|
||||
"nav_general_desc": "名称、描述和基本信息",
|
||||
"nav_agent_configs": "代理配置",
|
||||
"nav_agent_configs_desc": "LLM 模型配置提示词和引用",
|
||||
"nav_agent_models": "代理模型",
|
||||
"nav_agent_models_desc": "LLM 模型配置提示词和引用",
|
||||
"nav_role_assignments": "角色分配",
|
||||
"nav_role_assignments_desc": "为代理角色分配配置",
|
||||
"nav_image_models": "图像模型",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue