mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-18 23:11:12 +02:00
Merge pull request #1601 from CREDO23/feat/resumable-streaming-and-timestamps
[Feat] Chat : Resumable streaming across in-app navigation + optional message timestamps
This commit is contained in:
commit
9e72be1b15
15 changed files with 2156 additions and 1993 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { AppearanceContent } from "../components/AppearanceContent";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AppearanceContent />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { showMessageTimestampsAtom } from "@/atoms/chat/show-timestamps.atom";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
export function AppearanceContent() {
|
||||||
|
const [showTimestamps, setShowTimestamps] = useAtom(showMessageTimestampsAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
<section>
|
||||||
|
<div className="pb-2 md:pb-3">
|
||||||
|
<h2 className="text-base md:text-lg font-semibold">Chat</h2>
|
||||||
|
<p className="text-xs md:text-sm text-muted-foreground">
|
||||||
|
Control how messages are displayed in your conversations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-accent p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label
|
||||||
|
htmlFor="show-timestamps-toggle"
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
Show message timestamps
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Display the time under each message in a chat. Saved on this device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="show-timestamps-toggle"
|
||||||
|
checked={showTimestamps}
|
||||||
|
onCheckedChange={setShowTimestamps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Library,
|
Library,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
Palette,
|
||||||
ReceiptText,
|
ReceiptText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
WandSparkles,
|
WandSparkles,
|
||||||
|
|
@ -20,6 +21,7 @@ import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
|
||||||
export type UserSettingsTab =
|
export type UserSettingsTab =
|
||||||
| "profile"
|
| "profile"
|
||||||
|
| "appearance"
|
||||||
| "api-key"
|
| "api-key"
|
||||||
| "prompts"
|
| "prompts"
|
||||||
| "community-prompts"
|
| "community-prompts"
|
||||||
|
|
@ -49,6 +51,12 @@ export function UserSettingsLayoutShell({ workspaceId, children }: UserSettingsL
|
||||||
href: `/dashboard/${workspaceId}/user-settings/profile`,
|
href: `/dashboard/${workspaceId}/user-settings/profile`,
|
||||||
icon: <CircleUser className="h-4 w-4" />,
|
icon: <CircleUser className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "appearance" as const,
|
||||||
|
label: "Appearance",
|
||||||
|
href: `/dashboard/${workspaceId}/user-settings/appearance`,
|
||||||
|
icon: <Palette className="h-4 w-4" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "api-key" as const,
|
value: "api-key" as const,
|
||||||
label: t("api_key_nav_label"),
|
label: t("api_key_nav_label"),
|
||||||
|
|
|
||||||
11
surfsense_web/atoms/chat/show-timestamps.atom.ts
Normal file
11
surfsense_web/atoms/chat/show-timestamps.atom.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { atomWithStorage } from "jotai/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-device preference: show a timestamp under each chat message.
|
||||||
|
*
|
||||||
|
* Off by default to match streaming-AI chat convention (ChatGPT/Claude keep
|
||||||
|
* the message stream clean and put time in the conversation list). Persisted
|
||||||
|
* in localStorage, so it does not sync across devices — acceptable for a
|
||||||
|
* cosmetic display toggle.
|
||||||
|
*/
|
||||||
|
export const showMessageTimestampsAtom = atomWithStorage<boolean>("chat-show-timestamps:v1", false);
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
useAllCitationMetadata,
|
useAllCitationMetadata,
|
||||||
} from "@/components/assistant-ui/citation-metadata-context";
|
} from "@/components/assistant-ui/citation-metadata-context";
|
||||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||||
|
import { MessageTimestamp } from "@/components/assistant-ui/message-timestamp";
|
||||||
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
|
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
|
||||||
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
|
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -62,6 +63,7 @@ import { withArtifactAnchor } from "@/features/chat-artifacts";
|
||||||
import { useComments } from "@/hooks/use-comments";
|
import { useComments } from "@/hooks/use-comments";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
import { formatMessageTimestamp } from "@/lib/format-date";
|
||||||
import { getProviderIcon } from "@/lib/provider-icons";
|
import { getProviderIcon } from "@/lib/provider-icons";
|
||||||
import { tryGetHostname } from "@/lib/url";
|
import { tryGetHostname } from "@/lib/url";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -249,16 +251,6 @@ export const MessageError: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatMessageDate(date: Date): string {
|
|
||||||
return date.toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format provider USD cost (in micro-USD) for inline display next to a
|
* Format provider USD cost (in micro-USD) for inline display next to a
|
||||||
* token count. Falls back to ``"<$0.001"`` for sub-tenth-of-a-cent
|
* token count. Falls back to ``"<$0.001"`` for sub-tenth-of-a-cent
|
||||||
|
|
@ -367,7 +359,7 @@ const MessageInfoDropdown: FC<{ chatTurnId: string | null | undefined }> = ({ ch
|
||||||
>
|
>
|
||||||
{createdAt && (
|
{createdAt && (
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal select-none">
|
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal select-none">
|
||||||
{formatMessageDate(createdAt)}
|
{formatMessageTimestamp(createdAt)}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
)}
|
)}
|
||||||
{hasUsage && (
|
{hasUsage && (
|
||||||
|
|
@ -463,6 +455,8 @@ const AssistantMessageInner: FC = () => {
|
||||||
<MessageError />
|
<MessageError />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MessageTimestamp className="ml-2 mt-2" />
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<div className="ml-2 mt-2">
|
<div className="ml-2 mt-2">
|
||||||
<MobileCitationDrawer />
|
<MobileCitationDrawer />
|
||||||
|
|
|
||||||
24
surfsense_web/components/assistant-ui/message-timestamp.tsx
Normal file
24
surfsense_web/components/assistant-ui/message-timestamp.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useAuiState } from "@assistant-ui/react";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { showMessageTimestampsAtom } from "@/atoms/chat/show-timestamps.atom";
|
||||||
|
import { formatMessageTimestamp } from "@/lib/format-date";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Muted, always-visible timestamp under a chat message. Renders only when the
|
||||||
|
* user has opted in via {@link showMessageTimestampsAtom} and the message
|
||||||
|
* carries a ``createdAt`` (absent on optimistic pre-persist messages).
|
||||||
|
*/
|
||||||
|
export const MessageTimestamp: FC<{ className?: string }> = ({ className }) => {
|
||||||
|
const show = useAtomValue(showMessageTimestampsAtom);
|
||||||
|
const createdAt = useAuiState(({ message }) => message?.createdAt);
|
||||||
|
|
||||||
|
if (!show || !createdAt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("select-none text-[11px] text-muted-foreground", className)}>
|
||||||
|
{formatMessageTimestamp(createdAt)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -22,6 +22,7 @@ import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||||
import { MentionChip } from "@/components/assistant-ui/mention-chip";
|
import { MentionChip } from "@/components/assistant-ui/mention-chip";
|
||||||
|
import { MessageTimestamp } from "@/components/assistant-ui/message-timestamp";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
|
||||||
|
|
@ -182,6 +183,7 @@ export const UserMessage: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<MessageTimestamp className="mt-1 pl-1" />
|
||||||
</div>
|
</div>
|
||||||
</MessagePrimitive.Root>
|
</MessagePrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
23
surfsense_web/components/chat/active-chat-stream-runner.tsx
Normal file
23
surfsense_web/components/chat/active-chat-stream-runner.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { chatStreamStore } from "@/lib/chat/stream-engine/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent, render-null host that scopes the in-flight chat turn's lifetime
|
||||||
|
* to the workspace shell, not the chat page.
|
||||||
|
*
|
||||||
|
* Mounted in ``LayoutDataProvider``, it survives in-app navigation between
|
||||||
|
* workspace routes and aborts the single active turn only on workspace/app
|
||||||
|
* teardown, so ordinary navigation disconnects the view without stopping the
|
||||||
|
* stream.
|
||||||
|
*/
|
||||||
|
export function ActiveChatStreamRunner() {
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
chatStreamStore.abortActive();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||||
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
|
import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog";
|
||||||
import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight";
|
import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight";
|
||||||
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
|
import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog";
|
||||||
|
import { ActiveChatStreamRunner } from "@/components/chat/active-chat-stream-runner";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -644,6 +645,9 @@ export function LayoutDataProvider({ workspaceId, children }: LayoutDataProvider
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Persistent host: keeps an in-flight chat turn streaming across
|
||||||
|
in-app navigation and aborts it only on workspace teardown. */}
|
||||||
|
<ActiveChatStreamRunner />
|
||||||
<LayoutShell
|
<LayoutShell
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
activeWorkspaceId={Number(workspaceId)}
|
activeWorkspaceId={Number(workspaceId)}
|
||||||
|
|
|
||||||
1432
surfsense_web/lib/chat/stream-engine/engine.ts
Normal file
1432
surfsense_web/lib/chat/stream-engine/engine.ts
Normal file
File diff suppressed because it is too large
Load diff
154
surfsense_web/lib/chat/stream-engine/helpers.ts
Normal file
154
surfsense_web/lib/chat/stream-engine/helpers.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { MentionedDocumentInfo } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
|
import type { ToolUIGate } from "@/lib/chat/streaming-state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tool call renders a card. The sentinel ``"all"`` matches every tool
|
||||||
|
* — the legacy ``BASE_TOOLS_WITH_UI`` allowlist was dropped so unknown tool
|
||||||
|
* calls route through the generic ``ToolFallback``. Persisted payload size
|
||||||
|
* stays bounded because the backend's ``format_thinking_step`` summarisation
|
||||||
|
* and the ``result_length``-only default for unknown tools keep the JSON
|
||||||
|
* from ballooning.
|
||||||
|
*/
|
||||||
|
export const TOOLS_WITH_UI_ALL: ToolUIGate = "all";
|
||||||
|
|
||||||
|
export const TURN_CANCELLING_INITIAL_DELAY_MS = 200;
|
||||||
|
export const TURN_CANCELLING_BACKOFF_FACTOR = 2;
|
||||||
|
export const TURN_CANCELLING_MAX_DELAY_MS = 1500;
|
||||||
|
export const RECENT_CANCEL_WINDOW_MS = 5_000;
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFallbackTurnCancellingRetryDelay(attempt: number): number {
|
||||||
|
const safeAttempt = Math.max(1, attempt);
|
||||||
|
const raw =
|
||||||
|
TURN_CANCELLING_INITIAL_DELAY_MS * TURN_CANCELLING_BACKOFF_FACTOR ** (safeAttempt - 1);
|
||||||
|
return Math.min(raw, TURN_CANCELLING_MAX_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a synthetic ``toolCallId`` for an action_request that has no
|
||||||
|
* matching streamed tool-call card (HITL-blocked subagent calls don't surface
|
||||||
|
* as tool-call events). Suffixes a counter when the base id is already taken
|
||||||
|
* — sequential interrupts for the same tool name otherwise collide on
|
||||||
|
* ``interrupt-${name}-${i}`` and crash assistant-ui with a duplicate-key error.
|
||||||
|
*/
|
||||||
|
export function freshSynthToolCallId(
|
||||||
|
toolCallIndices: Map<string, number>,
|
||||||
|
toolName: string,
|
||||||
|
index: number
|
||||||
|
): string {
|
||||||
|
const base = `interrupt-${toolName}-${index}`;
|
||||||
|
if (!toolCallIndices.has(base)) return base;
|
||||||
|
let n = 1;
|
||||||
|
while (toolCallIndices.has(`${base}-${n}`)) n++;
|
||||||
|
return `${base}-${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pair each ``action_request`` to a unique pending tool-call card, preserving
|
||||||
|
* order so ``decisions[i]`` lines up with ``action_requests[i]`` on the wire.
|
||||||
|
*
|
||||||
|
* Same-name bundles (e.g. three ``create_jira_issue``) used to collapse onto
|
||||||
|
* one card because the matcher keyed by name; this consumes each card via the
|
||||||
|
* ``claimed`` set and walks forward in DOM order.
|
||||||
|
*/
|
||||||
|
export function pairBundleToolCallIds(
|
||||||
|
toolCallIndices: Map<string, number>,
|
||||||
|
contentParts: Array<{
|
||||||
|
type: string;
|
||||||
|
toolName?: string;
|
||||||
|
result?: unknown;
|
||||||
|
}>,
|
||||||
|
actionRequests: ReadonlyArray<{ name: string }>
|
||||||
|
): Array<string | null> {
|
||||||
|
const claimed = new Set<string>();
|
||||||
|
const paired: Array<string | null> = [];
|
||||||
|
for (const action of actionRequests) {
|
||||||
|
let matched: string | null = null;
|
||||||
|
for (const [tcId, idx] of toolCallIndices) {
|
||||||
|
if (claimed.has(tcId)) continue;
|
||||||
|
const part = contentParts[idx];
|
||||||
|
if (!part || part.type !== "tool-call" || part.toolName !== action.name) continue;
|
||||||
|
const result = part.result as Record<string, unknown> | undefined | null;
|
||||||
|
if (result == null || (result.__interrupt__ === true && !result.__decided__)) {
|
||||||
|
matched = tcId;
|
||||||
|
claimed.add(tcId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paired.push(matched);
|
||||||
|
}
|
||||||
|
return paired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schema for mentioned document info (for type-safe parsing).
|
||||||
|
*
|
||||||
|
* ``kind`` defaults to ``"doc"`` so messages persisted before folder
|
||||||
|
* mentions existed deserialise unchanged.
|
||||||
|
*/
|
||||||
|
const MentionedDocumentInfoSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
document_type: z.string().optional(),
|
||||||
|
kind: z
|
||||||
|
.union([z.literal("doc"), z.literal("folder"), z.literal("connector"), z.literal("thread")])
|
||||||
|
.optional()
|
||||||
|
.default("doc"),
|
||||||
|
connector_type: z.string().optional(),
|
||||||
|
account_name: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MentionedDocumentsPartSchema = z.object({
|
||||||
|
type: z.literal("mentioned-documents"),
|
||||||
|
documents: z.array(MentionedDocumentInfoSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract mentioned documents from message content (type-safe with Zod).
|
||||||
|
*/
|
||||||
|
export function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] {
|
||||||
|
if (!Array.isArray(content)) return [];
|
||||||
|
|
||||||
|
for (const part of content) {
|
||||||
|
const result = MentionedDocumentsPartSchema.safeParse(part);
|
||||||
|
if (result.success) {
|
||||||
|
return result.data.documents.map<MentionedDocumentInfo>((doc) => {
|
||||||
|
if (doc.kind === "connector") {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
kind: "connector",
|
||||||
|
connector_type: doc.connector_type ?? doc.document_type ?? "UNKNOWN",
|
||||||
|
account_name: doc.account_name ?? doc.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (doc.kind === "folder") {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
kind: "folder",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (doc.kind === "thread") {
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
kind: "thread",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
document_type: doc.document_type ?? "UNKNOWN",
|
||||||
|
kind: "doc",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
177
surfsense_web/lib/chat/stream-engine/store.ts
Normal file
177
surfsense_web/lib/chat/stream-engine/store.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||||
|
import { createTokenUsageStore } from "@/components/assistant-ui/token-usage-context";
|
||||||
|
import type { PendingInterruptState } from "@/features/chat-messages/hitl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable, per-thread streaming state for a single in-flight chat turn.
|
||||||
|
*
|
||||||
|
* Lives at module scope (see {@link chatStreamStore}) so a running turn's
|
||||||
|
* messages / interrupts survive the chat page unmounting during in-app
|
||||||
|
* navigation. The React tree consumes it through ``useChatStream`` via
|
||||||
|
* ``useSyncExternalStore`` (state lives outside the render cycle).
|
||||||
|
*/
|
||||||
|
export interface ThreadStreamState {
|
||||||
|
threadId: number;
|
||||||
|
messages: ThreadMessageLike[];
|
||||||
|
isRunning: boolean;
|
||||||
|
pendingInterrupts: PendingInterruptState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module-level singleton external store for chat streaming.
|
||||||
|
*
|
||||||
|
* Single-active-stream invariant: at most one turn streams at a time,
|
||||||
|
* tracked by {@link active}. Per-thread state is still keyed by threadId so
|
||||||
|
* the currently-streaming thread and the currently-viewed thread can differ
|
||||||
|
* (e.g. a stream finishes while the user is on another chat).
|
||||||
|
*
|
||||||
|
* ponytail: unbounded ``states`` map is bounded in practice by
|
||||||
|
* ``clearInactive`` (called on navigation) + ``clear`` (called after the DB
|
||||||
|
* re-hydrates a finished turn). Upgrade path if that ever leaks: LRU cap.
|
||||||
|
*/
|
||||||
|
class ChatStreamStore {
|
||||||
|
private states = new Map<number, ThreadStreamState>();
|
||||||
|
private listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
/** Shared, cross-navigation token-usage store (one instance app-wide). */
|
||||||
|
readonly tokenUsage = createTokenUsageStore();
|
||||||
|
|
||||||
|
/** The one in-flight turn's abort handle, or null when idle. */
|
||||||
|
private active: { threadId: number; controller: AbortController } | null = null;
|
||||||
|
|
||||||
|
/** Timestamp of the last explicit cancel, for the THREAD_BUSY retry window. */
|
||||||
|
recentCancelRequestedAt = 0;
|
||||||
|
|
||||||
|
subscribe = (listener: Listener): (() => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const l of this.listeners) l();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot for ``useSyncExternalStore``; stable ref between mutations. */
|
||||||
|
getSnapshot = (threadId: number | null): ThreadStreamState | null => {
|
||||||
|
if (threadId == null) return null;
|
||||||
|
return this.states.get(threadId) ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
isRunning(threadId: number | null): boolean {
|
||||||
|
if (threadId == null) return false;
|
||||||
|
return this.states.get(threadId)?.isRunning ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessages(threadId: number): ThreadMessageLike[] {
|
||||||
|
return this.states.get(threadId)?.messages ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPendingInterrupts(threadId: number): PendingInterruptState[] {
|
||||||
|
return this.states.get(threadId)?.pendingInterrupts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensure(threadId: number): ThreadStreamState {
|
||||||
|
let s = this.states.get(threadId);
|
||||||
|
if (!s) {
|
||||||
|
s = { threadId, messages: [], isRunning: false, pendingInterrupts: [] };
|
||||||
|
this.states.set(threadId, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private commit(threadId: number, next: ThreadStreamState): void {
|
||||||
|
this.states.set(threadId, next);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed a thread's state at the start of a fresh turn (running=true). */
|
||||||
|
begin(threadId: number, messages: ThreadMessageLike[]): void {
|
||||||
|
this.commit(threadId, { threadId, messages, isRunning: true, pendingInterrupts: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(threadId: number, updater: (prev: ThreadMessageLike[]) => ThreadMessageLike[]): void {
|
||||||
|
const prev = this.ensure(threadId);
|
||||||
|
const messages = updater(prev.messages);
|
||||||
|
if (messages === prev.messages) return;
|
||||||
|
this.commit(threadId, { ...prev, messages });
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(threadId: number, running: boolean): void {
|
||||||
|
const prev = this.ensure(threadId);
|
||||||
|
if (prev.isRunning === running) return;
|
||||||
|
this.commit(threadId, { ...prev, isRunning: running });
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingInterrupts(
|
||||||
|
threadId: number,
|
||||||
|
updater: (prev: PendingInterruptState[]) => PendingInterruptState[]
|
||||||
|
): void {
|
||||||
|
const prev = this.ensure(threadId);
|
||||||
|
const pendingInterrupts = updater(prev.pendingInterrupts);
|
||||||
|
if (pendingInterrupts === prev.pendingInterrupts) return;
|
||||||
|
this.commit(threadId, { ...prev, pendingInterrupts });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A thread whose overlay must survive DB re-hydration / navigation: it is
|
||||||
|
* either streaming or paused awaiting a HITL decision (the pending
|
||||||
|
* interrupts + interrupt cards live only in the overlay).
|
||||||
|
*/
|
||||||
|
private isPinned(s: ThreadStreamState): boolean {
|
||||||
|
return s.isRunning || s.pendingInterrupts.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a thread's overlay once the DB is authoritative. No-op while pinned. */
|
||||||
|
clear(threadId: number): void {
|
||||||
|
const s = this.states.get(threadId);
|
||||||
|
if (!s || this.isPinned(s)) return;
|
||||||
|
this.states.delete(threadId);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evict every non-pinned thread except ``exceptThreadId`` (memory bound). */
|
||||||
|
clearInactive(exceptThreadId: number | null): void {
|
||||||
|
let changed = false;
|
||||||
|
for (const [id, s] of this.states) {
|
||||||
|
if (id === exceptThreadId || this.isPinned(s)) continue;
|
||||||
|
this.states.delete(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- active-stream lifecycle -------------------------------------------
|
||||||
|
|
||||||
|
/** Register a new in-flight turn, aborting any previous one first. */
|
||||||
|
beginActive(threadId: number, controller: AbortController): void {
|
||||||
|
this.abortActive();
|
||||||
|
this.active = { threadId, controller };
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeThreadId(): number | null {
|
||||||
|
return this.active?.threadId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the active handle iff it still points at ``controller``. */
|
||||||
|
clearActive(controller: AbortController): void {
|
||||||
|
if (this.active?.controller === controller) this.active = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abort the in-flight turn's fetch (client disconnect, not a server stop). */
|
||||||
|
abortActive(): void {
|
||||||
|
if (this.active) {
|
||||||
|
this.active.controller.abort();
|
||||||
|
this.active = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markRecentCancel(): void {
|
||||||
|
this.recentCancelRequestedAt = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatStreamStore = new ChatStreamStore();
|
||||||
18
surfsense_web/lib/chat/stream-engine/use-chat-stream.ts
Normal file
18
surfsense_web/lib/chat/stream-engine/use-chat-stream.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
import { chatStreamStore, type ThreadStreamState } from "./store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to the durable streaming state for a given thread.
|
||||||
|
*
|
||||||
|
* Returns the live ``ThreadStreamState`` while a turn is streaming (or
|
||||||
|
* pending re-hydration from the DB), or ``null`` when the thread has no
|
||||||
|
* active overlay — in which case the page falls back to its DB-hydrated
|
||||||
|
* messages. State lives in the module-level {@link chatStreamStore}, so it
|
||||||
|
* survives the chat page unmounting during in-app navigation.
|
||||||
|
*/
|
||||||
|
export function useChatStream(threadId: number | null): ThreadStreamState | null {
|
||||||
|
const getSnapshot = useCallback(() => chatStreamStore.getSnapshot(threadId), [threadId]);
|
||||||
|
return useSyncExternalStore(chatStreamStore.subscribe, getSnapshot, () => null);
|
||||||
|
}
|
||||||
|
|
@ -68,3 +68,17 @@ export function formatRelativeFutureDate(dateString: string): string {
|
||||||
export function formatThreadTimestamp(dateString: string): string {
|
export function formatThreadTimestamp(dateString: string): string {
|
||||||
return format(new Date(dateString), "MMM d, yyyy 'at' h:mm a");
|
return format(new Date(dateString), "MMM d, yyyy 'at' h:mm a");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a chat message timestamp for inline display under a bubble.
|
||||||
|
* Locale-aware, 12-hour clock. Example: "Jul 13, 10:42 PM".
|
||||||
|
*/
|
||||||
|
export function formatMessageTimestamp(date: Date): string {
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue