rift-ds 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -5
- package/bin/rift-ds.mjs +142 -29
- package/bin/templates/chat/.env.chat.example +21 -0
- package/bin/templates/chat/app/api/chat/feedback/route.ts +12 -0
- package/bin/templates/chat/app/api/chat/followups/route.ts +23 -0
- package/bin/templates/chat/app/api/chat/guardrails.ts +18 -0
- package/bin/templates/chat/app/api/chat/route.ts +31 -0
- package/bin/templates/chat/components/chat/ChatMount.tsx +52 -0
- package/bin/templates/chat/components/chat/models.ts +29 -0
- package/chat/AssistantTurn.d.ts +5 -0
- package/chat/AssistantTurn.js +82 -0
- package/chat/ChatAgentPane.css +56 -0
- package/chat/ChatAgentPane.d.ts +21 -0
- package/chat/ChatAgentPane.js +25 -0
- package/chat/ChatLauncher.d.ts +64 -0
- package/chat/ChatLauncher.js +347 -0
- package/chat/ChatProvider.d.ts +53 -0
- package/chat/ChatProvider.js +181 -0
- package/chat/ChatTabs.d.ts +13 -0
- package/chat/ChatTabs.js +24 -0
- package/chat/ChatThreads.d.ts +26 -0
- package/chat/ChatThreads.js +55 -0
- package/chat/ChatWidget.css +1072 -0
- package/chat/ChatWidget.d.ts +113 -0
- package/chat/ChatWidget.js +495 -0
- package/chat/DemoWelcome.d.ts +13 -0
- package/chat/DemoWelcome.js +131 -0
- package/chat/FollowupSuggestions.d.ts +23 -0
- package/chat/FollowupSuggestions.js +34 -0
- package/chat/ResponseActions.d.ts +18 -0
- package/chat/ResponseActions.js +79 -0
- package/chat/TurnContent.d.ts +9 -0
- package/chat/TurnContent.js +12 -0
- package/chat/context.d.ts +65 -0
- package/chat/context.js +27 -0
- package/chat/demo-agent-panes.d.ts +5 -0
- package/chat/demo-agent-panes.js +49 -0
- package/chat/demo-agent.d.ts +3 -0
- package/chat/demo-agent.js +28 -0
- package/chat/demo-card-components.d.ts +46 -0
- package/chat/demo-card-components.js +59 -0
- package/chat/demo-cards.d.ts +32 -0
- package/chat/demo-cards.js +59 -0
- package/chat/demo-fixtures.d.ts +20 -0
- package/chat/demo-fixtures.js +113 -0
- package/chat/demo-script.d.ts +6 -0
- package/chat/demo-script.js +215 -0
- package/chat/demo.css +46 -0
- package/chat/demo.d.ts +9 -0
- package/chat/demo.js +24 -0
- package/chat/followups.d.ts +41 -0
- package/chat/followups.js +55 -0
- package/chat/greeting.d.ts +11 -0
- package/chat/greeting.js +21 -0
- package/chat/index.d.ts +34 -0
- package/chat/index.js +80 -0
- package/chat/placement.d.ts +48 -0
- package/chat/placement.js +39 -0
- package/chat/queries.d.ts +9 -0
- package/chat/queries.js +22 -0
- package/chat/registry.d.ts +25 -0
- package/chat/registry.js +9 -0
- package/chat/registry.json.d.ts +69 -0
- package/chat/registry.json.js +11 -0
- package/chat/server.d.ts +217 -0
- package/chat/server.js +488 -0
- package/chat/sim.d.ts +71 -0
- package/chat/sim.js +125 -0
- package/chat/starters.d.ts +15 -0
- package/chat/starters.js +38 -0
- package/chat/suggestions.d.ts +22 -0
- package/chat/suggestions.js +9 -0
- package/chat/threads.d.ts +73 -0
- package/chat/threads.js +270 -0
- package/chat/transport.d.ts +49 -0
- package/chat/transport.js +105 -0
- package/chat/types.d.ts +210 -0
- package/chat/types.js +16 -0
- package/chat/upstash.d.ts +63 -0
- package/chat/upstash.js +147 -0
- package/chat/useChat.d.ts +64 -0
- package/chat/useChat.js +241 -0
- package/cli/templates/chat/app/api/chat/feedback/route.d.ts +3 -0
- package/cli/templates/chat/app/api/chat/followups/route.d.ts +3 -0
- package/cli/templates/chat/app/api/chat/guardrails.d.ts +10 -0
- package/cli/templates/chat/app/api/chat/route.d.ts +4 -0
- package/cli/templates/chat/components/chat/ChatMount.d.ts +1 -0
- package/cli/templates/chat/components/chat/models.d.ts +11 -0
- package/components/ShaderField/ShaderField.d.ts +3 -2
- package/components/ShaderField/useShaderField.d.ts +2 -1
- package/components/ShaderField/useShaderField.js +6 -6
- package/fonts/material-symbols.css +2 -1
- package/package.json +39 -2
- package/tokens/presets/coral.css +18 -2
- package/tokens/presets/forest.css +18 -2
- package/tokens/presets/gold.css +18 -2
- package/tokens/presets/mono.css +18 -2
- package/tokens/presets/pink.css +18 -2
- package/tokens/presets/terminal.css +18 -2
- package/tokens/presets/violet.css +18 -2
- package/tokens/presets/warm.css +18 -2
- package/tokens/presets/zest.css +18 -2
- package/tokens/registry.json +6 -0
- package/tokens/registry.json.d.ts +6 -0
- package/tokens/registry.json.js +1 -1
- package/tokens/tokens-dark.css +10 -0
- package/tokens/tokens-light.css +10 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Starter } from './types';
|
|
2
|
+
export type { Starter };
|
|
3
|
+
/** How many chips the welcome screen shows, drawn from a pool. */
|
|
4
|
+
export declare const STARTER_COUNT = 3;
|
|
5
|
+
/** The named wording when it fits the chip, the unnamed one when it does not. */
|
|
6
|
+
export declare const namedStarter: (withName: string, without: string) => string;
|
|
7
|
+
export declare function hashString(input: string): number;
|
|
8
|
+
export declare function mulberry32(seed: number): () => number;
|
|
9
|
+
/**
|
|
10
|
+
* Draw STARTER_COUNT starters from a pool, deterministically for a given
|
|
11
|
+
* (seed, key) pair. A null seed returns the head of the pool unshuffled:
|
|
12
|
+
* pools lead with their strongest questions, so callers without a seed (and
|
|
13
|
+
* a server, if one ever renders this) get the curated order.
|
|
14
|
+
*/
|
|
15
|
+
export declare function pickStarters(pool: Starter[], seed: number | null, key: string): Starter[];
|
package/chat/starters.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { fitsChip } from "./suggestions.js";
|
|
2
|
+
const STARTER_COUNT = 3;
|
|
3
|
+
const namedStarter = (withName, without) => fitsChip(withName) ? withName : without;
|
|
4
|
+
function hashString(input) {
|
|
5
|
+
let hash = 2166136261;
|
|
6
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
7
|
+
hash ^= input.charCodeAt(i);
|
|
8
|
+
hash = Math.imul(hash, 16777619);
|
|
9
|
+
}
|
|
10
|
+
return hash >>> 0;
|
|
11
|
+
}
|
|
12
|
+
function mulberry32(seed) {
|
|
13
|
+
let state = seed >>> 0;
|
|
14
|
+
return () => {
|
|
15
|
+
state = state + 1831565813 | 0;
|
|
16
|
+
let t = Math.imul(state ^ state >>> 15, 1 | state);
|
|
17
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
18
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function pickStarters(pool, seed, key) {
|
|
22
|
+
if (pool.length <= STARTER_COUNT) return pool;
|
|
23
|
+
if (seed === null) return pool.slice(0, STARTER_COUNT);
|
|
24
|
+
const rand = mulberry32(hashString(key) ^ Math.floor(seed * 4294967295));
|
|
25
|
+
const shuffled = [...pool];
|
|
26
|
+
for (let i = shuffled.length - 1; i > 0; i -= 1) {
|
|
27
|
+
const j = Math.floor(rand() * (i + 1));
|
|
28
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
29
|
+
}
|
|
30
|
+
return shuffled.slice(0, STARTER_COUNT);
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
STARTER_COUNT,
|
|
34
|
+
hashString,
|
|
35
|
+
mulberry32,
|
|
36
|
+
namedStarter,
|
|
37
|
+
pickStarters
|
|
38
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chip budget for chat suggestions, in one place.
|
|
3
|
+
*
|
|
4
|
+
* Every suggestion the chat offers renders as a Chip inside PromptSuggestions,
|
|
5
|
+
* whether it is a conversation starter on the welcome screen or a follow-up
|
|
6
|
+
* question under an answer. A chip never wraps, which is what makes it a pill
|
|
7
|
+
* rather than a paragraph, so a label longer than the column runs past the
|
|
8
|
+
* edge of a docked panel and reads as broken.
|
|
9
|
+
*
|
|
10
|
+
* The narrowest place a suggestion is shown is a phone, where the panel drops
|
|
11
|
+
* its card chrome and fills the viewport: a 375px screen leaves the message
|
|
12
|
+
* column 362px, and this is the label length that fits it. A docked panel on
|
|
13
|
+
* a desktop is wider and has room to spare. Measured, not guessed: 44
|
|
14
|
+
* characters renders at 376px and runs past the edge of a small phone, which
|
|
15
|
+
* is what this number is set below.
|
|
16
|
+
*
|
|
17
|
+
* Every producer of a suggestion checks against it. A suggestion that will
|
|
18
|
+
* not fit is dropped, never truncated: half a question is not a question.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SUGGESTION_MAX_CHARS = 40;
|
|
21
|
+
/** Whether a suggestion label is one the chip row can actually show. */
|
|
22
|
+
export declare function fitsChip(label: string): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { ThreadPanelGroup, ThreadPanelProject, ThreadPanelThread, ThreadPanelThreadAction } from '../components/ThreadPanel/ThreadPanel';
|
|
2
|
+
import { ThreadTab } from '../components/ThreadTabs/ThreadTabs';
|
|
3
|
+
import { ChatState } from './useChat';
|
|
4
|
+
/**
|
|
5
|
+
* How long the shimmer runs after the first utterance before the name
|
|
6
|
+
* "arrives". Paced like a fast title-generation call, not a UI transition,
|
|
7
|
+
* so it is a schedule rather than a motion token.
|
|
8
|
+
*/
|
|
9
|
+
export declare const THREAD_NAMING_DELAY_MS = 1600;
|
|
10
|
+
export interface ThreadsOptions {
|
|
11
|
+
/** The history to start from. The groups' labels are the host's. */
|
|
12
|
+
groups?: ThreadPanelGroup[];
|
|
13
|
+
/** The projects section, when the rail shows one. */
|
|
14
|
+
projects?: ThreadPanelProject[];
|
|
15
|
+
/** The group pinned threads move into. Created on first pin when absent. */
|
|
16
|
+
pinnedGroup?: string;
|
|
17
|
+
/** The group a new thread is born into. Defaults to the first non-pinned group. */
|
|
18
|
+
homeGroup?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The name a new thread gets once its shimmer resolves. Called with the
|
|
21
|
+
* thread's ordinal; a host with a real title generator returns a promise.
|
|
22
|
+
*/
|
|
23
|
+
nameThread?: (index: number) => string | Promise<string>;
|
|
24
|
+
/** How long the naming shimmer runs. */
|
|
25
|
+
namingDelayMs?: number;
|
|
26
|
+
/** Offer pin and unpin in each row's menu, and keep the pinned group. */
|
|
27
|
+
pins?: boolean;
|
|
28
|
+
/** Keep each row's detail anatomy (description, status, icon, meta). Off strips rows to bare titles. */
|
|
29
|
+
details?: boolean;
|
|
30
|
+
/** The label of the New chat row and tab. */
|
|
31
|
+
newThreadLabel?: string;
|
|
32
|
+
/** Called when a thread is selected, so a host can load its conversation. */
|
|
33
|
+
onSelect?: (threadId: string) => void;
|
|
34
|
+
onRename?: (threadId: string, title: string) => void;
|
|
35
|
+
onDelete?: (threadId: string) => void;
|
|
36
|
+
onPin?: (threadId: string, pinned: boolean) => void;
|
|
37
|
+
}
|
|
38
|
+
/** The thread lifecycle, shaped for ThreadPanel and ThreadTabs. */
|
|
39
|
+
export interface ThreadsState {
|
|
40
|
+
/** The groups to hand ThreadPanel, shaped by the pins and details options. */
|
|
41
|
+
groups: ThreadPanelGroup[];
|
|
42
|
+
/** The selected thread, or '' on the greeting screen. */
|
|
43
|
+
activeThreadId: string;
|
|
44
|
+
/** Select a thread (and open its tab). */
|
|
45
|
+
select: (threadId: string) => void;
|
|
46
|
+
/** Back to the greeting: resets the conversation and deselects. */
|
|
47
|
+
startNew: () => void;
|
|
48
|
+
remove: (threadId: string) => void;
|
|
49
|
+
rename: (threadId: string, title: string) => void;
|
|
50
|
+
togglePin: (threadId: string) => void;
|
|
51
|
+
/** The thread whose row is showing the inline rename editor. */
|
|
52
|
+
renamingId: string | null;
|
|
53
|
+
setRenamingId: (threadId: string | null) => void;
|
|
54
|
+
/** Each row's overflow menu: pin or unpin (when pins are on), rename, delete. */
|
|
55
|
+
actionsFor: (thread: ThreadPanelThread) => ThreadPanelThreadAction[];
|
|
56
|
+
/** Run a row action by id: 'pin', 'rename' or 'delete'. */
|
|
57
|
+
runAction: (threadId: string, actionId: string) => void;
|
|
58
|
+
projects: ThreadPanelProject[];
|
|
59
|
+
activeProjectId: string | undefined;
|
|
60
|
+
selectProject: (projectId: string) => void;
|
|
61
|
+
createProject: () => void;
|
|
62
|
+
/** The open sessions, for ThreadTabs. Resolved live from the groups. */
|
|
63
|
+
tabs: ThreadTab[];
|
|
64
|
+
closeTab: (threadId: string) => void;
|
|
65
|
+
newThreadLabel: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The thread lifecycle over a conversation. `chat` is the state from
|
|
69
|
+
* `useChat` (the provider passes its own); the hook watches whether the
|
|
70
|
+
* conversation has an utterance to decide when a thread is born and when
|
|
71
|
+
* the selection clears.
|
|
72
|
+
*/
|
|
73
|
+
export declare function useThreads(chat: Pick<ChatState, 'turns' | 'live' | 'reset'>, options?: ThreadsOptions): ThreadsState;
|
package/chat/threads.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useMemo, useState, useRef, useEffect, useCallback } from "react";
|
|
3
|
+
const THREAD_NAMING_DELAY_MS = 1600;
|
|
4
|
+
const RENAME_ACTION = { id: "rename", label: "Rename", icon: "edit" };
|
|
5
|
+
const DELETE_ACTION = {
|
|
6
|
+
id: "delete",
|
|
7
|
+
label: "Delete",
|
|
8
|
+
icon: "delete",
|
|
9
|
+
destructive: true
|
|
10
|
+
};
|
|
11
|
+
const DEFAULT_NAMES = ["New conversation"];
|
|
12
|
+
function useThreads(chat, options = {}) {
|
|
13
|
+
const {
|
|
14
|
+
pins = true,
|
|
15
|
+
details = true,
|
|
16
|
+
namingDelayMs = THREAD_NAMING_DELAY_MS,
|
|
17
|
+
newThreadLabel = "New chat",
|
|
18
|
+
pinnedGroup = "Pinned",
|
|
19
|
+
onSelect,
|
|
20
|
+
onRename,
|
|
21
|
+
onDelete,
|
|
22
|
+
onPin
|
|
23
|
+
} = options;
|
|
24
|
+
const nameThreadOption = options.nameThread;
|
|
25
|
+
const nameThread = useMemo(
|
|
26
|
+
() => nameThreadOption ?? ((index) => DEFAULT_NAMES[index % DEFAULT_NAMES.length]),
|
|
27
|
+
[nameThreadOption]
|
|
28
|
+
);
|
|
29
|
+
const initialGroups = options.groups ?? [];
|
|
30
|
+
const homeGroup = options.homeGroup ?? initialGroups.find((group) => group.label !== pinnedGroup)?.label ?? "Recent";
|
|
31
|
+
const [groups, setGroups] = useState(initialGroups);
|
|
32
|
+
const [activeThreadId, setActiveThreadId] = useState("");
|
|
33
|
+
const [renamingId, setRenamingId] = useState(null);
|
|
34
|
+
const [projects, setProjects] = useState(options.projects ?? []);
|
|
35
|
+
const [activeProjectId, setActiveProjectId] = useState(
|
|
36
|
+
options.projects?.[0]?.id
|
|
37
|
+
);
|
|
38
|
+
const [openTabs, setOpenTabs] = useState([]);
|
|
39
|
+
const threadSeq = useRef(0);
|
|
40
|
+
const nameCursor = useRef(0);
|
|
41
|
+
const projectSeq = useRef(0);
|
|
42
|
+
const namingTimers = useRef(/* @__PURE__ */ new Map());
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const timers = namingTimers.current;
|
|
45
|
+
return () => timers.forEach((timer) => clearTimeout(timer));
|
|
46
|
+
}, []);
|
|
47
|
+
const updateThread = useCallback(
|
|
48
|
+
(id, patch) => {
|
|
49
|
+
setGroups(
|
|
50
|
+
(current) => current.map((group) => ({
|
|
51
|
+
...group,
|
|
52
|
+
threads: group.threads.map((thread) => thread.id === id ? patch(thread) : thread)
|
|
53
|
+
}))
|
|
54
|
+
);
|
|
55
|
+
},
|
|
56
|
+
[]
|
|
57
|
+
);
|
|
58
|
+
const beginThread = useCallback(() => {
|
|
59
|
+
threadSeq.current += 1;
|
|
60
|
+
const id = `thread-${threadSeq.current}`;
|
|
61
|
+
setGroups((current) => {
|
|
62
|
+
const hasHome = current.some((group) => group.label === homeGroup);
|
|
63
|
+
const withHome = hasHome ? current : [...current, { label: homeGroup, threads: [] }];
|
|
64
|
+
return withHome.map(
|
|
65
|
+
(group) => group.label === homeGroup ? { ...group, threads: [{ id, title: newThreadLabel, pending: true }, ...group.threads] } : group
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
setActiveThreadId(id);
|
|
69
|
+
setOpenTabs((tabs2) => tabs2.includes(id) ? tabs2 : [...tabs2, id]);
|
|
70
|
+
const ordinal = nameCursor.current;
|
|
71
|
+
nameCursor.current += 1;
|
|
72
|
+
const timer = setTimeout(async () => {
|
|
73
|
+
namingTimers.current.delete(id);
|
|
74
|
+
const title = await nameThread(ordinal);
|
|
75
|
+
updateThread(id, () => ({ id, title }));
|
|
76
|
+
}, namingDelayMs);
|
|
77
|
+
namingTimers.current.set(id, timer);
|
|
78
|
+
}, [homeGroup, nameThread, namingDelayMs, newThreadLabel, updateThread]);
|
|
79
|
+
const hasUtterance = chat.turns.length > 0 || chat.live !== null;
|
|
80
|
+
const hadUtteranceRef = useRef(hasUtterance);
|
|
81
|
+
const birthingRef = useRef(false);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const had = hadUtteranceRef.current;
|
|
84
|
+
hadUtteranceRef.current = hasUtterance;
|
|
85
|
+
if (hasUtterance && activeThreadId === "") {
|
|
86
|
+
if (!birthingRef.current) {
|
|
87
|
+
birthingRef.current = true;
|
|
88
|
+
beginThread();
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
birthingRef.current = false;
|
|
92
|
+
if (!hasUtterance && had && activeThreadId !== "") {
|
|
93
|
+
setActiveThreadId("");
|
|
94
|
+
setRenamingId(null);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}, [hasUtterance, activeThreadId, beginThread]);
|
|
98
|
+
const { reset } = chat;
|
|
99
|
+
const startNew = useCallback(() => {
|
|
100
|
+
reset();
|
|
101
|
+
setActiveThreadId("");
|
|
102
|
+
setRenamingId(null);
|
|
103
|
+
}, [reset]);
|
|
104
|
+
const select = useCallback(
|
|
105
|
+
(id) => {
|
|
106
|
+
setActiveThreadId(id);
|
|
107
|
+
setOpenTabs((tabs2) => tabs2.includes(id) ? tabs2 : [...tabs2, id]);
|
|
108
|
+
updateThread(
|
|
109
|
+
id,
|
|
110
|
+
(thread) => thread.status === "unread" ? { ...thread, status: "idle" } : thread
|
|
111
|
+
);
|
|
112
|
+
onSelect?.(id);
|
|
113
|
+
},
|
|
114
|
+
[onSelect, updateThread]
|
|
115
|
+
);
|
|
116
|
+
const remove = useCallback(
|
|
117
|
+
(id) => {
|
|
118
|
+
const timer = namingTimers.current.get(id);
|
|
119
|
+
if (timer != null) {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
namingTimers.current.delete(id);
|
|
122
|
+
}
|
|
123
|
+
setGroups(
|
|
124
|
+
(current) => current.map((group) => ({
|
|
125
|
+
...group,
|
|
126
|
+
threads: group.threads.filter((thread) => thread.id !== id)
|
|
127
|
+
}))
|
|
128
|
+
);
|
|
129
|
+
setOpenTabs((tabs2) => tabs2.filter((tab) => tab !== id));
|
|
130
|
+
setRenamingId((renaming) => renaming === id ? null : renaming);
|
|
131
|
+
if (activeThreadId === id) {
|
|
132
|
+
setActiveThreadId("");
|
|
133
|
+
reset();
|
|
134
|
+
}
|
|
135
|
+
onDelete?.(id);
|
|
136
|
+
},
|
|
137
|
+
[activeThreadId, onDelete, reset]
|
|
138
|
+
);
|
|
139
|
+
const rename = useCallback(
|
|
140
|
+
(id, title) => {
|
|
141
|
+
updateThread(id, (thread) => ({ ...thread, title }));
|
|
142
|
+
setRenamingId(null);
|
|
143
|
+
onRename?.(id, title);
|
|
144
|
+
},
|
|
145
|
+
[onRename, updateThread]
|
|
146
|
+
);
|
|
147
|
+
const togglePin = useCallback(
|
|
148
|
+
(id) => {
|
|
149
|
+
let nowPinned = false;
|
|
150
|
+
setGroups((current) => {
|
|
151
|
+
let moved;
|
|
152
|
+
let wasPinned = false;
|
|
153
|
+
const stripped = current.map((group) => ({
|
|
154
|
+
...group,
|
|
155
|
+
threads: group.threads.filter((thread) => {
|
|
156
|
+
if (thread.id !== id) return true;
|
|
157
|
+
moved = thread;
|
|
158
|
+
wasPinned = Boolean(thread.pinned);
|
|
159
|
+
return false;
|
|
160
|
+
})
|
|
161
|
+
}));
|
|
162
|
+
const taken = moved;
|
|
163
|
+
if (!taken) return current;
|
|
164
|
+
nowPinned = !wasPinned;
|
|
165
|
+
const hasPinned = stripped.some((group) => group.label === pinnedGroup);
|
|
166
|
+
const withPinned = hasPinned || wasPinned ? stripped : [{ label: pinnedGroup, threads: [] }, ...stripped];
|
|
167
|
+
return withPinned.map((group) => {
|
|
168
|
+
if (!wasPinned && group.label === pinnedGroup) {
|
|
169
|
+
return { ...group, threads: [...group.threads, { ...taken, pinned: true }] };
|
|
170
|
+
}
|
|
171
|
+
if (wasPinned && group.label === homeGroup) {
|
|
172
|
+
return { ...group, threads: [{ ...taken, pinned: void 0 }, ...group.threads] };
|
|
173
|
+
}
|
|
174
|
+
return group;
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
onPin?.(id, nowPinned);
|
|
178
|
+
},
|
|
179
|
+
[homeGroup, onPin, pinnedGroup]
|
|
180
|
+
);
|
|
181
|
+
const closeTab = useCallback(
|
|
182
|
+
(id) => {
|
|
183
|
+
const index = openTabs.indexOf(id);
|
|
184
|
+
setOpenTabs((tabs2) => tabs2.filter((tab) => tab !== id));
|
|
185
|
+
if (activeThreadId === id) {
|
|
186
|
+
const neighbour = openTabs[index + 1] ?? openTabs[index - 1];
|
|
187
|
+
if (neighbour) select(neighbour);
|
|
188
|
+
else startNew();
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
[activeThreadId, openTabs, select, startNew]
|
|
192
|
+
);
|
|
193
|
+
const selectProject = useCallback((id) => setActiveProjectId(id), []);
|
|
194
|
+
const createProject = useCallback(() => {
|
|
195
|
+
projectSeq.current += 1;
|
|
196
|
+
const id = `project-${projectSeq.current}`;
|
|
197
|
+
setProjects((list) => [...list, { id, label: `New project ${projectSeq.current}`, meta: "now" }]);
|
|
198
|
+
setActiveProjectId(id);
|
|
199
|
+
}, []);
|
|
200
|
+
const actionsFor = useCallback(
|
|
201
|
+
(thread) => pins ? [
|
|
202
|
+
{ id: "pin", label: thread.pinned ? "Unpin" : "Pin", icon: "keep" },
|
|
203
|
+
RENAME_ACTION,
|
|
204
|
+
DELETE_ACTION
|
|
205
|
+
] : [RENAME_ACTION, DELETE_ACTION],
|
|
206
|
+
[pins]
|
|
207
|
+
);
|
|
208
|
+
const runAction = useCallback(
|
|
209
|
+
(threadId, actionId) => {
|
|
210
|
+
if (actionId === "rename") setRenamingId(threadId);
|
|
211
|
+
else if (actionId === "delete") remove(threadId);
|
|
212
|
+
else if (actionId === "pin") togglePin(threadId);
|
|
213
|
+
},
|
|
214
|
+
[remove, togglePin]
|
|
215
|
+
);
|
|
216
|
+
const shapedGroups = useMemo(() => {
|
|
217
|
+
let shaped = groups;
|
|
218
|
+
if (!pins) {
|
|
219
|
+
const pinned = shaped.find((group) => group.label === pinnedGroup)?.threads ?? [];
|
|
220
|
+
shaped = shaped.filter((group) => group.label !== pinnedGroup).map(
|
|
221
|
+
(group) => group.label === homeGroup ? {
|
|
222
|
+
...group,
|
|
223
|
+
threads: [
|
|
224
|
+
...pinned.map((thread) => ({ ...thread, pinned: void 0 })),
|
|
225
|
+
...group.threads
|
|
226
|
+
]
|
|
227
|
+
} : group
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return shaped.map((group) => ({
|
|
231
|
+
...group,
|
|
232
|
+
threads: group.threads.map((thread) => {
|
|
233
|
+
const row = details ? thread : { ...thread, description: void 0, status: void 0, icon: void 0, meta: void 0 };
|
|
234
|
+
return { ...row, actions: actionsFor(thread) };
|
|
235
|
+
})
|
|
236
|
+
}));
|
|
237
|
+
}, [groups, pins, pinnedGroup, homeGroup, details, actionsFor]);
|
|
238
|
+
const tabs = useMemo(() => {
|
|
239
|
+
const byId = /* @__PURE__ */ new Map();
|
|
240
|
+
for (const group of groups) for (const thread of group.threads) byId.set(thread.id, thread);
|
|
241
|
+
return openTabs.flatMap((id) => {
|
|
242
|
+
const thread = byId.get(id);
|
|
243
|
+
return thread ? [{ id, label: thread.title, icon: "chat_bubble", status: details ? thread.status : void 0 }] : [];
|
|
244
|
+
});
|
|
245
|
+
}, [openTabs, groups, details]);
|
|
246
|
+
return {
|
|
247
|
+
groups: shapedGroups,
|
|
248
|
+
activeThreadId,
|
|
249
|
+
select,
|
|
250
|
+
startNew,
|
|
251
|
+
remove,
|
|
252
|
+
rename,
|
|
253
|
+
togglePin,
|
|
254
|
+
renamingId,
|
|
255
|
+
setRenamingId,
|
|
256
|
+
actionsFor,
|
|
257
|
+
runAction,
|
|
258
|
+
projects,
|
|
259
|
+
activeProjectId,
|
|
260
|
+
selectProject,
|
|
261
|
+
createProject,
|
|
262
|
+
tabs,
|
|
263
|
+
closeTab,
|
|
264
|
+
newThreadLabel
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
export {
|
|
268
|
+
THREAD_NAMING_DELAY_MS,
|
|
269
|
+
useThreads
|
|
270
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ChatTransport } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* How long to wait for the first byte before giving up. Generous on purpose:
|
|
4
|
+
* a cold prompt cache costs several seconds before the model starts, and a
|
|
5
|
+
* false timeout is worse than a slow answer.
|
|
6
|
+
*/
|
|
7
|
+
export declare const FIRST_BYTE_TIMEOUT_MS = 15000;
|
|
8
|
+
/** Turns sent upstream. The handler re-enforces this; sending less saves bytes. */
|
|
9
|
+
export declare const MAX_TRANSPORT_TURNS = 10;
|
|
10
|
+
/** The copy a failed request becomes, by its HTTP status. */
|
|
11
|
+
export interface FetchTransportCopy {
|
|
12
|
+
/** The request could not be sent at all. */
|
|
13
|
+
unreachable: string;
|
|
14
|
+
/** 400: the server rejected the message shape. */
|
|
15
|
+
badRequest: string;
|
|
16
|
+
/** 429: too many requests. */
|
|
17
|
+
rateLimited: string;
|
|
18
|
+
/** 5xx. */
|
|
19
|
+
serverError: string;
|
|
20
|
+
/** Anything else. */
|
|
21
|
+
other: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const DEFAULT_FETCH_COPY: FetchTransportCopy;
|
|
24
|
+
export interface FetchTransportOptions {
|
|
25
|
+
/**
|
|
26
|
+
* The visitor's explicit model pick (a wire value from the host's model
|
|
27
|
+
* list), read at send time so a mid-conversation switch applies from the
|
|
28
|
+
* next message. Null means no pick was made: the server applies its own
|
|
29
|
+
* default, which is the distinction that lets a budget tier move the
|
|
30
|
+
* default without fighting a visitor who never touched the picker.
|
|
31
|
+
*/
|
|
32
|
+
getModel?: () => string | null;
|
|
33
|
+
/**
|
|
34
|
+
* The context the request carries as `path`: by default the page the
|
|
35
|
+
* visitor is on, read at send time so a conversation that spans several
|
|
36
|
+
* pages sends each question with the page it was asked from. The handler
|
|
37
|
+
* treats it as untrusted input and sanitises it. Return null to send none.
|
|
38
|
+
*/
|
|
39
|
+
getContext?: () => string | null;
|
|
40
|
+
/** Replacement copy for the failure messages. */
|
|
41
|
+
copy?: Partial<FetchTransportCopy>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A transport over `fetch` to an NDJSON chat endpoint.
|
|
45
|
+
*
|
|
46
|
+
* `endpoint` defaults to `/api/chat`, which is where `npx rift-ds init chat`
|
|
47
|
+
* writes the server handler; a host with its own URL passes it.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createFetchTransport(endpoint?: string, options?: FetchTransportOptions): ChatTransport;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const FIRST_BYTE_TIMEOUT_MS = 15e3;
|
|
2
|
+
const MAX_TRANSPORT_TURNS = 10;
|
|
3
|
+
const DEFAULT_FETCH_COPY = {
|
|
4
|
+
unreachable: "Could not reach the chat backend. Check your connection and try again.",
|
|
5
|
+
badRequest: "That message did not go through. Try rephrasing it.",
|
|
6
|
+
rateLimited: "That is a lot of questions at once. Give it a minute and try again.",
|
|
7
|
+
serverError: "The chat backend is having trouble. Try again in a moment.",
|
|
8
|
+
other: "Something went wrong sending that. Try again."
|
|
9
|
+
};
|
|
10
|
+
function httpErrorMessage(status, copy) {
|
|
11
|
+
if (status === 400) return copy.badRequest;
|
|
12
|
+
if (status === 429) return copy.rateLimited;
|
|
13
|
+
if (status >= 500) return copy.serverError;
|
|
14
|
+
return copy.other;
|
|
15
|
+
}
|
|
16
|
+
function createFetchTransport(endpoint = "/api/chat", options = {}) {
|
|
17
|
+
const copy = { ...DEFAULT_FETCH_COPY, ...options.copy };
|
|
18
|
+
const getContext = options.getContext ?? (() => typeof window !== "undefined" ? window.location.pathname : null);
|
|
19
|
+
return async function* fetchTransport(messages, signal) {
|
|
20
|
+
const path = getContext() ?? void 0;
|
|
21
|
+
const model = options.getModel?.() ?? void 0;
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const abort = () => controller.abort();
|
|
24
|
+
signal.addEventListener("abort", abort);
|
|
25
|
+
let watchdog = setTimeout(
|
|
26
|
+
abort,
|
|
27
|
+
FIRST_BYTE_TIMEOUT_MS
|
|
28
|
+
);
|
|
29
|
+
const clearWatchdog = () => {
|
|
30
|
+
if (watchdog !== null) {
|
|
31
|
+
clearTimeout(watchdog);
|
|
32
|
+
watchdog = null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
let reader = null;
|
|
36
|
+
try {
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
response = await fetch(endpoint, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
/* Role and text, named field by field: a message may carry
|
|
44
|
+
attachment details for a scripted chat, and nothing about a
|
|
45
|
+
file is ever sent to the server. */
|
|
46
|
+
messages: messages.slice(-MAX_TRANSPORT_TURNS).map(({ role, content }) => ({ role, content })),
|
|
47
|
+
path,
|
|
48
|
+
model
|
|
49
|
+
}),
|
|
50
|
+
signal: controller.signal
|
|
51
|
+
});
|
|
52
|
+
} catch {
|
|
53
|
+
if (signal.aborted) return;
|
|
54
|
+
yield { type: "error", message: copy.unreachable };
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok || !response.body) {
|
|
58
|
+
yield { type: "error", message: httpErrorMessage(response.status, copy) };
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
reader = response.body.getReader();
|
|
62
|
+
const decoder = new TextDecoder();
|
|
63
|
+
let buffer = "";
|
|
64
|
+
while (true) {
|
|
65
|
+
if (signal.aborted) return;
|
|
66
|
+
const { done, value } = await reader.read();
|
|
67
|
+
clearWatchdog();
|
|
68
|
+
if (done) break;
|
|
69
|
+
buffer += decoder.decode(value, { stream: true });
|
|
70
|
+
const lines = buffer.split("\n");
|
|
71
|
+
buffer = lines.pop() ?? "";
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const event = parseEvent(line);
|
|
74
|
+
if (!event) continue;
|
|
75
|
+
yield event;
|
|
76
|
+
if (signal.aborted) return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const trailing = parseEvent(buffer);
|
|
80
|
+
if (trailing) yield trailing;
|
|
81
|
+
} finally {
|
|
82
|
+
clearWatchdog();
|
|
83
|
+
signal.removeEventListener("abort", abort);
|
|
84
|
+
if (reader) {
|
|
85
|
+
await reader.cancel().catch(() => {
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function parseEvent(line) {
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
if (!trimmed) return null;
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(trimmed);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export {
|
|
101
|
+
DEFAULT_FETCH_COPY,
|
|
102
|
+
FIRST_BYTE_TIMEOUT_MS,
|
|
103
|
+
MAX_TRANSPORT_TURNS,
|
|
104
|
+
createFetchTransport
|
|
105
|
+
};
|