mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-10 00:18:36 +00:00
feat(frontend): add side conversations for quoted follow-ups (#3934)
* feat(frontend): add side conversations for quoted follow-ups * style(frontend): apply prettier formatting to sidecar-chat files * fix(frontend): surface sidecar cascade cleanup failures via console.warn Previously deleteSidecarThreadsForParent silently swallowed both lookup errors and per-thread deletion failures, so parent thread deletions could succeed while orphaning sidecar threads with no signal to the caller. Log a warning that includes the parent id and the failed thread ids/reasons so the leak is discoverable in telemetry, matching the existing console.warn/error pattern in this file. * fix(frontend): address all sidecar review feedback Resolve every reviewer comment on PR #3934: - input-box/hooks/sidecar-panel: clear quoted references only via an `onSent` callback that fires after the in-flight guard, so a dropped send no longer silently discards quotes (willem-bd #3550). - message-list: flip the selection toolbar below the selection when it would clip above the viewport (willem-bd #3551). - reference-metadata/thread/input-box: keep referenced ids, roles, and count arrays 1:1 parallel instead of deduping ids (willem-bd #3552). - message-list: widen selection containment to the shared assistant-turn container and hint when a selection crosses messages (willem-bd #3553). - sidecar/api: coalesce concurrent sidecar creates for one parent behind a single in-flight promise to prevent duplicates (willem-bd #3554). - sidecar-trigger/context: force-restore on trigger click so a sidecar deleted elsewhere self-heals instead of opening a dead thread (willem-bd #3555). - threads/hooks: surface sidecar cascade cleanup failures via console.warn for both lookup and per-thread deletes (Copilot). Add unit + e2e coverage for parallel metadata, atomic create, and trigger self-healing.
This commit is contained in:
parent
84bbdf6e00
commit
6a4e5a3bb2
37 changed files with 5353 additions and 606 deletions
|
|
@ -1,5 +1,8 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000";
|
||||
const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEB_SERVER === "1";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: true,
|
||||
|
|
@ -10,7 +13,7 @@ export default defineConfig({
|
|||
timeout: 30_000,
|
||||
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
baseURL,
|
||||
locale: "en-US",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
|
@ -22,14 +25,17 @@ export default defineConfig({
|
|||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: "./node_modules/.bin/next build && ./node_modules/.bin/next start",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
SKIP_ENV_VALIDATION: "1",
|
||||
DEER_FLOW_AUTH_DISABLED: "1",
|
||||
},
|
||||
},
|
||||
webServer: skipWebServer
|
||||
? undefined
|
||||
: {
|
||||
command:
|
||||
"./node_modules/.bin/next build && ./node_modules/.bin/next start",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
SKIP_ENV_VALIDATION: "1",
|
||||
DEER_FLOW_AUTH_DISABLED: "1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,12 +12,19 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
|||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { GoalStatus } from "@/components/workspace/goal-status";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
InputBox,
|
||||
type InputBoxSubmitOptions,
|
||||
} from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import {
|
||||
SidecarProvider,
|
||||
SidecarTrigger,
|
||||
} from "@/components/workspace/sidecar";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
|
||||
|
|
@ -146,8 +153,13 @@ export default function AgentChatPage() {
|
|||
]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
const sendPromise = sendMessage(threadId, message, { agent_name });
|
||||
(message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
|
||||
const sendPromise = sendMessage(
|
||||
threadId,
|
||||
message,
|
||||
{ agent_name },
|
||||
options,
|
||||
);
|
||||
if (message.files.length > 0) {
|
||||
return sendPromise;
|
||||
}
|
||||
|
|
@ -170,156 +182,166 @@ export default function AgentChatPage() {
|
|||
);
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
||||
isWelcomeMode
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
{/* Agent badge */}
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="hidden max-w-24 truncate text-xs font-medium sm:inline sm:max-w-none">
|
||||
{agent?.name ?? agent_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center sm:mr-4">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
className="px-2 sm:px-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/workspace/agents/${agent_name}/chats/new`);
|
||||
}}
|
||||
>
|
||||
<PlusSquare />
|
||||
<span className="hidden sm:inline">{t.agents.newChat}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
threadId={isNewThread ? undefined : threadId}
|
||||
backendUsage={backendTokenUsage}
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
pendingMessages={pendingUsageMessages}
|
||||
preferences={localSettings.tokenUsage}
|
||||
onPreferencesChange={(preferences) =>
|
||||
setLocalSettings("tokenUsage", preferences)
|
||||
}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isWelcomeMode && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
tokenUsageInlineMode={tokenUsageInlineMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<SidecarProvider
|
||||
parentThreadId={threadId}
|
||||
context={{ ...settings.context, agent_name }}
|
||||
isMock={isMock}
|
||||
>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
|
||||
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
||||
isWelcomeMode
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
{/* Agent badge */}
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
|
||||
<BotIcon className="text-primary h-3.5 w-3.5" />
|
||||
<span className="hidden max-w-24 truncate text-xs font-medium sm:inline sm:max-w-none">
|
||||
{agent?.name ?? agent_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center sm:mr-4">
|
||||
<Tooltip content={t.agents.newChat}>
|
||||
<Button
|
||||
className="px-2 sm:px-3"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push(`/workspace/agents/${agent_name}/chats/new`);
|
||||
}}
|
||||
>
|
||||
<PlusSquare />
|
||||
<span className="hidden sm:inline">{t.agents.newChat}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TokenUsageIndicator
|
||||
threadId={isNewThread ? undefined : threadId}
|
||||
backendUsage={backendTokenUsage}
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
pendingMessages={pendingUsageMessages}
|
||||
preferences={localSettings.tokenUsage}
|
||||
onPreferencesChange={(preferences) =>
|
||||
setLocalSettings("tokenUsage", preferences)
|
||||
}
|
||||
/>
|
||||
<SidecarTrigger />
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isWelcomeMode && "pt-10")}
|
||||
testId="main-message-list"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
tokenUsageInlineMode={tokenUsageInlineMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isWelcomeMode &&
|
||||
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
|
||||
isWelcomeMode
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
|
||||
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
|
||||
)}
|
||||
>
|
||||
{(hasGoal || hasTodos) && (
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 left-0 z-0",
|
||||
isWelcomeMode ? "absolute -top-4" : "relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isWelcomeMode &&
|
||||
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
|
||||
isWelcomeMode
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
{(hasGoal || hasTodos) && (
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 flex flex-col",
|
||||
isWelcomeMode ? "absolute" : "relative",
|
||||
"right-0 left-0 z-0",
|
||||
isWelcomeMode ? "absolute -top-4" : "relative",
|
||||
)}
|
||||
>
|
||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||
{hasTodos && (
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 flex flex-col",
|
||||
isWelcomeMode ? "absolute" : "relative",
|
||||
)}
|
||||
>
|
||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||
{hasTodos && (
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputBox
|
||||
className={cn(
|
||||
"bg-background/5 w-full",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
isWelcomeMode={isWelcomeMode}
|
||||
threadId={threadId}
|
||||
autoFocus={isWelcomeMode}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isWelcomeMode &&
|
||||
!hasGoal &&
|
||||
!hasTodos && (
|
||||
<AgentWelcome agent={agent} agentName={agent_name} />
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InputBox
|
||||
className={cn(
|
||||
"bg-background/5 w-full",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
isWelcomeMode={isWelcomeMode}
|
||||
threadId={threadId}
|
||||
autoFocus={isWelcomeMode}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isWelcomeMode &&
|
||||
!hasGoal &&
|
||||
!hasTodos && (
|
||||
<AgentWelcome agent={agent} agentName={agent_name} />
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</SidecarProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,19 @@ import {
|
|||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { GoalStatus } from "@/components/workspace/goal-status";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import {
|
||||
InputBox,
|
||||
type InputBoxSubmitOptions,
|
||||
} from "@/components/workspace/input-box";
|
||||
import {
|
||||
MessageList,
|
||||
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
} from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import {
|
||||
SidecarProvider,
|
||||
SidecarTrigger,
|
||||
} from "@/components/workspace/sidecar";
|
||||
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
|
||||
import { ThreadTitle } from "@/components/workspace/thread-title";
|
||||
import { TodoList } from "@/components/workspace/todo-list";
|
||||
|
|
@ -151,8 +158,8 @@ export default function ChatPage() {
|
|||
]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: PromptInputMessage) => {
|
||||
const sendPromise = sendMessage(threadId, message);
|
||||
(message: PromptInputMessage, options?: InputBoxSubmitOptions) => {
|
||||
const sendPromise = sendMessage(threadId, message, undefined, options);
|
||||
if (message.files.length > 0) {
|
||||
return sendPromise;
|
||||
}
|
||||
|
|
@ -180,151 +187,161 @@ export default function ChatPage() {
|
|||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
||||
isWelcomeMode
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isNewThread && <ThreadScheduledTasksLink threadId={threadId} />}
|
||||
<TokenUsageIndicator
|
||||
threadId={isNewThread ? undefined : threadId}
|
||||
backendUsage={backendTokenUsage}
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
pendingMessages={pendingUsageMessages}
|
||||
preferences={localSettings.tokenUsage}
|
||||
onPreferencesChange={(preferences) =>
|
||||
setLocalSettings("tokenUsage", preferences)
|
||||
}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isWelcomeMode && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
tokenUsageInlineMode={tokenUsageInlineMode}
|
||||
canRegenerate={
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
|
||||
!isUploading &&
|
||||
!thread.isLoading
|
||||
}
|
||||
onRegenerateMessage={handleRegenerate}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
<SidecarProvider
|
||||
parentThreadId={threadId}
|
||||
context={settings.context}
|
||||
isMock={isMock}
|
||||
>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div className="relative flex size-full min-h-0 justify-between">
|
||||
<header
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
|
||||
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
|
||||
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-2 sm:px-4",
|
||||
isWelcomeMode
|
||||
? "bg-background/0 backdrop-blur-none"
|
||||
: "bg-background/80 shadow-xs backdrop-blur",
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger className="md:hidden" />
|
||||
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isNewThread && (
|
||||
<ThreadScheduledTasksLink threadId={threadId} />
|
||||
)}
|
||||
<TokenUsageIndicator
|
||||
threadId={isNewThread ? undefined : threadId}
|
||||
backendUsage={backendTokenUsage}
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
pendingMessages={pendingUsageMessages}
|
||||
preferences={localSettings.tokenUsage}
|
||||
onPreferencesChange={(preferences) =>
|
||||
setLocalSettings("tokenUsage", preferences)
|
||||
}
|
||||
/>
|
||||
<SidecarTrigger />
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<ArtifactTrigger />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex min-h-0 flex-1 justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isWelcomeMode && "pt-10")}
|
||||
testId="main-message-list"
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
tokenUsageInlineMode={tokenUsageInlineMode}
|
||||
canRegenerate={
|
||||
!isNewThread &&
|
||||
!isMock &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" &&
|
||||
!isUploading &&
|
||||
!thread.isLoading
|
||||
}
|
||||
onRegenerateMessage={handleRegenerate}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isWelcomeMode &&
|
||||
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
|
||||
isWelcomeMode
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
"right-0 bottom-0 left-0 z-30 flex justify-center px-3 sm:px-4",
|
||||
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
|
||||
)}
|
||||
>
|
||||
{(hasGoal || hasTodos) && (
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 left-0 z-0",
|
||||
isWelcomeMode ? "absolute -top-4" : "relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
isWelcomeMode &&
|
||||
"-translate-y-[calc(50vh-48px)] sm:-translate-y-[calc(50vh-96px)]",
|
||||
isWelcomeMode
|
||||
? "max-w-(--container-width-sm)"
|
||||
: "max-w-(--container-width-md)",
|
||||
)}
|
||||
>
|
||||
{(hasGoal || hasTodos) && (
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 flex flex-col",
|
||||
isWelcomeMode ? "absolute" : "relative",
|
||||
"right-0 left-0 z-0",
|
||||
isWelcomeMode ? "absolute -top-4" : "relative",
|
||||
)}
|
||||
>
|
||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||
{hasTodos && (
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"right-0 bottom-0 left-0 flex flex-col",
|
||||
isWelcomeMode ? "absolute" : "relative",
|
||||
)}
|
||||
>
|
||||
{activeGoal && <GoalStatus goal={activeGoal} />}
|
||||
{hasTodos && (
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
hidden={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mountedRef.current ? (
|
||||
<InputBox
|
||||
className={cn(
|
||||
"bg-background/5 w-full",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
isWelcomeMode={isWelcomeMode}
|
||||
threadId={threadId}
|
||||
autoFocus={isWelcomeMode}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isWelcomeMode &&
|
||||
!hasGoal &&
|
||||
!hasTodos && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
isMock ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full rounded-2xl",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{mountedRef.current ? (
|
||||
<InputBox
|
||||
className={cn(
|
||||
"bg-background/5 w-full",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
isWelcomeMode={isWelcomeMode}
|
||||
threadId={threadId}
|
||||
autoFocus={isWelcomeMode}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isWelcomeMode &&
|
||||
!hasGoal &&
|
||||
!hasTodos && <Welcome mode={settings.context.mode} />
|
||||
}
|
||||
disabled={
|
||||
isMock ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
onGoalChange={setLocalGoal}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-background/5 h-32 w-full rounded-2xl",
|
||||
isWelcomeMode && "-translate-y-2 sm:-translate-y-4",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</main>
|
||||
</div>
|
||||
</ChatBox>
|
||||
</SidecarProvider>
|
||||
</ThreadContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ import { Button } from "@/components/ui/button";
|
|||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { useMaybeSidecar } from "../sidecar/context";
|
||||
|
||||
import { useArtifacts } from "./context";
|
||||
|
||||
export const ArtifactTrigger = () => {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen: setArtifactsOpen } = useArtifacts();
|
||||
const sidecar = useMaybeSidecar();
|
||||
|
||||
if (!artifacts || artifacts.length === 0) {
|
||||
return null;
|
||||
|
|
@ -19,6 +22,7 @@ export const ArtifactTrigger = () => {
|
|||
className="text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
sidecar?.close();
|
||||
setArtifactsOpen(true);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,9 @@
|
|||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -19,9 +13,11 @@ import {
|
|||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
import { SidecarPanel, useMaybeSidecar } from "../sidecar";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const RIGHT_PANEL_ANIMATION_MS = 280;
|
||||
|
||||
type RightPanelKind = "sidecar" | "artifacts";
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
|
|
@ -30,7 +26,6 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
|
|
@ -41,6 +36,8 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||
deselect,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
const sidecar = useMaybeSidecar();
|
||||
const sidecarOpen = sidecar?.open ?? false;
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
|
|
@ -87,63 +84,94 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (sidecarOpen) {
|
||||
return false;
|
||||
}
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
}, [artifactsOpen, artifacts, sidecarOpen]);
|
||||
|
||||
const activeRightPanel: RightPanelKind | null = sidecarOpen
|
||||
? "sidecar"
|
||||
: artifactPanelOpen
|
||||
? "artifacts"
|
||||
: null;
|
||||
const rightPanelOpen = activeRightPanel !== null;
|
||||
const [renderedRightPanel, setRenderedRightPanel] =
|
||||
useState<RightPanelKind | null>(activeRightPanel);
|
||||
|
||||
const resizableIdBase = useMemo(() => {
|
||||
return pathname.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (artifactPanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
if (activeRightPanel) {
|
||||
setRenderedRightPanel(activeRightPanel);
|
||||
return;
|
||||
}
|
||||
}, [artifactPanelOpen]);
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setRenderedRightPanel(null);
|
||||
}, RIGHT_PANEL_ANIMATION_MS);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [activeRightPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidecarOpen && artifactsOpen) {
|
||||
setArtifactsOpen(false);
|
||||
}
|
||||
}, [artifactsOpen, setArtifactsOpen, sidecarOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
<div
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
className={cn(
|
||||
"[container-type:inline-size] grid size-full min-h-0 transition-[grid-template-columns] duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
rightPanelOpen
|
||||
? "grid-cols-[minmax(0,1fr)_1px_minmax(0,40%)]"
|
||||
: "grid-cols-[minmax(0,1fr)_0px_0px]",
|
||||
)}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
<div className="relative min-h-0 min-w-0" id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
</div>
|
||||
<div
|
||||
id={`${resizableIdBase}-separator`}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
"bg-border opacity-33 transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
||||
!rightPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
<aside
|
||||
aria-hidden={!rightPanelOpen}
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
"min-h-0 min-w-0 overflow-hidden transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
!rightPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full p-4 transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
"ml-auto h-full w-[40cqw] transition-opacity duration-[280ms] ease-out motion-reduce:transition-none",
|
||||
renderedRightPanel === "sidecar" ? "p-0" : "p-4",
|
||||
rightPanelOpen ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
{renderedRightPanel === "sidecar" ? (
|
||||
<SidecarPanel />
|
||||
) : renderedRightPanel === "artifacts" && selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : (
|
||||
) : renderedRightPanel === "artifacts" ? (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
|
|
@ -177,10 +205,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
CheckIcon,
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
PromptInputBody,
|
||||
PromptInputButton,
|
||||
PromptInputFooter,
|
||||
PromptInputHeader,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
|
|
@ -64,6 +66,10 @@ import { getBackendBaseURL } from "@/core/config";
|
|||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
buildReferenceMessageMetadata,
|
||||
type SidecarContext,
|
||||
} from "@/core/sidecar";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useSuggestionsConfig } from "@/core/suggestions/hooks";
|
||||
import type { AgentThreadContext, GoalState } from "@/core/threads";
|
||||
|
|
@ -112,6 +118,7 @@ import {
|
|||
} from "./input-box-helpers";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { ReferenceAttachmentSummary, useMaybeSidecar } from "./sidecar";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
|
@ -129,6 +136,68 @@ function getResolvedMode(
|
|||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
|
||||
function escapeXmlAttribute(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export type InputBoxSubmitOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
additionalInputMessages?: Message[];
|
||||
onSent?: () => void;
|
||||
};
|
||||
|
||||
function buildHiddenConversationQuoteMessage({
|
||||
contexts,
|
||||
}: {
|
||||
contexts: SidecarContext[];
|
||||
}): Message {
|
||||
return {
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
contexts.length === 1
|
||||
? "The user added the following quoted context to this conversation."
|
||||
: `The user added the following ${contexts.length} quoted contexts to this conversation.`,
|
||||
"Use the referenced_message blocks as reference material for the user's next message.",
|
||||
"",
|
||||
...contexts.flatMap((context, index) =>
|
||||
[
|
||||
`<referenced_message index="${index + 1}" label="${escapeXmlAttribute(
|
||||
context.label,
|
||||
)}">`,
|
||||
`Role: ${context.role === "user" ? "User" : "Assistant"}`,
|
||||
context.messageId ? `Message ID: ${context.messageId}` : null,
|
||||
"",
|
||||
context.content,
|
||||
"</referenced_message>",
|
||||
"",
|
||||
].filter((line): line is string => line !== null),
|
||||
),
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join("\n"),
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
hide_from_ui: true,
|
||||
conversation_quote_context: true,
|
||||
// Keep ids/roles/count 1:1 parallel with `contexts` so consumers can zip
|
||||
// them safely; do not dedupe ids here.
|
||||
referenced_message_ids: contexts.map(
|
||||
(context) => context.messageId ?? "",
|
||||
),
|
||||
referenced_message_roles: contexts.map((context) => context.role),
|
||||
quote_context_count: contexts.length,
|
||||
},
|
||||
} as Message;
|
||||
}
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
disabled,
|
||||
|
|
@ -176,7 +245,10 @@ export function InputBox({
|
|||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
onGoalChange?: (goal: GoalState | null) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void | Promise<void>;
|
||||
onSubmit?: (
|
||||
message: PromptInputMessage,
|
||||
options?: InputBoxSubmitOptions,
|
||||
) => void | Promise<void>;
|
||||
onStop?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
|
@ -185,6 +257,7 @@ export function InputBox({
|
|||
const { models } = useModels();
|
||||
const { thread, isMock } = useThread();
|
||||
const { attachments, textInput } = usePromptInputController();
|
||||
const sidecar = useMaybeSidecar();
|
||||
const attachmentParts = attachments.files;
|
||||
const removeAttachment = attachments.remove;
|
||||
const { skills } = useSkills();
|
||||
|
|
@ -559,6 +632,26 @@ export function InputBox({
|
|||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
const quotes = sidecar?.conversationQuotes ?? [];
|
||||
const quoteIds = quotes.map((quote) => quote.id);
|
||||
const quoteContexts = quotes.map((quote) => quote.context);
|
||||
const submitOptions: InputBoxSubmitOptions | undefined = quotes.length
|
||||
? {
|
||||
additionalKwargs: buildReferenceMessageMetadata(quoteContexts),
|
||||
additionalInputMessages: [
|
||||
buildHiddenConversationQuoteMessage({
|
||||
contexts: quoteContexts,
|
||||
}),
|
||||
],
|
||||
// Clear quotes only once the send genuinely proceeds. If the send
|
||||
// is dropped by the in-flight guard, `onSent` never fires and the
|
||||
// quotes stay attached so they aren't silently lost.
|
||||
onSent: () => {
|
||||
sidecar?.clearConversationQuotes(quoteIds);
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const submit = () => onSubmit?.(message, submitOptions);
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
|
|
@ -573,12 +666,12 @@ export function InputBox({
|
|||
});
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
Promise.resolve(onSubmit?.(message)).then(resolve).catch(reject);
|
||||
Promise.resolve(submit()).then(resolve).catch(reject);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
return onSubmit?.(message);
|
||||
return submit();
|
||||
},
|
||||
[
|
||||
context,
|
||||
|
|
@ -587,6 +680,7 @@ export function InputBox({
|
|||
reportUploadLimitViolations,
|
||||
resolvedModelName,
|
||||
selectedModel?.supports_thinking,
|
||||
sidecar,
|
||||
t.inputBox.suggestionPlaceholderRequired,
|
||||
uploadLimits,
|
||||
],
|
||||
|
|
@ -629,7 +723,13 @@ export function InputBox({
|
|||
}
|
||||
return submitThreadMessage(message);
|
||||
},
|
||||
[handleGoalCommand, onStop, status, submitThreadMessage],
|
||||
[
|
||||
handleGoalCommand,
|
||||
onStop,
|
||||
status,
|
||||
submitThreadMessage,
|
||||
t.inputBox.pleaseWaitStreaming,
|
||||
],
|
||||
);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
|
|
@ -1073,9 +1173,22 @@ export function InputBox({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PromptInputAttachments>
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
<PromptInputHeader className="flex-wrap px-3 pt-3 pb-0 empty:hidden">
|
||||
<PromptInputAttachments className="contents p-0">
|
||||
{(attachment) => (
|
||||
<div className="max-w-60">
|
||||
<PromptInputAttachment data={attachment} />
|
||||
</div>
|
||||
)}
|
||||
</PromptInputAttachments>
|
||||
{sidecar && sidecar.conversationQuotes.length > 0 && (
|
||||
<ReferenceAttachmentSummary
|
||||
references={sidecar.conversationQuotes}
|
||||
testId="conversation-quote-attachment"
|
||||
onClear={() => sidecar.clearConversationQuotes()}
|
||||
/>
|
||||
)}
|
||||
</PromptInputHeader>
|
||||
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
|
||||
<PromptInputTextarea
|
||||
className={cn("size-full")}
|
||||
|
|
|
|||
|
|
@ -42,11 +42,13 @@ import {
|
|||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { readReferenceMessageContexts } from "@/core/sidecar";
|
||||
import { SafeReasoningContent } from "@/core/streamdown/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { createMarkdownLinkComponent } from "./markdown-link";
|
||||
|
|
@ -245,7 +247,7 @@ function MessageContent_({
|
|||
clientTurnDurations.set(`${threadId}:${message.id}`, rawTurnDuration);
|
||||
setCachedDuration(rawTurnDuration);
|
||||
}
|
||||
}, [rawTurnDuration, message.id]);
|
||||
}, [rawTurnDuration, message.id, threadId]);
|
||||
|
||||
const handleDurationChange = useCallback(
|
||||
(d: number | undefined) => {
|
||||
|
|
@ -254,7 +256,7 @@ function MessageContent_({
|
|||
setCachedDuration(d);
|
||||
}
|
||||
},
|
||||
[message.id],
|
||||
[message.id, threadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -295,6 +297,16 @@ function MessageContent_({
|
|||
}
|
||||
return files as FileInMessage[];
|
||||
}, [message.additional_kwargs?.files, rawContent]);
|
||||
const referenceAttachments = useMemo(
|
||||
() =>
|
||||
readReferenceMessageContexts(message.additional_kwargs).map(
|
||||
(context, index) => ({
|
||||
id: index,
|
||||
context,
|
||||
}),
|
||||
),
|
||||
[message.additional_kwargs],
|
||||
);
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
|
|
@ -357,6 +369,13 @@ function MessageContent_({
|
|||
className,
|
||||
)}
|
||||
>
|
||||
{referenceAttachments.length > 0 && (
|
||||
<ReferenceAttachmentSummary
|
||||
className="self-end shadow-none"
|
||||
references={referenceAttachments}
|
||||
testId="message-reference-attachment"
|
||||
/>
|
||||
)}
|
||||
{filesList}
|
||||
{contentToDisplay && (
|
||||
<AIElementMessageContent className="w-full max-w-full">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,26 @@
|
|||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { ChevronUpIcon, Loader2Icon, RefreshCcwIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronUpIcon,
|
||||
Loader2Icon,
|
||||
MessageCircleIcon,
|
||||
MessageSquarePlusIcon,
|
||||
RefreshCcwIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
type ConversationProps,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Reasoning,
|
||||
|
|
@ -31,6 +46,10 @@ import {
|
|||
isAssistantMessageGroupStreaming,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import {
|
||||
buildMessageSidecarContext,
|
||||
type SidecarContext,
|
||||
} from "@/core/sidecar";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import {
|
||||
|
|
@ -42,7 +61,7 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
import { useMaybeSidecar } from "../sidecar/context";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
|
@ -59,6 +78,19 @@ export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 24;
|
|||
|
||||
const LOAD_MORE_HISTORY_THROTTLE_MS = 1200;
|
||||
|
||||
const SELECTION_TOOLBAR_MARGIN = 8;
|
||||
// Approximate rendered height of the pill (p-1 padding + h-8 button). Used only
|
||||
// to decide whether the toolbar fits above the selection; exact height isn't
|
||||
// needed because we flip below when space is tight.
|
||||
const SELECTION_TOOLBAR_ESTIMATED_HEIGHT = 48;
|
||||
|
||||
type SelectionToolbarState = {
|
||||
context: SidecarContext;
|
||||
x: number;
|
||||
y: number;
|
||||
placement: "top" | "bottom";
|
||||
};
|
||||
|
||||
function LoadMoreHistoryIndicator({
|
||||
isLoading,
|
||||
hasMore,
|
||||
|
|
@ -166,6 +198,7 @@ function LoadMoreHistoryIndicator({
|
|||
|
||||
export function MessageList({
|
||||
className,
|
||||
testId,
|
||||
threadId,
|
||||
thread,
|
||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
|
|
@ -175,8 +208,12 @@ export function MessageList({
|
|||
isHistoryLoading,
|
||||
onRegenerateMessage,
|
||||
canRegenerate = false,
|
||||
enableSidecarActions = true,
|
||||
initialScroll = "smooth",
|
||||
resizeScroll = "smooth",
|
||||
}: {
|
||||
className?: string;
|
||||
testId?: string;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
|
|
@ -189,8 +226,14 @@ export function MessageList({
|
|||
supersededMessageIds: string[],
|
||||
) => void | Promise<void>;
|
||||
canRegenerate?: boolean;
|
||||
enableSidecarActions?: boolean;
|
||||
initialScroll?: ConversationProps["initial"];
|
||||
resizeScroll?: ConversationProps["resize"];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const sidecar = useMaybeSidecar();
|
||||
const [selectionToolbar, setSelectionToolbar] =
|
||||
useState<SelectionToolbarState | null>(null);
|
||||
const [turnStartTime, setTurnStartTime] = useState<number | null>(null);
|
||||
const prevIsLoading = useRef(thread.isLoading);
|
||||
|
||||
|
|
@ -250,6 +293,133 @@ export function MessageList({
|
|||
return null;
|
||||
}, [groupedMessages, thread.isLoading]);
|
||||
|
||||
const clearSelectionToolbar = useCallback(() => {
|
||||
setSelectionToolbar(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionToolbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hideOnScroll = () => {
|
||||
setSelectionToolbar(null);
|
||||
};
|
||||
const hideOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectionToolbar(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", hideOnScroll, true);
|
||||
document.addEventListener("keydown", hideOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", hideOnScroll, true);
|
||||
document.removeEventListener("keydown", hideOnEscape);
|
||||
};
|
||||
}, [selectionToolbar]);
|
||||
|
||||
const handleAssistantTextSelection = useCallback(
|
||||
(
|
||||
event: MouseEvent<HTMLDivElement>,
|
||||
message: Message,
|
||||
displayIndex: number,
|
||||
) => {
|
||||
if (
|
||||
!enableSidecarActions ||
|
||||
thread.isLoading ||
|
||||
!sidecar ||
|
||||
message.type !== "ai"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString().trim();
|
||||
if (
|
||||
!selection ||
|
||||
selection.isCollapsed ||
|
||||
!selectedText ||
|
||||
selection.rangeCount === 0
|
||||
) {
|
||||
setSelectionToolbar(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.anchorNode || !selection.focusNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Widen containment to the shared assistant-turn container so a selection
|
||||
// that spans multiple AI messages within the same turn still yields a
|
||||
// toolbar (#3553). Fall back to the per-message wrapper if the turn
|
||||
// container can't be found.
|
||||
const turnContainer =
|
||||
event.currentTarget.closest<HTMLElement>("[data-assistant-turn]") ??
|
||||
event.currentTarget;
|
||||
if (!turnContainer.contains(selection.anchorNode)) {
|
||||
return;
|
||||
}
|
||||
if (!turnContainer.contains(selection.focusNode)) {
|
||||
// The selection leaked into another turn/message; the quote would be
|
||||
// ambiguous, so surface a hint instead of failing silently.
|
||||
toast.info(t.sidecar.selectionCrossesMessages);
|
||||
setSelectionToolbar(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextContext = buildMessageSidecarContext(message, displayIndex, {
|
||||
selectedText,
|
||||
});
|
||||
if (!nextContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The pill is rendered with `-translate-y-full`, so anchoring it at
|
||||
// `rect.top` moves it up by its own height. When the selection sits near
|
||||
// the viewport top there isn't room above, so flip it below the selection
|
||||
// to keep both actions reachable (#3551).
|
||||
const rect = selection.getRangeAt(0).getBoundingClientRect();
|
||||
const fitsAbove =
|
||||
rect.top -
|
||||
SELECTION_TOOLBAR_MARGIN -
|
||||
SELECTION_TOOLBAR_ESTIMATED_HEIGHT >=
|
||||
0;
|
||||
setSelectionToolbar({
|
||||
context: nextContext,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: fitsAbove
|
||||
? rect.top - SELECTION_TOOLBAR_MARGIN
|
||||
: rect.bottom + SELECTION_TOOLBAR_MARGIN,
|
||||
placement: fitsAbove ? "top" : "bottom",
|
||||
});
|
||||
},
|
||||
[
|
||||
enableSidecarActions,
|
||||
sidecar,
|
||||
t.sidecar.selectionCrossesMessages,
|
||||
thread.isLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const handleAddSelectionToConversation = useCallback(() => {
|
||||
if (!selectionToolbar) {
|
||||
return;
|
||||
}
|
||||
sidecar?.addContextToConversation(selectionToolbar.context);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelectionToolbar(null);
|
||||
}, [selectionToolbar, sidecar]);
|
||||
|
||||
const handleAskSelectionInSidecar = useCallback(() => {
|
||||
if (!selectionToolbar) {
|
||||
return;
|
||||
}
|
||||
sidecar?.openContext(selectionToolbar.context);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelectionToolbar(null);
|
||||
}, [selectionToolbar, sidecar]);
|
||||
|
||||
const renderAssistantActions = useCallback(
|
||||
(
|
||||
messages: Message[],
|
||||
|
|
@ -264,7 +434,6 @@ export function MessageList({
|
|||
.filter((message) => message.type === "ai" && message.id)
|
||||
.map((message) => message.id)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
|
||||
if (!clipboardData && !regenerateTarget) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -370,237 +539,315 @@ export function MessageList({
|
|||
}
|
||||
|
||||
return (
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-8">
|
||||
<LoadMoreHistoryIndicator
|
||||
isLoading={isHistoryLoading}
|
||||
hasMore={hasMoreHistory}
|
||||
loadMore={loadMoreHistory}
|
||||
/>
|
||||
{groupedMessages.map((group, groupIndex) => {
|
||||
const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex];
|
||||
const groupIsLoading =
|
||||
thread.isLoading && groupIndex === lastGroupIndex;
|
||||
<>
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
data-testid={testId}
|
||||
initial={initialScroll}
|
||||
resize={resizeScroll}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-8">
|
||||
<LoadMoreHistoryIndicator
|
||||
isLoading={isHistoryLoading}
|
||||
hasMore={hasMoreHistory}
|
||||
loadMore={loadMoreHistory}
|
||||
/>
|
||||
{groupedMessages.map((group, groupIndex) => {
|
||||
const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex];
|
||||
const groupIsLoading =
|
||||
thread.isLoading && groupIndex === lastGroupIndex;
|
||||
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
className={cn(
|
||||
"w-full",
|
||||
group.type === "assistant" && "group/assistant-turn",
|
||||
)}
|
||||
>
|
||||
{group.messages.map((msg) => {
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
message={msg}
|
||||
isLoading={
|
||||
thread.isLoading &&
|
||||
groupIndex === groupedMessages.length - 1
|
||||
}
|
||||
threadId={threadId}
|
||||
showCopyButton={group.type !== "assistant"}
|
||||
turnStartTime={
|
||||
groupIndex === groupedMessages.length - 1
|
||||
? turnStartTime
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
})}
|
||||
{group.type === "assistant" &&
|
||||
renderAssistantActions(
|
||||
group.messages,
|
||||
isAssistantMessageGroupStreaming(
|
||||
group.messages,
|
||||
streamingMessages,
|
||||
),
|
||||
group.id === latestAssistantGroupId,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return (
|
||||
<div key={group.id} className="w-full">
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(message)}
|
||||
isLoading={thread.isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
<div
|
||||
key={group.id}
|
||||
data-assistant-turn={
|
||||
group.type === "assistant" ? "" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-full",
|
||||
group.type === "assistant" && "group/assistant-turn",
|
||||
)}
|
||||
>
|
||||
{group.messages.map((msg) => {
|
||||
const item = (
|
||||
<MessageListItem
|
||||
message={msg}
|
||||
isLoading={
|
||||
thread.isLoading &&
|
||||
groupIndex === groupedMessages.length - 1
|
||||
}
|
||||
threadId={threadId}
|
||||
showCopyButton={group.type !== "assistant"}
|
||||
turnStartTime={
|
||||
groupIndex === groupedMessages.length - 1
|
||||
? turnStartTime
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (
|
||||
group.type !== "assistant" ||
|
||||
!enableSidecarActions ||
|
||||
msg.type !== "ai"
|
||||
) {
|
||||
return <div key={`${group.id}/${msg.id}`}>{item}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${group.id}/${msg.id}`}
|
||||
onMouseUp={(event) =>
|
||||
handleAssistantTextSelection(
|
||||
event,
|
||||
msg,
|
||||
groupIndex + 1,
|
||||
)
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
})}
|
||||
{group.type === "assistant" &&
|
||||
renderAssistantActions(
|
||||
group.messages,
|
||||
isAssistantMessageGroupStreaming(
|
||||
group.messages,
|
||||
streamingMessages,
|
||||
),
|
||||
group.id === latestAssistantGroupId,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (group.type === "assistant:clarification") {
|
||||
const message = group.messages[0];
|
||||
if (message && hasContent(message)) {
|
||||
return (
|
||||
<div key={group.id} className="w-full">
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(message)}
|
||||
isLoading={thread.isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
/>
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
if (hasPresentFiles(message)) {
|
||||
const presentFiles = extractPresentFilesFromMessage(message);
|
||||
files.push(...presentFiles);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="w-full" key={group.id}>
|
||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
isLoading={thread.isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
<ArtifactFileList files={files} threadId={threadId} />
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
if (hasPresentFiles(message)) {
|
||||
const presentFiles = extractPresentFilesFromMessage(message);
|
||||
files.push(...presentFiles);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="w-full" key={group.id}>
|
||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
isLoading={thread.isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
<ArtifactFileList files={files} threadId={threadId} />
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
} else if (group.type === "assistant:subagent") {
|
||||
const tasks = new Set<Subtask>();
|
||||
for (const message of group.messages) {
|
||||
if (message.type === "ai") {
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
if (toolCall.name === "task") {
|
||||
const taskId = toolCall.id;
|
||||
if (!taskId) {
|
||||
continue;
|
||||
} else if (group.type === "assistant:subagent") {
|
||||
const tasks = new Set<Subtask>();
|
||||
for (const message of group.messages) {
|
||||
if (message.type === "ai") {
|
||||
for (const toolCall of message.tool_calls ?? []) {
|
||||
if (toolCall.name === "task") {
|
||||
const taskId = toolCall.id;
|
||||
if (!taskId) {
|
||||
continue;
|
||||
}
|
||||
const status = derivePendingSubtaskStatus(
|
||||
taskId,
|
||||
group.messages,
|
||||
groupIsLoading,
|
||||
);
|
||||
const task: Subtask = {
|
||||
id: taskId,
|
||||
subagent_type: toolCall.args.subagent_type,
|
||||
description: toolCall.args.description,
|
||||
prompt: toolCall.args.prompt,
|
||||
status,
|
||||
...(status === "failed"
|
||||
? { error: t.subtasks.failed }
|
||||
: {}),
|
||||
};
|
||||
updateSubtask(task);
|
||||
tasks.add(task);
|
||||
}
|
||||
const status = derivePendingSubtaskStatus(
|
||||
taskId,
|
||||
group.messages,
|
||||
groupIsLoading,
|
||||
}
|
||||
} else if (message.type === "tool") {
|
||||
const taskId = message.tool_call_id;
|
||||
if (taskId) {
|
||||
const parsed = parseSubtaskResult(
|
||||
extractTextFromMessage(message),
|
||||
message.additional_kwargs,
|
||||
);
|
||||
const task: Subtask = {
|
||||
id: taskId,
|
||||
subagent_type: toolCall.args.subagent_type,
|
||||
description: toolCall.args.description,
|
||||
prompt: toolCall.args.prompt,
|
||||
status,
|
||||
...(status === "failed"
|
||||
? { error: t.subtasks.failed }
|
||||
: {}),
|
||||
};
|
||||
updateSubtask(task);
|
||||
tasks.add(task);
|
||||
updateSubtask({ id: taskId, ...parsed });
|
||||
}
|
||||
}
|
||||
} else if (message.type === "tool") {
|
||||
const taskId = message.tool_call_id;
|
||||
if (taskId) {
|
||||
const parsed = parseSubtaskResult(
|
||||
extractTextFromMessage(message),
|
||||
message.additional_kwargs,
|
||||
}
|
||||
|
||||
const results: React.ReactNode[] = [];
|
||||
const subagentDebugMessageIds: string[] = [];
|
||||
if (tasks.size > 0) {
|
||||
results.push(
|
||||
<div
|
||||
key="subtask-count"
|
||||
className="text-muted-foreground pt-2 text-sm font-normal"
|
||||
>
|
||||
{t.subtasks.executing(tasks.size)}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
for (const message of group.messages.filter(
|
||||
(message) => message.type === "ai",
|
||||
)) {
|
||||
if (hasReasoning(message)) {
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={groupIsLoading}
|
||||
tokenDebugSteps={tokenDebugSteps.filter(
|
||||
(step) => step.messageId === message.id,
|
||||
)}
|
||||
showTokenDebugSummaries={
|
||||
tokenUsageInlineMode === "step_debug"
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else if (message.id) {
|
||||
subagentDebugMessageIds.push(message.id);
|
||||
}
|
||||
const taskIds = message.tool_calls?.flatMap((toolCall) =>
|
||||
toolCall.name === "task" && toolCall.id ? [toolCall.id] : [],
|
||||
);
|
||||
for (const taskId of taskIds ?? []) {
|
||||
results.push(
|
||||
<SubtaskCard
|
||||
key={"task-group-" + taskId}
|
||||
taskId={taskId}
|
||||
threadId={threadId}
|
||||
runId={(message as { run_id?: string }).run_id}
|
||||
isLoading={groupIsLoading}
|
||||
/>,
|
||||
);
|
||||
updateSubtask({ id: taskId, ...parsed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: React.ReactNode[] = [];
|
||||
const subagentDebugMessageIds: string[] = [];
|
||||
if (tasks.size > 0) {
|
||||
results.push(
|
||||
return (
|
||||
<div
|
||||
key="subtask-count"
|
||||
className="text-muted-foreground pt-2 text-sm font-normal"
|
||||
key={"subtask-group-" + group.id}
|
||||
className="relative z-1 flex flex-col gap-2"
|
||||
>
|
||||
{t.subtasks.executing(tasks.size)}
|
||||
</div>,
|
||||
{results}
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
debugMessageIds: subagentDebugMessageIds,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
for (const message of group.messages.filter(
|
||||
(message) => message.type === "ai",
|
||||
)) {
|
||||
if (hasReasoning(message)) {
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={groupIsLoading}
|
||||
tokenDebugSteps={tokenDebugSteps.filter(
|
||||
(step) => step.messageId === message.id,
|
||||
)}
|
||||
showTokenDebugSummaries={
|
||||
tokenUsageInlineMode === "step_debug"
|
||||
}
|
||||
/>,
|
||||
);
|
||||
} else if (message.id) {
|
||||
subagentDebugMessageIds.push(message.id);
|
||||
}
|
||||
const taskIds = message.tool_calls?.flatMap((toolCall) =>
|
||||
toolCall.name === "task" && toolCall.id ? [toolCall.id] : [],
|
||||
);
|
||||
for (const taskId of taskIds ?? []) {
|
||||
results.push(
|
||||
<SubtaskCard
|
||||
key={"task-group-" + taskId}
|
||||
taskId={taskId}
|
||||
threadId={threadId}
|
||||
runId={(message as { run_id?: string }).run_id}
|
||||
isLoading={groupIsLoading}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={"subtask-group-" + group.id}
|
||||
className="relative z-1 flex flex-col gap-2"
|
||||
>
|
||||
{results}
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
tokenDebugSteps={tokenDebugSteps.filter((step) =>
|
||||
group.messages.some(
|
||||
(message) => message.id === step.messageId,
|
||||
),
|
||||
)}
|
||||
showTokenDebugSummaries={
|
||||
tokenUsageInlineMode === "step_debug"
|
||||
}
|
||||
/>
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
debugMessageIds: subagentDebugMessageIds,
|
||||
inlineDebug: false,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={"group-" + group.id} className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
tokenDebugSteps={tokenDebugSteps.filter((step) =>
|
||||
group.messages.some(
|
||||
(message) => message.id === step.messageId,
|
||||
),
|
||||
)}
|
||||
showTokenDebugSummaries={tokenUsageInlineMode === "step_debug"}
|
||||
/>
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
inlineDebug: false,
|
||||
})}
|
||||
})}
|
||||
{thread.isLoading && !hasActiveAssistantText && (
|
||||
<div className="w-full">
|
||||
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
|
||||
<ReasoningTrigger hasContent={false} />
|
||||
</Reasoning>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{thread.isLoading && !hasActiveAssistantText && (
|
||||
<div className="w-full">
|
||||
<Reasoning isStreaming={true} startTimeProp={turnStartTime}>
|
||||
<ReasoningTrigger hasContent={false} />
|
||||
</Reasoning>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
)}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
{selectionToolbar && sidecar && (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground border-border fixed z-50 flex -translate-x-1/2 items-center gap-1 rounded-full border p-1 shadow-lg",
|
||||
selectionToolbar.placement === "bottom"
|
||||
? "translate-y-0"
|
||||
: "-translate-y-full",
|
||||
)}
|
||||
data-sidecar-selection-toolbar
|
||||
style={{ left: selectionToolbar.x, top: selectionToolbar.y }}
|
||||
>
|
||||
<Button
|
||||
className="h-8 rounded-full px-2.5 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleAddSelectionToConversation}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<MessageCircleIcon className="size-3.5" />
|
||||
{t.sidecar.addToConversation}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 rounded-full px-2.5 text-xs"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleAskSelectionInSidecar}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<MessageSquarePlusIcon className="size-3.5" />
|
||||
{t.sidecar.askInSideChat}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
className="size-8 rounded-full"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={clearSelectionToolbar}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
286
frontend/src/components/workspace/sidecar/context.tsx
Normal file
286
frontend/src/components/workspace/sidecar/context.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
appendSidecarReference,
|
||||
buildMessageSidecarContext,
|
||||
getNextSidecarOpenState,
|
||||
type SidecarContext,
|
||||
type SidecarReferenceStateItem,
|
||||
} from "@/core/sidecar";
|
||||
import { findLatestSidecarThread } from "@/core/sidecar/api";
|
||||
import type { ThreadStreamOptions } from "@/core/threads/hooks";
|
||||
|
||||
export type SidecarReference = SidecarReferenceStateItem;
|
||||
|
||||
type SidecarContextValue = {
|
||||
open: boolean;
|
||||
activeReferences: SidecarReference[];
|
||||
conversationQuotes: SidecarReference[];
|
||||
parentThreadId: string;
|
||||
context: ThreadStreamOptions["context"];
|
||||
setContext: (context: ThreadStreamOptions["context"]) => void;
|
||||
isMock?: boolean;
|
||||
sidecarThreadId: string | null;
|
||||
setSidecarThreadId: (threadId: string | null) => void;
|
||||
restoreSidecarThread: (options?: {
|
||||
force?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
addContextToConversation: (context: SidecarContext) => void;
|
||||
clearConversationQuotes: (ids?: number[]) => void;
|
||||
clearActiveReferences: () => void;
|
||||
openSidecar: () => void;
|
||||
openContext: (context: SidecarContext) => void;
|
||||
openSelectedText: (
|
||||
message: Message,
|
||||
selectedText: string,
|
||||
displayIndex?: number,
|
||||
) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const SidecarContextObject = createContext<SidecarContextValue | null>(null);
|
||||
|
||||
export function SidecarProvider({
|
||||
children,
|
||||
parentThreadId,
|
||||
context,
|
||||
isMock,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
parentThreadId: string;
|
||||
context: ThreadStreamOptions["context"];
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeReferences, setActiveReferences] = useState<SidecarReference[]>(
|
||||
[],
|
||||
);
|
||||
const [sidecarThreadId, setSidecarThreadId] = useState<string | null>(null);
|
||||
const [sidecarContext, setSidecarContext] =
|
||||
useState<ThreadStreamOptions["context"]>(context);
|
||||
const [conversationQuotes, setConversationQuotes] = useState<
|
||||
SidecarReference[]
|
||||
>([]);
|
||||
const referenceIdRef = useRef(0);
|
||||
const parentThreadIdRef = useRef(parentThreadId);
|
||||
const sidecarThreadIdRef = useRef<string | null>(null);
|
||||
const restoreRequestRef = useRef<{
|
||||
parentThreadId: string;
|
||||
promise: Promise<string | null>;
|
||||
} | null>(null);
|
||||
|
||||
const updateSidecarThreadId = useCallback((threadId: string | null) => {
|
||||
sidecarThreadIdRef.current = threadId;
|
||||
setSidecarThreadId(threadId);
|
||||
}, []);
|
||||
|
||||
const createReference = useCallback((nextContext: SidecarContext) => {
|
||||
referenceIdRef.current += 1;
|
||||
return {
|
||||
id: referenceIdRef.current,
|
||||
context: nextContext,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (parentThreadIdRef.current === parentThreadId) {
|
||||
return;
|
||||
}
|
||||
parentThreadIdRef.current = parentThreadId;
|
||||
setOpen(false);
|
||||
setActiveReferences([]);
|
||||
setSidecarContext(context);
|
||||
updateSidecarThreadId(null);
|
||||
setConversationQuotes([]);
|
||||
}, [context, parentThreadId, updateSidecarThreadId]);
|
||||
|
||||
const restoreSidecarThread = useCallback(
|
||||
async (options?: { force?: boolean }) => {
|
||||
// A non-forced restore trusts the cached id; a forced restore always
|
||||
// re-queries the backend so a sidecar deleted elsewhere reconciles to
|
||||
// null instead of pointing the trigger at a dead thread (#3555).
|
||||
if (!options?.force && sidecarThreadIdRef.current) {
|
||||
return sidecarThreadIdRef.current;
|
||||
}
|
||||
|
||||
const restoreRequest = restoreRequestRef.current;
|
||||
if (restoreRequest?.parentThreadId === parentThreadId) {
|
||||
return restoreRequest.promise;
|
||||
}
|
||||
|
||||
const promise = findLatestSidecarThread({
|
||||
parentThreadId,
|
||||
isMock,
|
||||
})
|
||||
.then((thread) => {
|
||||
const threadId = thread?.thread_id ?? null;
|
||||
if (parentThreadIdRef.current !== parentThreadId) {
|
||||
return null;
|
||||
}
|
||||
// Reconcile the cache with the backend: adopt a freshly found
|
||||
// thread, and on a forced refresh clear a stale id when the backend
|
||||
// no longer has a matching sidecar thread.
|
||||
if (threadId) {
|
||||
if (!sidecarThreadIdRef.current) {
|
||||
updateSidecarThreadId(threadId);
|
||||
}
|
||||
} else if (options?.force && sidecarThreadIdRef.current) {
|
||||
updateSidecarThreadId(null);
|
||||
}
|
||||
return threadId;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
if (restoreRequestRef.current?.promise === promise) {
|
||||
restoreRequestRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
restoreRequestRef.current = {
|
||||
parentThreadId,
|
||||
promise,
|
||||
};
|
||||
|
||||
return promise;
|
||||
},
|
||||
[isMock, parentThreadId, updateSidecarThreadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void restoreSidecarThread();
|
||||
}, [restoreSidecarThread]);
|
||||
|
||||
const openContext = useCallback(
|
||||
(nextContext: SidecarContext) => {
|
||||
const nextReference = createReference(nextContext);
|
||||
|
||||
setActiveReferences(
|
||||
(references) =>
|
||||
getNextSidecarOpenState({
|
||||
open,
|
||||
sidecarThreadId,
|
||||
activeReferences: references,
|
||||
nextReference,
|
||||
}).activeReferences,
|
||||
);
|
||||
setOpen(true);
|
||||
},
|
||||
[createReference, open, sidecarThreadId],
|
||||
);
|
||||
|
||||
const addContextToConversation = useCallback(
|
||||
(nextContext: SidecarContext) => {
|
||||
const nextReference = createReference(nextContext);
|
||||
setConversationQuotes((references) =>
|
||||
appendSidecarReference(references, nextReference),
|
||||
);
|
||||
},
|
||||
[createReference],
|
||||
);
|
||||
|
||||
const clearConversationQuotes = useCallback((ids?: number[]) => {
|
||||
if (!ids) {
|
||||
setConversationQuotes([]);
|
||||
return;
|
||||
}
|
||||
const idsToClear = new Set(ids);
|
||||
setConversationQuotes((quotes) =>
|
||||
quotes.filter((quote) => !idsToClear.has(quote.id)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearActiveReferences = useCallback(() => {
|
||||
setActiveReferences([]);
|
||||
}, []);
|
||||
|
||||
const openSidecar = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const openSelectedText = useCallback(
|
||||
(message: Message, selectedText: string, displayIndex?: number) => {
|
||||
const nextContext = buildMessageSidecarContext(message, displayIndex, {
|
||||
selectedText,
|
||||
});
|
||||
if (!nextContext) {
|
||||
return;
|
||||
}
|
||||
openContext(nextContext);
|
||||
},
|
||||
[openContext],
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<SidecarContextValue>(
|
||||
() => ({
|
||||
open,
|
||||
activeReferences,
|
||||
conversationQuotes,
|
||||
parentThreadId,
|
||||
context: sidecarContext,
|
||||
setContext: setSidecarContext,
|
||||
isMock,
|
||||
sidecarThreadId,
|
||||
setSidecarThreadId: updateSidecarThreadId,
|
||||
restoreSidecarThread,
|
||||
addContextToConversation,
|
||||
clearConversationQuotes,
|
||||
clearActiveReferences,
|
||||
openSidecar,
|
||||
openContext,
|
||||
openSelectedText,
|
||||
close,
|
||||
}),
|
||||
[
|
||||
activeReferences,
|
||||
addContextToConversation,
|
||||
clearActiveReferences,
|
||||
clearConversationQuotes,
|
||||
close,
|
||||
conversationQuotes,
|
||||
isMock,
|
||||
open,
|
||||
openContext,
|
||||
openSelectedText,
|
||||
openSidecar,
|
||||
parentThreadId,
|
||||
restoreSidecarThread,
|
||||
sidecarContext,
|
||||
sidecarThreadId,
|
||||
updateSidecarThreadId,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidecarContextObject.Provider value={value}>
|
||||
{children}
|
||||
</SidecarContextObject.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMaybeSidecar() {
|
||||
return useContext(SidecarContextObject);
|
||||
}
|
||||
|
||||
export function useSidecar() {
|
||||
const context = useMaybeSidecar();
|
||||
if (!context) {
|
||||
throw new Error("useSidecar must be used within a SidecarProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
4
frontend/src/components/workspace/sidecar/index.ts
Normal file
4
frontend/src/components/workspace/sidecar/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { SidecarProvider, useMaybeSidecar, useSidecar } from "./context";
|
||||
export { ReferenceAttachmentSummary } from "./reference-attachments";
|
||||
export { SidecarPanel } from "./sidecar-panel";
|
||||
export { SidecarTrigger } from "./sidecar-trigger";
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquareQuoteIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import type { SidecarReference } from "./context";
|
||||
|
||||
function formatReferenceCount({
|
||||
count,
|
||||
one,
|
||||
many,
|
||||
}: {
|
||||
count: number;
|
||||
one: string;
|
||||
many: string;
|
||||
}) {
|
||||
return (count === 1 ? one : many).replace("{count}", String(count));
|
||||
}
|
||||
|
||||
function formatPreviewText(content: string) {
|
||||
return content.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function ReferencePreview({ references }: { references: SidecarReference[] }) {
|
||||
if (references.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-72 max-w-[80vw] space-y-1.5">
|
||||
{references.map((reference) => (
|
||||
<div
|
||||
className="line-clamp-3 text-left text-sm leading-6 break-words"
|
||||
key={reference.id}
|
||||
>
|
||||
{`"${formatPreviewText(reference.context.content)}"`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReferenceAttachmentSummary({
|
||||
references,
|
||||
onClear,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
references: SidecarReference[];
|
||||
onClear?: () => void;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
if (references.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = formatReferenceCount({
|
||||
count: references.length,
|
||||
one: t.sidecar.selectedTextFragment,
|
||||
many: t.sidecar.selectedTextFragments,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border bg-muted/40 text-foreground inline-flex max-w-[min(18rem,100%)] items-center gap-1.5 rounded-full border px-2.5 py-1.5 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Tooltip content={<ReferencePreview references={references} />}>
|
||||
<span className="flex min-w-0 cursor-default items-center gap-1.5">
|
||||
<MessageSquareQuoteIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="truncate text-sm font-medium">{label}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{onClear && (
|
||||
<Button
|
||||
aria-label={t.sidecar.clearReferences}
|
||||
className="text-muted-foreground hover:text-foreground size-6 rounded-full"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClear}
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
817
frontend/src/components/workspace/sidecar/sidecar-panel.tsx
Normal file
817
frontend/src/components/workspace/sidecar/sidecar-panel.tsx
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
CheckIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
MessageSquareTextIcon,
|
||||
PaperclipIcon,
|
||||
RocketIcon,
|
||||
XIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputActionMenu,
|
||||
PromptInputActionMenuContent,
|
||||
PromptInputActionMenuItem,
|
||||
PromptInputActionMenuTrigger,
|
||||
PromptInputAttachment,
|
||||
PromptInputAttachments,
|
||||
PromptInputBody,
|
||||
PromptInputButton,
|
||||
PromptInputFooter,
|
||||
PromptInputHeader,
|
||||
PromptInputProvider,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
usePromptInputAttachments,
|
||||
type PromptInputMessage,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Model } from "@/core/models/types";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
buildParentConversationContext,
|
||||
buildReferenceMessageMetadata,
|
||||
buildSidecarContextPrompt,
|
||||
} from "@/core/sidecar";
|
||||
import { createSidecarThread } from "@/core/sidecar/api";
|
||||
import {
|
||||
useThreadStream,
|
||||
type ThreadStreamOptions,
|
||||
} from "@/core/threads/hooks";
|
||||
import {
|
||||
formatUploadSize,
|
||||
useUploadLimits,
|
||||
validateUploadLimits,
|
||||
type UploadLimits,
|
||||
type UploadLimitViolation,
|
||||
} from "@/core/uploads";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ModelSelector,
|
||||
ModelSelectorContent,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorName,
|
||||
ModelSelectorTrigger,
|
||||
} from "../../ai-elements/model-selector";
|
||||
import { MessageList, MESSAGE_LIST_DEFAULT_PADDING_BOTTOM } from "../messages";
|
||||
import { useThread as useParentThread } from "../messages/context";
|
||||
import { ModeHoverGuide } from "../mode-hover-guide";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { type SidecarReference, useSidecar } from "./context";
|
||||
import { ReferenceAttachmentSummary } from "./reference-attachments";
|
||||
|
||||
function buildHiddenSidecarContextMessage({
|
||||
prompt,
|
||||
parentThreadId,
|
||||
}: {
|
||||
prompt: string;
|
||||
parentThreadId: string;
|
||||
}): Message {
|
||||
return {
|
||||
type: "human",
|
||||
content: [{ type: "text", text: prompt }],
|
||||
additional_kwargs: {
|
||||
hide_from_ui: true,
|
||||
sidecar_context: true,
|
||||
parent_thread_id: parentThreadId,
|
||||
},
|
||||
} as Message;
|
||||
}
|
||||
|
||||
type SidecarInputMode = NonNullable<ThreadStreamOptions["context"]["mode"]>;
|
||||
|
||||
function getResolvedMode(
|
||||
mode: ThreadStreamOptions["context"]["mode"],
|
||||
supportsThinking: boolean,
|
||||
): SidecarInputMode {
|
||||
if (!supportsThinking && mode !== "flash") {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
|
||||
function reasoningEffortForMode(mode: SidecarInputMode) {
|
||||
return mode === "ultra"
|
||||
? "high"
|
||||
: mode === "pro"
|
||||
? "medium"
|
||||
: mode === "thinking"
|
||||
? "low"
|
||||
: "minimal";
|
||||
}
|
||||
|
||||
function promptMessageFiles(message: PromptInputMessage) {
|
||||
return message.files.flatMap((file) =>
|
||||
file.file instanceof File ? [file.file] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function SidecarPanel({ className }: { className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const sidecar = useSidecar();
|
||||
const { thread: parentThread } = useParentThread();
|
||||
const [localSettings] = useLocalSettings();
|
||||
const { models, tokenUsageEnabled } = useModels();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const [creatingThread, setCreatingThread] = useState(false);
|
||||
const [queuedSubmit, setQueuedSubmit] = useState<{
|
||||
message: PromptInputMessage;
|
||||
references: SidecarReference[];
|
||||
} | null>(null);
|
||||
const { data: uploadLimits } = useUploadLimits(
|
||||
sidecar.sidecarThreadId ?? sidecar.parentThreadId,
|
||||
);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (models.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
models.find((model) => model.name === sidecar.context.model_name) ??
|
||||
models[0]
|
||||
);
|
||||
}, [models, sidecar.context.model_name]);
|
||||
|
||||
const supportThinking = selectedModel?.supports_thinking ?? false;
|
||||
|
||||
const {
|
||||
thread,
|
||||
sendMessage,
|
||||
isUploading,
|
||||
isHistoryLoading,
|
||||
hasMoreHistory,
|
||||
loadMoreHistory,
|
||||
} = useThreadStream({
|
||||
threadId: sidecar.sidecarThreadId ?? undefined,
|
||||
displayThreadId: sidecar.sidecarThreadId ?? undefined,
|
||||
context: sidecar.context,
|
||||
isMock: sidecar.isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
sidecar.setSidecarThreadId(createdThreadId);
|
||||
},
|
||||
});
|
||||
|
||||
const referenceCountLabel = useMemo(() => {
|
||||
const count = sidecar.activeReferences.length;
|
||||
const template =
|
||||
count === 1
|
||||
? t.sidecar.selectedTextFragment
|
||||
: t.sidecar.selectedTextFragments;
|
||||
return template.replace("{count}", String(count));
|
||||
}, [
|
||||
sidecar.activeReferences.length,
|
||||
t.sidecar.selectedTextFragment,
|
||||
t.sidecar.selectedTextFragments,
|
||||
]);
|
||||
|
||||
const hasPendingReferences = sidecar.activeReferences.length > 0;
|
||||
const hasSidecarThread = Boolean(sidecar.sidecarThreadId);
|
||||
const tokenUsageInlineMode = tokenUsageEnabled
|
||||
? localSettings.tokenUsage.inlineMode
|
||||
: "off";
|
||||
const disabled =
|
||||
(!hasSidecarThread && !hasPendingReferences) ||
|
||||
thread.isLoading ||
|
||||
creatingThread ||
|
||||
Boolean(queuedSubmit) ||
|
||||
isUploading ||
|
||||
(sidecar.isMock ?? false) ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentModel = models.find(
|
||||
(model) => model.name === sidecar.context.model_name,
|
||||
);
|
||||
const fallbackModel = currentModel ?? models[0]!;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = getResolvedMode(
|
||||
sidecar.context.mode,
|
||||
fallbackModel.supports_thinking ?? false,
|
||||
);
|
||||
const modeChanged = sidecar.context.mode !== nextMode;
|
||||
|
||||
if (sidecar.context.model_name === nextModelName && !modeChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
sidecar.setContext({
|
||||
...sidecar.context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
reasoning_effort: modeChanged
|
||||
? reasoningEffortForMode(nextMode)
|
||||
: sidecar.context.reasoning_effort,
|
||||
});
|
||||
}, [models, sidecar]);
|
||||
|
||||
const reportUploadLimitViolations = useCallback(
|
||||
(violations: UploadLimitViolation[]) => {
|
||||
for (const violation of violations) {
|
||||
if (violation.code === "max_file_size") {
|
||||
toast.error(
|
||||
t.uploads.filesTooLarge(
|
||||
violation.files.map((file) => file.name).join(", "),
|
||||
formatUploadSize(violation.limit),
|
||||
),
|
||||
);
|
||||
} else if (violation.code === "max_files") {
|
||||
toast.error(
|
||||
t.uploads.tooManyFiles(violation.files.length, violation.limit),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t.uploads.totalSizeTooLarge(
|
||||
violation.files.length,
|
||||
formatUploadSize(violation.limit),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[t.uploads],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(modelName: string) => {
|
||||
const model = models.find((candidate) => candidate.name === modelName);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
const nextMode = getResolvedMode(
|
||||
sidecar.context.mode,
|
||||
model.supports_thinking ?? false,
|
||||
);
|
||||
const modeChanged = sidecar.context.mode !== nextMode;
|
||||
sidecar.setContext({
|
||||
...sidecar.context,
|
||||
model_name: modelName,
|
||||
mode: nextMode,
|
||||
reasoning_effort: modeChanged
|
||||
? reasoningEffortForMode(nextMode)
|
||||
: sidecar.context.reasoning_effort,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
[models, sidecar],
|
||||
);
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(mode: SidecarInputMode) => {
|
||||
const nextMode = getResolvedMode(mode, supportThinking);
|
||||
sidecar.setContext({
|
||||
...sidecar.context,
|
||||
mode: nextMode,
|
||||
reasoning_effort: reasoningEffortForMode(nextMode),
|
||||
});
|
||||
},
|
||||
[sidecar, supportThinking],
|
||||
);
|
||||
|
||||
const ensureSidecarThread = useCallback(
|
||||
async (references: SidecarReference[]) => {
|
||||
if (sidecar.sidecarThreadId) {
|
||||
return sidecar.sidecarThreadId;
|
||||
}
|
||||
const restoredThreadId = await sidecar.restoreSidecarThread();
|
||||
if (restoredThreadId) {
|
||||
return restoredThreadId;
|
||||
}
|
||||
if (references.length === 0) {
|
||||
throw new Error(t.sidecar.noContext);
|
||||
}
|
||||
setCreatingThread(true);
|
||||
try {
|
||||
const created = await createSidecarThread({
|
||||
parentThreadId: sidecar.parentThreadId,
|
||||
context: references.map((reference) => reference.context),
|
||||
});
|
||||
sidecar.setSidecarThreadId(created.thread_id);
|
||||
return created.thread_id;
|
||||
} finally {
|
||||
setCreatingThread(false);
|
||||
}
|
||||
},
|
||||
[sidecar, t.sidecar.noContext],
|
||||
);
|
||||
|
||||
const submitToSidecarThread = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
message: PromptInputMessage,
|
||||
references: SidecarReference[],
|
||||
onSent?: () => void,
|
||||
) => {
|
||||
const contexts = references.map((reference) => reference.context);
|
||||
const parentConversation = buildParentConversationContext(
|
||||
parentThread.messages,
|
||||
);
|
||||
const hiddenContextPrompt =
|
||||
contexts.length > 0 || parentConversation.length > 0
|
||||
? buildSidecarContextPrompt(contexts, { parentConversation })
|
||||
: null;
|
||||
await sendMessage(threadId, message, undefined, {
|
||||
additionalInputMessages: hiddenContextPrompt
|
||||
? [
|
||||
buildHiddenSidecarContextMessage({
|
||||
prompt: hiddenContextPrompt,
|
||||
parentThreadId: sidecar.parentThreadId,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
additionalKwargs: {
|
||||
sidecar_visible_message: true,
|
||||
...(contexts.length > 0
|
||||
? buildReferenceMessageMetadata(contexts)
|
||||
: {}),
|
||||
},
|
||||
onSent,
|
||||
});
|
||||
},
|
||||
[parentThread.messages, sendMessage, sidecar.parentThreadId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedSubmit || !sidecar.sidecarThreadId || thread.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSubmit = queuedSubmit;
|
||||
setQueuedSubmit(null);
|
||||
void submitToSidecarThread(
|
||||
sidecar.sidecarThreadId,
|
||||
nextSubmit.message,
|
||||
nextSubmit.references,
|
||||
// Clear references only once the send genuinely proceeds; a send dropped
|
||||
// by the in-flight guard leaves them attached instead of losing them.
|
||||
() => {
|
||||
if (nextSubmit.references.length > 0) {
|
||||
sidecar.clearActiveReferences();
|
||||
}
|
||||
},
|
||||
).catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.sidecar.sendFailed,
|
||||
);
|
||||
});
|
||||
}, [
|
||||
queuedSubmit,
|
||||
sidecar,
|
||||
sidecar.sidecarThreadId,
|
||||
submitToSidecarThread,
|
||||
t.sidecar.sendFailed,
|
||||
thread.isLoading,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
const text = message.text.trim();
|
||||
const files = promptMessageFiles(message);
|
||||
if ((!text && message.files.length === 0) || disabled) {
|
||||
return;
|
||||
}
|
||||
const uploadValidation = validateUploadLimits([], files, uploadLimits);
|
||||
if (uploadValidation.violations.length > 0) {
|
||||
reportUploadLimitViolations(uploadValidation.violations);
|
||||
return Promise.reject(new Error("Attachment limits exceeded."));
|
||||
}
|
||||
|
||||
const pendingReferences = [...sidecar.activeReferences];
|
||||
try {
|
||||
if (!sidecar.sidecarThreadId) {
|
||||
await ensureSidecarThread(pendingReferences);
|
||||
setQueuedSubmit({ message, references: pendingReferences });
|
||||
return;
|
||||
}
|
||||
|
||||
await submitToSidecarThread(
|
||||
sidecar.sidecarThreadId,
|
||||
message,
|
||||
pendingReferences,
|
||||
() => {
|
||||
if (pendingReferences.length > 0) {
|
||||
sidecar.clearActiveReferences();
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t.sidecar.sendFailed,
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
disabled,
|
||||
ensureSidecarThread,
|
||||
reportUploadLimitViolations,
|
||||
sidecar,
|
||||
submitToSidecarThread,
|
||||
t.sidecar.sendFailed,
|
||||
uploadLimits,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex size-full min-h-0 flex-col", className)}
|
||||
data-testid="sidecar-panel"
|
||||
>
|
||||
<header className="border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||
<MessageSquareTextIcon className="text-muted-foreground size-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{t.sidecar.title}</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{sidecar.activeReferences.length > 0
|
||||
? referenceCountLabel
|
||||
: sidecar.sidecarThreadId
|
||||
? t.sidecar.continuing
|
||||
: t.sidecar.noContext}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip content={t.common.close}>
|
||||
<Button
|
||||
aria-label={t.common.close}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => sidecar.close()}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{sidecar.sidecarThreadId ? (
|
||||
<MessageList
|
||||
className="size-full"
|
||||
testId="sidecar-message-list"
|
||||
threadId={sidecar.sidecarThreadId}
|
||||
thread={thread}
|
||||
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM / 2}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
loadMoreHistory={loadMoreHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
tokenUsageInlineMode={tokenUsageInlineMode}
|
||||
initialScroll="instant"
|
||||
resizeScroll="instant"
|
||||
/>
|
||||
) : (
|
||||
<ConversationEmptyState
|
||||
icon={<MessageSquareTextIcon className="size-5" />}
|
||||
title={t.sidecar.emptyTitle}
|
||||
description={t.sidecar.emptyDescription}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background/95 shrink-0 px-3 pt-3 pb-4 sm:px-4">
|
||||
<PromptInputProvider key={sidecar.parentThreadId}>
|
||||
<PromptInput
|
||||
className="bg-background/85 rounded-2xl backdrop-blur-sm *:data-[slot='input-group']:rounded-2xl"
|
||||
disabled={disabled}
|
||||
multiple
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<PromptInputHeader className="flex-wrap px-3 pt-3 pb-0 empty:hidden">
|
||||
<PromptInputAttachments className="contents p-0">
|
||||
{(attachment) => (
|
||||
<div className="max-w-48">
|
||||
<PromptInputAttachment data={attachment} />
|
||||
</div>
|
||||
)}
|
||||
</PromptInputAttachments>
|
||||
{sidecar.activeReferences.length > 0 && (
|
||||
<ReferenceAttachmentSummary
|
||||
references={sidecar.activeReferences}
|
||||
testId="sidecar-reference-attachment"
|
||||
onClear={() => sidecar.clearActiveReferences()}
|
||||
/>
|
||||
)}
|
||||
</PromptInputHeader>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
className="max-h-36 min-h-16 text-sm"
|
||||
disabled={disabled}
|
||||
placeholder={t.sidecar.placeholder}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="@container flex flex-nowrap gap-2">
|
||||
<PromptInputTools className="min-w-0 flex-1 flex-nowrap overflow-hidden">
|
||||
<SidecarAddAttachmentsButton uploadLimits={uploadLimits} />
|
||||
<SidecarModeMenu
|
||||
context={sidecar.context}
|
||||
supportThinking={supportThinking}
|
||||
onModeSelect={handleModeSelect}
|
||||
/>
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className="min-w-0 justify-end">
|
||||
<SidecarModelSelector
|
||||
className="max-w-40 min-w-0 sm:max-w-56 @max-[240px]:hidden"
|
||||
context={sidecar.context}
|
||||
models={models}
|
||||
open={modelDialogOpen}
|
||||
selectedModel={selectedModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
onOpenChange={setModelDialogOpen}
|
||||
/>
|
||||
<Tooltip content={t.sidecar.send}>
|
||||
<PromptInputSubmit
|
||||
className="rounded-full"
|
||||
disabled={disabled}
|
||||
status={
|
||||
thread.isLoading || creatingThread || queuedSubmit
|
||||
? "submitted"
|
||||
: "ready"
|
||||
}
|
||||
variant="outline"
|
||||
/>
|
||||
</Tooltip>
|
||||
</PromptInputTools>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</PromptInputProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidecarAddAttachmentsButton({
|
||||
uploadLimits,
|
||||
}: {
|
||||
uploadLimits?: UploadLimits;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const tooltipContent = uploadLimits
|
||||
? t.uploads.limitsHint(
|
||||
uploadLimits.max_files,
|
||||
formatUploadSize(uploadLimits.max_file_size),
|
||||
formatUploadSize(uploadLimits.max_total_size),
|
||||
)
|
||||
: t.inputBox.addAttachments;
|
||||
|
||||
return (
|
||||
<Tooltip content={<span className="block max-w-80">{tooltipContent}</span>}>
|
||||
<PromptInputButton
|
||||
aria-label={t.inputBox.addAttachments}
|
||||
className="px-2!"
|
||||
data-testid="sidecar-add-attachments-button"
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
>
|
||||
<PaperclipIcon className="size-3" />
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidecarModeMenu({
|
||||
context,
|
||||
supportThinking,
|
||||
onModeSelect,
|
||||
}: {
|
||||
context: ThreadStreamOptions["context"];
|
||||
supportThinking: boolean;
|
||||
onModeSelect: (mode: SidecarInputMode) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const mode = getResolvedMode(context.mode, supportThinking);
|
||||
|
||||
return (
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide mode={mode}>
|
||||
<PromptInputActionMenuTrigger className="max-w-20 min-w-0 gap-1! px-2!">
|
||||
<div>
|
||||
{mode === "flash" && <ZapIcon className="size-3" />}
|
||||
{mode === "thinking" && <LightbulbIcon className="size-3" />}
|
||||
{mode === "pro" && <GraduationCapIcon className="size-3" />}
|
||||
{mode === "ultra" && (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-xs font-normal",
|
||||
mode === "ultra" && "golden-text",
|
||||
)}
|
||||
>
|
||||
{(mode === "flash" && t.inputBox.flashMode) ||
|
||||
(mode === "thinking" && t.inputBox.reasoningMode) ||
|
||||
(mode === "pro" && t.inputBox.proMode) ||
|
||||
(mode === "ultra" && t.inputBox.ultraMode)}
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
</ModeHoverGuide>
|
||||
<PromptInputActionMenuContent className="w-80">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
{t.inputBox.mode}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
mode === "flash"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => onModeSelect("flash")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
mode === "flash" && "text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.flashMode}
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
{t.inputBox.flashModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{mode === "flash" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
{supportThinking && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
mode === "thinking"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => onModeSelect("thinking")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<LightbulbIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
mode === "thinking" && "text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.reasoningMode}
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
{t.inputBox.reasoningModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{mode === "thinking" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
mode === "pro"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => onModeSelect("pro")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<GraduationCapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
mode === "pro" && "text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.proMode}
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
{t.inputBox.proModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{mode === "pro" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
mode === "ultra"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => onModeSelect("ultra")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<RocketIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
mode === "ultra" && "text-[#dabb5e]",
|
||||
)}
|
||||
/>
|
||||
<div className={cn(mode === "ultra" && "golden-text")}>
|
||||
{t.inputBox.ultraMode}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-7 text-xs">
|
||||
{t.inputBox.ultraModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{mode === "ultra" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function SidecarModelSelector({
|
||||
className,
|
||||
context,
|
||||
models,
|
||||
open,
|
||||
selectedModel,
|
||||
onModelSelect,
|
||||
onOpenChange,
|
||||
}: {
|
||||
className?: string;
|
||||
context: ThreadStreamOptions["context"];
|
||||
models: Model[];
|
||||
open: boolean;
|
||||
selectedModel?: Model;
|
||||
onModelSelect: (modelName: string) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!selectedModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelSelector open={open} onOpenChange={onOpenChange}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton className={cn("min-w-0 px-2!", className)}>
|
||||
<div className="flex min-w-0 flex-col items-start text-left">
|
||||
<ModelSelectorName className="truncate text-xs font-normal">
|
||||
{selectedModel.display_name}
|
||||
</ModelSelectorName>
|
||||
</div>
|
||||
</PromptInputButton>
|
||||
</ModelSelectorTrigger>
|
||||
<ModelSelectorContent>
|
||||
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
||||
<ModelSelectorList>
|
||||
{models.map((model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.name}
|
||||
value={model.name}
|
||||
onSelect={() => onModelSelect(model.name)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<ModelSelectorName>{model.display_name}</ModelSelectorName>
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{model.model}
|
||||
</span>
|
||||
</div>
|
||||
{model.name === context.model_name ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquareTextIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { useMaybeSidecar } from "./context";
|
||||
|
||||
export function SidecarTrigger() {
|
||||
const { t } = useI18n();
|
||||
const sidecar = useMaybeSidecar();
|
||||
const [isReconciling, setIsReconciling] = useState(false);
|
||||
|
||||
if (!sidecar?.sidecarThreadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = sidecar.open ? t.sidecar.close : t.sidecar.open;
|
||||
|
||||
const handleClick = async () => {
|
||||
if (sidecar.open) {
|
||||
sidecar.close();
|
||||
return;
|
||||
}
|
||||
// The cached id may point at a sidecar thread deleted elsewhere. Re-query
|
||||
// the backend before opening; if it's gone, the forced restore clears the
|
||||
// id and this trigger unmounts (self-heals) instead of opening a dead
|
||||
// thread (#3555).
|
||||
setIsReconciling(true);
|
||||
try {
|
||||
const restoredThreadId = await sidecar.restoreSidecarThread({
|
||||
force: true,
|
||||
});
|
||||
if (restoredThreadId) {
|
||||
sidecar.openSidecar();
|
||||
}
|
||||
} finally {
|
||||
setIsReconciling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
data-testid="sidecar-header-trigger"
|
||||
disabled={isReconciling}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={sidecar.open ? "secondary" : "ghost"}
|
||||
onClick={() => {
|
||||
void handleClick();
|
||||
}}
|
||||
>
|
||||
<MessageSquareTextIcon />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -422,6 +422,28 @@ export const enUS: Translations = {
|
|||
loadOlderChats: "Load older chats",
|
||||
},
|
||||
|
||||
// Sidecar
|
||||
sidecar: {
|
||||
title: "Side chat",
|
||||
open: "Open side chat",
|
||||
close: "Close side chat",
|
||||
addToConversation: "Add to conversation",
|
||||
askInSideChat: "Ask in side chat",
|
||||
reference: "Reference",
|
||||
selectedTextFragment: "{count} selected text fragment",
|
||||
selectedTextFragments: "{count} selected text fragments",
|
||||
clearReferences: "Clear selected references",
|
||||
emptyTitle: "Ask a follow-up",
|
||||
emptyDescription: "Ask a follow-up grounded in the referenced text.",
|
||||
placeholder: "Ask a deeper follow-up...",
|
||||
send: "Send",
|
||||
sendFailed: "Failed to send side chat message.",
|
||||
noContext: "No context selected",
|
||||
continuing: "Continue in this side chat",
|
||||
selectionCrossesMessages:
|
||||
"Selection spans multiple messages. Select text within a single reply to quote it.",
|
||||
},
|
||||
|
||||
// Channels
|
||||
channels: {
|
||||
title: "Channels",
|
||||
|
|
|
|||
|
|
@ -333,6 +333,27 @@ export interface Translations {
|
|||
loadOlderChats: string;
|
||||
};
|
||||
|
||||
// Sidecar
|
||||
sidecar: {
|
||||
title: string;
|
||||
open: string;
|
||||
close: string;
|
||||
addToConversation: string;
|
||||
askInSideChat: string;
|
||||
reference: string;
|
||||
selectedTextFragment: string;
|
||||
selectedTextFragments: string;
|
||||
clearReferences: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
placeholder: string;
|
||||
send: string;
|
||||
sendFailed: string;
|
||||
noContext: string;
|
||||
continuing: string;
|
||||
selectionCrossesMessages: string;
|
||||
};
|
||||
|
||||
// Channels
|
||||
channels: {
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {
|
||||
import {
|
||||
CompassIcon,
|
||||
GraduationCapIcon,
|
||||
ImageIcon,
|
||||
|
|
@ -406,6 +406,28 @@ export const zhCN: Translations = {
|
|||
loadOlderChats: "加载更早的对话",
|
||||
},
|
||||
|
||||
// Sidecar
|
||||
sidecar: {
|
||||
title: "侧边对话",
|
||||
open: "打开侧边对话",
|
||||
close: "关闭侧边对话",
|
||||
addToConversation: "添加到对话",
|
||||
askInSideChat: "在侧边聊天中提问",
|
||||
reference: "引用",
|
||||
selectedTextFragment: "{count} 个已选文本片段",
|
||||
selectedTextFragments: "{count} 个已选文本片段",
|
||||
clearReferences: "清除已选引用",
|
||||
emptyTitle: "继续深入追问",
|
||||
emptyDescription: "基于引用内容单独追问。",
|
||||
placeholder: "继续深入追问...",
|
||||
send: "发送",
|
||||
sendFailed: "侧边对话发送失败。",
|
||||
noContext: "未选择上下文",
|
||||
continuing: "继续当前侧边对话",
|
||||
selectionCrossesMessages:
|
||||
"选区跨越了多条消息,请在同一条回复内选择要引用的文本。",
|
||||
},
|
||||
|
||||
// Channels
|
||||
channels: {
|
||||
title: "渠道",
|
||||
|
|
|
|||
|
|
@ -14,9 +14,21 @@ export function getUsageMetadata(message: Message): TokenUsage | null {
|
|||
if (message.type !== "ai") {
|
||||
return null;
|
||||
}
|
||||
const usage = (message as Record<string, unknown>).usage_metadata as
|
||||
| { input_tokens?: number; output_tokens?: number; total_tokens?: number }
|
||||
| undefined;
|
||||
const usage =
|
||||
((message as Record<string, unknown>).usage_metadata as
|
||||
| {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
| undefined) ??
|
||||
(message.additional_kwargs?.usage_metadata as
|
||||
| {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
| undefined);
|
||||
if (!usage) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
100
frontend/src/core/sidecar/api.ts
Normal file
100
frontend/src/core/sidecar/api.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { getAPIClient } from "@/core/api";
|
||||
import { fetch as fetchWithAuth } from "@/core/api/fetcher";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import type { AgentThread } from "@/core/threads";
|
||||
|
||||
import type { SidecarContext } from "./context";
|
||||
import {
|
||||
SIDECAR_METADATA_KEY,
|
||||
buildSidecarThreadMetadata,
|
||||
isSidecarThread,
|
||||
} from "./thread";
|
||||
|
||||
type SidecarThreadSearchClient = {
|
||||
threads: {
|
||||
search: (query: Record<string, unknown>) => Promise<AgentThread[]>;
|
||||
};
|
||||
};
|
||||
|
||||
// The find-then-create flow is two independent round-trips with no backend
|
||||
// upsert, so a double-click on "Ask in side chat" or two callers racing on the
|
||||
// same parent thread can each create a duplicate sidecar thread. Coalesce
|
||||
// concurrent creates for the same parent behind a single in-flight promise so
|
||||
// only one thread is created; the entry is cleared once it settles.
|
||||
const inFlightCreates = new Map<string, Promise<AgentThread>>();
|
||||
|
||||
export async function createSidecarThread({
|
||||
parentThreadId,
|
||||
context,
|
||||
}: {
|
||||
parentThreadId: string;
|
||||
context: SidecarContext | SidecarContext[];
|
||||
}): Promise<AgentThread> {
|
||||
const inFlight = inFlightCreates.get(parentThreadId);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const request = createSidecarThreadRequest({ parentThreadId, context });
|
||||
inFlightCreates.set(parentThreadId, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (inFlightCreates.get(parentThreadId) === request) {
|
||||
inFlightCreates.delete(parentThreadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createSidecarThreadRequest({
|
||||
parentThreadId,
|
||||
context,
|
||||
}: {
|
||||
parentThreadId: string;
|
||||
context: SidecarContext | SidecarContext[];
|
||||
}): Promise<AgentThread> {
|
||||
const response = await fetchWithAuth(`${getBackendBaseURL()}/api/threads`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: buildSidecarThreadMetadata(parentThreadId, context),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create side conversation.");
|
||||
}
|
||||
|
||||
return (await response.json()) as AgentThread;
|
||||
}
|
||||
|
||||
export async function findLatestSidecarThread({
|
||||
parentThreadId,
|
||||
isMock,
|
||||
apiClient = getAPIClient(isMock) as SidecarThreadSearchClient,
|
||||
}: {
|
||||
parentThreadId: string;
|
||||
isMock?: boolean;
|
||||
apiClient?: SidecarThreadSearchClient;
|
||||
}): Promise<AgentThread | null> {
|
||||
const response = await apiClient.threads.search({
|
||||
metadata: {
|
||||
[SIDECAR_METADATA_KEY]: true,
|
||||
parent_thread_id: parentThreadId,
|
||||
},
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
|
||||
return (
|
||||
response.find(
|
||||
(thread) =>
|
||||
isSidecarThread(thread) &&
|
||||
thread.metadata?.parent_thread_id === parentThreadId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
212
frontend/src/core/sidecar/context.ts
Normal file
212
frontend/src/core/sidecar/context.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import {
|
||||
extractTextFromMessage,
|
||||
isHiddenFromUIMessage,
|
||||
} from "@/core/messages/utils";
|
||||
|
||||
export type SidecarContextRole = "user" | "assistant";
|
||||
|
||||
export type ReferencedMessageSidecarContext = {
|
||||
type: "referenced_message";
|
||||
label: string;
|
||||
messageId?: string;
|
||||
role: SidecarContextRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type SidecarContext = ReferencedMessageSidecarContext;
|
||||
|
||||
export type ParentConversationContextMessage = {
|
||||
messageId?: string;
|
||||
role: SidecarContextRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export function normalizeSidecarContexts(
|
||||
contextOrContexts: SidecarContext | SidecarContext[],
|
||||
): SidecarContext[] {
|
||||
return Array.isArray(contextOrContexts)
|
||||
? contextOrContexts
|
||||
: [contextOrContexts];
|
||||
}
|
||||
|
||||
function roleOfMessage(message: Message): SidecarContextRole | null {
|
||||
if (message.type === "human") {
|
||||
return "user";
|
||||
}
|
||||
if (message.type === "ai") {
|
||||
return "assistant";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function labelOfRole(role: SidecarContextRole) {
|
||||
return role === "user" ? "User" : "Assistant";
|
||||
}
|
||||
|
||||
function truncateContextText(content: string, maxChars: number) {
|
||||
if (content.length <= maxChars) {
|
||||
return content;
|
||||
}
|
||||
return `${content.slice(0, maxChars).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
export function buildParentConversationContext(
|
||||
messages: Message[],
|
||||
{
|
||||
maxMessages = 8,
|
||||
maxCharsPerMessage = 1200,
|
||||
maxTotalChars = 6000,
|
||||
}: {
|
||||
maxMessages?: number;
|
||||
maxCharsPerMessage?: number;
|
||||
maxTotalChars?: number;
|
||||
} = {},
|
||||
): ParentConversationContextMessage[] {
|
||||
const visibleMessages = messages.flatMap((message) => {
|
||||
const role = roleOfMessage(message);
|
||||
if (!role || isHiddenFromUIMessage(message)) {
|
||||
return [];
|
||||
}
|
||||
const content = extractTextFromMessage(message).trim();
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
messageId: message.id,
|
||||
role,
|
||||
content,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const recentMessages = visibleMessages.slice(-maxMessages);
|
||||
const selectedMessages: ParentConversationContextMessage[] = [];
|
||||
let selectedChars = 0;
|
||||
|
||||
for (let index = recentMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = recentMessages[index];
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
const remainingChars = Math.max(maxTotalChars - selectedChars, 0);
|
||||
if (remainingChars <= 0) {
|
||||
break;
|
||||
}
|
||||
const content = truncateContextText(
|
||||
message.content,
|
||||
Math.min(maxCharsPerMessage, remainingChars),
|
||||
);
|
||||
selectedMessages.unshift({
|
||||
...message,
|
||||
content,
|
||||
});
|
||||
selectedChars += content.length;
|
||||
}
|
||||
|
||||
return selectedMessages;
|
||||
}
|
||||
|
||||
export function buildMessageSidecarContext(
|
||||
message: Message,
|
||||
displayIndex?: number,
|
||||
{
|
||||
selectedText,
|
||||
}: {
|
||||
selectedText?: string;
|
||||
} = {},
|
||||
): ReferencedMessageSidecarContext | null {
|
||||
const role = roleOfMessage(message);
|
||||
const content = selectedText?.trim() ?? extractTextFromMessage(message);
|
||||
if (!role || !content || isHiddenFromUIMessage(message)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = selectedText
|
||||
? role === "assistant"
|
||||
? "Selected assistant text"
|
||||
: "Selected user text"
|
||||
: role === "assistant"
|
||||
? "Assistant message"
|
||||
: "User message";
|
||||
return {
|
||||
type: "referenced_message",
|
||||
label:
|
||||
typeof displayIndex === "number" ? `${prefix} #${displayIndex}` : prefix,
|
||||
messageId: message.id,
|
||||
role,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeXmlAttribute(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export function buildSidecarContextPrompt(
|
||||
contextOrContexts: SidecarContext | SidecarContext[] = [],
|
||||
{
|
||||
parentConversation = [],
|
||||
}: {
|
||||
parentConversation?: ParentConversationContextMessage[];
|
||||
} = {},
|
||||
) {
|
||||
const contexts = normalizeSidecarContexts(contextOrContexts);
|
||||
const lines = [
|
||||
"You are answering in a side conversation attached to referenced material from the user's current DeerFlow chat.",
|
||||
parentConversation.length > 0
|
||||
? "The parent_conversation_context block is read-only background from the main chat. Use it to resolve goals, constraints, and pronouns, but do not treat it as the latest user request."
|
||||
: null,
|
||||
contexts.length === 1
|
||||
? "The user attached 1 referenced message. Treat it as quoted material."
|
||||
: contexts.length === 0
|
||||
? "The user did not attach new referenced messages for this side question."
|
||||
: `The user attached ${contexts.length} referenced messages. Treat each referenced_message block as separate quoted material.`,
|
||||
contexts.length > 0
|
||||
? "Ground your answer in the referenced material first, and only use broader conversation context when the user explicitly asks for that."
|
||||
: "Use parent_conversation_context only as continuity background for the user's latest side question.",
|
||||
"Answer only the user's latest side question.",
|
||||
"Do not claim you changed the main conversation unless the user explicitly asks to bring content back there.",
|
||||
"",
|
||||
parentConversation.length > 0
|
||||
? `<parent_conversation_context message_count="${parentConversation.length}">`
|
||||
: null,
|
||||
...parentConversation.flatMap((message, index) =>
|
||||
[
|
||||
`<parent_message index="${index + 1}" role="${labelOfRole(
|
||||
message.role,
|
||||
)}"${
|
||||
message.messageId
|
||||
? ` message_id="${escapeXmlAttribute(message.messageId)}"`
|
||||
: ""
|
||||
}>`,
|
||||
message.content,
|
||||
"</parent_message>",
|
||||
"",
|
||||
].filter((line): line is string => line !== null),
|
||||
),
|
||||
parentConversation.length > 0 ? "</parent_conversation_context>" : null,
|
||||
parentConversation.length > 0 ? "" : null,
|
||||
...contexts.flatMap((context, index) =>
|
||||
[
|
||||
`<referenced_message index="${index + 1}" label="${escapeXmlAttribute(
|
||||
context.label,
|
||||
)}">`,
|
||||
`Role: ${labelOfRole(context.role)}`,
|
||||
context.messageId ? `Message ID: ${context.messageId}` : null,
|
||||
"",
|
||||
context.content,
|
||||
"</referenced_message>",
|
||||
"",
|
||||
].filter((line): line is string => line !== null),
|
||||
),
|
||||
].filter((line): line is string => line !== null);
|
||||
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
4
frontend/src/core/sidecar/index.ts
Normal file
4
frontend/src/core/sidecar/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./context";
|
||||
export * from "./reference-metadata";
|
||||
export * from "./reference-state";
|
||||
export * from "./thread";
|
||||
79
frontend/src/core/sidecar/reference-metadata.ts
Normal file
79
frontend/src/core/sidecar/reference-metadata.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { SidecarContext, SidecarContextRole } from "./context";
|
||||
|
||||
export type ReferenceMessageContextMetadata = {
|
||||
label: string;
|
||||
message_id?: string;
|
||||
role: SidecarContextRole;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ReferenceMessageMetadata = {
|
||||
referenced_message_count: number;
|
||||
referenced_message_ids: string[];
|
||||
referenced_message_roles: SidecarContextRole[];
|
||||
referenced_message_contexts: ReferenceMessageContextMetadata[];
|
||||
};
|
||||
|
||||
function isSidecarContextRole(value: unknown): value is SidecarContextRole {
|
||||
return value === "user" || value === "assistant";
|
||||
}
|
||||
|
||||
export function buildReferenceMessageMetadata(
|
||||
contexts: SidecarContext[],
|
||||
): ReferenceMessageMetadata {
|
||||
// `referenced_message_count`, `referenced_message_ids`, and
|
||||
// `referenced_message_roles` are kept 1:1 parallel with `contexts` so
|
||||
// consumers can safely zip them. Do not dedupe ids here: two fragments of the
|
||||
// same source message would otherwise leave the arrays non-parallel.
|
||||
return {
|
||||
referenced_message_count: contexts.length,
|
||||
referenced_message_ids: contexts.map((context) => context.messageId ?? ""),
|
||||
referenced_message_roles: contexts.map((context) => context.role),
|
||||
referenced_message_contexts: contexts.map((context) => ({
|
||||
label: context.label,
|
||||
...(context.messageId ? { message_id: context.messageId } : {}),
|
||||
role: context.role,
|
||||
content: context.content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function readReferenceMessageContexts(
|
||||
additionalKwargs: unknown,
|
||||
): SidecarContext[] {
|
||||
if (!isObjectRecord(additionalKwargs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawContexts = additionalKwargs.referenced_message_contexts;
|
||||
if (!Array.isArray(rawContexts)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawContexts.flatMap((rawContext) => {
|
||||
if (
|
||||
!isObjectRecord(rawContext) ||
|
||||
typeof rawContext.label !== "string" ||
|
||||
typeof rawContext.content !== "string" ||
|
||||
!isSidecarContextRole(rawContext.role)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: rawContext.label,
|
||||
...(typeof rawContext.message_id === "string"
|
||||
? { messageId: rawContext.message_id }
|
||||
: {}),
|
||||
role: rawContext.role,
|
||||
content: rawContext.content,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
55
frontend/src/core/sidecar/reference-state.ts
Normal file
55
frontend/src/core/sidecar/reference-state.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { SidecarContext } from "./context";
|
||||
|
||||
export type SidecarReferenceStateItem = {
|
||||
id: number;
|
||||
context: SidecarContext;
|
||||
};
|
||||
|
||||
export function isSameSidecarContext(
|
||||
left: SidecarContext,
|
||||
right: SidecarContext,
|
||||
) {
|
||||
return (
|
||||
left.type === right.type &&
|
||||
left.role === right.role &&
|
||||
left.messageId === right.messageId &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
export function appendSidecarReference<
|
||||
TReference extends SidecarReferenceStateItem,
|
||||
>(references: TReference[], nextReference: TReference) {
|
||||
if (
|
||||
references.some((reference) =>
|
||||
isSameSidecarContext(reference.context, nextReference.context),
|
||||
)
|
||||
) {
|
||||
return references;
|
||||
}
|
||||
return [...references, nextReference];
|
||||
}
|
||||
|
||||
export function getNextSidecarOpenState<
|
||||
TReference extends SidecarReferenceStateItem,
|
||||
>({
|
||||
open,
|
||||
sidecarThreadId,
|
||||
activeReferences,
|
||||
nextReference,
|
||||
}: {
|
||||
open: boolean;
|
||||
sidecarThreadId: string | null;
|
||||
activeReferences: TReference[];
|
||||
nextReference: TReference;
|
||||
}) {
|
||||
const shouldAppendToCurrentSidecar =
|
||||
open && (Boolean(sidecarThreadId) || activeReferences.length > 0);
|
||||
|
||||
return {
|
||||
sidecarThreadId,
|
||||
activeReferences: shouldAppendToCurrentSidecar
|
||||
? appendSidecarReference(activeReferences, nextReference)
|
||||
: [nextReference],
|
||||
};
|
||||
}
|
||||
64
frontend/src/core/sidecar/thread.ts
Normal file
64
frontend/src/core/sidecar/thread.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { AgentThread } from "@/core/threads";
|
||||
|
||||
import { normalizeSidecarContexts, type SidecarContext } from "./context";
|
||||
|
||||
export const SIDECAR_METADATA_KEY = "deerflow_sidecar";
|
||||
|
||||
export type SidecarThreadMetadata = {
|
||||
[SIDECAR_METADATA_KEY]: true;
|
||||
parent_thread_id: string;
|
||||
sidecar_context_type: SidecarContext["type"];
|
||||
sidecar_context_label: string;
|
||||
sidecar_context_count: number;
|
||||
referenced_message_id?: string;
|
||||
referenced_message_ids: string[];
|
||||
referenced_message_role: SidecarContext["role"];
|
||||
referenced_message_roles: SidecarContext["role"][];
|
||||
};
|
||||
|
||||
export function buildSidecarThreadMetadata(
|
||||
parentThreadId: string,
|
||||
contextOrContexts: SidecarContext | SidecarContext[],
|
||||
): SidecarThreadMetadata {
|
||||
const contexts = normalizeSidecarContexts(contextOrContexts);
|
||||
const primaryContext = contexts[0];
|
||||
if (!primaryContext) {
|
||||
throw new Error("At least one sidecar context is required.");
|
||||
}
|
||||
|
||||
// Keep `referenced_message_ids`, `referenced_message_roles`, and
|
||||
// `sidecar_context_count` 1:1 parallel with `contexts` so consumers can zip
|
||||
// them safely (two fragments of the same source message would otherwise make
|
||||
// a deduped id array shorter than the role array).
|
||||
const referencedMessageIds = contexts.map(
|
||||
(context) => context.messageId ?? "",
|
||||
);
|
||||
|
||||
return {
|
||||
[SIDECAR_METADATA_KEY]: true,
|
||||
parent_thread_id: parentThreadId,
|
||||
sidecar_context_type: primaryContext.type,
|
||||
sidecar_context_label: primaryContext.label,
|
||||
sidecar_context_count: contexts.length,
|
||||
referenced_message_id: primaryContext.messageId,
|
||||
referenced_message_ids: referencedMessageIds,
|
||||
referenced_message_role: primaryContext.role,
|
||||
referenced_message_roles: contexts.map((context) => context.role),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSidecarThread(
|
||||
thread:
|
||||
| Pick<AgentThread, "metadata">
|
||||
| { metadata?: Record<string, unknown> },
|
||||
) {
|
||||
return thread.metadata?.[SIDECAR_METADATA_KEY] === true;
|
||||
}
|
||||
|
||||
export function shouldShowInPrimaryThreadLists(
|
||||
thread:
|
||||
| Pick<AgentThread, "metadata">
|
||||
| { metadata?: Record<string, unknown> },
|
||||
) {
|
||||
return !isSidecarThread(thread);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { useI18n } from "../i18n/hooks";
|
|||
import { isHiddenFromUIMessage } from "../messages/utils";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isSidecarThread, SIDECAR_METADATA_KEY } from "../sidecar/thread";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import { messageToStep } from "../tasks/steps";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
|
|
@ -30,6 +31,7 @@ import { fetchThreadTokenUsage } from "./api";
|
|||
import {
|
||||
buildThreadsSearchQueryOptions,
|
||||
DEFAULT_THREAD_SEARCH_PARAMS,
|
||||
filterThreadSearchResults,
|
||||
type ThreadSearchParams,
|
||||
} from "./thread-search-query";
|
||||
import { threadTokenUsageQueryKey } from "./token-usage";
|
||||
|
|
@ -58,6 +60,27 @@ export type ThreadStreamOptions = {
|
|||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
additionalInputMessages?: Message[];
|
||||
/**
|
||||
* Invoked exactly once when the send passes the in-flight guard and is
|
||||
* genuinely dispatched. It never fires on the early-return path, so callers
|
||||
* can safely perform one-time cleanup (e.g. clearing quoted references)
|
||||
* without losing state when a concurrent send is dropped.
|
||||
*/
|
||||
onSent?: () => void;
|
||||
};
|
||||
|
||||
type ThreadDeleteClient = {
|
||||
threads: {
|
||||
delete: (threadId: string) => Promise<unknown>;
|
||||
search: (query: Record<string, unknown>) => Promise<AgentThread[]>;
|
||||
};
|
||||
};
|
||||
|
||||
type ThreadSidecarSearchClient = {
|
||||
threads: {
|
||||
search: (query: Record<string, unknown>) => Promise<AgentThread[]>;
|
||||
};
|
||||
};
|
||||
|
||||
type RegeneratePrepareResponse = {
|
||||
|
|
@ -71,6 +94,35 @@ type RegeneratePrepareResponse = {
|
|||
target_run_id: string;
|
||||
};
|
||||
|
||||
export function buildThreadSubmitMessages({
|
||||
text,
|
||||
additionalKwargs,
|
||||
additionalInputMessages = [],
|
||||
filesForSubmit = [],
|
||||
}: {
|
||||
text: string;
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
additionalInputMessages?: Message[];
|
||||
filesForSubmit?: FileInMessage[];
|
||||
}): Message[] {
|
||||
return [
|
||||
...additionalInputMessages,
|
||||
{
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
...additionalKwargs,
|
||||
...(filesForSubmit.length > 0 ? { files: filesForSubmit } : {}),
|
||||
},
|
||||
} as Message,
|
||||
];
|
||||
}
|
||||
|
||||
const EMPTY_THREAD_VALUES: AgentThreadState = {
|
||||
title: "",
|
||||
messages: [],
|
||||
|
|
@ -1165,6 +1217,10 @@ export function useThreadStream({
|
|||
}
|
||||
sendInFlightRef.current = true;
|
||||
|
||||
// The send has genuinely proceeded past the in-flight guard, so callers
|
||||
// can now run one-time cleanup that must not fire on the dropped path.
|
||||
options?.onSent?.();
|
||||
|
||||
const text = message.text.trim();
|
||||
|
||||
// Capture the current human message count before showing optimistic
|
||||
|
|
@ -1297,23 +1353,12 @@ export function useThreadStream({
|
|||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: buildThreadSubmitMessages({
|
||||
text,
|
||||
additionalKwargs: options?.additionalKwargs,
|
||||
additionalInputMessages: options?.additionalInputMessages,
|
||||
filesForSubmit,
|
||||
}),
|
||||
},
|
||||
{
|
||||
threadId: threadId,
|
||||
|
|
@ -1747,16 +1792,80 @@ export const INFINITE_THREADS_QUERY_KEY_PREFIX = [
|
|||
"searchInfinite",
|
||||
] as const;
|
||||
|
||||
const INFINITE_THREADS_NEXT_PAGE_PARAM = Symbol(
|
||||
"deerflow.infiniteThreads.nextPageParam",
|
||||
);
|
||||
|
||||
type InfiniteThreadsParams = Omit<
|
||||
Parameters<ThreadsClient["search"]>[0],
|
||||
"limit" | "offset"
|
||||
>;
|
||||
|
||||
type InfiniteThreadsSearchClient = {
|
||||
threads: {
|
||||
search: ThreadsClient["search"];
|
||||
};
|
||||
};
|
||||
|
||||
type InfiniteThreadsPageWithNextParam = AgentThread[] & {
|
||||
[INFINITE_THREADS_NEXT_PAGE_PARAM]?: number;
|
||||
};
|
||||
|
||||
function annotateInfiniteThreadsPage(
|
||||
page: AgentThread[],
|
||||
nextPageParam: number | undefined,
|
||||
): AgentThread[] {
|
||||
if (nextPageParam !== undefined) {
|
||||
Reflect.set(page, INFINITE_THREADS_NEXT_PAGE_PARAM, nextPageParam);
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
export async function fetchInfiniteThreadsPage(
|
||||
apiClient: InfiniteThreadsSearchClient,
|
||||
params: InfiniteThreadsParams,
|
||||
pageParam: number,
|
||||
pageSize: number = INFINITE_THREADS_PAGE_SIZE,
|
||||
): Promise<AgentThread[]> {
|
||||
const threads: AgentThread[] = [];
|
||||
let offset = pageParam;
|
||||
let nextPageParam: number | undefined;
|
||||
|
||||
while (threads.length < pageSize) {
|
||||
const currentLimit = pageSize - threads.length;
|
||||
const response = (await apiClient.threads.search<AgentThreadState>({
|
||||
...params,
|
||||
limit: currentLimit,
|
||||
offset,
|
||||
})) as AgentThread[];
|
||||
|
||||
threads.push(...filterThreadSearchResults(response, params));
|
||||
offset += response.length;
|
||||
|
||||
if (response.length < currentLimit) {
|
||||
nextPageParam = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
nextPageParam = offset;
|
||||
}
|
||||
|
||||
return annotateInfiniteThreadsPage(threads, nextPageParam);
|
||||
}
|
||||
|
||||
export function getInfiniteThreadsNextPageParam(
|
||||
lastPage: AgentThread[],
|
||||
allPages: AgentThread[][],
|
||||
pageSize: number = INFINITE_THREADS_PAGE_SIZE,
|
||||
): number | undefined {
|
||||
const annotatedNextPageParam = Reflect.get(
|
||||
lastPage as InfiniteThreadsPageWithNextParam,
|
||||
INFINITE_THREADS_NEXT_PAGE_PARAM,
|
||||
);
|
||||
if (typeof annotatedNextPageParam === "number") {
|
||||
return annotatedNextPageParam;
|
||||
}
|
||||
|
||||
if (lastPage.length < pageSize) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -1806,14 +1915,13 @@ export function useInfiniteThreads(
|
|||
>({
|
||||
queryKey: [...INFINITE_THREADS_QUERY_KEY_PREFIX, params],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const response = (await apiClient.threads.search<AgentThreadState>({
|
||||
...params,
|
||||
limit: INFINITE_THREADS_PAGE_SIZE,
|
||||
offset: pageParam,
|
||||
})) as AgentThread[];
|
||||
return response;
|
||||
},
|
||||
queryFn: async ({ pageParam }) =>
|
||||
fetchInfiniteThreadsPage(
|
||||
apiClient,
|
||||
params,
|
||||
pageParam,
|
||||
INFINITE_THREADS_PAGE_SIZE,
|
||||
),
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
getInfiniteThreadsNextPageParam(lastPage, allPages),
|
||||
refetchOnWindowFocus: false,
|
||||
|
|
@ -1899,9 +2007,118 @@ export function useRunDetail(threadId: string, runId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
async function deleteLocalThreadData(threadId: string) {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to delete local thread data." }));
|
||||
throw new Error(error.detail ?? "Failed to delete local thread data.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteThreadEverywhere(
|
||||
apiClient: ThreadDeleteClient,
|
||||
threadId: string,
|
||||
) {
|
||||
await apiClient.threads.delete(threadId);
|
||||
await deleteLocalThreadData(threadId);
|
||||
}
|
||||
|
||||
export async function findSidecarThreadIdsForParent(
|
||||
apiClient: ThreadSidecarSearchClient,
|
||||
parentThreadId: string,
|
||||
) {
|
||||
const threadIds: string[] = [];
|
||||
const limit = 100;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const response = await apiClient.threads.search({
|
||||
metadata: {
|
||||
[SIDECAR_METADATA_KEY]: true,
|
||||
parent_thread_id: parentThreadId,
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
select: ["thread_id", "metadata"],
|
||||
});
|
||||
|
||||
for (const thread of response) {
|
||||
if (
|
||||
isSidecarThread(thread) &&
|
||||
thread.metadata?.parent_thread_id === parentThreadId
|
||||
) {
|
||||
threadIds.push(thread.thread_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.length < limit) {
|
||||
break;
|
||||
}
|
||||
offset += response.length;
|
||||
}
|
||||
|
||||
return threadIds;
|
||||
}
|
||||
|
||||
async function deleteSidecarThreadsForParent(
|
||||
apiClient: ThreadDeleteClient,
|
||||
parentThreadId: string,
|
||||
) {
|
||||
let sidecarThreadIds: string[];
|
||||
try {
|
||||
sidecarThreadIds = await findSidecarThreadIdsForParent(
|
||||
apiClient,
|
||||
parentThreadId,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to look up sidecar threads for parent ${parentThreadId}; skipping cascade cleanup. Orphaned sidecar threads may remain.`,
|
||||
err,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
sidecarThreadIds.map((threadId) =>
|
||||
deleteThreadEverywhere(apiClient, threadId),
|
||||
),
|
||||
);
|
||||
|
||||
const failedDeletions = results
|
||||
.map((result, index) =>
|
||||
result.status === "rejected"
|
||||
? { threadId: sidecarThreadIds[index], reason: result.reason }
|
||||
: null,
|
||||
)
|
||||
.filter((entry): entry is { threadId: string; reason: unknown } =>
|
||||
Boolean(entry),
|
||||
);
|
||||
|
||||
if (failedDeletions.length > 0) {
|
||||
console.warn(
|
||||
`Failed to delete ${failedDeletions.length} sidecar thread(s) for parent ${parentThreadId}; orphaned sidecar threads may remain.`,
|
||||
failedDeletions,
|
||||
);
|
||||
}
|
||||
|
||||
return sidecarThreadIds.filter((_, index) => {
|
||||
return results[index]?.status === "fulfilled";
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteThread() {
|
||||
const queryClient = useQueryClient();
|
||||
const apiClient = getAPIClient();
|
||||
const apiClient = getAPIClient() as ThreadDeleteClient;
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
threadId,
|
||||
|
|
@ -1910,24 +2127,17 @@ export function useDeleteThread() {
|
|||
threadId: string;
|
||||
onRemoteDeleted?: () => void;
|
||||
}) => {
|
||||
const deletedSidecarThreadIds = await deleteSidecarThreadsForParent(
|
||||
apiClient,
|
||||
threadId,
|
||||
);
|
||||
await apiClient.threads.delete(threadId);
|
||||
onRemoteDeleted?.();
|
||||
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to delete local thread data." }));
|
||||
throw new Error(error.detail ?? "Failed to delete local thread data.");
|
||||
}
|
||||
await deleteLocalThreadData(threadId);
|
||||
return deletedSidecarThreadIds;
|
||||
},
|
||||
onSuccess(_, { threadId }) {
|
||||
onSuccess(deletedSidecarThreadIds, { threadId }) {
|
||||
const deletedThreadIds = new Set([threadId, ...deletedSidecarThreadIds]);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["threads", "search"],
|
||||
|
|
@ -1937,7 +2147,7 @@ export function useDeleteThread() {
|
|||
if (oldData == null) {
|
||||
return oldData;
|
||||
}
|
||||
return oldData.filter((t) => t.thread_id !== threadId);
|
||||
return oldData.filter((t) => !deletedThreadIds.has(t.thread_id));
|
||||
},
|
||||
);
|
||||
queryClient.setQueriesData(
|
||||
|
|
@ -1946,7 +2156,10 @@ export function useDeleteThread() {
|
|||
exact: false,
|
||||
},
|
||||
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||
filterInfiniteThreadsCache(oldData, (t) => t.thread_id !== threadId),
|
||||
filterInfiniteThreadsCache(
|
||||
oldData,
|
||||
(t) => !deletedThreadIds.has(t.thread_id),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import {
|
||||
SIDECAR_METADATA_KEY,
|
||||
shouldShowInPrimaryThreadLists,
|
||||
} from "@/core/sidecar/thread";
|
||||
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
|
||||
type ThreadsSearchClient = {
|
||||
|
|
@ -21,6 +26,28 @@ export const DEFAULT_THREAD_SEARCH_PARAMS: ThreadSearchParams = {
|
|||
|
||||
export const THREAD_SEARCH_REFETCH_INTERVAL_MS = 5000;
|
||||
|
||||
type ThreadSearchFilterParams = Pick<ThreadSearchParams, "metadata">;
|
||||
|
||||
export function shouldIncludeSidecarThreads(params: ThreadSearchFilterParams) {
|
||||
const metadata = params.metadata;
|
||||
return (
|
||||
typeof metadata === "object" &&
|
||||
metadata !== null &&
|
||||
!Array.isArray(metadata) &&
|
||||
Reflect.get(metadata, SIDECAR_METADATA_KEY) === true
|
||||
);
|
||||
}
|
||||
|
||||
export function filterThreadSearchResults(
|
||||
threads: AgentThread[],
|
||||
params: ThreadSearchFilterParams,
|
||||
) {
|
||||
if (shouldIncludeSidecarThreads(params)) {
|
||||
return threads;
|
||||
}
|
||||
return threads.filter(shouldShowInPrimaryThreadLists);
|
||||
}
|
||||
|
||||
export function buildThreadsSearchQueryOptions(
|
||||
apiClient: ThreadsSearchClient,
|
||||
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
|
||||
|
|
@ -37,7 +64,7 @@ export function buildThreadsSearchQueryOptions(
|
|||
if (maxResults !== undefined && maxResults <= 0) {
|
||||
const response =
|
||||
await apiClient.threads.search<AgentThreadState>(params);
|
||||
return response as AgentThread[];
|
||||
return filterThreadSearchResults(response as AgentThread[], params);
|
||||
}
|
||||
|
||||
const pageSize =
|
||||
|
|
@ -68,7 +95,7 @@ export function buildThreadsSearchQueryOptions(
|
|||
offset,
|
||||
})) as AgentThread[];
|
||||
|
||||
threads.push(...response);
|
||||
threads.push(...filterThreadSearchResults(response, params));
|
||||
|
||||
if (response.length < currentLimit) {
|
||||
break;
|
||||
|
|
|
|||
1270
frontend/tests/e2e/sidecar-chat.spec.ts
Normal file
1270
frontend/tests/e2e/sidecar-chat.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,7 @@ import type { Page, Route } from "@playwright/test";
|
|||
|
||||
export const MOCK_THREAD_ID = "00000000-0000-0000-0000-000000000001";
|
||||
export const MOCK_THREAD_ID_2 = "00000000-0000-0000-0000-000000000002";
|
||||
export const MOCK_SIDECAR_THREAD_ID = "00000000-0000-0000-0000-0000000000aa";
|
||||
export const MOCK_RUN_ID = "00000000-0000-0000-0000-000000000099";
|
||||
|
||||
const MOCK_AUTH_USER = {
|
||||
|
|
@ -109,21 +110,85 @@ const DEFAULT_SKILLS: MockSkill[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function mockStreamMessages() {
|
||||
function isHiddenInputMessage(message: unknown) {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return false;
|
||||
}
|
||||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||
return (
|
||||
typeof additionalKwargs === "object" &&
|
||||
additionalKwargs !== null &&
|
||||
Reflect.get(additionalKwargs, "hide_from_ui") === true
|
||||
);
|
||||
}
|
||||
|
||||
function visibleInputMessages(messages: unknown[]) {
|
||||
return messages.filter((message) => !isHiddenInputMessage(message));
|
||||
}
|
||||
|
||||
function visibleRunInputMessages(route: Route) {
|
||||
try {
|
||||
const body = route.request().postDataJSON() as {
|
||||
input?: { messages?: unknown[] };
|
||||
};
|
||||
return visibleInputMessages(body.input?.messages ?? []);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mockStreamMessages(route?: Route, inputMessages?: unknown[]) {
|
||||
const submittedMessages = inputMessages
|
||||
? visibleInputMessages(inputMessages)
|
||||
: route
|
||||
? visibleRunInputMessages(route)
|
||||
: [];
|
||||
const responseMessage = {
|
||||
type: "ai",
|
||||
id: "msg-ai-1",
|
||||
content: "Hello from DeerFlow!",
|
||||
};
|
||||
if (submittedMessages.length > 0) {
|
||||
return [...submittedMessages, responseMessage];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-1",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-1",
|
||||
content: "Hello from DeerFlow!",
|
||||
},
|
||||
responseMessage,
|
||||
];
|
||||
}
|
||||
|
||||
function runStreamThreadId(route: Route) {
|
||||
const pathThreadId = /\/threads\/([^/]+)\/runs\/stream/.exec(
|
||||
new URL(route.request().url()).pathname,
|
||||
)?.[1];
|
||||
if (pathThreadId) {
|
||||
return pathThreadId;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = route.request().postDataJSON() as {
|
||||
thread_id?: string;
|
||||
threadId?: string;
|
||||
context?: { thread_id?: string };
|
||||
config?: { configurable?: { thread_id?: string } };
|
||||
};
|
||||
return (
|
||||
body.thread_id ??
|
||||
body.threadId ??
|
||||
body.context?.thread_id ??
|
||||
body.config?.configurable?.thread_id ??
|
||||
MOCK_THREAD_ID
|
||||
);
|
||||
} catch {
|
||||
return MOCK_THREAD_ID;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mockLangGraphAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -473,7 +538,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||
|
||||
// Thread search — sidebar thread list & chats list page
|
||||
void page.route("**/api/langgraph/threads/search", async (route) => {
|
||||
const body = threads.map(threadSearchResult);
|
||||
let body = threads.map(threadSearchResult);
|
||||
|
||||
let limit: number | undefined;
|
||||
let offset = 0;
|
||||
|
|
@ -481,6 +546,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||
const postData = route.request().postDataJSON() as {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
} | null;
|
||||
if (postData) {
|
||||
if (typeof postData.limit === "number") {
|
||||
|
|
@ -489,6 +555,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||
if (typeof postData.offset === "number") {
|
||||
offset = postData.offset;
|
||||
}
|
||||
if (postData.metadata && typeof postData.metadata === "object") {
|
||||
body = body.filter((thread) =>
|
||||
Object.entries(postData.metadata ?? {}).every(
|
||||
([key, value]) => thread.metadata?.[key] === value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No / invalid JSON body — fall back to returning the full list.
|
||||
|
|
@ -567,6 +640,36 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||
return route.fallback();
|
||||
});
|
||||
|
||||
void page.route("**/api/threads", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
const body = route.request().postDataJSON() as {
|
||||
thread_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
const threadId = body.thread_id ?? MOCK_SIDECAR_THREAD_ID;
|
||||
upsertThread({
|
||||
thread_id: threadId,
|
||||
title: "Side chat",
|
||||
updated_at: new Date().toISOString(),
|
||||
metadata: body.metadata ?? {},
|
||||
messages: [],
|
||||
});
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
thread_id: threadId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
metadata: body.metadata ?? {},
|
||||
status: "idle",
|
||||
values: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
void page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
return route.fulfill({
|
||||
|
|
@ -787,18 +890,19 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||
|
||||
// Run stream — returns a minimal SSE response with an AI message
|
||||
const handleMockRunStream = (route: Route) => {
|
||||
const requestUrl = route.request().url();
|
||||
const matchingThread = threads.find((thread) =>
|
||||
requestUrl.includes(encodeURIComponent(thread.thread_id)),
|
||||
const threadId = runStreamThreadId(route);
|
||||
const existingThread = threads.find(
|
||||
(thread) => thread.thread_id === threadId,
|
||||
);
|
||||
const fallbackGoal = threads.find((thread) => thread.goal)?.goal ?? null;
|
||||
const goal = matchingThread?.goal ?? fallbackGoal;
|
||||
const goal = existingThread?.goal ?? fallbackGoal;
|
||||
upsertThread({
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "New Chat",
|
||||
thread_id: threadId,
|
||||
title: threadId === MOCK_SIDECAR_THREAD_ID ? "Side chat" : "New Chat",
|
||||
updated_at: new Date().toISOString(),
|
||||
goal,
|
||||
messages: mockStreamMessages(),
|
||||
metadata: existingThread?.metadata,
|
||||
messages: mockStreamMessages(route),
|
||||
});
|
||||
return handleRunStream(route, { goal });
|
||||
};
|
||||
|
|
@ -906,17 +1010,19 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
|||
export function handleRunStream(
|
||||
route: Route,
|
||||
values: Record<string, unknown> = {},
|
||||
inputMessages?: unknown[],
|
||||
) {
|
||||
const threadId = runStreamThreadId(route);
|
||||
const events = [
|
||||
{
|
||||
event: "metadata",
|
||||
data: { run_id: MOCK_RUN_ID, thread_id: MOCK_THREAD_ID },
|
||||
data: { run_id: MOCK_RUN_ID, thread_id: threadId },
|
||||
},
|
||||
{
|
||||
event: "values",
|
||||
data: {
|
||||
...values,
|
||||
messages: mockStreamMessages(),
|
||||
messages: mockStreamMessages(route, inputMessages),
|
||||
},
|
||||
},
|
||||
{ event: "end", data: {} },
|
||||
|
|
|
|||
|
|
@ -42,6 +42,27 @@ test("counts later usage-bearing snapshots for the same AI message id", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("reads usage metadata from additional kwargs when the SDK nests it there", () => {
|
||||
const aiMessage = {
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "Answer",
|
||||
additional_kwargs: {
|
||||
usage_metadata: {
|
||||
input_tokens: 8,
|
||||
output_tokens: 3,
|
||||
total_tokens: 11,
|
||||
},
|
||||
},
|
||||
} as unknown as Message;
|
||||
|
||||
expect(accumulateUsage([aiMessage])).toEqual({
|
||||
inputTokens: 8,
|
||||
outputTokens: 3,
|
||||
totalTokens: 11,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps header and per-turn aggregation consistent for a reasoning+answer message", () => {
|
||||
// A single AI message carrying both reasoning (here via inline <think>) and
|
||||
// answer text now lands in exactly one assistant group (#3868), so its usage
|
||||
|
|
|
|||
150
frontend/tests/unit/core/sidecar/api.test.ts
Normal file
150
frontend/tests/unit/core/sidecar/api.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { beforeEach, expect, rs, test } from "@rstest/core";
|
||||
|
||||
rs.mock("@/core/api/fetcher", () => ({
|
||||
fetch: rs.fn(),
|
||||
}));
|
||||
|
||||
rs.mock("@/core/config", () => ({
|
||||
getBackendBaseURL: () => "http://localhost",
|
||||
}));
|
||||
|
||||
import { fetch as fetcher } from "@/core/api/fetcher";
|
||||
import type { SidecarContext } from "@/core/sidecar";
|
||||
import {
|
||||
createSidecarThread,
|
||||
findLatestSidecarThread,
|
||||
} from "@/core/sidecar/api";
|
||||
import type { AgentThread } from "@/core/threads";
|
||||
|
||||
const fetchWithAuth = rs.mocked(fetcher);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuth.mockReset();
|
||||
});
|
||||
|
||||
function makeThread(
|
||||
threadId: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): AgentThread {
|
||||
return {
|
||||
thread_id: threadId,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
metadata,
|
||||
status: "idle",
|
||||
values: { title: threadId, messages: [] },
|
||||
} as unknown as AgentThread;
|
||||
}
|
||||
|
||||
function threadResponse(threadId: string): Response {
|
||||
return new Response(JSON.stringify(makeThread(threadId)), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const context: SidecarContext = {
|
||||
type: "referenced_message",
|
||||
label: "Assistant message",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "Answer",
|
||||
};
|
||||
|
||||
test("finds the latest sidecar thread for a parent thread", async () => {
|
||||
const sidecar = makeThread("sidecar-1", {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-1",
|
||||
});
|
||||
const search = rs.fn().mockResolvedValue([sidecar]);
|
||||
|
||||
await expect(
|
||||
findLatestSidecarThread({
|
||||
parentThreadId: "parent-1",
|
||||
apiClient: { threads: { search } },
|
||||
}),
|
||||
).resolves.toBe(sidecar);
|
||||
|
||||
expect(search).toHaveBeenCalledWith({
|
||||
metadata: {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-1",
|
||||
},
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
test("ignores malformed sidecar search results", async () => {
|
||||
const search = rs.fn().mockResolvedValue([makeThread("primary-1")]);
|
||||
|
||||
await expect(
|
||||
findLatestSidecarThread({
|
||||
parentThreadId: "parent-1",
|
||||
apiClient: { threads: { search } },
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("ignores sidecar search results from another parent thread", async () => {
|
||||
const search = rs.fn().mockResolvedValue([
|
||||
makeThread("sidecar-1", {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-2",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
findLatestSidecarThread({
|
||||
parentThreadId: "parent-1",
|
||||
apiClient: { threads: { search } },
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("coalesces concurrent creates for the same parent into one request", async () => {
|
||||
let resolveFetch: ((value: Response) => void) | undefined;
|
||||
fetchWithAuth.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const first = createSidecarThread({ parentThreadId: "parent-1", context });
|
||||
const second = createSidecarThread({ parentThreadId: "parent-1", context });
|
||||
|
||||
resolveFetch?.(threadResponse("sidecar-1"));
|
||||
|
||||
const [firstThread, secondThread] = await Promise.all([first, second]);
|
||||
|
||||
expect(fetchWithAuth).toHaveBeenCalledTimes(1);
|
||||
expect(firstThread).toEqual(secondThread);
|
||||
});
|
||||
|
||||
test("allows a new create after the in-flight request settles", async () => {
|
||||
fetchWithAuth
|
||||
.mockResolvedValueOnce(threadResponse("s-1"))
|
||||
.mockResolvedValueOnce(threadResponse("s-2"));
|
||||
|
||||
await createSidecarThread({ parentThreadId: "parent-1", context });
|
||||
await createSidecarThread({ parentThreadId: "parent-1", context });
|
||||
|
||||
expect(fetchWithAuth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("clears the in-flight entry when a create fails", async () => {
|
||||
fetchWithAuth
|
||||
.mockResolvedValueOnce(new Response(null, { status: 500 }))
|
||||
.mockResolvedValueOnce(threadResponse("s-1"));
|
||||
|
||||
await expect(
|
||||
createSidecarThread({ parentThreadId: "parent-1", context }),
|
||||
).rejects.toThrow("Failed to create side conversation.");
|
||||
|
||||
await expect(
|
||||
createSidecarThread({ parentThreadId: "parent-1", context }),
|
||||
).resolves.toMatchObject({ thread_id: "s-1" });
|
||||
expect(fetchWithAuth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
175
frontend/tests/unit/core/sidecar/context.test.ts
Normal file
175
frontend/tests/unit/core/sidecar/context.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
buildMessageSidecarContext,
|
||||
buildParentConversationContext,
|
||||
buildSidecarContextPrompt,
|
||||
} from "@/core/sidecar/context";
|
||||
|
||||
test("builds message sidecar context with a readable label", () => {
|
||||
const context = buildMessageSidecarContext(
|
||||
{ type: "ai", id: "msg-1", content: "A focused answer." },
|
||||
3,
|
||||
);
|
||||
|
||||
expect(context).toMatchObject({
|
||||
type: "referenced_message",
|
||||
label: "Assistant message #3",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "A focused answer.",
|
||||
});
|
||||
});
|
||||
|
||||
test("builds sidecar context from selected text", () => {
|
||||
const context = buildMessageSidecarContext(
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-1",
|
||||
content: "The full answer includes more detail.",
|
||||
},
|
||||
2,
|
||||
{ selectedText: "includes more detail" },
|
||||
);
|
||||
|
||||
expect(context).toMatchObject({
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #2",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "includes more detail",
|
||||
});
|
||||
});
|
||||
|
||||
test("renders hidden sidecar context prompt around quoted material", () => {
|
||||
const prompt = buildSidecarContextPrompt({
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #2",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "A side conversation panel.",
|
||||
});
|
||||
|
||||
expect(prompt).toContain("You are answering in a side conversation");
|
||||
expect(prompt).toContain(
|
||||
'<referenced_message index="1" label="Selected assistant text #2">',
|
||||
);
|
||||
expect(prompt).toContain("Message ID: msg-1");
|
||||
expect(prompt).toContain("A side conversation panel.");
|
||||
});
|
||||
|
||||
test("renders hidden sidecar context prompt around multiple quoted materials", () => {
|
||||
const prompt = buildSidecarContextPrompt([
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #2",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "First quoted fragment.",
|
||||
},
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Selected user text #3",
|
||||
messageId: "msg-2",
|
||||
role: "user",
|
||||
content: "Second quoted fragment.",
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(prompt).toContain('referenced_message index="1"');
|
||||
expect(prompt).toContain('referenced_message index="2"');
|
||||
expect(prompt).toContain("First quoted fragment.");
|
||||
expect(prompt).toContain("Second quoted fragment.");
|
||||
});
|
||||
|
||||
test("builds compact parent conversation context from visible messages", () => {
|
||||
const parentContext = buildParentConversationContext([
|
||||
{
|
||||
type: "human",
|
||||
id: "parent-human-1",
|
||||
content: "Plan the feature.",
|
||||
},
|
||||
{
|
||||
type: "human",
|
||||
id: "hidden-context",
|
||||
content: "Hidden implementation note.",
|
||||
additional_kwargs: { hide_from_ui: true },
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "parent-ai-1",
|
||||
content: "Use a side conversation with cited snippets.",
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(parentContext).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
messageId: "parent-human-1",
|
||||
content: "Plan the feature.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
messageId: "parent-ai-1",
|
||||
content: "Use a side conversation with cited snippets.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders parent conversation as read-only background in sidecar prompt", () => {
|
||||
const prompt = buildSidecarContextPrompt(
|
||||
[
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #2",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "Quoted fragment.",
|
||||
},
|
||||
] as never,
|
||||
{
|
||||
parentConversation: [
|
||||
{
|
||||
role: "user",
|
||||
messageId: "parent-human-1",
|
||||
content: "Plan the feature.",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
messageId: "parent-ai-1",
|
||||
content: "Use a side conversation.",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expect(prompt).toContain("<parent_conversation_context");
|
||||
expect(prompt).toContain(
|
||||
'<parent_message index="1" role="User" message_id="parent-human-1">',
|
||||
);
|
||||
expect(prompt).toContain("Plan the feature.");
|
||||
expect(prompt).toContain("Use a side conversation.");
|
||||
expect(prompt).toContain('referenced_message index="1"');
|
||||
});
|
||||
|
||||
test("renders parent conversation for sidecar follow-ups without new references", () => {
|
||||
const prompt = buildSidecarContextPrompt([], {
|
||||
parentConversation: [
|
||||
{
|
||||
role: "assistant",
|
||||
messageId: "parent-ai-1",
|
||||
content: "Use a side conversation.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(prompt).toContain(
|
||||
"The user did not attach new referenced messages for this side question.",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Use parent_conversation_context only as continuity background",
|
||||
);
|
||||
expect(prompt).toContain("<parent_conversation_context");
|
||||
expect(prompt).toContain("Use a side conversation.");
|
||||
expect(prompt).not.toContain("<referenced_message");
|
||||
});
|
||||
75
frontend/tests/unit/core/sidecar/reference-metadata.test.ts
Normal file
75
frontend/tests/unit/core/sidecar/reference-metadata.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
buildReferenceMessageMetadata,
|
||||
readReferenceMessageContexts,
|
||||
type SidecarContext,
|
||||
} from "@/core/sidecar";
|
||||
|
||||
const contexts: SidecarContext[] = [
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #2",
|
||||
messageId: "parent-ai-1",
|
||||
role: "assistant",
|
||||
content: "Build it as a side conversation.",
|
||||
},
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #2",
|
||||
messageId: "parent-ai-1",
|
||||
role: "assistant",
|
||||
content: "Keep the cited snippets compact.",
|
||||
},
|
||||
];
|
||||
|
||||
test("builds visible message reference metadata for selected contexts", () => {
|
||||
expect(buildReferenceMessageMetadata(contexts)).toEqual({
|
||||
referenced_message_count: 2,
|
||||
referenced_message_ids: ["parent-ai-1", "parent-ai-1"],
|
||||
referenced_message_roles: ["assistant", "assistant"],
|
||||
referenced_message_contexts: [
|
||||
{
|
||||
label: "Selected assistant text #2",
|
||||
message_id: "parent-ai-1",
|
||||
role: "assistant",
|
||||
content: "Build it as a side conversation.",
|
||||
},
|
||||
{
|
||||
label: "Selected assistant text #2",
|
||||
message_id: "parent-ai-1",
|
||||
role: "assistant",
|
||||
content: "Keep the cited snippets compact.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("reads visible message reference metadata defensively", () => {
|
||||
expect(
|
||||
readReferenceMessageContexts({
|
||||
referenced_message_contexts: [
|
||||
{
|
||||
label: "Selected assistant text #2",
|
||||
message_id: "parent-ai-1",
|
||||
role: "assistant",
|
||||
content: "Build it as a side conversation.",
|
||||
},
|
||||
{
|
||||
label: 42,
|
||||
message_id: "ignored",
|
||||
role: "assistant",
|
||||
content: "Bad reference",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #2",
|
||||
messageId: "parent-ai-1",
|
||||
role: "assistant",
|
||||
content: "Build it as a side conversation.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
52
frontend/tests/unit/core/sidecar/reference-state.test.ts
Normal file
52
frontend/tests/unit/core/sidecar/reference-state.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
getNextSidecarOpenState,
|
||||
type SidecarReferenceStateItem,
|
||||
} from "@/core/sidecar/reference-state";
|
||||
|
||||
const firstReference: SidecarReferenceStateItem = {
|
||||
id: 1,
|
||||
context: {
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #1",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "First selected text.",
|
||||
},
|
||||
};
|
||||
|
||||
const secondReference: SidecarReferenceStateItem = {
|
||||
id: 2,
|
||||
context: {
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #1",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "Second selected text.",
|
||||
},
|
||||
};
|
||||
|
||||
test("keeps the existing sidecar thread when adding a new reference", () => {
|
||||
const nextState = getNextSidecarOpenState({
|
||||
open: true,
|
||||
sidecarThreadId: "sidecar-thread-1",
|
||||
activeReferences: [],
|
||||
nextReference: secondReference,
|
||||
});
|
||||
|
||||
expect(nextState.sidecarThreadId).toBe("sidecar-thread-1");
|
||||
expect(nextState.activeReferences).toEqual([secondReference]);
|
||||
});
|
||||
|
||||
test("accumulates references while drafting a new sidecar thread", () => {
|
||||
const nextState = getNextSidecarOpenState({
|
||||
open: true,
|
||||
sidecarThreadId: null,
|
||||
activeReferences: [firstReference],
|
||||
nextReference: secondReference,
|
||||
});
|
||||
|
||||
expect(nextState.sidecarThreadId).toBeNull();
|
||||
expect(nextState.activeReferences).toEqual([firstReference, secondReference]);
|
||||
});
|
||||
106
frontend/tests/unit/core/sidecar/thread.test.ts
Normal file
106
frontend/tests/unit/core/sidecar/thread.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
SIDECAR_METADATA_KEY,
|
||||
buildSidecarThreadMetadata,
|
||||
isSidecarThread,
|
||||
shouldShowInPrimaryThreadLists,
|
||||
} from "@/core/sidecar/thread";
|
||||
|
||||
test("builds sidecar thread metadata from parent thread and context", () => {
|
||||
const metadata = buildSidecarThreadMetadata("parent-1", {
|
||||
type: "referenced_message",
|
||||
label: "Assistant message",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "Answer",
|
||||
});
|
||||
|
||||
expect(metadata).toEqual({
|
||||
[SIDECAR_METADATA_KEY]: true,
|
||||
parent_thread_id: "parent-1",
|
||||
sidecar_context_type: "referenced_message",
|
||||
sidecar_context_label: "Assistant message",
|
||||
sidecar_context_count: 1,
|
||||
referenced_message_id: "msg-1",
|
||||
referenced_message_ids: ["msg-1"],
|
||||
referenced_message_role: "assistant",
|
||||
referenced_message_roles: ["assistant"],
|
||||
});
|
||||
});
|
||||
|
||||
test("builds searchable sidecar thread metadata from multiple contexts", () => {
|
||||
const metadata = buildSidecarThreadMetadata("parent-1", [
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Assistant message #1",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "First answer",
|
||||
},
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "User message #2",
|
||||
messageId: "msg-2",
|
||||
role: "user",
|
||||
content: "Follow-up request",
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
[SIDECAR_METADATA_KEY]: true,
|
||||
parent_thread_id: "parent-1",
|
||||
sidecar_context_type: "referenced_message",
|
||||
sidecar_context_label: "Assistant message #1",
|
||||
referenced_message_id: "msg-1",
|
||||
referenced_message_role: "assistant",
|
||||
sidecar_context_count: 2,
|
||||
referenced_message_ids: ["msg-1", "msg-2"],
|
||||
referenced_message_roles: ["assistant", "user"],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps referenced ids/roles parallel when quoting one message twice", () => {
|
||||
const metadata = buildSidecarThreadMetadata("parent-1", [
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #1",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "First fragment",
|
||||
},
|
||||
{
|
||||
type: "referenced_message",
|
||||
label: "Selected assistant text #1",
|
||||
messageId: "msg-1",
|
||||
role: "assistant",
|
||||
content: "Second fragment",
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(metadata.sidecar_context_count).toBe(2);
|
||||
expect(metadata.referenced_message_ids).toEqual(["msg-1", "msg-1"]);
|
||||
expect(metadata.referenced_message_roles).toEqual(["assistant", "assistant"]);
|
||||
expect(metadata.referenced_message_ids).toHaveLength(
|
||||
metadata.referenced_message_roles.length,
|
||||
);
|
||||
expect(metadata.referenced_message_ids).toHaveLength(
|
||||
metadata.sidecar_context_count,
|
||||
);
|
||||
});
|
||||
|
||||
test("identifies sidecar threads and hides them from primary thread lists", () => {
|
||||
const sidecar = {
|
||||
thread_id: "sidecar-1",
|
||||
metadata: { [SIDECAR_METADATA_KEY]: true },
|
||||
};
|
||||
const primary = {
|
||||
thread_id: "primary-1",
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
expect(isSidecarThread(sidecar)).toBe(true);
|
||||
expect(shouldShowInPrimaryThreadLists(sidecar)).toBe(false);
|
||||
expect(isSidecarThread(primary)).toBe(false);
|
||||
expect(shouldShowInPrimaryThreadLists(primary)).toBe(true);
|
||||
});
|
||||
55
frontend/tests/unit/core/threads/delete-thread.test.ts
Normal file
55
frontend/tests/unit/core/threads/delete-thread.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { expect, rs, test } from "@rstest/core";
|
||||
|
||||
import { findSidecarThreadIdsForParent } from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
function makeThread(
|
||||
threadId: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): AgentThread {
|
||||
return {
|
||||
thread_id: threadId,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
metadata,
|
||||
status: "idle",
|
||||
values: { title: threadId, messages: [] },
|
||||
} as unknown as AgentThread;
|
||||
}
|
||||
|
||||
test("finds only sidecar threads attached to the deleted parent thread", async () => {
|
||||
const search = rs.fn().mockResolvedValueOnce([
|
||||
makeThread("sidecar-1", {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-1",
|
||||
}),
|
||||
makeThread("sidecar-other-parent", {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-2",
|
||||
}),
|
||||
makeThread("primary-1"),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
findSidecarThreadIdsForParent(
|
||||
{
|
||||
threads: {
|
||||
search,
|
||||
},
|
||||
},
|
||||
"parent-1",
|
||||
),
|
||||
).resolves.toEqual(["sidecar-1"]);
|
||||
|
||||
expect(search).toHaveBeenCalledWith({
|
||||
metadata: {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-1",
|
||||
},
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
select: ["thread_id", "metadata"],
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
fetchInfiniteThreadsPage,
|
||||
filterInfiniteThreadsCache,
|
||||
getInfiniteThreadsNextPageParam,
|
||||
INFINITE_THREADS_PAGE_SIZE,
|
||||
|
|
@ -25,12 +26,16 @@ import type { AgentThread } from "@/core/threads/types";
|
|||
// / stream-finish in sync with both the legacy array cache and the new
|
||||
// infinite cache.
|
||||
|
||||
function makeThread(id: string, title = `Title ${id}`): AgentThread {
|
||||
function makeThread(
|
||||
id: string,
|
||||
title = `Title ${id}`,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): AgentThread {
|
||||
return {
|
||||
thread_id: id,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
metadata: {},
|
||||
metadata,
|
||||
status: "idle",
|
||||
values: { title },
|
||||
} as unknown as AgentThread;
|
||||
|
|
@ -86,6 +91,66 @@ describe("getInfiniteThreadsNextPageParam", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("fetchInfiniteThreadsPage", () => {
|
||||
test("fills a visible page while advancing offsets by raw backend rows", async () => {
|
||||
const search = rs
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
makeThread("sidecar-1", "Sidecar", { deerflow_sidecar: true }),
|
||||
makeThread("primary-1"),
|
||||
])
|
||||
.mockResolvedValueOnce([makeThread("primary-2")]);
|
||||
|
||||
const page = await fetchInfiniteThreadsPage(
|
||||
{ threads: { search } },
|
||||
{ sortBy: "updated_at", sortOrder: "desc" },
|
||||
0,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(page.map((thread) => thread.thread_id)).toEqual([
|
||||
"primary-1",
|
||||
"primary-2",
|
||||
]);
|
||||
expect(search).toHaveBeenNthCalledWith(1, {
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
});
|
||||
expect(search).toHaveBeenNthCalledWith(2, {
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
limit: 1,
|
||||
offset: 2,
|
||||
});
|
||||
expect(getInfiniteThreadsNextPageParam(page, [page], 2)).toBe(3);
|
||||
});
|
||||
|
||||
test("keeps sidecar rows when the caller explicitly searches for sidecars", async () => {
|
||||
const search = rs.fn().mockResolvedValueOnce([
|
||||
makeThread("sidecar-1", "Sidecar", {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-1",
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchInfiniteThreadsPage(
|
||||
{ threads: { search } },
|
||||
{
|
||||
sortBy: "updated_at",
|
||||
sortOrder: "desc",
|
||||
metadata: { deerflow_sidecar: true, parent_thread_id: "parent-1" },
|
||||
},
|
||||
0,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(page.map((thread) => thread.thread_id)).toEqual(["sidecar-1"]);
|
||||
expect(getInfiniteThreadsNextPageParam(page, [page], 2)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapInfiniteThreadsCache", () => {
|
||||
test("returns undefined when oldData is undefined", () => {
|
||||
expect(mapInfiniteThreadsCache(undefined, (t) => t)).toBeUndefined();
|
||||
|
|
|
|||
62
frontend/tests/unit/core/threads/send-message.test.ts
Normal file
62
frontend/tests/unit/core/threads/send-message.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import { buildThreadSubmitMessages } from "@/core/threads/hooks";
|
||||
|
||||
test("builds thread submit messages with hidden sidecar context before the visible user message", () => {
|
||||
const hiddenContext = {
|
||||
type: "human",
|
||||
content: "Hidden sidecar context",
|
||||
additional_kwargs: {
|
||||
hide_from_ui: true,
|
||||
sidecar_context: true,
|
||||
},
|
||||
} as Message;
|
||||
|
||||
const messages = buildThreadSubmitMessages({
|
||||
text: "What should we do next?",
|
||||
additionalInputMessages: [hiddenContext],
|
||||
});
|
||||
|
||||
expect(messages).toEqual([
|
||||
hiddenContext,
|
||||
{
|
||||
type: "human",
|
||||
content: [{ type: "text", text: "What should we do next?" }],
|
||||
additional_kwargs: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps uploaded files on the visible user message only", () => {
|
||||
const messages = buildThreadSubmitMessages({
|
||||
text: "Use this file",
|
||||
additionalInputMessages: [
|
||||
{
|
||||
type: "human",
|
||||
content: "Hidden sidecar context",
|
||||
additional_kwargs: { hide_from_ui: true },
|
||||
} as Message,
|
||||
],
|
||||
filesForSubmit: [
|
||||
{
|
||||
filename: "report.pdf",
|
||||
size: 42,
|
||||
path: "/uploads/report.pdf",
|
||||
status: "uploaded",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(messages[0]?.additional_kwargs).toEqual({ hide_from_ui: true });
|
||||
expect(messages[1]?.additional_kwargs).toEqual({
|
||||
files: [
|
||||
{
|
||||
filename: "report.pdf",
|
||||
size: 42,
|
||||
path: "/uploads/report.pdf",
|
||||
status: "uploaded",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,21 @@ import {
|
|||
DEFAULT_THREAD_SEARCH_PARAMS,
|
||||
THREAD_SEARCH_REFETCH_INTERVAL_MS,
|
||||
} from "@/core/threads/thread-search-query";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
function makeThread(
|
||||
threadId: string,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): AgentThread {
|
||||
return {
|
||||
thread_id: threadId,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
metadata,
|
||||
status: "idle",
|
||||
values: { title: threadId, messages: [] },
|
||||
} as unknown as AgentThread;
|
||||
}
|
||||
|
||||
test("thread search query refreshes so IM-created sessions appear in the sidebar", () => {
|
||||
const search = rs.fn();
|
||||
|
|
@ -17,3 +32,42 @@ test("thread search query refreshes so IM-created sessions appear in the sidebar
|
|||
expect(options.refetchIntervalInBackground).toBe(false);
|
||||
expect(options.refetchOnWindowFocus).toBe(false);
|
||||
});
|
||||
|
||||
test("thread search hides sidecar threads from primary lists by default", async () => {
|
||||
const search = rs
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
makeThread("primary-1"),
|
||||
makeThread("sidecar-1", { deerflow_sidecar: true }),
|
||||
makeThread("primary-2"),
|
||||
]);
|
||||
const options = buildThreadsSearchQueryOptions(
|
||||
{ threads: { search } },
|
||||
DEFAULT_THREAD_SEARCH_PARAMS,
|
||||
);
|
||||
|
||||
await expect(options.queryFn()).resolves.toEqual([
|
||||
makeThread("primary-1"),
|
||||
makeThread("primary-2"),
|
||||
]);
|
||||
});
|
||||
|
||||
test("thread search can explicitly include sidecar threads for parent lookup", async () => {
|
||||
const sidecar = makeThread("sidecar-1", {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-1",
|
||||
});
|
||||
const search = rs.fn().mockResolvedValue([sidecar]);
|
||||
const options = buildThreadsSearchQueryOptions(
|
||||
{ threads: { search } },
|
||||
{
|
||||
...DEFAULT_THREAD_SEARCH_PARAMS,
|
||||
metadata: {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: "parent-1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(options.queryFn()).resolves.toEqual([sidecar]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue