@sutrata/editor 0.1.1 → 0.2.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.
Files changed (268) hide show
  1. package/README.md +87 -0
  2. package/dist/ai/VoiceConfirmDialog.d.ts +1 -0
  3. package/dist/ai/VoiceConfirmDialog.js +6 -6
  4. package/dist/ai/VoiceToolbar.d.ts +1 -0
  5. package/dist/ai/VoiceToolbar.js +22 -16
  6. package/dist/ai/clean-response.d.ts +7 -0
  7. package/dist/ai/clean-response.js +58 -0
  8. package/dist/ai/format-transcript.d.ts +3 -0
  9. package/dist/ai/format-transcript.js +7 -0
  10. package/dist/ai/prompts.d.ts +8 -1
  11. package/dist/ai/prompts.js +34 -7
  12. package/dist/autocomplete/suggestions.js +1 -1
  13. package/dist/context/DocumentContext.d.ts +28 -4
  14. package/dist/context/DocumentContext.js +67 -37
  15. package/dist/editor/EditorView.js +32 -4
  16. package/dist/editor/SelectionMenu.js +5 -3
  17. package/dist/editor/external-decorations-plugin.d.ts +23 -0
  18. package/dist/editor/external-decorations-plugin.js +151 -0
  19. package/dist/editor/omitted-scene-plugin.d.ts +9 -0
  20. package/dist/editor/omitted-scene-plugin.js +44 -0
  21. package/dist/editor/plugins.js +4 -0
  22. package/dist/editor/private-metadata-plugin.d.ts +10 -0
  23. package/dist/editor/private-metadata-plugin.js +60 -0
  24. package/dist/editor/prosemirror-to-sutra.js +179 -74
  25. package/dist/editor/scene-heading-view.js +2 -0
  26. package/dist/editor/schema.js +69 -12
  27. package/dist/editor/sutra-to-prosemirror.d.ts +2 -0
  28. package/dist/editor/sutra-to-prosemirror.js +32 -34
  29. package/dist/extensions/ai-provider.d.ts +44 -0
  30. package/dist/extensions/ai-provider.js +27 -0
  31. package/dist/extensions/collab.d.ts +21 -0
  32. package/dist/extensions/collab.js +6 -0
  33. package/dist/extensions/command-registry.d.ts +49 -0
  34. package/dist/extensions/command-registry.js +28 -0
  35. package/dist/extensions/decorations.d.ts +48 -0
  36. package/dist/extensions/decorations.js +6 -0
  37. package/dist/extensions/export-registry.d.ts +59 -0
  38. package/dist/extensions/export-registry.js +10 -0
  39. package/dist/extensions/panel-registry.d.ts +30 -0
  40. package/dist/extensions/panel-registry.js +16 -0
  41. package/dist/extensions/registries.d.ts +15 -0
  42. package/dist/extensions/registries.js +12 -0
  43. package/dist/extensions/registry-store.d.ts +26 -0
  44. package/dist/extensions/registry-store.js +35 -0
  45. package/dist/extensions/session.d.ts +29 -0
  46. package/dist/extensions/session.js +20 -0
  47. package/dist/extensions/speech-provider.d.ts +35 -0
  48. package/dist/extensions/speech-provider.js +17 -0
  49. package/dist/file/builtin-exporters.d.ts +9 -0
  50. package/dist/file/builtin-exporters.js +72 -0
  51. package/dist/file/docx-exporter.js +7 -4
  52. package/dist/file/docx-importer.d.ts +9 -0
  53. package/dist/file/docx-importer.js +47 -19
  54. package/dist/file/scene-number.d.ts +5 -0
  55. package/dist/file/scene-number.js +8 -0
  56. package/dist/file/workflow-reports.js +13 -7
  57. package/dist/findreplace/FindReplace.js +5 -2
  58. package/dist/import/AIImportDialog.d.ts +11 -0
  59. package/dist/import/AIImportDialog.js +112 -0
  60. package/dist/import/ai-import.d.ts +51 -0
  61. package/dist/import/ai-import.js +200 -0
  62. package/dist/import/apply-import.d.ts +7 -0
  63. package/dist/import/apply-import.js +35 -0
  64. package/dist/import/extract.d.ts +17 -0
  65. package/dist/import/extract.js +18 -0
  66. package/dist/index.d.ts +17 -2
  67. package/dist/index.js +7 -0
  68. package/dist/locales/bn.json +8 -0
  69. package/dist/locales/en.json +8 -0
  70. package/dist/locales/gu.json +8 -0
  71. package/dist/locales/hi.json +8 -0
  72. package/dist/locales/kn.json +8 -0
  73. package/dist/locales/ml.json +8 -0
  74. package/dist/locales/mr.json +8 -0
  75. package/dist/locales/or.json +8 -0
  76. package/dist/locales/pa.json +8 -0
  77. package/dist/locales/si.json +8 -0
  78. package/dist/locales/ta.json +8 -0
  79. package/dist/locales/te.json +8 -0
  80. package/dist/navigator/SceneNavigator.js +125 -131
  81. package/dist/navigator/StatisticsDialog.js +5 -1
  82. package/dist/navigator/scene-edits.d.ts +18 -0
  83. package/dist/navigator/scene-edits.js +143 -0
  84. package/dist/navigator/scene-list.js +4 -4
  85. package/dist/navigator/scene-numbering.d.ts +37 -0
  86. package/dist/navigator/scene-numbering.js +88 -0
  87. package/dist/settings/SettingsDialog.js +8 -120
  88. package/dist/shell/AppBar.js +21 -2
  89. package/dist/shell/AppShell.js +18 -4
  90. package/dist/shell/ElementToolbar.d.ts +5 -0
  91. package/dist/shell/ElementToolbar.js +35 -25
  92. package/dist/shell/LanguageStatus.js +6 -0
  93. package/dist/shell/StatusBar.js +11 -6
  94. package/dist/shell/builtin-commands.d.ts +10 -0
  95. package/dist/shell/builtin-commands.js +43 -0
  96. package/dist/shell/icons.d.ts +6 -0
  97. package/dist/shell/icons.js +6 -0
  98. package/dist/shell/use-commands.d.ts +7 -0
  99. package/dist/shell/use-commands.js +24 -0
  100. package/dist/shell/use-panel-context.d.ts +3 -0
  101. package/dist/shell/use-panel-context.js +39 -0
  102. package/dist/source/SourceView.js +18 -1
  103. package/dist/styles/screenplay.css +113 -1
  104. package/dist/titlepage/ExportDialog.d.ts +5 -0
  105. package/dist/titlepage/ExportDialog.js +59 -96
  106. package/package.json +32 -5
  107. package/dist/ai/OnboardingDialog.d.ts +0 -6
  108. package/dist/ai/OnboardingDialog.js +0 -115
  109. package/dist/ai/ai-client.d.ts +0 -71
  110. package/dist/ai/ai-client.js +0 -494
  111. package/dist/ai/generated-providers.json +0 -3696
  112. package/dist/ai/voice-service.d.ts +0 -15
  113. package/dist/ai/voice-service.js +0 -81
  114. package/dist/editor/character-view.d.ts +0 -35
  115. package/dist/editor/character-view.js +0 -112
  116. package/dist/editor/transliterate-input.d.ts +0 -10
  117. package/dist/editor/transliterate-input.js +0 -88
  118. package/dist/extensions/types.d.ts +0 -73
  119. package/dist/extensions/types.js +0 -12
  120. package/scripts/copy-assets.mjs +0 -16
  121. package/scripts/generate-providers.mjs +0 -127
  122. package/src/ai/OnboardingDialog.tsx +0 -390
  123. package/src/ai/VoiceConfirmDialog.tsx +0 -137
  124. package/src/ai/VoiceToolbar.tsx +0 -321
  125. package/src/ai/ai-client.ts +0 -596
  126. package/src/ai/commands.ts +0 -164
  127. package/src/ai/generated-providers.json +0 -3696
  128. package/src/ai/prompts.ts +0 -62
  129. package/src/ai/voice-service.ts +0 -96
  130. package/src/autocomplete/AutocompletePlugin.ts +0 -21
  131. package/src/autocomplete/suggestions.ts +0 -147
  132. package/src/context/DocumentContext.tsx +0 -648
  133. package/src/desktop-api.d.ts +0 -40
  134. package/src/editor/EditorView.tsx +0 -82
  135. package/src/editor/SelectionMenu.tsx +0 -152
  136. package/src/editor/attr-pencil-plugin.ts +0 -172
  137. package/src/editor/character-decoration-plugin.ts +0 -104
  138. package/src/editor/editor-bus.ts +0 -51
  139. package/src/editor/find-highlight-plugin.ts +0 -68
  140. package/src/editor/frontmatter-field-view.ts +0 -52
  141. package/src/editor/frontmatter-field.ts +0 -115
  142. package/src/editor/grapheme-cursor.ts +0 -99
  143. package/src/editor/ime-plugin.ts +0 -85
  144. package/src/editor/insert-helper.ts +0 -43
  145. package/src/editor/keymap.ts +0 -124
  146. package/src/editor/language-class-plugin.ts +0 -91
  147. package/src/editor/plugins.ts +0 -26
  148. package/src/editor/prosemirror-to-sutra.ts +0 -192
  149. package/src/editor/scene-heading-view.ts +0 -147
  150. package/src/editor/schema.ts +0 -156
  151. package/src/editor/sutra-to-prosemirror.ts +0 -156
  152. package/src/extensions/storage-adapter.ts +0 -65
  153. package/src/extensions/types.ts +0 -60
  154. package/src/file/docx-exporter.ts +0 -585
  155. package/src/file/docx-importer.ts +0 -313
  156. package/src/file/print-paginator.ts +0 -301
  157. package/src/file/templates.ts +0 -73
  158. package/src/file/workflow-reports.ts +0 -820
  159. package/src/findreplace/FindReplace.tsx +0 -242
  160. package/src/findreplace/find-engine.ts +0 -97
  161. package/src/findreplace/scroll-to-match.ts +0 -89
  162. package/src/fonts/tofu-detect.ts +0 -53
  163. package/src/i18n/LanguageContext.tsx +0 -207
  164. package/src/i18n/language-detect.ts +0 -73
  165. package/src/i18n/useTranslation.tsx +0 -74
  166. package/src/index.ts +0 -27
  167. package/src/locales/bn.json +0 -143
  168. package/src/locales/en.json +0 -143
  169. package/src/locales/gu.json +0 -143
  170. package/src/locales/hi.json +0 -143
  171. package/src/locales/kn.json +0 -143
  172. package/src/locales/ml.json +0 -143
  173. package/src/locales/mr.json +0 -143
  174. package/src/locales/or.json +0 -143
  175. package/src/locales/pa.json +0 -143
  176. package/src/locales/si.json +0 -143
  177. package/src/locales/ta.json +0 -143
  178. package/src/locales/te.json +0 -143
  179. package/src/navigator/SceneNavigator.tsx +0 -844
  180. package/src/navigator/StatisticsDialog.tsx +0 -249
  181. package/src/navigator/scene-list.ts +0 -84
  182. package/src/settings/SettingsDialog.tsx +0 -303
  183. package/src/shell/AppBar.tsx +0 -354
  184. package/src/shell/AppShell.tsx +0 -71
  185. package/src/shell/ConfirmDialog.tsx +0 -71
  186. package/src/shell/ElementToolbar.tsx +0 -175
  187. package/src/shell/LanguageStatus.tsx +0 -101
  188. package/src/shell/StatusBar.tsx +0 -135
  189. package/src/shell/SutrataLogo.tsx +0 -89
  190. package/src/shell/Toast.tsx +0 -46
  191. package/src/shell/ToolbarButton.tsx +0 -30
  192. package/src/shell/VersionHistory.tsx +0 -212
  193. package/src/shell/Watermark.tsx +0 -9
  194. package/src/shell/fonts/fonts.css +0 -1133
  195. package/src/shell/icons.tsx +0 -335
  196. package/src/shell/toolbar-actions.ts +0 -59
  197. package/src/source/SourceView.tsx +0 -111
  198. package/src/source/find-highlight-source.ts +0 -57
  199. package/src/source/sutra-lang.ts +0 -121
  200. package/src/spellcheck/dictionaries.ts +0 -21
  201. package/src/spellcheck/hunspell.ts +0 -110
  202. package/src/spellcheck/spellcheck-plugin.ts +0 -83
  203. package/src/styles/StyleDialog.tsx +0 -148
  204. package/src/styles/builtin-styles.ts +0 -139
  205. package/src/styles/css-adapter.ts +0 -76
  206. package/src/styles/docx-adapter.ts +0 -109
  207. package/src/styles/embed-fonts.ts +0 -81
  208. package/src/styles/pagination.ts +0 -27
  209. package/src/styles/print-adapter.ts +0 -288
  210. package/src/styles/registry.ts +0 -20
  211. package/src/styles/screenplay.css +0 -3006
  212. package/src/styles/script-ranges.ts +0 -17
  213. package/src/styles/types.ts +0 -104
  214. package/src/styles/validate.ts +0 -114
  215. package/src/templates/templates.ts +0 -194
  216. package/src/titlepage/ExportDialog.tsx +0 -494
  217. package/src/titlepage/TitlePageDialog.tsx +0 -312
  218. package/src/titlepage/frontmatter-form.ts +0 -280
  219. package/src/titlepage/languages.ts +0 -37
  220. package/src/types.ts +0 -7
  221. package/tests/AppBar.test.tsx +0 -35
  222. package/tests/AppShell.test.tsx +0 -22
  223. package/tests/ElementToolbar.test.tsx +0 -36
  224. package/tests/LanguageStatus.test.tsx +0 -129
  225. package/tests/SelectionMenu.test.tsx +0 -71
  226. package/tests/ToolbarButton.test.tsx +0 -24
  227. package/tests/ai-onboarding.test.ts +0 -102
  228. package/tests/ai-status.test.tsx +0 -72
  229. package/tests/ai-structured.test.ts +0 -94
  230. package/tests/autocomplete/AutocompletePlugin.test.ts +0 -20
  231. package/tests/autocomplete/suggestions.test.ts +0 -106
  232. package/tests/document-context.test.tsx +0 -565
  233. package/tests/docx-importer.test.ts +0 -160
  234. package/tests/editor/character-decoration-plugin.test.ts +0 -101
  235. package/tests/editor/character-extension.test.ts +0 -44
  236. package/tests/editor/grapheme-cursor.test.ts +0 -118
  237. package/tests/editor/ime-plugin.test.ts +0 -72
  238. package/tests/editor/inline-note-bridge.test.ts +0 -32
  239. package/tests/editor/language-class-plugin.test.ts +0 -265
  240. package/tests/editor/title-page.test.ts +0 -124
  241. package/tests/embed-fonts.test.ts +0 -17
  242. package/tests/exporters.test.ts +0 -365
  243. package/tests/findreplace/FindReplace.test.tsx +0 -391
  244. package/tests/findreplace/find-engine.test.ts +0 -86
  245. package/tests/findreplace/scroll-to-match.test.ts +0 -50
  246. package/tests/fonts/tofu-detect.test.ts +0 -122
  247. package/tests/i18n/LanguageContext.test.tsx +0 -146
  248. package/tests/i18n/language-detect.test.ts +0 -54
  249. package/tests/i18n/locale-parity.test.ts +0 -25
  250. package/tests/i18n/useTranslation.test.tsx +0 -45
  251. package/tests/keymap.test.ts +0 -18
  252. package/tests/navigator/SceneNavigator.test.tsx +0 -177
  253. package/tests/navigator/StatisticsDialog.test.tsx +0 -63
  254. package/tests/navigator/scene-list.test.ts +0 -71
  255. package/tests/print-paginator.test.ts +0 -82
  256. package/tests/shell/VersionHistory.test.tsx +0 -129
  257. package/tests/spellcheck/hunspell.test.ts +0 -82
  258. package/tests/spellcheck/spellcheck-plugin.test.ts +0 -56
  259. package/tests/styles.test.ts +0 -315
  260. package/tests/templates/templates.test.ts +0 -83
  261. package/tests/titlepage/TitlePageDialog.test.tsx +0 -90
  262. package/tests/titlepage/apply-title-page.test.ts +0 -61
  263. package/tests/titlepage/frontmatter-form.test.ts +0 -115
  264. package/tests/toolbar-actions.test.ts +0 -93
  265. package/tests/voice-commands.test.ts +0 -58
  266. package/tsconfig.json +0 -17
  267. package/vitest.config.ts +0 -11
  268. 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
-