@guuey/chat 0.4.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/LICENSE +21 -0
- package/README.md +137 -0
- package/dist/history-inputs.d.ts +24 -0
- package/dist/history-inputs.d.ts.map +1 -0
- package/dist/history-inputs.js +34 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/plan.d.ts +5 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +629 -0
- package/dist/policy.d.ts +110 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +75 -0
- package/dist/react/components.d.ts +87 -0
- package/dist/react/components.d.ts.map +1 -0
- package/dist/react/components.js +270 -0
- package/dist/react/guuey-chat.d.ts +84 -0
- package/dist/react/guuey-chat.d.ts.map +1 -0
- package/dist/react/guuey-chat.js +103 -0
- package/dist/react/markdown.d.ts +32 -0
- package/dist/react/markdown.d.ts.map +1 -0
- package/dist/react/markdown.js +40 -0
- package/dist/react/theme-css.d.ts +16 -0
- package/dist/react/theme-css.d.ts.map +1 -0
- package/dist/react/theme-css.js +37 -0
- package/dist/react/transcript.d.ts +42 -0
- package/dist/react/transcript.d.ts.map +1 -0
- package/dist/react/transcript.js +88 -0
- package/dist/react/use-transcript.d.ts +39 -0
- package/dist/react/use-transcript.d.ts.map +1 -0
- package/dist/react/use-transcript.js +201 -0
- package/dist/react.d.ts +21 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +20 -0
- package/dist/strings.d.ts +74 -0
- package/dist/strings.d.ts.map +1 -0
- package/dist/strings.js +45 -0
- package/dist/theme.d.ts +99 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +182 -0
- package/dist/types.d.ts +283 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +87 -0
- package/src/corpus/README.md +40 -0
- package/src/corpus/__snapshots__/corpus.test.ts.snap +1590 -0
- package/src/corpus/capture.ts +67 -0
- package/src/corpus/captures/issue2627-render-capture.coalesced.sse.txt +173 -0
- package/src/corpus/drive.ts +184 -0
- package/src/corpus/fixtures.ts +338 -0
- package/src/history-inputs.ts +48 -0
- package/src/index.ts +58 -0
- package/src/plan.ts +740 -0
- package/src/policy.ts +146 -0
- package/src/react/components.tsx +655 -0
- package/src/react/guuey-chat.tsx +227 -0
- package/src/react/markdown.tsx +114 -0
- package/src/react/theme-css.ts +50 -0
- package/src/react/transcript.tsx +187 -0
- package/src/react/use-transcript.ts +274 -0
- package/src/react.tsx +51 -0
- package/src/strings.ts +144 -0
- package/src/theme.ts +195 -0
- package/src/types.ts +320 -0
- package/styles.css +514 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The renderer-state owner + the LIVE input assembler (spec §7).
|
|
3
|
+
*
|
|
4
|
+
* `planTranscript` is pure — everything stateful about rendering lives
|
|
5
|
+
* here, in renderer-owned React state:
|
|
6
|
+
*
|
|
7
|
+
* - `useTranscript` owns the user's collapse overrides, the live
|
|
8
|
+
* `ViewHostPhase` reports, and locator-mount resolution, merges them
|
|
9
|
+
* into the inputs, and returns the (memoized) plan;
|
|
10
|
+
* - `useTranscriptInputs` assembles `TranscriptInputs` from
|
|
11
|
+
* `useAgentInvoke`'s return — the live twin of the root export's
|
|
12
|
+
* `transcriptInputsFromHistory`.
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import type { UseAgentInvokeReturn } from "@guuey/agent-client";
|
|
16
|
+
import {
|
|
17
|
+
resolveViewMount,
|
|
18
|
+
type ResolvedViewMount,
|
|
19
|
+
type UiResourceReader,
|
|
20
|
+
type ViewHostPhase,
|
|
21
|
+
} from "@guuey/mcp-apps-host";
|
|
22
|
+
import { planTranscript } from "../plan.js";
|
|
23
|
+
import type { TranscriptPolicy } from "../policy.js";
|
|
24
|
+
import type {
|
|
25
|
+
ItemKey,
|
|
26
|
+
PromptItemInput,
|
|
27
|
+
TranscriptInputs,
|
|
28
|
+
TranscriptMessage,
|
|
29
|
+
TranscriptOverrides,
|
|
30
|
+
TranscriptPlan,
|
|
31
|
+
} from "../types.js";
|
|
32
|
+
|
|
33
|
+
// ─── useTranscript ─────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export interface UseTranscriptArgs {
|
|
36
|
+
inputs: TranscriptInputs;
|
|
37
|
+
policy: TranscriptPolicy;
|
|
38
|
+
/**
|
|
39
|
+
* Resolves R6 `locator` mounts (history cards persisted as `ui://`
|
|
40
|
+
* identities) with a fresh authenticated `resources/read`. Without one,
|
|
41
|
+
* locators render the R13 "view expired" state after a failed local
|
|
42
|
+
* resolution — labeled, never blank.
|
|
43
|
+
*/
|
|
44
|
+
reader?: UiResourceReader;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UseTranscriptResult {
|
|
48
|
+
plan: TranscriptPlan;
|
|
49
|
+
/** Flip one item's collapse state (wired to every toggle's onClick). */
|
|
50
|
+
toggle: (key: ItemKey) => void;
|
|
51
|
+
/** The live collapse-override map (renderer-owned, plan input). */
|
|
52
|
+
overrides: TranscriptOverrides;
|
|
53
|
+
/** Wire to `<GuueyView onPhaseChange>` (the default kit already does). */
|
|
54
|
+
onViewPhase: (key: ItemKey, phase: ViewHostPhase) => void;
|
|
55
|
+
/** Locator resolutions: mount material, or `"expired"` for a miss. */
|
|
56
|
+
resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useTranscript({ inputs, policy, reader }: UseTranscriptArgs): UseTranscriptResult {
|
|
60
|
+
const [overrides, setOverrides] = useState<TranscriptOverrides>({});
|
|
61
|
+
const [phases, setPhases] = useState<Readonly<Record<string, ViewHostPhase>>>({});
|
|
62
|
+
const [resolvedMounts, setResolvedMounts] = useState<
|
|
63
|
+
ReadonlyMap<ItemKey, ResolvedViewMount | "expired">
|
|
64
|
+
>(new Map());
|
|
65
|
+
|
|
66
|
+
const merged = useMemo<TranscriptInputs>(
|
|
67
|
+
() => ({ ...inputs, viewPhases: { ...inputs.viewPhases, ...phases } }),
|
|
68
|
+
[inputs, phases],
|
|
69
|
+
);
|
|
70
|
+
const plan = useMemo(() => planTranscript(merged, policy, overrides), [merged, policy, overrides]);
|
|
71
|
+
|
|
72
|
+
// Toggle flips the item's CURRENT resolved state (policy default or a
|
|
73
|
+
// previous override) — read from the plan so the first toggle of a
|
|
74
|
+
// default-expanded item collapses it.
|
|
75
|
+
const planRef = useRef(plan);
|
|
76
|
+
planRef.current = plan;
|
|
77
|
+
const toggle = useCallback((key: ItemKey) => {
|
|
78
|
+
const findExpanded = (): boolean => {
|
|
79
|
+
for (const item of planRef.current.items) {
|
|
80
|
+
if (item.key === key) return item.expanded;
|
|
81
|
+
if (item.kind === "tool-group") {
|
|
82
|
+
for (const tool of item.tools) {
|
|
83
|
+
if (tool.key === key) return tool.expanded;
|
|
84
|
+
if (tool.result?.key === key) return tool.result.expanded;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (item.kind === "tool" && item.result?.key === key) return item.result.expanded;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
const current = findExpanded();
|
|
92
|
+
setOverrides((prev) => ({ ...prev, [key]: { expanded: !current } }));
|
|
93
|
+
}, []);
|
|
94
|
+
|
|
95
|
+
const onViewPhase = useCallback((key: ItemKey, phase: ViewHostPhase) => {
|
|
96
|
+
setPhases((prev) => (prev[key] === phase ? prev : { ...prev, [key]: phase }));
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// Locator resolution — one read per locator key, misses become "expired".
|
|
100
|
+
const readerRef = useRef(reader);
|
|
101
|
+
readerRef.current = reader;
|
|
102
|
+
const inFlight = useRef(new Set<ItemKey>());
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
for (const item of plan.items) {
|
|
105
|
+
if (item.kind !== "view") continue;
|
|
106
|
+
if (item.mount === null || item.mount.channel !== "locator") continue;
|
|
107
|
+
if (resolvedMounts.has(item.key) || inFlight.current.has(item.key)) continue;
|
|
108
|
+
const read = readerRef.current;
|
|
109
|
+
const locator = item.mount;
|
|
110
|
+
inFlight.current.add(item.key);
|
|
111
|
+
const settle = (value: ResolvedViewMount | "expired"): void => {
|
|
112
|
+
inFlight.current.delete(item.key);
|
|
113
|
+
setResolvedMounts((prev) => {
|
|
114
|
+
const next = new Map(prev);
|
|
115
|
+
next.set(item.key, value);
|
|
116
|
+
return next;
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
if (read === undefined) {
|
|
120
|
+
settle("expired");
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
void resolveViewMount(locator, read).then(
|
|
124
|
+
(resolved) => settle(resolved ?? "expired"),
|
|
125
|
+
() => settle("expired"),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}, [plan, resolvedMounts]);
|
|
129
|
+
|
|
130
|
+
return { plan, toggle, overrides, onViewPhase, resolvedMounts };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── useTranscriptInputs (the live assembler) ──────────────────────────────
|
|
134
|
+
|
|
135
|
+
export interface UseTranscriptInputsResult {
|
|
136
|
+
inputs: TranscriptInputs;
|
|
137
|
+
/**
|
|
138
|
+
* Record the user's action on an R10 prompt (the host performs the
|
|
139
|
+
* actual grant/decline through its own channel; this moves the
|
|
140
|
+
* transcript record). A pending prompt whose hook-side request vanishes
|
|
141
|
+
* without a recorded action reads as `dismissed`.
|
|
142
|
+
*/
|
|
143
|
+
resolvePrompt: (id: string, state: "answered" | "declined" | "dismissed") => void;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** How often the escalation clock ticks while a status needs one. */
|
|
147
|
+
const ELAPSED_TICK_MS = 500;
|
|
148
|
+
|
|
149
|
+
export function useTranscriptInputs(invoke: UseAgentInvokeReturn): UseTranscriptInputsResult {
|
|
150
|
+
// §4: the elapsed clock is a view-model INPUT — this is the renderer-side
|
|
151
|
+
// timer that feeds it. Reset on every status change; ticking only while a
|
|
152
|
+
// status line is showing (R12 escalation + debug detail).
|
|
153
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
setElapsedMs(0);
|
|
156
|
+
if (invoke.status === "ready" || invoke.status === "responding") return;
|
|
157
|
+
const startedAt = Date.now();
|
|
158
|
+
const timer = setInterval(() => setElapsedMs(Date.now() - startedAt), ELAPSED_TICK_MS);
|
|
159
|
+
return () => clearInterval(timer);
|
|
160
|
+
}, [invoke.status]);
|
|
161
|
+
|
|
162
|
+
// R10 ledger: the hook exposes only the LATEST pending ask; the
|
|
163
|
+
// transcript keeps the record of every ask and its resolution.
|
|
164
|
+
const [prompts, setPrompts] = useState<PromptItemInput[]>([]);
|
|
165
|
+
const promptSeq = useRef(0);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
const request = invoke.profileConsentRequest;
|
|
168
|
+
if (request === null) {
|
|
169
|
+
setPrompts((prev) =>
|
|
170
|
+
prev.some((p) => p.kind === "consent" && p.state === "pending")
|
|
171
|
+
? prev.map((p) =>
|
|
172
|
+
p.kind === "consent" && p.state === "pending" ? { ...p, state: "dismissed" } : p,
|
|
173
|
+
)
|
|
174
|
+
: prev,
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
setPrompts((prev) => {
|
|
179
|
+
if (prev.some((p) => p.kind === "consent" && p.state === "pending")) return prev;
|
|
180
|
+
return [
|
|
181
|
+
...prev,
|
|
182
|
+
{
|
|
183
|
+
id: `consent.${promptSeq.current++}`,
|
|
184
|
+
kind: "consent",
|
|
185
|
+
appId: request.appId,
|
|
186
|
+
requested: request.requested,
|
|
187
|
+
state: "pending",
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
});
|
|
191
|
+
}, [invoke.profileConsentRequest]);
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const request = invoke.profileLinkRequest;
|
|
194
|
+
if (request === null) {
|
|
195
|
+
setPrompts((prev) =>
|
|
196
|
+
prev.some((p) => p.kind === "link" && p.state === "pending")
|
|
197
|
+
? prev.map((p) =>
|
|
198
|
+
p.kind === "link" && p.state === "pending" ? { ...p, state: "dismissed" } : p,
|
|
199
|
+
)
|
|
200
|
+
: prev,
|
|
201
|
+
);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
setPrompts((prev) => {
|
|
205
|
+
if (prev.some((p) => p.kind === "link" && p.state === "pending")) return prev;
|
|
206
|
+
return [
|
|
207
|
+
...prev,
|
|
208
|
+
{
|
|
209
|
+
id: `link.${promptSeq.current++}`,
|
|
210
|
+
kind: "link",
|
|
211
|
+
appId: request.appId,
|
|
212
|
+
requested: request.requested,
|
|
213
|
+
state: "pending",
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
});
|
|
217
|
+
}, [invoke.profileLinkRequest]);
|
|
218
|
+
|
|
219
|
+
const resolvePrompt = useCallback(
|
|
220
|
+
(id: string, state: "answered" | "declined" | "dismissed") => {
|
|
221
|
+
setPrompts((prev) => prev.map((p) => (p.id === id ? { ...p, state } : p)));
|
|
222
|
+
// Clearing the hook's pending request AFTER the ledger moved keeps the
|
|
223
|
+
// dismissal effect above from double-transitioning it.
|
|
224
|
+
const pending = prompts.find((p) => p.id === id);
|
|
225
|
+
if (pending?.kind === "consent") invoke.clearProfileConsentRequest();
|
|
226
|
+
if (pending?.kind === "link") invoke.clearProfileLinkRequest();
|
|
227
|
+
},
|
|
228
|
+
[invoke, prompts],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const inputs = useMemo<TranscriptInputs>(() => {
|
|
232
|
+
// Source-ownership split (plan.ts's rules): the trailing assistant
|
|
233
|
+
// entry is the IN-FLIGHT fold (or the abort-kept partial) — it moves to
|
|
234
|
+
// `assistantText` so the plan can mark it streaming/stopped; settled
|
|
235
|
+
// turns stay in `messages`.
|
|
236
|
+
const inFlight = invoke.status !== "ready";
|
|
237
|
+
let messages: TranscriptMessage[] = invoke.messages;
|
|
238
|
+
let assistantText = "";
|
|
239
|
+
const last = messages[messages.length - 1];
|
|
240
|
+
if (last !== undefined && last.role === "assistant" && (inFlight || invoke.aborted)) {
|
|
241
|
+
assistantText = last.text;
|
|
242
|
+
messages = messages.slice(0, -1);
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
result: invoke.reduceResult,
|
|
246
|
+
assistantText,
|
|
247
|
+
status: invoke.status,
|
|
248
|
+
statusElapsedMs: elapsedMs,
|
|
249
|
+
activeTool: invoke.activeTool,
|
|
250
|
+
error: invoke.error !== null ? { message: invoke.error, code: invoke.errorCode } : null,
|
|
251
|
+
prompts,
|
|
252
|
+
messages,
|
|
253
|
+
...(invoke.historyCards.length > 0 ? { historyCards: invoke.historyCards } : {}),
|
|
254
|
+
sendStates: invoke.sendStates,
|
|
255
|
+
aborted: invoke.aborted,
|
|
256
|
+
adopted: invoke.adopted,
|
|
257
|
+
};
|
|
258
|
+
}, [
|
|
259
|
+
invoke.messages,
|
|
260
|
+
invoke.status,
|
|
261
|
+
invoke.activeTool,
|
|
262
|
+
invoke.error,
|
|
263
|
+
invoke.errorCode,
|
|
264
|
+
invoke.reduceResult,
|
|
265
|
+
invoke.historyCards,
|
|
266
|
+
invoke.sendStates,
|
|
267
|
+
invoke.aborted,
|
|
268
|
+
invoke.adopted,
|
|
269
|
+
elapsedMs,
|
|
270
|
+
prompts,
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
return { inputs, resolvePrompt };
|
|
274
|
+
}
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React entry point (`@guuey/chat/react`) — the component kit over the
|
|
3
|
+
* root subpath's headless view-model (wave 3b, guuey#135).
|
|
4
|
+
*
|
|
5
|
+
* The root subpath stays React-free forever; everything React-coupled —
|
|
6
|
+
* the per-category components, `<Transcript>`, the renderer-state owner
|
|
7
|
+
* (`useTranscript`), and the live input assembler (`useTranscriptInputs`)
|
|
8
|
+
* — lives behind this arm, with `react` as an optional peer (the
|
|
9
|
+
* mcp-apps-host precedent).
|
|
10
|
+
*
|
|
11
|
+
* Composition ladder (spec §1 "configurable"): use `<Transcript>` with the
|
|
12
|
+
* default kit as-is → override per-category components → tune policy knobs
|
|
13
|
+
* → drop to `planTranscript` + `attachViewHost` and render it all yourself.
|
|
14
|
+
*/
|
|
15
|
+
export {
|
|
16
|
+
Transcript,
|
|
17
|
+
type TranscriptProps,
|
|
18
|
+
type TranscriptWindowing,
|
|
19
|
+
} from "./react/transcript.js";
|
|
20
|
+
export {
|
|
21
|
+
defaultTranscriptComponents,
|
|
22
|
+
renderItem,
|
|
23
|
+
DefaultUserMessage,
|
|
24
|
+
DefaultText,
|
|
25
|
+
DefaultReasoning,
|
|
26
|
+
DefaultTool,
|
|
27
|
+
DefaultToolGroup,
|
|
28
|
+
DefaultDataResult,
|
|
29
|
+
DefaultView,
|
|
30
|
+
DefaultMedia,
|
|
31
|
+
DefaultCode,
|
|
32
|
+
DefaultCitations,
|
|
33
|
+
DefaultPrompt,
|
|
34
|
+
DefaultError,
|
|
35
|
+
DefaultHistoryBoundary,
|
|
36
|
+
DefaultCompaction,
|
|
37
|
+
DefaultUnknown,
|
|
38
|
+
DefaultStatus,
|
|
39
|
+
type TranscriptComponents,
|
|
40
|
+
type TranscriptItemContext,
|
|
41
|
+
} from "./react/components.js";
|
|
42
|
+
export {
|
|
43
|
+
useTranscript,
|
|
44
|
+
useTranscriptInputs,
|
|
45
|
+
type UseTranscriptArgs,
|
|
46
|
+
type UseTranscriptResult,
|
|
47
|
+
type UseTranscriptInputsResult,
|
|
48
|
+
} from "./react/use-transcript.js";
|
|
49
|
+
export { GuueyChat, type GuueyChatProps } from "./react/guuey-chat.js";
|
|
50
|
+
export { Markdown } from "./react/markdown.js";
|
|
51
|
+
export { themeCssVars, type ThemeMode } from "./react/theme-css.js";
|
package/src/strings.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE place every user-facing string lives — the i18n seam (spec §4.2).
|
|
3
|
+
*
|
|
4
|
+
* Builders override any string (or all of them, for a locale) through
|
|
5
|
+
* `TranscriptPolicy.strings` without forking components; nothing else in the
|
|
6
|
+
* package carries literal copy. Parameterized strings are functions so word
|
|
7
|
+
* order stays translatable ("Ran 4 tools" vs "4 Werkzeuge ausgeführt").
|
|
8
|
+
*
|
|
9
|
+
* The en defaults below are the founder-review copy from the wave-3a design
|
|
10
|
+
* (§4.2 + per-row labels; guuey#135 — F7 keeps them wordsmith-able until 3b
|
|
11
|
+
* freezes the voice).
|
|
12
|
+
*/
|
|
13
|
+
export interface ChatStrings {
|
|
14
|
+
/** R12 escalation ladder (spec §4.2). */
|
|
15
|
+
connecting: string;
|
|
16
|
+
starting: string;
|
|
17
|
+
longStart: string;
|
|
18
|
+
thinking: string;
|
|
19
|
+
usingTool: (toolTitle: string) => string;
|
|
20
|
+
/** R1 aborted-partial marker + §4.2 aborted line. */
|
|
21
|
+
stopped: string;
|
|
22
|
+
|
|
23
|
+
/** R11 family copy — each is a PREFIX slot for the builder's own wording. */
|
|
24
|
+
errorAuth: string;
|
|
25
|
+
errorQuota: string;
|
|
26
|
+
errorTransient: string;
|
|
27
|
+
errorInvalid: string;
|
|
28
|
+
|
|
29
|
+
/** R3/R4. */
|
|
30
|
+
toolGroup: (count: number) => string;
|
|
31
|
+
toolGroupFailures: (count: number) => string;
|
|
32
|
+
toolDidntFinish: string;
|
|
33
|
+
/** R4 calm attribution chrome on a display-bearing call ("via {tool}"). */
|
|
34
|
+
viaTool: (toolTitle: string) => string;
|
|
35
|
+
|
|
36
|
+
/** R2. */
|
|
37
|
+
reasoningLabel: string;
|
|
38
|
+
|
|
39
|
+
/** R9. */
|
|
40
|
+
citations: (count: number) => string;
|
|
41
|
+
|
|
42
|
+
/** R15 — the trust invariant's label. */
|
|
43
|
+
unknownLabel: string;
|
|
44
|
+
|
|
45
|
+
/** R14. */
|
|
46
|
+
compaction: string;
|
|
47
|
+
|
|
48
|
+
/** R0. */
|
|
49
|
+
userCouldntSend: string;
|
|
50
|
+
userRetry: string;
|
|
51
|
+
|
|
52
|
+
/** R6 states. */
|
|
53
|
+
viewNegotiating: string;
|
|
54
|
+
viewBootFailure: string;
|
|
55
|
+
viewInlineFallback: string;
|
|
56
|
+
viewExpired: string;
|
|
57
|
+
viewSandboxUnavailable: string;
|
|
58
|
+
|
|
59
|
+
/** #192 debug-preset marker (calm never shows it — spec §3, F10). */
|
|
60
|
+
recoveredFromHistory: string;
|
|
61
|
+
|
|
62
|
+
/** R5 empty result. */
|
|
63
|
+
noOutput: string;
|
|
64
|
+
/** R5/R15 byte-count note. */
|
|
65
|
+
bytes: (byteCount: number) => string;
|
|
66
|
+
|
|
67
|
+
/** R13 states. */
|
|
68
|
+
historyLoading: string;
|
|
69
|
+
threadGone: string;
|
|
70
|
+
|
|
71
|
+
/** Renderer chrome (spec §3.2 — the 3b kit's own affordances). */
|
|
72
|
+
jumpToLatest: string;
|
|
73
|
+
showEarlier: (count: number) => string;
|
|
74
|
+
copy: string;
|
|
75
|
+
copied: string;
|
|
76
|
+
|
|
77
|
+
/** The 3c composer (`<GuueyChat>`). */
|
|
78
|
+
composerPlaceholder: string;
|
|
79
|
+
composerUnavailable: string;
|
|
80
|
+
composerLabel: string;
|
|
81
|
+
send: string;
|
|
82
|
+
stop: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Humanize a wire tool name: `render_weather-card` → `render weather card`. */
|
|
86
|
+
export function humanizeToolName(wireName: string): string {
|
|
87
|
+
return wireName.replace(/[_-]+/g, " ").trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const defaultChatStrings: ChatStrings = {
|
|
91
|
+
connecting: "Connecting…",
|
|
92
|
+
starting: "Starting your agent…",
|
|
93
|
+
longStart: "Starting your agent… first load can take a minute",
|
|
94
|
+
thinking: "Thinking…",
|
|
95
|
+
usingTool: (toolTitle) => `Using ${toolTitle}…`,
|
|
96
|
+
stopped: "Stopped.",
|
|
97
|
+
|
|
98
|
+
errorAuth: "Sign in to continue.",
|
|
99
|
+
errorQuota: "This agent is over its usage limit.",
|
|
100
|
+
errorTransient: "Something went wrong on our side — try again.",
|
|
101
|
+
errorInvalid: "The app sent a request the agent couldn't read.",
|
|
102
|
+
|
|
103
|
+
toolGroup: (count) => `Ran ${count} tools`,
|
|
104
|
+
toolGroupFailures: (count) => (count === 1 ? "1 failed" : `${count} failed`),
|
|
105
|
+
toolDidntFinish: "didn't finish",
|
|
106
|
+
viaTool: (toolTitle) => `via ${toolTitle}`,
|
|
107
|
+
|
|
108
|
+
reasoningLabel: "Thought for a moment",
|
|
109
|
+
|
|
110
|
+
citations: (count) => (count === 1 ? "1 source" : `${count} sources`),
|
|
111
|
+
|
|
112
|
+
unknownLabel: "Unrecognized content",
|
|
113
|
+
|
|
114
|
+
compaction: "Earlier conversation summarized",
|
|
115
|
+
|
|
116
|
+
userCouldntSend: "Couldn't send",
|
|
117
|
+
userRetry: "Retry",
|
|
118
|
+
|
|
119
|
+
viewNegotiating: "Loading view…",
|
|
120
|
+
viewBootFailure: "This view couldn't start",
|
|
121
|
+
viewInlineFallback: "Showing plain content",
|
|
122
|
+
viewExpired: "This view expired",
|
|
123
|
+
viewSandboxUnavailable: "Interactive view unavailable",
|
|
124
|
+
|
|
125
|
+
recoveredFromHistory: "recovered from history",
|
|
126
|
+
|
|
127
|
+
noOutput: "no output",
|
|
128
|
+
bytes: (byteCount) =>
|
|
129
|
+
byteCount >= 1024 ? `${Math.round(byteCount / 1024)} KB` : `${byteCount} B`,
|
|
130
|
+
|
|
131
|
+
historyLoading: "Loading conversation…",
|
|
132
|
+
threadGone: "This conversation is no longer available.",
|
|
133
|
+
|
|
134
|
+
jumpToLatest: "Jump to latest",
|
|
135
|
+
showEarlier: (count) => `Show ${count} earlier`,
|
|
136
|
+
copy: "Copy",
|
|
137
|
+
copied: "Copied",
|
|
138
|
+
|
|
139
|
+
composerPlaceholder: "Message the agent…",
|
|
140
|
+
composerUnavailable: "Chat is unavailable.",
|
|
141
|
+
composerLabel: "Message",
|
|
142
|
+
send: "Send",
|
|
143
|
+
stop: "Stop",
|
|
144
|
+
};
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme = platform data (wave-3a design §6, founder-ratified).
|
|
3
|
+
*
|
|
4
|
+
* ONE serializable token schema, four consumers: this package's default
|
|
5
|
+
* theme, the per-app theme configured on platform.guuey.com (console editor
|
|
6
|
+
* + app-record field + runtime delivery — a platform-lane slice sharing this
|
|
7
|
+
* schema), the widget (defaults to the app's configured theme), and portal
|
|
8
|
+
* (defaults to the GUUEY theme, per-app override).
|
|
9
|
+
*
|
|
10
|
+
* EVOLUTION RULE (spec §6): this schema becomes PERSISTED platform data —
|
|
11
|
+
* changes are additive-only, parsing is LENIENT (unknown keys pass through,
|
|
12
|
+
* never rejected), and every new token ships with a default-theme fallback.
|
|
13
|
+
* A stored theme from any earlier schema version must always parse:
|
|
14
|
+
* `resolveTheme` merges per-token over the default, so a half-configured (or
|
|
15
|
+
* old-schema) app theme can never produce an unreadable surface.
|
|
16
|
+
*
|
|
17
|
+
* The React kit (3b) projects these as `--guuey-chat-*` CSS custom
|
|
18
|
+
* properties; RN (3c) maps the same object to style values — the schema is
|
|
19
|
+
* the cross-platform contract, CSS is one projection.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One mode's palette — grounded in the widget's shipped `--guuey-*`
|
|
25
|
+
* custom-property precedent (`apps/widget/src/app/globals.css`).
|
|
26
|
+
*/
|
|
27
|
+
export const GuueyChatPalette = z
|
|
28
|
+
.object({
|
|
29
|
+
accent: z.string(),
|
|
30
|
+
onAccent: z.string(),
|
|
31
|
+
ink: z.string(),
|
|
32
|
+
inkMuted: z.string(),
|
|
33
|
+
surface: z.string(),
|
|
34
|
+
canvas: z.string(),
|
|
35
|
+
canvasMuted: z.string(),
|
|
36
|
+
error: z.string(),
|
|
37
|
+
})
|
|
38
|
+
.loose();
|
|
39
|
+
export type GuueyChatPalette = z.infer<typeof GuueyChatPalette>;
|
|
40
|
+
|
|
41
|
+
export const GuueyChatTheme = z
|
|
42
|
+
.object({
|
|
43
|
+
name: z.string(),
|
|
44
|
+
/** BOTH palettes always present — mode is the consumer's runtime choice. */
|
|
45
|
+
colors: z.object({ light: GuueyChatPalette, dark: GuueyChatPalette }).loose(),
|
|
46
|
+
typography: z
|
|
47
|
+
.object({
|
|
48
|
+
fontFamily: z.string().optional(),
|
|
49
|
+
monoFontFamily: z.string().optional(),
|
|
50
|
+
scale: z.number().optional(),
|
|
51
|
+
})
|
|
52
|
+
.loose(),
|
|
53
|
+
shape: z
|
|
54
|
+
.object({
|
|
55
|
+
radius: z.enum(["none", "soft", "round"]),
|
|
56
|
+
density: z.enum(["compact", "comfortable"]),
|
|
57
|
+
})
|
|
58
|
+
.loose(),
|
|
59
|
+
})
|
|
60
|
+
.loose();
|
|
61
|
+
export type GuueyChatTheme = z.infer<typeof GuueyChatTheme>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The brand-neutral-but-polished package default — the theme a builder gets
|
|
65
|
+
* before configuring anything, and the per-token fallback floor every other
|
|
66
|
+
* theme resolves against.
|
|
67
|
+
*/
|
|
68
|
+
export const DEFAULT_CHAT_THEME: GuueyChatTheme = {
|
|
69
|
+
name: "default",
|
|
70
|
+
colors: {
|
|
71
|
+
light: {
|
|
72
|
+
accent: "#2f6bff",
|
|
73
|
+
onAccent: "#ffffff",
|
|
74
|
+
ink: "#111318",
|
|
75
|
+
inkMuted: "#5b6270",
|
|
76
|
+
surface: "#ffffff",
|
|
77
|
+
canvas: "#f7f7f5",
|
|
78
|
+
canvasMuted: "#eceded",
|
|
79
|
+
error: "#d64545",
|
|
80
|
+
},
|
|
81
|
+
dark: {
|
|
82
|
+
accent: "#5c8dff",
|
|
83
|
+
onAccent: "#0b0d12",
|
|
84
|
+
ink: "#e8e9ee",
|
|
85
|
+
inkMuted: "#9aa0ac",
|
|
86
|
+
surface: "#1b1e26",
|
|
87
|
+
canvas: "#0f1116",
|
|
88
|
+
canvasMuted: "#1b1e26",
|
|
89
|
+
error: "#ff6b6b",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
typography: {},
|
|
93
|
+
shape: { radius: "soft", density: "comfortable" },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The guuey visual identity — portal's default, and the look an app
|
|
98
|
+
* "unleashes" its own theme against. Values are the widget's shipped
|
|
99
|
+
* slime/ink/fog tokens verbatim; the saturated accents deliberately do not
|
|
100
|
+
* change between modes (they read the same on any canvas — the widget's own
|
|
101
|
+
* documented posture).
|
|
102
|
+
*/
|
|
103
|
+
export const GUUEY_CHAT_THEME: GuueyChatTheme = {
|
|
104
|
+
name: "guuey",
|
|
105
|
+
colors: {
|
|
106
|
+
light: {
|
|
107
|
+
accent: "#b8ff3a",
|
|
108
|
+
onAccent: "#0e1014",
|
|
109
|
+
ink: "#0e1014",
|
|
110
|
+
inkMuted: "#1a1d24",
|
|
111
|
+
surface: "#ffffff",
|
|
112
|
+
canvas: "#f6f5ee",
|
|
113
|
+
canvasMuted: "#ecebe0",
|
|
114
|
+
error: "#ff5b5b",
|
|
115
|
+
},
|
|
116
|
+
dark: {
|
|
117
|
+
accent: "#b8ff3a",
|
|
118
|
+
onAccent: "#0e1014",
|
|
119
|
+
ink: "#e7e8ec",
|
|
120
|
+
inkMuted: "#9aa0ac",
|
|
121
|
+
surface: "#1a1d24",
|
|
122
|
+
canvas: "#0e1014",
|
|
123
|
+
canvasMuted: "#1a1d24",
|
|
124
|
+
error: "#ff5b5b",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
typography: {},
|
|
128
|
+
shape: { radius: "soft", density: "comfortable" },
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The candidate shape `resolveTheme` accepts: anything partial, unknown, or stale. */
|
|
132
|
+
const PartialPalette = GuueyChatPalette.partial();
|
|
133
|
+
const PartialTheme = z
|
|
134
|
+
.object({
|
|
135
|
+
name: z.string().optional(),
|
|
136
|
+
colors: z
|
|
137
|
+
.object({ light: PartialPalette.optional(), dark: PartialPalette.optional() })
|
|
138
|
+
.loose()
|
|
139
|
+
.optional(),
|
|
140
|
+
typography: GuueyChatTheme.shape.typography.optional(),
|
|
141
|
+
shape: GuueyChatTheme.shape.shape.partial().loose().optional(),
|
|
142
|
+
})
|
|
143
|
+
.loose();
|
|
144
|
+
|
|
145
|
+
/** The known token set — the per-token fallback iterates THIS, so unknown
|
|
146
|
+
* (future-schema) keys are preserved by the parse but never projected. */
|
|
147
|
+
const PALETTE_TOKENS = [
|
|
148
|
+
"accent",
|
|
149
|
+
"onAccent",
|
|
150
|
+
"ink",
|
|
151
|
+
"inkMuted",
|
|
152
|
+
"surface",
|
|
153
|
+
"canvas",
|
|
154
|
+
"canvasMuted",
|
|
155
|
+
"error",
|
|
156
|
+
] as const;
|
|
157
|
+
|
|
158
|
+
function mergePalette(
|
|
159
|
+
base: GuueyChatPalette,
|
|
160
|
+
over: z.infer<typeof PartialPalette> | undefined,
|
|
161
|
+
): GuueyChatPalette {
|
|
162
|
+
if (!over) return { ...base };
|
|
163
|
+
const merged: GuueyChatPalette = { ...base };
|
|
164
|
+
for (const token of PALETTE_TOKENS) {
|
|
165
|
+
const value = over[token];
|
|
166
|
+
if (typeof value === "string") merged[token] = value;
|
|
167
|
+
}
|
|
168
|
+
return merged;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve a stored (possibly partial, possibly old-schema, possibly not even
|
|
173
|
+
* object-shaped) theme against a base — per-token fallback, lenient parse.
|
|
174
|
+
* NEVER throws: unparseable input resolves to the base theme untouched.
|
|
175
|
+
*/
|
|
176
|
+
export function resolveTheme(
|
|
177
|
+
candidate: unknown,
|
|
178
|
+
base: GuueyChatTheme = DEFAULT_CHAT_THEME,
|
|
179
|
+
): GuueyChatTheme {
|
|
180
|
+
const parsed = PartialTheme.safeParse(candidate);
|
|
181
|
+
if (!parsed.success) return { ...base, colors: { light: { ...base.colors.light }, dark: { ...base.colors.dark } } };
|
|
182
|
+
const p = parsed.data;
|
|
183
|
+
return {
|
|
184
|
+
name: p.name ?? base.name,
|
|
185
|
+
colors: {
|
|
186
|
+
light: mergePalette(base.colors.light, p.colors?.light),
|
|
187
|
+
dark: mergePalette(base.colors.dark, p.colors?.dark),
|
|
188
|
+
},
|
|
189
|
+
typography: { ...base.typography, ...(p.typography ?? {}) },
|
|
190
|
+
shape: {
|
|
191
|
+
radius: p.shape?.radius ?? base.shape.radius,
|
|
192
|
+
density: p.shape?.density ?? base.shape.density,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|