@sutrata/editor 0.1.1 → 0.1.2

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.
Files changed (152) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/generated-providers.json +1 -1
  3. package/package.json +30 -3
  4. package/scripts/copy-assets.mjs +0 -16
  5. package/scripts/generate-providers.mjs +0 -127
  6. package/src/ai/OnboardingDialog.tsx +0 -390
  7. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  8. package/src/ai/VoiceToolbar.tsx +0 -321
  9. package/src/ai/ai-client.ts +0 -596
  10. package/src/ai/commands.ts +0 -164
  11. package/src/ai/generated-providers.json +0 -3696
  12. package/src/ai/prompts.ts +0 -62
  13. package/src/ai/voice-service.ts +0 -96
  14. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  15. package/src/autocomplete/suggestions.ts +0 -147
  16. package/src/context/DocumentContext.tsx +0 -648
  17. package/src/desktop-api.d.ts +0 -40
  18. package/src/editor/EditorView.tsx +0 -82
  19. package/src/editor/SelectionMenu.tsx +0 -152
  20. package/src/editor/attr-pencil-plugin.ts +0 -172
  21. package/src/editor/character-decoration-plugin.ts +0 -104
  22. package/src/editor/editor-bus.ts +0 -51
  23. package/src/editor/find-highlight-plugin.ts +0 -68
  24. package/src/editor/frontmatter-field-view.ts +0 -52
  25. package/src/editor/frontmatter-field.ts +0 -115
  26. package/src/editor/grapheme-cursor.ts +0 -99
  27. package/src/editor/ime-plugin.ts +0 -85
  28. package/src/editor/insert-helper.ts +0 -43
  29. package/src/editor/keymap.ts +0 -124
  30. package/src/editor/language-class-plugin.ts +0 -91
  31. package/src/editor/plugins.ts +0 -26
  32. package/src/editor/prosemirror-to-sutra.ts +0 -192
  33. package/src/editor/scene-heading-view.ts +0 -147
  34. package/src/editor/schema.ts +0 -156
  35. package/src/editor/sutra-to-prosemirror.ts +0 -156
  36. package/src/extensions/storage-adapter.ts +0 -65
  37. package/src/extensions/types.ts +0 -60
  38. package/src/file/docx-exporter.ts +0 -585
  39. package/src/file/docx-importer.ts +0 -313
  40. package/src/file/print-paginator.ts +0 -301
  41. package/src/file/templates.ts +0 -73
  42. package/src/file/workflow-reports.ts +0 -820
  43. package/src/findreplace/FindReplace.tsx +0 -242
  44. package/src/findreplace/find-engine.ts +0 -97
  45. package/src/findreplace/scroll-to-match.ts +0 -89
  46. package/src/fonts/tofu-detect.ts +0 -53
  47. package/src/i18n/LanguageContext.tsx +0 -207
  48. package/src/i18n/language-detect.ts +0 -73
  49. package/src/i18n/useTranslation.tsx +0 -74
  50. package/src/index.ts +0 -27
  51. package/src/locales/bn.json +0 -143
  52. package/src/locales/en.json +0 -143
  53. package/src/locales/gu.json +0 -143
  54. package/src/locales/hi.json +0 -143
  55. package/src/locales/kn.json +0 -143
  56. package/src/locales/ml.json +0 -143
  57. package/src/locales/mr.json +0 -143
  58. package/src/locales/or.json +0 -143
  59. package/src/locales/pa.json +0 -143
  60. package/src/locales/si.json +0 -143
  61. package/src/locales/ta.json +0 -143
  62. package/src/locales/te.json +0 -143
  63. package/src/navigator/SceneNavigator.tsx +0 -844
  64. package/src/navigator/StatisticsDialog.tsx +0 -249
  65. package/src/navigator/scene-list.ts +0 -84
  66. package/src/settings/SettingsDialog.tsx +0 -303
  67. package/src/shell/AppBar.tsx +0 -354
  68. package/src/shell/AppShell.tsx +0 -71
  69. package/src/shell/ConfirmDialog.tsx +0 -71
  70. package/src/shell/ElementToolbar.tsx +0 -175
  71. package/src/shell/LanguageStatus.tsx +0 -101
  72. package/src/shell/StatusBar.tsx +0 -135
  73. package/src/shell/SutrataLogo.tsx +0 -89
  74. package/src/shell/Toast.tsx +0 -46
  75. package/src/shell/ToolbarButton.tsx +0 -30
  76. package/src/shell/VersionHistory.tsx +0 -212
  77. package/src/shell/Watermark.tsx +0 -9
  78. package/src/shell/fonts/fonts.css +0 -1133
  79. package/src/shell/icons.tsx +0 -335
  80. package/src/shell/toolbar-actions.ts +0 -59
  81. package/src/source/SourceView.tsx +0 -111
  82. package/src/source/find-highlight-source.ts +0 -57
  83. package/src/source/sutra-lang.ts +0 -121
  84. package/src/spellcheck/dictionaries.ts +0 -21
  85. package/src/spellcheck/hunspell.ts +0 -110
  86. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  87. package/src/styles/StyleDialog.tsx +0 -148
  88. package/src/styles/builtin-styles.ts +0 -139
  89. package/src/styles/css-adapter.ts +0 -76
  90. package/src/styles/docx-adapter.ts +0 -109
  91. package/src/styles/embed-fonts.ts +0 -81
  92. package/src/styles/pagination.ts +0 -27
  93. package/src/styles/print-adapter.ts +0 -288
  94. package/src/styles/registry.ts +0 -20
  95. package/src/styles/screenplay.css +0 -3006
  96. package/src/styles/script-ranges.ts +0 -17
  97. package/src/styles/types.ts +0 -104
  98. package/src/styles/validate.ts +0 -114
  99. package/src/templates/templates.ts +0 -194
  100. package/src/titlepage/ExportDialog.tsx +0 -494
  101. package/src/titlepage/TitlePageDialog.tsx +0 -312
  102. package/src/titlepage/frontmatter-form.ts +0 -280
  103. package/src/titlepage/languages.ts +0 -37
  104. package/src/types.ts +0 -7
  105. package/tests/AppBar.test.tsx +0 -35
  106. package/tests/AppShell.test.tsx +0 -22
  107. package/tests/ElementToolbar.test.tsx +0 -36
  108. package/tests/LanguageStatus.test.tsx +0 -129
  109. package/tests/SelectionMenu.test.tsx +0 -71
  110. package/tests/ToolbarButton.test.tsx +0 -24
  111. package/tests/ai-onboarding.test.ts +0 -102
  112. package/tests/ai-status.test.tsx +0 -72
  113. package/tests/ai-structured.test.ts +0 -94
  114. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  115. package/tests/autocomplete/suggestions.test.ts +0 -106
  116. package/tests/document-context.test.tsx +0 -565
  117. package/tests/docx-importer.test.ts +0 -160
  118. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  119. package/tests/editor/character-extension.test.ts +0 -44
  120. package/tests/editor/grapheme-cursor.test.ts +0 -118
  121. package/tests/editor/ime-plugin.test.ts +0 -72
  122. package/tests/editor/inline-note-bridge.test.ts +0 -32
  123. package/tests/editor/language-class-plugin.test.ts +0 -265
  124. package/tests/editor/title-page.test.ts +0 -124
  125. package/tests/embed-fonts.test.ts +0 -17
  126. package/tests/exporters.test.ts +0 -365
  127. package/tests/findreplace/FindReplace.test.tsx +0 -391
  128. package/tests/findreplace/find-engine.test.ts +0 -86
  129. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  130. package/tests/fonts/tofu-detect.test.ts +0 -122
  131. package/tests/i18n/LanguageContext.test.tsx +0 -146
  132. package/tests/i18n/language-detect.test.ts +0 -54
  133. package/tests/i18n/locale-parity.test.ts +0 -25
  134. package/tests/i18n/useTranslation.test.tsx +0 -45
  135. package/tests/keymap.test.ts +0 -18
  136. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  137. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  138. package/tests/navigator/scene-list.test.ts +0 -71
  139. package/tests/print-paginator.test.ts +0 -82
  140. package/tests/shell/VersionHistory.test.tsx +0 -129
  141. package/tests/spellcheck/hunspell.test.ts +0 -82
  142. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  143. package/tests/styles.test.ts +0 -315
  144. package/tests/templates/templates.test.ts +0 -83
  145. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  146. package/tests/titlepage/apply-title-page.test.ts +0 -61
  147. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  148. package/tests/toolbar-actions.test.ts +0 -93
  149. package/tests/voice-commands.test.ts +0 -58
  150. package/tsconfig.json +0 -17
  151. package/vitest.config.ts +0 -11
  152. package/vitest.setup.ts +0 -65
