@chai-ui/core 0.1.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 +84 -0
- package/dist/engine.d.ts +84 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +173 -0
- package/dist/engine.js.map +1 -0
- package/dist/engines/fal.d.ts +95 -0
- package/dist/engines/fal.d.ts.map +1 -0
- package/dist/engines/fal.js +209 -0
- package/dist/engines/fal.js.map +1 -0
- package/dist/engines/openrouter.d.ts +141 -0
- package/dist/engines/openrouter.d.ts.map +1 -0
- package/dist/engines/openrouter.js +220 -0
- package/dist/engines/openrouter.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/media-analysis-prompts.d.ts +18 -0
- package/dist/media-analysis-prompts.d.ts.map +1 -0
- package/dist/media-analysis-prompts.js +35 -0
- package/dist/media-analysis-prompts.js.map +1 -0
- package/dist/model-routing.d.ts +49 -0
- package/dist/model-routing.d.ts.map +1 -0
- package/dist/model-routing.js +72 -0
- package/dist/model-routing.js.map +1 -0
- package/dist/run.d.ts +75 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +90 -0
- package/dist/run.js.map +1 -0
- package/dist/server/chai-handler.d.ts +43 -0
- package/dist/server/chai-handler.d.ts.map +1 -0
- package/dist/server/chai-handler.js +106 -0
- package/dist/server/chai-handler.js.map +1 -0
- package/dist/server/index.d.ts +3 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +4 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/node.d.ts +24 -0
- package/dist/server/node.d.ts.map +1 -0
- package/dist/server/node.js +60 -0
- package/dist/server/node.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -0
- package/src/engine.ts +237 -0
- package/src/engines/fal.ts +271 -0
- package/src/engines/openrouter.ts +301 -0
- package/src/index.ts +7 -0
- package/src/media-analysis-prompts.ts +45 -0
- package/src/model-routing.ts +104 -0
- package/src/run.ts +145 -0
- package/src/server/chai-handler.ts +146 -0
- package/src/server/index.ts +3 -0
- package/src/server/node.ts +75 -0
- package/src/types.ts +96 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real text/reasoning `GenerationEngine` backed by OpenRouter
|
|
3
|
+
* (openrouter.ai) — a fully-supported alternative to the default Fal.ai
|
|
4
|
+
* (see the Develop page's "Model providers" section). Returns text: prompt
|
|
5
|
+
* enhancement, media analysis (vision/video/audio understanding), and any
|
|
6
|
+
* other text-out call.
|
|
7
|
+
*
|
|
8
|
+
* Security: this engine never sees an API key and never calls
|
|
9
|
+
* `openrouter.ai` directly. It POSTs to a same-origin `endpoint` (default
|
|
10
|
+
* `/api/chai/openrouter/chat`, served by `createChaiHandler`) that a server-side proxy owns — the proxy is the
|
|
11
|
+
* only thing that reads `OPENROUTER_API_KEY` and attaches the
|
|
12
|
+
* `Authorization` header. Passing a key into this file would put it in the
|
|
13
|
+
* browser bundle; don't add one.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readEvaluations, type GenerationEngine } from "../engine.js";
|
|
17
|
+
import type { DroppedMedia, Evaluation, MediaKind, Usage } from "../types.js";
|
|
18
|
+
|
|
19
|
+
export interface OpenRouterEngineOptions {
|
|
20
|
+
/** Same-origin proxy path that attaches the real key server-side. */
|
|
21
|
+
endpoint?: string;
|
|
22
|
+
/** OpenRouter model id (e.g. "google/gemini-2.5-flash"), used when the caller passes no `modelId` and no `modelByKind` entry matches. */
|
|
23
|
+
model?: string;
|
|
24
|
+
/** Per-attached-media-kind model override, e.g. a vision model for images and a different one for audio. */
|
|
25
|
+
modelByKind?: Partial<Record<MediaKind, string>>;
|
|
26
|
+
/**
|
|
27
|
+
* Cap on the model's response length. Required in practice, not just
|
|
28
|
+
* cost hygiene: some providers/models reject a request with no cap at
|
|
29
|
+
* all (defaulting to their max, e.g. 65535) once it exceeds the
|
|
30
|
+
* account's available credits, rather than just truncating. Defaults to
|
|
31
|
+
* a one-paragraph-ish budget — enough for a "detailed" seed-prompt
|
|
32
|
+
* description.
|
|
33
|
+
*/
|
|
34
|
+
maxTokens?: number;
|
|
35
|
+
/**
|
|
36
|
+
* USD per million tokens for whichever model this engine calls, if you
|
|
37
|
+
* want a real cost figure on the returned `usage` instead of just token
|
|
38
|
+
* counts. Deliberately not defaulted to anything: OpenRouter's real
|
|
39
|
+
* per-model pricing isn't tracked anywhere in this repo, and a guessed
|
|
40
|
+
* number would be worse than an honestly absent cost. Pull the real rate
|
|
41
|
+
* from OpenRouter's `/models` endpoint or your own account dashboard and
|
|
42
|
+
* pass it here if you want `usage.costUsd` populated.
|
|
43
|
+
*/
|
|
44
|
+
pricePerMillionTokens?: { input: number; output: number };
|
|
45
|
+
/** Injectable for tests; defaults to the global `fetch`. */
|
|
46
|
+
fetchImpl?: typeof fetch;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_MODEL = "google/gemini-2.5-flash";
|
|
50
|
+
const DEFAULT_MAX_TOKENS = 800;
|
|
51
|
+
|
|
52
|
+
type ChatContentPart =
|
|
53
|
+
| { type: "text"; text: string }
|
|
54
|
+
| { type: "image_url"; image_url: { url: string } }
|
|
55
|
+
| { type: "video_url"; video_url: { url: string } }
|
|
56
|
+
| { type: "input_audio"; input_audio: { data: string; format: string } };
|
|
57
|
+
|
|
58
|
+
interface ChatCompletionResponse {
|
|
59
|
+
choices?: { message?: { content?: string } }[];
|
|
60
|
+
// OpenAI-compatible shape OpenRouter itself uses; absent on some
|
|
61
|
+
// providers routed through it, hence all-optional.
|
|
62
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
63
|
+
// `metadata.raw` carries the upstream provider's own JSON error as a
|
|
64
|
+
// string when OpenRouter's own `message` is just a generic wrapper (e.g.
|
|
65
|
+
// "Provider returned error") — seen live when Google AI Studio rejected
|
|
66
|
+
// a malformed image with a much more specific "Provided image is not
|
|
67
|
+
// valid" underneath.
|
|
68
|
+
error?: { message?: string; metadata?: { raw?: string } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Converts OpenRouter's raw `usage` into `Usage`, computing `costUsd` only when a real rate was supplied. */
|
|
72
|
+
function toUsage(
|
|
73
|
+
usage: ChatCompletionResponse["usage"],
|
|
74
|
+
pricePerMillionTokens?: { input: number; output: number }
|
|
75
|
+
): Usage | undefined {
|
|
76
|
+
if (!usage) return undefined;
|
|
77
|
+
const promptTokens = usage.prompt_tokens ?? 0;
|
|
78
|
+
const completionTokens = usage.completion_tokens ?? 0;
|
|
79
|
+
const costUsd = pricePerMillionTokens
|
|
80
|
+
? (promptTokens * pricePerMillionTokens.input +
|
|
81
|
+
completionTokens * pricePerMillionTokens.output) /
|
|
82
|
+
1_000_000
|
|
83
|
+
: undefined;
|
|
84
|
+
return {
|
|
85
|
+
promptTokens,
|
|
86
|
+
completionTokens,
|
|
87
|
+
totalTokens: usage.total_tokens ?? promptTokens + completionTokens,
|
|
88
|
+
costUsd,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Prefers the upstream provider's own error message over OpenRouter's generic wrapper, when both are present. */
|
|
93
|
+
function describeError(json: ChatCompletionResponse, fallback: string): string {
|
|
94
|
+
const base = json.error?.message ?? fallback;
|
|
95
|
+
const raw = json.error?.metadata?.raw;
|
|
96
|
+
if (!raw) return base;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(raw) as { error?: { message?: string } };
|
|
99
|
+
if (parsed.error?.message && parsed.error.message !== base) {
|
|
100
|
+
return `${base} — ${parsed.error.message}`;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Not JSON; the generic message is all we have.
|
|
104
|
+
}
|
|
105
|
+
return base;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Builds the one non-text content part for an attached image/video/audio file. */
|
|
109
|
+
export function contentPartForMedia(media: DroppedMedia): ChatContentPart {
|
|
110
|
+
if (media.kind === "video") {
|
|
111
|
+
return { type: "video_url", video_url: { url: media.src } };
|
|
112
|
+
}
|
|
113
|
+
if (media.kind === "audio") {
|
|
114
|
+
// OpenRouter's input_audio wants raw base64 + a format string, not a
|
|
115
|
+
// data: URL — audio can't be passed by URL at all per OpenRouter docs.
|
|
116
|
+
const match = /^data:audio\/(\w+);base64,(.+)$/.exec(media.src);
|
|
117
|
+
if (!match) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
"openRouterEngine: audio media must be a base64 data URL (data:audio/<format>;base64,...)"
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return { type: "input_audio", input_audio: { data: match[2]!, format: match[1]! } };
|
|
123
|
+
}
|
|
124
|
+
return { type: "image_url", image_url: { url: media.src } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Builds the OpenRouter chat-completions request body for a prompt plus any attached media. */
|
|
128
|
+
export function buildChatRequest(args: {
|
|
129
|
+
model: string;
|
|
130
|
+
prompt: string;
|
|
131
|
+
media?: DroppedMedia[];
|
|
132
|
+
maxTokens?: number;
|
|
133
|
+
}): {
|
|
134
|
+
model: string;
|
|
135
|
+
messages: { role: "user"; content: ChatContentPart[] }[];
|
|
136
|
+
max_tokens: number;
|
|
137
|
+
} {
|
|
138
|
+
const content: ChatContentPart[] = [{ type: "text", text: args.prompt }];
|
|
139
|
+
for (const media of args.media ?? []) content.push(contentPartForMedia(media));
|
|
140
|
+
return {
|
|
141
|
+
model: args.model,
|
|
142
|
+
messages: [{ role: "user", content }],
|
|
143
|
+
max_tokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface ChatCompletionChunk {
|
|
148
|
+
choices?: { delta?: { content?: string } }[];
|
|
149
|
+
usage?: ChatCompletionResponse["usage"];
|
|
150
|
+
error?: ChatCompletionResponse["error"];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Reads an OpenRouter server-sent-events stream (`stream: true`), calling
|
|
155
|
+
* `onText` with the accumulated reply as each chunk lands. Handles the
|
|
156
|
+
* `: OPENROUTER PROCESSING` keep-alive comments and the `[DONE]` sentinel,
|
|
157
|
+
* and still works if a proxy buffered the whole stream into one body.
|
|
158
|
+
*/
|
|
159
|
+
export async function readChatStream(
|
|
160
|
+
res: Response,
|
|
161
|
+
onText: (textSoFar: string) => void
|
|
162
|
+
): Promise<{ text: string; usage?: ChatCompletionResponse["usage"]; evaluations?: Evaluation[] }> {
|
|
163
|
+
let text = "";
|
|
164
|
+
let usage: ChatCompletionResponse["usage"];
|
|
165
|
+
let evaluations: Evaluation[] | undefined;
|
|
166
|
+
let buffer = "";
|
|
167
|
+
|
|
168
|
+
const handleLine = (line: string) => {
|
|
169
|
+
if (!line.startsWith("data:")) return; // blank separators and ": keep-alive" comments
|
|
170
|
+
const data = line.slice(5).trim();
|
|
171
|
+
if (!data || data === "[DONE]") return;
|
|
172
|
+
let chunk: ChatCompletionChunk;
|
|
173
|
+
try {
|
|
174
|
+
chunk = JSON.parse(data) as ChatCompletionChunk;
|
|
175
|
+
} catch {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (chunk.error) {
|
|
179
|
+
throw new Error(`openRouterEngine: ${describeError({ error: chunk.error }, "stream error")}`);
|
|
180
|
+
}
|
|
181
|
+
if (chunk.usage) usage = chunk.usage;
|
|
182
|
+
// A backend that checks the output appends its verdicts as their own chunk.
|
|
183
|
+
evaluations = readEvaluations(chunk) ?? evaluations;
|
|
184
|
+
const delta = chunk.choices?.[0]?.delta?.content;
|
|
185
|
+
if (delta) {
|
|
186
|
+
text += delta;
|
|
187
|
+
onText(text);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const drain = (final: boolean) => {
|
|
192
|
+
const lines = buffer.split("\n");
|
|
193
|
+
buffer = final ? "" : lines.pop()!;
|
|
194
|
+
for (const line of lines) handleLine(line.trimEnd());
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (res.body) {
|
|
198
|
+
const reader = res.body.getReader();
|
|
199
|
+
const decoder = new TextDecoder();
|
|
200
|
+
for (;;) {
|
|
201
|
+
const { done, value } = await reader.read();
|
|
202
|
+
if (done) break;
|
|
203
|
+
buffer += decoder.decode(value, { stream: true });
|
|
204
|
+
drain(false);
|
|
205
|
+
}
|
|
206
|
+
buffer += decoder.decode();
|
|
207
|
+
} else {
|
|
208
|
+
buffer = await res.text();
|
|
209
|
+
}
|
|
210
|
+
drain(true);
|
|
211
|
+
return { text, usage, evaluations };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Sends the prompt plus every attached file to OpenRouter via the local
|
|
216
|
+
* proxy and returns the model's reply as `"text"`. Calls the caller's
|
|
217
|
+
* `modelId` when given, otherwise `modelByKind` (keyed on the first
|
|
218
|
+
* attachment's kind), `model`, or the built-in default. Streams (OpenRouter's
|
|
219
|
+
* `stream: true`) when the caller passes `onText`; the proxy must pass the
|
|
220
|
+
* response body through rather than buffer it for that to be incremental.
|
|
221
|
+
*/
|
|
222
|
+
export function createOpenRouterEngine(
|
|
223
|
+
options: OpenRouterEngineOptions = {}
|
|
224
|
+
): GenerationEngine {
|
|
225
|
+
const endpoint = options.endpoint ?? "/api/chai/openrouter/chat";
|
|
226
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
async generate({ modelId, prompt, attachments, onText, signal }) {
|
|
230
|
+
const firstKind = attachments[0]?.kind;
|
|
231
|
+
const model =
|
|
232
|
+
modelId || (firstKind && options.modelByKind?.[firstKind]) || options.model || DEFAULT_MODEL;
|
|
233
|
+
const body = buildChatRequest({ model, prompt, media: attachments, maxTokens: options.maxTokens });
|
|
234
|
+
|
|
235
|
+
// Aborting closes the connection. On a stream, OpenRouter then stops
|
|
236
|
+
// the model and billing for providers that support it; a
|
|
237
|
+
// non-streaming request keeps running at the provider.
|
|
238
|
+
const res = await fetchImpl(endpoint, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { "Content-Type": "application/json" },
|
|
241
|
+
body: JSON.stringify(onText ? { ...body, stream: true } : body),
|
|
242
|
+
signal,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// A rejected request comes back as plain JSON even when streaming was
|
|
246
|
+
// asked for, so only a successful response is read as a stream.
|
|
247
|
+
if (onText && res.ok) {
|
|
248
|
+
const { text, usage, evaluations } = await readChatStream(res, onText);
|
|
249
|
+
if (!text) throw new Error("openRouterEngine: empty response from OpenRouter");
|
|
250
|
+
return { src: text, kind: "text", usage: toUsage(usage, options.pricePerMillionTokens), evaluations };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const json = (await res.json().catch(() => ({}))) as ChatCompletionResponse;
|
|
254
|
+
if (!res.ok || json.error) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`openRouterEngine: ${describeError(json, `${res.status} ${res.statusText}`)}`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const text = json.choices?.[0]?.message?.content;
|
|
260
|
+
if (!text) throw new Error("openRouterEngine: empty response from OpenRouter");
|
|
261
|
+
return {
|
|
262
|
+
src: text,
|
|
263
|
+
kind: "text",
|
|
264
|
+
usage: toUsage(json.usage, options.pricePerMillionTokens),
|
|
265
|
+
evaluations: readEvaluations(json),
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A minimal, cheap connectivity check — the "use real inference sparingly"
|
|
273
|
+
* path. Deliberately not a `GenerationEngine` and not exercised by any
|
|
274
|
+
* automated test: it's meant to be triggered once, by a person, from the
|
|
275
|
+
* Develop page's "Test connection" button.
|
|
276
|
+
*/
|
|
277
|
+
export async function pingOpenRouter(
|
|
278
|
+
options: { endpoint?: string; model?: string; fetchImpl?: typeof fetch } = {}
|
|
279
|
+
): Promise<{ ok: true; reply: string } | { ok: false; error: string }> {
|
|
280
|
+
const endpoint = options.endpoint ?? "/api/chai/openrouter/chat";
|
|
281
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
282
|
+
const model = options.model ?? DEFAULT_MODEL;
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetchImpl(endpoint, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { "Content-Type": "application/json" },
|
|
287
|
+
body: JSON.stringify({
|
|
288
|
+
model,
|
|
289
|
+
messages: [{ role: "user", content: "Reply with exactly one word: OK." }],
|
|
290
|
+
max_tokens: 5,
|
|
291
|
+
}),
|
|
292
|
+
});
|
|
293
|
+
const json = (await res.json().catch(() => ({}))) as ChatCompletionResponse;
|
|
294
|
+
if (!res.ok || json.error) {
|
|
295
|
+
return { ok: false, error: describeError(json, `${res.status} ${res.statusText}`) };
|
|
296
|
+
}
|
|
297
|
+
return { ok: true, reply: json.choices?.[0]?.message?.content?.trim() ?? "" };
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
300
|
+
}
|
|
301
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The instruction text `MediaAnalyzer` sends to an analysis model — one
|
|
3
|
+
* complete, final string per (media kind × prompt length), not a template
|
|
4
|
+
* with placeholders, so tuning the wording is just editing English.
|
|
5
|
+
*
|
|
6
|
+
* Phrase optimization (rewriting for the target model's known prompt
|
|
7
|
+
* style) is folded directly into every prompt's wording below rather than
|
|
8
|
+
* modeled as a separate option.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type MediaAnalysisKind = "image" | "video" | "audio";
|
|
12
|
+
export type MediaAnalysisPromptLength = "terse" | "concise" | "detailed";
|
|
13
|
+
|
|
14
|
+
const OPTIMIZE_CLAUSE =
|
|
15
|
+
"Rewrite the description for the selected model's known prompt style before finishing.";
|
|
16
|
+
const RESPOND_ONLY = "Respond only with the description, no preamble.";
|
|
17
|
+
|
|
18
|
+
export const MEDIA_ANALYSIS_PROMPTS: Record<
|
|
19
|
+
MediaAnalysisKind,
|
|
20
|
+
Record<MediaAnalysisPromptLength, string>
|
|
21
|
+
> = {
|
|
22
|
+
image: {
|
|
23
|
+
terse: `Analyze the attached image and produce a terse, single-sentence description suitable as a generation seed prompt — subject and composition only. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
24
|
+
concise: `Analyze the attached image and produce a concise description suitable as a generation seed prompt — subject, composition, and lighting in a few sentences. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
25
|
+
detailed: `Analyze the attached image and produce a detailed description suitable as a generation seed prompt — subject, composition, lighting, color palette, and style, thorough enough to reproduce it closely. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
26
|
+
},
|
|
27
|
+
video: {
|
|
28
|
+
terse: `Analyze the attached video's key frames and motion and produce a terse, single-sentence description suitable as a generation seed prompt — subject and camera movement only. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
29
|
+
concise: `Analyze the attached video's key frames and motion — subject, action, camera movement, pacing — and produce a concise description suitable as a generation seed prompt. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
30
|
+
detailed: `Analyze the attached video's key frames and motion — subject, action, camera movement, pacing, lighting, and style — and produce a detailed description suitable as a generation seed prompt, thorough enough to reproduce the shot closely. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
31
|
+
},
|
|
32
|
+
audio: {
|
|
33
|
+
terse: `Analyze the attached audio and produce a terse, single-sentence description suitable as a generation seed prompt — content and voice/instrumentation only. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
34
|
+
concise: `Analyze the attached audio — content, voice/instrumentation, tone, pacing — and produce a concise description suitable as a generation seed prompt. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
35
|
+
detailed: `Analyze the attached audio — content, voice/instrumentation, tone, pacing, and production style — and produce a detailed description suitable as a generation seed prompt, thorough enough to reproduce it closely. ${OPTIMIZE_CLAUSE} ${RESPOND_ONLY}`,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_MEDIA_ANALYSIS_PROMPT_LENGTH: MediaAnalysisPromptLength = "concise";
|
|
40
|
+
|
|
41
|
+
export const MEDIA_ANALYSIS_PROMPT_LENGTHS: { value: MediaAnalysisPromptLength; label: string }[] = [
|
|
42
|
+
{ value: "terse", label: "Terse" },
|
|
43
|
+
{ value: "concise", label: "Concise" },
|
|
44
|
+
{ value: "detailed", label: "Detailed" },
|
|
45
|
+
];
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-select model routing: a reasoning model reads the request and picks
|
|
3
|
+
* one model from the builder's own list. The instruction is plain English
|
|
4
|
+
* in `buildModelRoutingPrompt` below, so tuning it is just editing text;
|
|
5
|
+
* pass your own builder to the hooks' `routingPrompt` option to replace it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { normalizeGenerationError, type GenerationEngine } from "./engine.js";
|
|
9
|
+
import type { DroppedMedia, MediaKind, ModelOption } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface ModelRoutingInput {
|
|
12
|
+
/** What the person typed. Empty for media-only requests (e.g. Media Analyzer). */
|
|
13
|
+
prompt: string;
|
|
14
|
+
/** What the request produces, e.g. "image" for Create image, "text" for analysis. */
|
|
15
|
+
outputKind: MediaKind;
|
|
16
|
+
/** Kinds of media attached, if any. */
|
|
17
|
+
attachmentKinds: MediaKind[];
|
|
18
|
+
/** The candidates: only models the builder offers for this request. */
|
|
19
|
+
models: ModelOption[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildModelRoutingPrompt({ prompt, outputKind, attachmentKinds, models }: ModelRoutingInput): string {
|
|
23
|
+
const roster = models.map((m) => `- ${m.id}: ${m.label} (${m.provider}, ${m.speed})`).join("\n");
|
|
24
|
+
const attached = attachmentKinds.length ? attachmentKinds.join(", ") : "none";
|
|
25
|
+
const request = prompt.trim() ? prompt.trim() : "(no text; the attached media is the request)";
|
|
26
|
+
return [
|
|
27
|
+
`Pick the one model best suited to this request. The output is ${outputKind}.`,
|
|
28
|
+
`Weigh what the request needs (subject, detail, style, length, attached media) against each model's strengths. Prefer a faster model when the request is simple.`,
|
|
29
|
+
``,
|
|
30
|
+
`Request: ${request}`,
|
|
31
|
+
`Attached media: ${attached}`,
|
|
32
|
+
``,
|
|
33
|
+
`Models:`,
|
|
34
|
+
roster,
|
|
35
|
+
``,
|
|
36
|
+
`Reply with only the chosen model id, exactly as written above, and nothing else.`,
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Finds the model id in a reasoning model's reply, or `undefined` if it named none of `models`. */
|
|
41
|
+
export function parseModelRoutingReply(reply: string, models: ModelOption[]): string | undefined {
|
|
42
|
+
const cleaned = reply.trim().replace(/^[`"'*\s]+|[`"'*.\s]+$/g, "");
|
|
43
|
+
const exact = models.find((m) => m.id === cleaned);
|
|
44
|
+
if (exact) return exact.id;
|
|
45
|
+
// A chatty reply ("I'd pick openai/gpt-5.2 because…"): take the longest
|
|
46
|
+
// id it contains, so "x/model-pro" wins over its prefix "x/model".
|
|
47
|
+
return [...models].sort((a, b) => b.id.length - a.id.length).find((m) => reply.includes(m.id))?.id;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ModelRoutingResult =
|
|
51
|
+
| { status: "routed"; modelId: string }
|
|
52
|
+
| {
|
|
53
|
+
status: "fallback";
|
|
54
|
+
modelId: string;
|
|
55
|
+
reason: "no-reasoning" | "request-failed" | "invalid-reply";
|
|
56
|
+
/** The provider's own message when the reasoning call failed. */
|
|
57
|
+
error?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface RouteModelArgs extends ModelRoutingInput {
|
|
61
|
+
/** The reasoning engine and model. Omit both and routing falls back without a call. */
|
|
62
|
+
engine?: GenerationEngine;
|
|
63
|
+
model?: string;
|
|
64
|
+
attachments?: DroppedMedia[];
|
|
65
|
+
/** Replaces `buildModelRoutingPrompt`. */
|
|
66
|
+
routingPrompt?: (input: ModelRoutingInput) => string;
|
|
67
|
+
/** Stops the reasoning call; the result is then a `"request-failed"` fallback. */
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Asks the reasoning model to pick from `models`. Never throws: when it
|
|
73
|
+
* can't route (no reasoning model, a failed call, an answer not in the
|
|
74
|
+
* list) it falls back to the first listed model and says why, so a
|
|
75
|
+
* routing problem never blocks the request itself.
|
|
76
|
+
*/
|
|
77
|
+
export async function routeModel(args: RouteModelArgs): Promise<ModelRoutingResult> {
|
|
78
|
+
const fallback = args.models[0]?.id;
|
|
79
|
+
if (!fallback) throw new Error("routeModel: `models` is empty; there's nothing to pick from.");
|
|
80
|
+
if (args.models.length === 1) return { status: "routed", modelId: fallback };
|
|
81
|
+
if (!args.engine || !args.model) return { status: "fallback", modelId: fallback, reason: "no-reasoning" };
|
|
82
|
+
|
|
83
|
+
const input: ModelRoutingInput = {
|
|
84
|
+
prompt: args.prompt,
|
|
85
|
+
outputKind: args.outputKind,
|
|
86
|
+
attachmentKinds: args.attachmentKinds,
|
|
87
|
+
models: args.models,
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
const { src } = await args.engine.generate({
|
|
91
|
+
modelId: args.model,
|
|
92
|
+
prompt: (args.routingPrompt ?? buildModelRoutingPrompt)(input),
|
|
93
|
+
// The media itself helps (e.g. a photo needing an edit-capable model).
|
|
94
|
+
attachments: args.attachments ?? [],
|
|
95
|
+
signal: args.signal,
|
|
96
|
+
});
|
|
97
|
+
const picked = parseModelRoutingReply(src, args.models);
|
|
98
|
+
return picked
|
|
99
|
+
? { status: "routed", modelId: picked }
|
|
100
|
+
: { status: "fallback", modelId: fallback, reason: "invalid-reply" };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return { status: "fallback", modelId: fallback, reason: "request-failed", error: normalizeGenerationError(err).message };
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The the design data model — Request/Run/Result — plus the pure state
|
|
3
|
+
* transitions over it: pure, framework-free logic here; `useComposer`
|
|
4
|
+
* (`@chai-ui/react`) is the thin React binding on top.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DroppedMedia, Evaluation, GenerationUseCase, MediaKind, Usage } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export interface Request {
|
|
10
|
+
/** The specific generation use case, when one could be derived — see `deriveGenerationUseCase`. Undefined when the use case (or its kind) has no `GenerationUseCase` match yet, e.g. audio. */
|
|
11
|
+
mode?: GenerationUseCase;
|
|
12
|
+
prompt: string;
|
|
13
|
+
attachments: DroppedMedia[];
|
|
14
|
+
modelIds: string[];
|
|
15
|
+
params: Record<string, unknown>;
|
|
16
|
+
presetId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** `"cancelled"`: the person stopped it. A streamed text result keeps the text that arrived. */
|
|
20
|
+
export type ResultStatus = "queued" | "running" | "done" | "error" | "cancelled";
|
|
21
|
+
|
|
22
|
+
export interface Result {
|
|
23
|
+
id: string;
|
|
24
|
+
runId: string;
|
|
25
|
+
modelId: string;
|
|
26
|
+
status: ResultStatus;
|
|
27
|
+
output?: { src: string; kind: MediaKind };
|
|
28
|
+
usage?: Usage;
|
|
29
|
+
/** Verdicts from the builder's own backend checks on this output — see `Evaluation`. */
|
|
30
|
+
evaluations?: Evaluation[];
|
|
31
|
+
error?: { message: string; reasons?: string[] };
|
|
32
|
+
/** Set by `startResult` — internal bookkeeping for `durationMs`, not meant to be read directly by a consumer (it's a raw `Date.now()`, not itself a duration). */
|
|
33
|
+
startedAt?: number;
|
|
34
|
+
/** How long this result took from `startResult` to `resolveResult`/`failResult`, in ms — `undefined` if it resolved without ever going through `startResult` (e.g. resolved directly from `"queued"`). ResultCard's info-flip is what actually surfaces this (reference: the Figma mock's "3.03s" chip). */
|
|
35
|
+
durationMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Run {
|
|
39
|
+
id: string;
|
|
40
|
+
request: Request;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
results: Result[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let idCounter = 0;
|
|
46
|
+
function nextId(prefix: string): string {
|
|
47
|
+
idCounter += 1;
|
|
48
|
+
return `${prefix}_${idCounter}_${Math.random().toString(36).slice(2, 7)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fans a `Request` out into one `"queued"` `Result` per model — a run's
|
|
53
|
+
* results all exist from the start, so a caller can render "3 of 4 done"
|
|
54
|
+
* immediately rather than watching the list grow.
|
|
55
|
+
*/
|
|
56
|
+
export function createRun(request: Request): Run {
|
|
57
|
+
const id = nextId("run");
|
|
58
|
+
return {
|
|
59
|
+
id,
|
|
60
|
+
request,
|
|
61
|
+
createdAt: Date.now(),
|
|
62
|
+
results: request.modelIds.map((modelId) => ({
|
|
63
|
+
id: nextId("result"),
|
|
64
|
+
runId: id,
|
|
65
|
+
modelId,
|
|
66
|
+
status: "queued",
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function updateResult(run: Run, resultId: string, fn: (result: Result) => Result): Run {
|
|
72
|
+
return { ...run, results: run.results.map((r) => (r.id === resultId ? fn(r) : r)) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function startResult(run: Run, resultId: string): Run {
|
|
76
|
+
return updateResult(run, resultId, (r) => ({ ...r, status: "running", startedAt: Date.now() }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Folds partial streamed text into a running result: `output` fills in
|
|
81
|
+
* while `status` stays `"running"`, so "running with output" is what a
|
|
82
|
+
* streaming result looks like. A no-op once the result has settled, so a
|
|
83
|
+
* late chunk can't overwrite a final `resolveResult`/`failResult`.
|
|
84
|
+
*/
|
|
85
|
+
export function streamResult(run: Run, resultId: string, textSoFar: string): Run {
|
|
86
|
+
return updateResult(run, resultId, (r) =>
|
|
87
|
+
r.status === "running" ? { ...r, output: { src: textSoFar, kind: "text" } } : r
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function resolveResult(
|
|
92
|
+
run: Run,
|
|
93
|
+
resultId: string,
|
|
94
|
+
output: Result["output"],
|
|
95
|
+
usage?: Usage,
|
|
96
|
+
evaluations?: Evaluation[]
|
|
97
|
+
): Run {
|
|
98
|
+
return updateResult(run, resultId, (r) => ({
|
|
99
|
+
...r,
|
|
100
|
+
status: "done",
|
|
101
|
+
output,
|
|
102
|
+
usage,
|
|
103
|
+
evaluations,
|
|
104
|
+
durationMs: r.startedAt != null ? Date.now() - r.startedAt : undefined,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function failResult(run: Run, resultId: string, error: Result["error"]): Run {
|
|
109
|
+
return updateResult(run, resultId, (r) => ({
|
|
110
|
+
...r,
|
|
111
|
+
status: "error",
|
|
112
|
+
error,
|
|
113
|
+
durationMs: r.startedAt != null ? Date.now() - r.startedAt : undefined,
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Marks a queued or running result as stopped by the person. A settled result is left as it is. */
|
|
118
|
+
export function cancelResult(run: Run, resultId: string): Run {
|
|
119
|
+
return updateResult(run, resultId, (r) =>
|
|
120
|
+
r.status === "queued" || r.status === "running"
|
|
121
|
+
? { ...r, status: "cancelled", durationMs: r.startedAt != null ? Date.now() - r.startedAt : undefined }
|
|
122
|
+
: r
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Derives a `GenerationUseCase` from Composer's own `{ kind, label }` use
|
|
128
|
+
* case plus whether anything is attached — this is real derivation logic,
|
|
129
|
+
* not just plumbing: the same `kind: "video"` means `"text-to-video"` with
|
|
130
|
+
* nothing attached but `"image-to-video"` once an image is. Returns
|
|
131
|
+
* `undefined` for "audio" (no `GenerationUseCase` member exists yet;
|
|
132
|
+
* audio generation is planned). `"text"` is a text request: a reply to
|
|
133
|
+
* the prompt, or a description of the attached media ("vision").
|
|
134
|
+
*/
|
|
135
|
+
export function deriveGenerationUseCase(
|
|
136
|
+
useCase: { kind: MediaKind } | null | undefined,
|
|
137
|
+
attachments: DroppedMedia[]
|
|
138
|
+
): GenerationUseCase | undefined {
|
|
139
|
+
if (!useCase) return undefined;
|
|
140
|
+
const hasMedia = attachments.length > 0;
|
|
141
|
+
if (useCase.kind === "video") return hasMedia ? "image-to-video" : "text-to-video";
|
|
142
|
+
if (useCase.kind === "image") return hasMedia ? "image-edit" : "text-to-image";
|
|
143
|
+
if (useCase.kind === "text") return hasMedia ? "vision" : "text-to-text";
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|