@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,249 +0,0 @@
1
- import React from 'react'
2
- import { useDocument } from '../context/DocumentContext'
3
- import { extractWorkflowData } from '../file/workflow-reports'
4
- import { getEditorView } from '../editor/editor-bus'
5
- import { schema } from '../editor/schema'
6
-
7
- interface Props {
8
- onClose: () => void
9
- }
10
-
11
- export function StatisticsDialog({ onClose }: Props) {
12
- const { ast, text, setText } = useDocument()
13
- const { scenes, characters } = extractWorkflowData(ast)
14
-
15
- const handleUpdateActor = (charName: string, actorName: string) => {
16
- const cleanChar = charName.trim().toUpperCase()
17
- if (!cleanChar) return
18
-
19
- const view = getEditorView()
20
- const key = `characters.${cleanChar}`
21
-
22
- if (view) {
23
- let tr = view.state.tr
24
- let titlePagePos: number | null = null
25
- view.state.doc.forEach((node, offset) => {
26
- if (node.type.name === 'title_page') titlePagePos = offset
27
- })
28
-
29
- if (titlePagePos !== null) {
30
- const tpNode = view.state.doc.nodeAt(titlePagePos)!
31
- let existingPos: number | null = null
32
- let pos = titlePagePos + 1 // inside title_page
33
-
34
- tpNode.forEach((field) => {
35
- if (field.attrs['fmKey'] === key) {
36
- existingPos = pos
37
- }
38
- pos += field.nodeSize
39
- })
40
-
41
- if (existingPos !== null) {
42
- const fieldNode = view.state.doc.nodeAt(existingPos)!
43
- if (actorName.trim() === '') {
44
- tr = tr.delete(existingPos, existingPos + fieldNode.nodeSize)
45
- } else {
46
- tr = tr.replaceWith(
47
- existingPos + 1,
48
- existingPos + fieldNode.nodeSize - 1,
49
- schema.text(actorName)
50
- )
51
- }
52
- } else if (actorName.trim() !== '') {
53
- const insertPos = titlePagePos + tpNode.nodeSize - 1 // end of title_page
54
- const newField = schema.nodes['frontmatter_field']!.create(
55
- { fmKey: key },
56
- schema.text(actorName)
57
- )
58
- tr = tr.insert(insertPos, newField)
59
- }
60
- view.dispatch(tr)
61
- } else {
62
- const freshText = updateActorInText(text, cleanChar, actorName)
63
- setText(freshText)
64
- }
65
- } else {
66
- const freshText = updateActorInText(text, cleanChar, actorName)
67
- setText(freshText)
68
- }
69
- }
70
-
71
- // Word count helper
72
- const wordCount = text.split(/\s+/).filter(Boolean).length
73
-
74
- // Sum up estimated duration. Each scene's est-duration is "MM:SS" — sum
75
- // in seconds and only convert to minutes/hours once, at the end. Summing
76
- // the MM parts alone (the previous approach) silently dropped every
77
- // scene's seconds component, undercounting the total by tens of minutes
78
- // on a script with many scenes.
79
- let totalSeconds = 0
80
- scenes.forEach(s => {
81
- const match = /^(\d+):(\d{1,2})$/.exec(s.estDuration.trim())
82
- if (match && match[1] && match[2]) {
83
- totalSeconds += Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10)
84
- }
85
- })
86
- const totalMinutes = Math.round(totalSeconds / 60)
87
- const totalDurationLabel = totalSeconds === 0
88
- ? 'TBD'
89
- : totalMinutes >= 60
90
- ? `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`
91
- : `${totalMinutes} min`
92
-
93
- return (
94
- <div
95
- className="cs-dialog-overlay"
96
- role="presentation"
97
- onClick={e => {
98
- if (e.target === e.currentTarget) onClose()
99
- }}
100
- >
101
- <div
102
- className="cs-dialog"
103
- role="dialog"
104
- aria-modal="true"
105
- aria-labelledby="cs-stats-dialog-title"
106
- style={{ maxWidth: '640px', width: '92%', maxHeight: '85vh', display: 'flex', flexDirection: 'column' }}
107
- >
108
- <div className="cs-panel-header">
109
- <span id="cs-stats-dialog-title" className="cs-panel-title">Script Statistics</span>
110
- <button className="cs-panel-close" onClick={onClose} aria-label="Close">
111
- ×
112
- </button>
113
- </div>
114
- <div className="cs-panel-body" style={{ overflowY: 'auto', flex: 1, padding: '20px' }}>
115
- <div className="cs-stats-summary-grid">
116
- <div className="cs-stats-card">
117
- <div className="cs-stats-card-val">{scenes.length}</div>
118
- <div className="cs-stats-card-lbl">Scenes</div>
119
- </div>
120
- <div className="cs-stats-card">
121
- <div className="cs-stats-card-val">{wordCount.toLocaleString()}</div>
122
- <div className="cs-stats-card-lbl">Words</div>
123
- </div>
124
- <div className="cs-stats-card">
125
- <div className="cs-stats-card-val">
126
- {totalDurationLabel}
127
- </div>
128
- <div className="cs-stats-card-lbl">Est. Duration</div>
129
- </div>
130
- </div>
131
-
132
- <div className="cs-stats-section-hdr">
133
- Cast List &amp; Occurrences ({characters.length})
134
- </div>
135
-
136
- {characters.length === 0 ? (
137
- <div style={{ color: 'var(--cs-ui-text-light-muted, #8c897f)', fontStyle: 'italic', fontSize: '12px' }}>
138
- No character cues detected in the screenplay yet.
139
- </div>
140
- ) : (
141
- <table className="cs-stats-table">
142
- <thead>
143
- <tr>
144
- <th style={{ textAlign: 'left', width: '35%' }}>Character</th>
145
- <th style={{ textAlign: 'left' }}>Actor</th>
146
- <th style={{ textAlign: 'center', width: '15%' }}>Scenes</th>
147
- </tr>
148
- </thead>
149
- <tbody>
150
- {characters.map((c, i) => (
151
- <tr key={i}>
152
- <td style={{ fontWeight: '600' }}>{c.name}</td>
153
- <td>
154
- <input
155
- type="text"
156
- className="cs-stats-actor-input"
157
- value={c.actor}
158
- onChange={(e) => handleUpdateActor(c.name, e.target.value)}
159
- placeholder="Assign actor..."
160
- aria-label={`Actor for ${c.name}`}
161
- />
162
- </td>
163
- <td style={{ textAlign: 'center', fontWeight: '500' }}>{c.sceneCount}</td>
164
- </tr>
165
- ))}
166
- </tbody>
167
- </table>
168
- )}
169
- </div>
170
- <div
171
- style={{
172
- borderTop: '1px solid var(--cs-ui-border-light-subtle, #eeece4)',
173
- display: 'flex',
174
- justifyContent: 'flex-end',
175
- padding: '12px 20px',
176
- background: 'var(--cs-ui-bg-panel-subtle, #f4f2ea)',
177
- }}
178
- >
179
- <button
180
- type="button"
181
- className="cs-confirm-btn-primary"
182
- onClick={onClose}
183
- >
184
- Close
185
- </button>
186
- </div>
187
- </div>
188
- </div>
189
- )
190
- }
191
-
192
- function updateActorInText(text: string, charName: string, actorName: string): string {
193
- const lines = text.split('\n')
194
- const fmStartIndex = lines.indexOf('---')
195
- if (fmStartIndex === -1) {
196
- return `---\ncharacters:\n ${charName}: ${actorName}\n---\n\n${text}`
197
- }
198
- const fmEndIndex = lines.indexOf('---', fmStartIndex + 1)
199
- if (fmEndIndex === -1) return text
200
-
201
- const fmLines = lines.slice(fmStartIndex + 1, fmEndIndex)
202
- let charsLineIdx = -1
203
- for (let i = 0; i < fmLines.length; i++) {
204
- if (fmLines[i]!.trim() === 'characters:') {
205
- charsLineIdx = i
206
- break
207
- }
208
- }
209
-
210
- const cleanChar = charName.toUpperCase()
211
- if (charsLineIdx === -1) {
212
- if (actorName.trim() !== '') {
213
- fmLines.push('characters:')
214
- fmLines.push(` ${cleanChar}: ${actorName}`)
215
- }
216
- } else {
217
- let insertIdx = charsLineIdx + 1
218
- let charLineIdx = -1
219
- while (insertIdx < fmLines.length) {
220
- const line = fmLines[insertIdx]!
221
- if (!line.startsWith(' ') && line.trim() !== '') {
222
- break
223
- }
224
- const match = /^\s+([A-Za-z0-9_ -]+)\s*:(.*)/.exec(line)
225
- if (match && match[1]?.trim().toUpperCase() === cleanChar) {
226
- charLineIdx = insertIdx
227
- break
228
- }
229
- insertIdx++
230
- }
231
-
232
- if (charLineIdx !== -1) {
233
- if (actorName.trim() === '') {
234
- fmLines.splice(charLineIdx, 1)
235
- } else {
236
- fmLines[charLineIdx] = ` ${cleanChar}: ${actorName}`
237
- }
238
- } else if (actorName.trim() !== '') {
239
- fmLines.splice(insertIdx, 0, ` ${cleanChar}: ${actorName}`)
240
- }
241
- }
242
-
243
- const newLines = [
244
- ...lines.slice(0, fmStartIndex + 1),
245
- ...fmLines,
246
- ...lines.slice(fmEndIndex)
247
- ]
248
- return newLines.join('\n')
249
- }
@@ -1,84 +0,0 @@
1
- export interface SceneEntry {
2
- id: string | null
3
- heading: string
4
- synopsis: string
5
- status: string
6
- tags: string[]
7
- location: string
8
- index: number // 1-based position (internal, used for PM navigation)
9
- textOffset: number // character offset in source text where this scene starts
10
- estDuration?: string
11
- }
12
-
13
- /**
14
- * Derive a flat scene list from Sutra source text.
15
- * Scenes are identified by scene heading lines (## prefix).
16
- */
17
- export function buildSceneList(text: string): SceneEntry[] {
18
- const lines = text.split('\n')
19
- const scenes: SceneEntry[] = []
20
- let sceneIndex = 0
21
- let currentOffset = 0
22
-
23
- for (let i = 0; i < lines.length; i++) {
24
- const line = lines[i] ?? ''
25
- const trimmed = line.trim()
26
-
27
- // Scene headings start with ## (Sutra sigil)
28
- if (trimmed.startsWith('## ') || trimmed.startsWith('##\t')) {
29
- const headingRaw = trimmed.slice(3).trim()
30
- const sceneStart = currentOffset
31
-
32
- // Extract scene id from inline {#...} on the heading line, then strip it
33
- const idMatch = /\{#([^}]+)\}/.exec(headingRaw)
34
- const sceneId: string | null = idMatch?.[1] ?? null
35
- const heading = headingRaw.replace(/\s*\{#[^}]+\}/, '').trim()
36
-
37
- // Collect metadata (& key: value) until next heading
38
- let synopsis = ''
39
- let status = ''
40
- let tags: string[] = []
41
- let location = ''
42
- let estDuration = ''
43
-
44
- let j = i + 1
45
- while (j < lines.length) {
46
- const nextLine = (lines[j] ?? '').trim()
47
- if (nextLine.startsWith('## ') || nextLine.startsWith('##\t')) break
48
- if (nextLine.startsWith('& synopsis:')) {
49
- if (!synopsis) synopsis = nextLine.replace('& synopsis:', '').trim()
50
- } else if (nextLine.startsWith('& status:')) {
51
- status = nextLine.replace('& status:', '').trim()
52
- } else if (nextLine.startsWith('& tags:')) {
53
- tags = nextLine
54
- .replace('& tags:', '')
55
- .trim()
56
- .split(',')
57
- .map((t) => t.trim())
58
- .filter(Boolean)
59
- } else if (nextLine.startsWith('& location:')) {
60
- location = nextLine.replace('& location:', '').trim()
61
- } else if (nextLine.startsWith('& est-duration:')) {
62
- estDuration = nextLine.replace('& est-duration:', '').trim()
63
- }
64
- j++
65
- }
66
-
67
- scenes.push({
68
- id: sceneId,
69
- heading,
70
- synopsis,
71
- status,
72
- tags,
73
- location,
74
- estDuration: estDuration || undefined,
75
- index: ++sceneIndex,
76
- textOffset: sceneStart,
77
- })
78
- }
79
-
80
- currentOffset += (line?.length ?? 0) + 1 // +1 for \n
81
- }
82
-
83
- return scenes
84
- }
@@ -1,303 +0,0 @@
1
- import React, { useEffect, useRef, useState } from 'react'
2
- import { useTranslation } from '../i18n/useTranslation'
3
- import { useDocument } from '../context/DocumentContext'
4
- import {
5
- getAIConfig,
6
- saveAIConfig,
7
- fetchProviders,
8
- getApiKey,
9
- setApiKey,
10
- deleteApiKey,
11
- testConnection,
12
- listModels,
13
- ProviderConfig,
14
- ModelConfig,
15
- AIConfig,
16
- } from '../ai/ai-client'
17
-
18
- const UI_LANGUAGES = [
19
- { code: 'en', label: 'English' },
20
- { code: 'hi', label: 'हिन्दी' },
21
- { code: 'ta', label: 'தமிழ்' },
22
- { code: 'te', label: 'తెలుగు' },
23
- { code: 'ml', label: 'മലയാളം' },
24
- { code: 'kn', label: 'ಕನ್ನಡ' },
25
- { code: 'bn', label: 'বাংলা' },
26
- { code: 'gu', label: 'ગુજરાતી' },
27
- { code: 'mr', label: 'मराठी' },
28
- { code: 'pa', label: 'ਪੰਜਾਬੀ' },
29
- { code: 'or', label: 'ଓଡ଼ିଆ' },
30
- { code: 'si', label: 'සිංහල' },
31
- ]
32
-
33
- interface Props {
34
- onClose: () => void
35
- }
36
-
37
- export function SettingsDialog({ onClose }: Props) {
38
- const { t, locale, setLocale } = useTranslation()
39
- const { setAIOnboardingVisible } = useDocument()
40
- const dialogRef = useRef<HTMLDivElement>(null)
41
-
42
- // AI states
43
- const [aiConfig, setAiConfig] = useState<AIConfig>(getAIConfig())
44
- const [providers, setProviders] = useState<ProviderConfig[]>([])
45
- const [activeKey, setActiveKey] = useState<string>('')
46
- const [showKey, setShowKey] = useState<boolean>(false)
47
- const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
48
- const [testError, setTestError] = useState<string>('')
49
- const [dynamicModels, setDynamicModels] = useState<ModelConfig[] | null>(null)
50
- const [modelsLoading, setModelsLoading] = useState<boolean>(false)
51
-
52
- useEffect(() => {
53
- dialogRef.current?.focus()
54
- void loadProviders()
55
- }, [])
56
-
57
- const loadProviders = async () => {
58
- const p = await fetchProviders(aiConfig)
59
- setProviders(p)
60
- }
61
-
62
- // Loads the stored key for the selected provider, then refreshes its
63
- // model list from the provider's live /models endpoint (falls back to the
64
- // static list on any error — see listModels). Depends on `providers` too
65
- // so it re-runs once fetchProviders() resolves on mount, instead of
66
- // racing it.
67
- useEffect(() => {
68
- let cancelled = false
69
- const run = async () => {
70
- setTestStatus('idle')
71
- setTestError('')
72
- const key = await getApiKey(aiConfig.preferredProvider)
73
- if (cancelled) return
74
- setActiveKey(key || '')
75
- setDynamicModels(null)
76
-
77
- const provider = providers.find(p => p.id === aiConfig.preferredProvider)
78
- if (key && provider) {
79
- setModelsLoading(true)
80
- const models = await listModels(provider, key)
81
- if (!cancelled) {
82
- setDynamicModels(models)
83
- setModelsLoading(false)
84
- }
85
- }
86
- }
87
- void run()
88
- return () => { cancelled = true }
89
- }, [aiConfig.preferredProvider, providers])
90
-
91
- const handleKeyDown = (e: React.KeyboardEvent) => {
92
- if (e.key === 'Escape') onClose()
93
- }
94
-
95
- const handleProviderChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
96
- const nextProvider = e.target.value
97
- const provider = providers.find(p => p.id === nextProvider)
98
- const nextModel = provider?.models[0]?.id || ''
99
-
100
- const updated = {
101
- ...aiConfig,
102
- preferredProvider: nextProvider,
103
- preferredModel: nextModel,
104
- }
105
- setAiConfig(updated)
106
- saveAIConfig(updated)
107
- }
108
-
109
- const handleModelChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
110
- const updated = {
111
- ...aiConfig,
112
- preferredModel: e.target.value,
113
- }
114
- setAiConfig(updated)
115
- saveAIConfig(updated)
116
- }
117
-
118
- const handleKeyChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
119
- const val = e.target.value
120
- setActiveKey(val)
121
- if (val.trim()) {
122
- await setApiKey(aiConfig.preferredProvider, val.trim())
123
- } else {
124
- await deleteApiKey(aiConfig.preferredProvider)
125
- }
126
- setTestStatus('idle')
127
- }
128
-
129
- const handleTestKey = async () => {
130
- if (!activeKey.trim()) {
131
- setTestStatus('error')
132
- setTestError('Please enter an API key to test.')
133
- return
134
- }
135
- setTestStatus('testing')
136
- setTestError('')
137
- try {
138
- const ok = await testConnection(aiConfig.preferredProvider, activeKey.trim(), aiConfig)
139
- if (ok) {
140
- setTestStatus('success')
141
- const provider = providers.find(p => p.id === aiConfig.preferredProvider)
142
- if (provider) {
143
- setModelsLoading(true)
144
- setDynamicModels(await listModels(provider, activeKey.trim()))
145
- setModelsLoading(false)
146
- }
147
- } else {
148
- setTestStatus('error')
149
- setTestError('Connection failed: empty or invalid response.')
150
- }
151
- } catch (err: any) {
152
- setTestStatus('error')
153
- setTestError(err.message || String(err))
154
- }
155
- }
156
-
157
- const currentProvider = providers.find(p => p.id === aiConfig.preferredProvider)
158
- const models = dynamicModels ?? currentProvider?.models ?? []
159
-
160
- return (
161
- <div className="cs-dialog-overlay" onClick={onClose}>
162
- <div
163
- ref={dialogRef}
164
- className="cs-dialog cs-settings-dialog"
165
- role="dialog"
166
- aria-modal="true"
167
- aria-label={t('settings.title')}
168
- tabIndex={-1}
169
- onClick={e => e.stopPropagation()}
170
- onKeyDown={handleKeyDown}
171
- style={{ maxWidth: '480px' }}
172
- >
173
- <div className="cs-dialog-header">
174
- <span className="cs-dialog-title">{t('settings.title')}</span>
175
- <button className="cs-fr-close" aria-label={t('settings.close')} onClick={onClose}>×</button>
176
- </div>
177
-
178
- <div className="cs-settings-body" style={{ maxHeight: '80vh', overflowY: 'auto' }}>
179
- <div className="cs-settings-section-label">{t('settings.general')}</div>
180
-
181
- <div className="cs-settings-row">
182
- <label className="cs-settings-label" htmlFor="cs-ui-lang-select">
183
- {t('settings.uiLanguage')}
184
- </label>
185
- <select
186
- id="cs-ui-lang-select"
187
- className="cs-settings-select"
188
- value={locale}
189
- onChange={e => setLocale(e.target.value)}
190
- >
191
- {UI_LANGUAGES.map(lang => (
192
- <option key={lang.code} value={lang.code}>{lang.label}</option>
193
- ))}
194
- </select>
195
- </div>
196
-
197
- <div style={{ margin: '14px 0 6px 0', borderTop: '1px solid var(--cs-ui-border-light, #e8e8e4)', paddingTop: '14px' }} className="cs-settings-section-label">
198
- Intelligence &amp; Voice Settings
199
- </div>
200
-
201
- <div className="cs-settings-row">
202
- <label className="cs-settings-label" htmlFor="cs-ai-provider-select">
203
- Preferred Provider
204
- </label>
205
- <select
206
- id="cs-ai-provider-select"
207
- className="cs-settings-select"
208
- value={aiConfig.preferredProvider}
209
- onChange={handleProviderChange}
210
- >
211
- {providers.map(p => (
212
- <option key={p.id} value={p.id}>{p.name}</option>
213
- ))}
214
- </select>
215
- </div>
216
-
217
- <div className="cs-settings-row">
218
- <label className="cs-settings-label" htmlFor="cs-ai-model-select">
219
- Preferred Model{modelsLoading ? ' (refreshing...)' : ''}
220
- </label>
221
- <select
222
- id="cs-ai-model-select"
223
- className="cs-settings-select"
224
- value={aiConfig.preferredModel}
225
- onChange={handleModelChange}
226
- disabled={models.length === 0}
227
- >
228
- {models.map(m => (
229
- <option key={m.id} value={m.id}>{m.name}</option>
230
- ))}
231
- </select>
232
- </div>
233
-
234
- <div className="cs-settings-row" style={{ flexDirection: 'column', alignItems: 'stretch', gap: '6px' }}>
235
- <div style={{ display: 'flex', justifyContent: 'space-between', width: '100%' }}>
236
- <label className="cs-settings-label" htmlFor="cs-ai-key-input">
237
- API Key for {currentProvider?.name || aiConfig.preferredProvider}
238
- </label>
239
- <button
240
- type="button"
241
- style={{ background: 'none', border: 'none', color: 'var(--cs-ui-accent, #c4760a)', cursor: 'pointer', fontSize: '11px', padding: 0 }}
242
- onClick={() => setShowKey(!showKey)}
243
- >
244
- {showKey ? 'Hide' : 'Show'}
245
- </button>
246
- </div>
247
- <input
248
- id="cs-ai-key-input"
249
- type={showKey ? 'text' : 'password'}
250
- style={{
251
- width: '100%',
252
- padding: '7px 10px',
253
- border: '1px solid var(--cs-ui-border-light, #ccc)',
254
- borderRadius: '4px',
255
- boxSizing: 'border-box',
256
- fontFamily: 'monospace',
257
- fontSize: '12px',
258
- }}
259
- value={activeKey}
260
- onChange={handleKeyChange}
261
- placeholder={`Enter API Key for ${aiConfig.preferredProvider}`}
262
- />
263
- <div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
264
- <button
265
- type="button"
266
- className="cs-voice-btn-secondary"
267
- style={{ padding: '4px 10px', fontSize: '12px' }}
268
- onClick={handleTestKey}
269
- disabled={testStatus === 'testing'}
270
- >
271
- {testStatus === 'testing' ? 'Testing...' : 'Test Connection'}
272
- </button>
273
- </div>
274
- {testStatus === 'success' && (
275
- <span style={{ color: '#10b981', fontSize: '12px', marginTop: '2px' }}>
276
- ✓ Key verified! Connection succeeded.
277
- </span>
278
- )}
279
- {testStatus === 'error' && (
280
- <span style={{ color: '#ef4444', fontSize: '12px', marginTop: '2px', whiteSpace: 'pre-wrap' }}>
281
- ✗ Error: {testError}
282
- </span>
283
- )}
284
- </div>
285
-
286
- <div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-start' }}>
287
- <button
288
- type="button"
289
- className="cs-voice-btn-primary"
290
- style={{ padding: '6px 12px', fontSize: '12px' }}
291
- onClick={() => {
292
- onClose()
293
- setAIOnboardingVisible(true)
294
- }}
295
- >
296
- Open Setup Wizard
297
- </button>
298
- </div>
299
- </div>
300
- </div>
301
- </div>
302
- )
303
- }