@@ -1,596 +0,0 @@
1
- import generatedProvidersData from './generated-providers.json';
2
-
3
- export interface AIConfig {
4
- preferredProvider: string;
5
- preferredModel: string;
6
- customProviders: CustomProvider[];
7
- }
8
-
9
- export interface CustomProvider {
10
- id: string;
11
- name: string;
12
- baseUrl: string;
13
- models: { id: string; name: string }[];
14
- }
15
-
16
- export interface ModelConfig {
17
- id: string;
18
- name: string;
19
- }
20
-
21
- export interface ProviderConfig {
22
- id: string;
23
- name: string;
24
- baseUrl: string;
25
- models: ModelConfig[];
26
- }
27
-
28
- /** Opaque JSON Schema object — forwarded into provider request bodies and/or
29
- * embedded in the prompt text; see callAIStructured. */
30
- export type JsonSchema = Record<string, unknown>;
31
-
32
- /** Compiled-in last resort when both the remote and local models-config.json
33
- * fetches fail (e.g. fully offline). Generated by scripts/generate-providers.mjs
34
- * from the same source as models-config.json, so this can no longer drift
35
- * from the runtime-fetched list the way the old hand-written array did. */
36
- const FALLBACK_PROVIDERS: ProviderConfig[] = (
37
- generatedProvidersData.providers as (ProviderConfig & { essential?: boolean })[]
38
- )
39
- .filter(p => p.essential)
40
- .map(({ id, name, baseUrl, models }) => ({ id, name, baseUrl, models }));
41
-
42
-
43
- export async function getApiKey(provider: string): Promise<string | null> {
44
- if (window.desktopAPI?.getApiKey) {
45
- try {
46
- return (await window.desktopAPI.getApiKey(provider)) || null;
47
- } catch (e) {
48
- console.error('Failed to get key from tauri keyring:', e);
49
- }
50
- }
51
- return localStorage.getItem(`sutrata_api_key_${provider}`);
52
- }
53
-
54
- export async function setApiKey(provider: string, key: string): Promise<void> {
55
- if (window.desktopAPI?.setApiKey) {
56
- try {
57
- await window.desktopAPI.setApiKey(provider, key);
58
- return;
59
- } catch (e) {
60
- console.error('Failed to set key in tauri keyring:', e);
61
- }
62
- }
63
- localStorage.setItem(`sutrata_api_key_${provider}`, key);
64
- }
65
-
66
- export async function deleteApiKey(provider: string): Promise<void> {
67
- if (window.desktopAPI?.deleteApiKey) {
68
- try {
69
- await window.desktopAPI.deleteApiKey(provider);
70
- return;
71
- } catch (e) {
72
- console.error('Failed to delete key from tauri keyring:', e);
73
- }
74
- }
75
- localStorage.removeItem(`sutrata_api_key_${provider}`);
76
- }
77
-
78
- export function getAIConfig(): AIConfig {
79
- const stored = localStorage.getItem('sutrata_ai_config');
80
- if (stored) {
81
- try {
82
- return JSON.parse(stored);
83
- } catch {
84
- // ignore
85
- }
86
- }
87
- return {
88
- // No model id is hardcoded here — callAI/callAIStructured already fall
89
- // back to the chosen provider's first available model when
90
- // preferredModel doesn't match one of its models.
91
- preferredProvider: 'anthropic',
92
- preferredModel: '',
93
- customProviders: [],
94
- };
95
- }
96
-
97
- export function saveAIConfig(config: AIConfig): void {
98
- localStorage.setItem('sutrata_ai_config', JSON.stringify(config));
99
- }
100
-
101
- let cachedProviders: ProviderConfig[] | null = null;
102
-
103
- export async function fetchProviders(config?: AIConfig): Promise<ProviderConfig[]> {
104
- if (cachedProviders) {
105
- return mergeCustomProviders(cachedProviders, config?.customProviders);
106
- }
107
-
108
- const isProd = (import.meta as any).env?.PROD;
109
- const githubUrl = 'https://raw.githubusercontent.com/sivarajd/sutrata/main/packages/app/public/models-config.json';
110
-
111
- let providers: ProviderConfig[] = [];
112
-
113
- if (isProd) {
114
- try {
115
- const res = await fetch(githubUrl);
116
- if (res.ok) {
117
- const data = await res.json();
118
- providers = data.providers || [];
119
- }
120
- } catch (e) {
121
- console.warn('Failed to fetch models config from github raw URL, falling back to local:', e);
122
- }
123
- }
124
-
125
- if (providers.length === 0) {
126
- try {
127
- const res = await fetch('/models-config.json');
128
- if (res.ok) {
129
- const data = await res.json();
130
- providers = data.providers || [];
131
- }
132
- } catch (e) {
133
- console.error('Failed to fetch local models-config.json:', e);
134
- }
135
- }
136
-
137
- if (providers.length === 0) {
138
- // Both the remote and local fetches failed (or returned nothing) — use
139
- // the compiled-in snapshot generated at build time. See FALLBACK_PROVIDERS.
140
- providers = FALLBACK_PROVIDERS;
141
- }
142
-
143
- cachedProviders = providers;
144
- return mergeCustomProviders(providers, config?.customProviders);
145
- }
146
-
147
- function mergeCustomProviders(
148
- base: ProviderConfig[],
149
- custom: CustomProvider[] | undefined
150
- ): ProviderConfig[] {
151
- if (!custom || custom.length === 0) return base;
152
- const list = [...base];
153
- for (const c of custom) {
154
- const index = list.findIndex(p => p.id === c.id);
155
- if (index >= 0) {
156
- const existing = list[index]!;
157
- list[index] = {
158
- ...existing,
159
- name: c.name,
160
- baseUrl: c.baseUrl,
161
- models: c.models,
162
- };
163
- } else {
164
- list.push({
165
- id: c.id,
166
- name: c.name,
167
- baseUrl: c.baseUrl,
168
- models: c.models,
169
- });
170
- }
171
- }
172
- return list;
173
- }
174
-
175
- interface ApiResponse {
176
- status: number;
177
- data: any;
178
- }
179
-
180
- async function requestHttp(
181
- url: string,
182
- method: string,
183
- headers: Record<string, string>,
184
- body: any
185
- ): Promise<ApiResponse> {
186
- if (window.desktopAPI?.callProviderApi) {
187
- try {
188
- const res = await window.desktopAPI.callProviderApi(url, method, headers, body);
189
- return res;
190
- } catch (e) {
191
- throw new Error(`Desktop API Error: ${e}`);
192
- }
193
- } else {
194
- try {
195
- const res = await fetch(url, {
196
- method,
197
- headers,
198
- body: body ? JSON.stringify(body) : undefined,
199
- });
200
- let data;
201
- const text = await res.text();
202
- try {
203
- data = JSON.parse(text);
204
- } catch {
205
- data = text;
206
- }
207
- return {
208
- status: res.status,
209
- data,
210
- };
211
- } catch (e) {
212
- throw new Error(`Web Fetch Error: ${e}`);
213
- }
214
- }
215
- }
216
-
217
- const STRUCTURED_TOOL_NAME = 'emit_result';
218
-
219
- /**
220
- * Anthropic has no OpenAI-compatible endpoint, so it's the only provider
221
- * that still needs a bespoke request/response shape here — everyone else
222
- * (including Gemini, via its official /v1beta/openai compat base URL) goes
223
- * through the generic OpenAI-compatible branch below.
224
- */
225
- async function invokeProvider(
226
- provider: ProviderConfig,
227
- model: string,
228
- key: string,
229
- prompt: string,
230
- systemPrompt: string,
231
- responseSchema?: JsonSchema
232
- ): Promise<string> {
233
- let url = '';
234
- let headers: Record<string, string> = {};
235
- let body: any = null;
236
- const method = 'POST';
237
-
238
- if (provider.id === 'anthropic') {
239
- url = `${provider.baseUrl}/messages`;
240
- headers = {
241
- 'x-api-key': key,
242
- 'anthropic-version': '2023-06-01',
243
- 'content-type': 'application/json',
244
- };
245
- body = {
246
- model,
247
- max_tokens: 2000,
248
- system: systemPrompt,
249
- messages: [
250
- { role: 'user', content: prompt }
251
- ],
252
- temperature: 0.1,
253
- };
254
- if (responseSchema) {
255
- // Force a schema-valid tool call instead of parsing free text —
256
- // guarantees the fields we asked for, in the shape we asked for.
257
- body.tools = [{
258
- name: STRUCTURED_TOOL_NAME,
259
- description: 'Return the structured result for this request.',
260
- input_schema: responseSchema,
261
- strict: true,
262
- }];
263
- body.tool_choice = { type: 'tool', name: STRUCTURED_TOOL_NAME };
264
- }
265
- } else {
266
- url = `${provider.baseUrl}/chat/completions`;
267
- headers = {
268
- 'Authorization': `Bearer ${key}`,
269
- 'Content-Type': 'application/json',
270
- };
271
- const effectiveSystemPrompt = responseSchema
272
- ? `${systemPrompt}\n\n[OUTPUT FORMAT]\nRespond with ONLY a single JSON object matching this JSON schema — no markdown fences, no commentary, no extra text before or after it:\n${JSON.stringify(responseSchema)}`
273
- : systemPrompt;
274
- body = {
275
- model,
276
- messages: [
277
- { role: 'system', content: effectiveSystemPrompt },
278
- { role: 'user', content: prompt }
279
- ],
280
- temperature: 0.1,
281
- };
282
- if (responseSchema) {
283
- // Broadest-compatibility structured-output mode — many self-hosted/
284
- // aggregator OpenAI-compatible backends don't support the stricter
285
- // json_schema mode, so this is paired with the prompt instruction
286
- // above and extractJson()'s tolerant parsing on the caller side.
287
- body.response_format = { type: 'json_object' };
288
- }
289
- }
290
-
291
- const res = await requestHttp(url, method, headers, body);
292
- if (res.status === 429) {
293
- throw new Error(`Rate limit exceeded (HTTP 429)`);
294
- }
295
- if (res.status < 200 || res.status >= 300) {
296
- const errMsg = typeof res.data === 'object'
297
- ? JSON.stringify(res.data)
298
- : res.data;
299
- throw new Error(`HTTP ${res.status}: ${errMsg}`);
300
- }
301
-
302
- if (provider.id === 'anthropic') {
303
- if (responseSchema) {
304
- const toolUse = (res.data?.content as any[] | undefined)?.find(
305
- (block) => block?.type === 'tool_use' && block?.name === STRUCTURED_TOOL_NAME
306
- );
307
- if (!toolUse) throw new Error('Invalid response from Anthropic: missing tool_use block');
308
- return JSON.stringify(toolUse.input);
309
- }
310
- const text = res.data?.content?.[0]?.text;
311
- if (!text) throw new Error('Invalid response from Anthropic: missing content text');
312
- return text;
313
- } else {
314
- const text = res.data?.choices?.[0]?.message?.content;
315
- if (!text) throw new Error(`Invalid response from ${provider.name}: missing message content`);
316
- return text;
317
- }
318
- }
319
-
320
- /**
321
- * Parses a structured AI response into T, tolerating a model that wraps the
322
- * JSON in a markdown fence or adds stray text around it despite being asked
323
- * not to (some providers don't strictly honor response_format hints).
324
- */
325
- export function extractJson<T>(raw: string): T {
326
- const attempts: string[] = [raw.trim()];
327
-
328
- const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
329
- if (fenceMatch?.[1]) attempts.push(fenceMatch[1].trim());
330
-
331
- const firstBrace = raw.indexOf('{');
332
- const lastBrace = raw.lastIndexOf('}');
333
- if (firstBrace !== -1 && lastBrace > firstBrace) {
334
- attempts.push(raw.slice(firstBrace, lastBrace + 1));
335
- }
336
-
337
- for (const attempt of attempts) {
338
- try {
339
- return JSON.parse(attempt) as T;
340
- } catch {
341
- continue;
342
- }
343
- }
344
-
345
- throw new Error(`AI did not return valid JSON: ${raw.slice(0, 200)}`);
346
- }
347
-
348
- export function cleanVoiceResponse(text: string): string {
349
- let cleaned = text.trim();
350
- // Strip markdown code block wrapper if present
351
- cleaned = cleaned.replace(/^```[a-zA-Z0-9]*\n/, '').replace(/\n```$/, '');
352
-
353
- const lines = cleaned.split('\n');
354
- const filteredLines = lines.map(line => {
355
- let l = line.trim();
356
- // Strip leading list bullet (e.g. *, -, +, •) and spaces
357
- l = l.replace(/^[*•\-+]\s*/, '').trim();
358
- // Strip leading/trailing backticks if any
359
- l = l.replace(/^`+/, '').replace(/`+$/, '').trim();
360
-
361
- // Strip common explanatory trailing comments in parens
362
- l = l.replace(/\s*\(Standard screenplay shorthand[^)]*\)/gi, '');
363
- l = l.replace(/\s*\(Standard shorthand[^)]*\)/gi, '');
364
- return l;
365
- }).filter(line => {
366
- const trimmed = line.toLowerCase();
367
- if (!trimmed) return false;
368
-
369
- // Discard reasoning/mapping/explanation patterns
370
- if (
371
- trimmed.includes('->') ||
372
- trimmed.includes('=>') ||
373
- trimmed.startsWith('input:') ||
374
- trimmed.startsWith('task:') ||
375
- trimmed.startsWith('rules:') ||
376
- trimmed.startsWith('instructions:') ||
377
- trimmed.startsWith('sutra rules:') ||
378
- trimmed.startsWith('scene headings:') ||
379
- trimmed.startsWith('scene id:') ||
380
- trimmed.startsWith('characters:') ||
381
- trimmed.startsWith('parentheticals:') ||
382
- trimmed.startsWith('dialogue:') ||
383
- trimmed.startsWith('transitions:') ||
384
- trimmed.startsWith('lyrics:') ||
385
- trimmed.startsWith('action:') ||
386
- trimmed.startsWith('format:') ||
387
- trimmed.startsWith('components:') ||
388
- trimmed.startsWith('output only') ||
389
- trimmed.startsWith('no markdown') ||
390
- trimmed.includes('convert it into valid sutra') ||
391
- trimmed.includes('sutra syntax rules') ||
392
- trimmed.includes('clearly a scene heading') ||
393
- trimmed.includes('screenplay shorthand') ||
394
- trimmed.includes('append as')
395
- ) {
396
- return false;
397
- }
398
-
399
- return true;
400
- });
401
-
402
- return filteredLines.join('\n').trim();
403
- }
404
-
405
- let nextCallId = 0;
406
- const activeCalls = new Map<string, { providerName: string; model: string }>();
407
- const subscribers = new Set<(calls: { providerName: string; model: string }[]) => void>();
408
-
409
- export function subscribeToAICalls(callback: (calls: { providerName: string; model: string }[]) => void): () => void {
410
- subscribers.add(callback);
411
- callback(Array.from(activeCalls.values()));
412
- return () => {
413
- subscribers.delete(callback);
414
- };
415
- }
416
-
417
- function notifySubscribers() {
418
- const list = Array.from(activeCalls.values());
419
- for (const sub of subscribers) {
420
- sub(list);
421
- }
422
- }
423
-
424
- export function formatActiveCalls(calls: { providerName: string; model: string }[]): string {
425
- if (calls.length === 0) return '';
426
- if (calls.length === 1) {
427
- return `Calling ${calls[0]!.providerName} (${calls[0]!.model})...`;
428
- }
429
- const unique = Array.from(new Set(calls.map(c => `${c.providerName} (${c.model})`)));
430
- if (unique.length === 1) {
431
- return `Calling ${unique[0]} (${calls.length} calls)...`;
432
- }
433
- return `Calling ${unique.join(', ')}...`;
434
- }
435
-
436
- /**
437
- * Resolves the provider/key/model fallback chain and invokes it, retrying
438
- * the next configured provider on failure. Shared by callAI and
439
- * callAIStructured so the two don't duplicate this logic.
440
- */
441
- async function resolveAndInvoke(
442
- prompt: string,
443
- systemPrompt: string,
444
- config: AIConfig,
445
- responseSchema?: JsonSchema
446
- ): Promise<string> {
447
- const providers = await fetchProviders(config);
448
-
449
- const availableProviders: string[] = [];
450
- for (const p of providers) {
451
- const key = await getApiKey(p.id);
452
- if (key) {
453
- availableProviders.push(p.id);
454
- }
455
- }
456
-
457
- if (availableProviders.length === 0) {
458
- throw new Error('No AI API keys configured. Please configure keys in Settings.');
459
- }
460
-
461
- let providersToTry = [config.preferredProvider, ...availableProviders.filter(p => p !== config.preferredProvider)];
462
- providersToTry = providersToTry.filter(p => availableProviders.includes(p));
463
-
464
- if (providersToTry.length === 0) {
465
- providersToTry = [availableProviders[0]!];
466
- }
467
-
468
- let lastError = '';
469
-
470
- for (const providerId of providersToTry) {
471
- const provider = providers.find(p => p.id === providerId);
472
- if (!provider) continue;
473
-
474
- const key = await getApiKey(providerId);
475
- if (!key) continue;
476
-
477
- // config.preferredModel is only meaningful for config.preferredProvider
478
- // — Settings/Onboarding always save them together, and the model is
479
- // picked from that provider's *live* listModels() result, which is
480
- // usually not a member of `provider.models` (the static generated
481
- // list, kept only as a last-resort default). Validating it against
482
- // that static list here would silently discard a perfectly valid
483
- // live-picked model and substitute a possibly-stale/retired one
484
- // instead. For any other (fallback) provider in this loop,
485
- // preferredModel genuinely doesn't apply, so fall back to its first
486
- // static model.
487
- const model = providerId === config.preferredProvider
488
- ? (config.preferredModel || provider.models[0]?.id || '')
489
- : (provider.models[0]?.id || '');
490
-
491
- const callId = `call-${nextCallId++}`;
492
- activeCalls.set(callId, { providerName: provider.name, model });
493
- notifySubscribers();
494
-
495
- try {
496
- return await invokeProvider(provider, model, key, prompt, systemPrompt, responseSchema);
497
- } catch (e: any) {
498
- console.warn(`Provider ${providerId} failed:`, e);
499
- lastError = `[${provider.name} / ${model}] ${e.message || String(e)}`;
500
- continue;
501
- } finally {
502
- activeCalls.delete(callId);
503
- notifySubscribers();
504
- }
505
- }
506
-
507
- throw new Error(`AI generation failed. Last error: ${lastError}`);
508
- }
509
-
510
- export async function callAI(
511
- prompt: string,
512
- systemPrompt: string,
513
- config: AIConfig,
514
- options?: { clean?: 'voice' }
515
- ): Promise<string> {
516
- const response = await resolveAndInvoke(prompt, systemPrompt, config);
517
- return options?.clean === 'voice' ? cleanVoiceResponse(response) : response;
518
- }
519
-
520
- /**
521
- * Like callAI, but requests and validates a JSON response matching `schema`
522
- * instead of returning free text — the explicit contract that replaced
523
- * sniffing the system prompt for keywords like "duration"/"synopsis".
524
- */
525
- export async function callAIStructured<T = unknown>(
526
- prompt: string,
527
- systemPrompt: string,
528
- config: AIConfig,
529
- schema: JsonSchema
530
- ): Promise<T> {
531
- const response = await resolveAndInvoke(prompt, systemPrompt, config, schema);
532
- return extractJson<T>(response);
533
- }
534
-
535
- /** Any provider (from the fetched + custom list) with a stored API key. */
536
- export async function hasConfiguredApiKey(config: AIConfig): Promise<boolean> {
537
- const providers = await fetchProviders(config);
538
- for (const p of providers) {
539
- if (await getApiKey(p.id)) return true;
540
- }
541
- return false;
542
- }
543
-
544
- /**
545
- * Fetches the provider's live model list so Settings/Onboarding show
546
- * current models instead of a potentially stale static one. Never throws —
547
- * falls back to `provider.models` on any error (missing endpoint, bad key,
548
- * network failure), since this is a progressive enhancement.
549
- */
550
- export async function listModels(provider: ProviderConfig, apiKey: string): Promise<ModelConfig[]> {
551
- try {
552
- const isAnthropic = provider.id === 'anthropic';
553
- const url = isAnthropic ? 'https://api.anthropic.com/v1/models' : `${provider.baseUrl}/models`;
554
- const headers: Record<string, string> = isAnthropic
555
- ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
556
- : { 'Authorization': `Bearer ${apiKey}` };
557
-
558
- const res = await requestHttp(url, 'GET', headers, null);
559
- if (res.status < 200 || res.status >= 300) {
560
- throw new Error(`HTTP ${res.status}`);
561
- }
562
-
563
- const data = res.data?.data;
564
- if (!Array.isArray(data) || data.length === 0) {
565
- throw new Error('Provider returned an empty model list');
566
- }
567
-
568
- return data.map((m: any) => ({
569
- id: m.id,
570
- name: m.display_name || m.id,
571
- }));
572
- } catch (e) {
573
- console.warn(`Failed to list live models for ${provider.id}, using static list:`, e);
574
- return provider.models;
575
- }
576
- }
577
-
578
- export async function testConnection(providerId: string, apiKey: string, config: AIConfig): Promise<boolean> {
579
- const providers = await fetchProviders(config);
580
- const provider = providers.find(p => p.id === providerId);
581
- if (!provider) throw new Error('Unknown provider');
582
-
583
- const liveModels = await listModels(provider, apiKey);
584
- const testModel = liveModels[0]?.id;
585
- if (!testModel) throw new Error('No models found for provider');
586
-
587
- const resText = await invokeProvider(provider, testModel, apiKey, "Hello, reply with one word: OK", "Connection test verification.");
588
- return resText.trim().length > 0;
589
- }
590
-
591
- export function mockActiveCallsList(calls: { providerName: string; model: string }[]) {
592
- activeCalls.clear();
593
- calls.forEach((c, idx) => activeCalls.set(`mock-${idx}`, c));
594
- notifySubscribers();
595
- }
596
-