@jonsoc/app 1.1.34
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/AGENTS.md +30 -0
- package/README.md +51 -0
- package/bunfig.toml +2 -0
- package/e2e/context.spec.ts +45 -0
- package/e2e/file-open.spec.ts +23 -0
- package/e2e/file-viewer.spec.ts +35 -0
- package/e2e/fixtures.ts +40 -0
- package/e2e/home.spec.ts +21 -0
- package/e2e/model-picker.spec.ts +43 -0
- package/e2e/navigation.spec.ts +9 -0
- package/e2e/palette.spec.ts +15 -0
- package/e2e/prompt-mention.spec.ts +26 -0
- package/e2e/prompt-slash-open.spec.ts +22 -0
- package/e2e/prompt.spec.ts +62 -0
- package/e2e/session.spec.ts +21 -0
- package/e2e/settings.spec.ts +44 -0
- package/e2e/sidebar.spec.ts +21 -0
- package/e2e/terminal-init.spec.ts +25 -0
- package/e2e/terminal.spec.ts +16 -0
- package/e2e/tsconfig.json +8 -0
- package/e2e/utils.ts +38 -0
- package/happydom.ts +75 -0
- package/index.html +23 -0
- package/package.json +72 -0
- package/playwright.config.ts +43 -0
- package/public/_headers +17 -0
- package/public/apple-touch-icon-v3.png +1 -0
- package/public/apple-touch-icon.png +1 -0
- package/public/favicon-96x96-v3.png +1 -0
- package/public/favicon-96x96.png +1 -0
- package/public/favicon-v3.ico +1 -0
- package/public/favicon-v3.svg +1 -0
- package/public/favicon.ico +1 -0
- package/public/favicon.svg +1 -0
- package/public/oc-theme-preload.js +28 -0
- package/public/site.webmanifest +1 -0
- package/public/social-share-zen.png +1 -0
- package/public/social-share.png +1 -0
- package/public/web-app-manifest-192x192.png +1 -0
- package/public/web-app-manifest-512x512.png +1 -0
- package/script/e2e-local.ts +143 -0
- package/src/addons/serialize.test.ts +319 -0
- package/src/addons/serialize.ts +591 -0
- package/src/app.tsx +150 -0
- package/src/components/dialog-connect-provider.tsx +428 -0
- package/src/components/dialog-edit-project.tsx +259 -0
- package/src/components/dialog-fork.tsx +104 -0
- package/src/components/dialog-manage-models.tsx +59 -0
- package/src/components/dialog-select-directory.tsx +208 -0
- package/src/components/dialog-select-file.tsx +196 -0
- package/src/components/dialog-select-mcp.tsx +96 -0
- package/src/components/dialog-select-model-unpaid.tsx +130 -0
- package/src/components/dialog-select-model.tsx +162 -0
- package/src/components/dialog-select-provider.tsx +70 -0
- package/src/components/dialog-select-server.tsx +249 -0
- package/src/components/dialog-settings.tsx +112 -0
- package/src/components/file-tree.tsx +112 -0
- package/src/components/link.tsx +17 -0
- package/src/components/model-tooltip.tsx +91 -0
- package/src/components/prompt-input.tsx +2076 -0
- package/src/components/session/index.ts +5 -0
- package/src/components/session/session-context-tab.tsx +428 -0
- package/src/components/session/session-header.tsx +343 -0
- package/src/components/session/session-new-view.tsx +93 -0
- package/src/components/session/session-sortable-tab.tsx +56 -0
- package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
- package/src/components/session-context-usage.tsx +113 -0
- package/src/components/session-lsp-indicator.tsx +42 -0
- package/src/components/session-mcp-indicator.tsx +34 -0
- package/src/components/settings-agents.tsx +15 -0
- package/src/components/settings-commands.tsx +15 -0
- package/src/components/settings-general.tsx +306 -0
- package/src/components/settings-keybinds.tsx +437 -0
- package/src/components/settings-mcp.tsx +15 -0
- package/src/components/settings-models.tsx +15 -0
- package/src/components/settings-permissions.tsx +234 -0
- package/src/components/settings-providers.tsx +15 -0
- package/src/components/terminal.tsx +315 -0
- package/src/components/titlebar.tsx +156 -0
- package/src/context/command.tsx +308 -0
- package/src/context/comments.tsx +140 -0
- package/src/context/file.tsx +409 -0
- package/src/context/global-sdk.tsx +106 -0
- package/src/context/global-sync.tsx +898 -0
- package/src/context/language.tsx +161 -0
- package/src/context/layout-scroll.test.ts +73 -0
- package/src/context/layout-scroll.ts +118 -0
- package/src/context/layout.tsx +648 -0
- package/src/context/local.tsx +578 -0
- package/src/context/notification.tsx +173 -0
- package/src/context/permission.tsx +167 -0
- package/src/context/platform.tsx +59 -0
- package/src/context/prompt.tsx +245 -0
- package/src/context/sdk.tsx +48 -0
- package/src/context/server.tsx +214 -0
- package/src/context/settings.tsx +166 -0
- package/src/context/sync.tsx +320 -0
- package/src/context/terminal.tsx +267 -0
- package/src/custom-elements.d.ts +17 -0
- package/src/entry.tsx +76 -0
- package/src/env.d.ts +8 -0
- package/src/hooks/use-providers.ts +31 -0
- package/src/i18n/ar.ts +656 -0
- package/src/i18n/br.ts +667 -0
- package/src/i18n/da.ts +582 -0
- package/src/i18n/de.ts +591 -0
- package/src/i18n/en.ts +665 -0
- package/src/i18n/es.ts +585 -0
- package/src/i18n/fr.ts +592 -0
- package/src/i18n/ja.ts +579 -0
- package/src/i18n/ko.ts +580 -0
- package/src/i18n/no.ts +602 -0
- package/src/i18n/pl.ts +661 -0
- package/src/i18n/ru.ts +664 -0
- package/src/i18n/zh.ts +574 -0
- package/src/i18n/zht.ts +570 -0
- package/src/index.css +57 -0
- package/src/index.ts +2 -0
- package/src/pages/directory-layout.tsx +57 -0
- package/src/pages/error.tsx +290 -0
- package/src/pages/home.tsx +125 -0
- package/src/pages/layout.tsx +2599 -0
- package/src/pages/session.tsx +2505 -0
- package/src/sst-env.d.ts +10 -0
- package/src/utils/dom.ts +51 -0
- package/src/utils/id.ts +99 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/perf.ts +135 -0
- package/src/utils/persist.ts +377 -0
- package/src/utils/prompt.ts +203 -0
- package/src/utils/same.ts +6 -0
- package/src/utils/solid-dnd.tsx +55 -0
- package/src/utils/sound.ts +110 -0
- package/src/utils/speech.ts +302 -0
- package/src/utils/worktree.ts +58 -0
- package/sst-env.d.ts +9 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +15 -0
- package/vite.js +26 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@jonsoc/sdk/v2"
|
|
2
|
+
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
|
3
|
+
|
|
4
|
+
type Inline =
|
|
5
|
+
| {
|
|
6
|
+
type: "file"
|
|
7
|
+
start: number
|
|
8
|
+
end: number
|
|
9
|
+
value: string
|
|
10
|
+
path: string
|
|
11
|
+
selection?: {
|
|
12
|
+
startLine: number
|
|
13
|
+
endLine: number
|
|
14
|
+
startChar: number
|
|
15
|
+
endChar: number
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
type: "agent"
|
|
20
|
+
start: number
|
|
21
|
+
end: number
|
|
22
|
+
value: string
|
|
23
|
+
name: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
|
|
27
|
+
const queryIndex = url.indexOf("?")
|
|
28
|
+
if (queryIndex === -1) return undefined
|
|
29
|
+
const params = new URLSearchParams(url.slice(queryIndex + 1))
|
|
30
|
+
const startLine = Number(params.get("start"))
|
|
31
|
+
const endLine = Number(params.get("end"))
|
|
32
|
+
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
|
|
33
|
+
return {
|
|
34
|
+
startLine,
|
|
35
|
+
endLine,
|
|
36
|
+
startChar: 0,
|
|
37
|
+
endChar: 0,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function textPartValue(parts: Part[]) {
|
|
42
|
+
const candidates = parts
|
|
43
|
+
.filter((part): part is TextPart => part.type === "text")
|
|
44
|
+
.filter((part) => !part.synthetic && !part.ignored)
|
|
45
|
+
return candidates.reduce((best: TextPart | undefined, part) => {
|
|
46
|
+
if (!best) return part
|
|
47
|
+
if (part.text.length > best.text.length) return part
|
|
48
|
+
return best
|
|
49
|
+
}, undefined)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract prompt content from message parts for restoring into the prompt input.
|
|
54
|
+
* This is used by undo to restore the original user prompt.
|
|
55
|
+
*/
|
|
56
|
+
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string; attachmentName?: string }): Prompt {
|
|
57
|
+
const textPart = textPartValue(parts)
|
|
58
|
+
const text = textPart?.text ?? ""
|
|
59
|
+
const directory = opts?.directory
|
|
60
|
+
const attachmentName = opts?.attachmentName ?? "attachment"
|
|
61
|
+
|
|
62
|
+
const toRelative = (path: string) => {
|
|
63
|
+
if (!directory) return path
|
|
64
|
+
|
|
65
|
+
const prefix = directory.endsWith("/") ? directory : directory + "/"
|
|
66
|
+
if (path.startsWith(prefix)) return path.slice(prefix.length)
|
|
67
|
+
|
|
68
|
+
if (path.startsWith(directory)) {
|
|
69
|
+
const next = path.slice(directory.length)
|
|
70
|
+
if (next.startsWith("/")) return next.slice(1)
|
|
71
|
+
return next
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return path
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const inline: Inline[] = []
|
|
78
|
+
const images: ImageAttachmentPart[] = []
|
|
79
|
+
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
if (part.type === "file") {
|
|
82
|
+
const filePart = part as FilePart
|
|
83
|
+
const sourceText = filePart.source?.text
|
|
84
|
+
if (sourceText) {
|
|
85
|
+
const value = sourceText.value
|
|
86
|
+
const start = sourceText.start
|
|
87
|
+
const end = sourceText.end
|
|
88
|
+
let path = value
|
|
89
|
+
if (value.startsWith("@")) path = value.slice(1)
|
|
90
|
+
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
|
|
91
|
+
path = filePart.source.path
|
|
92
|
+
}
|
|
93
|
+
inline.push({
|
|
94
|
+
type: "file",
|
|
95
|
+
start,
|
|
96
|
+
end,
|
|
97
|
+
value,
|
|
98
|
+
path: toRelative(path),
|
|
99
|
+
selection: selectionFromFileUrl(filePart.url),
|
|
100
|
+
})
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (filePart.url.startsWith("data:")) {
|
|
105
|
+
images.push({
|
|
106
|
+
type: "image",
|
|
107
|
+
id: filePart.id,
|
|
108
|
+
filename: filePart.filename ?? attachmentName,
|
|
109
|
+
mime: filePart.mime,
|
|
110
|
+
dataUrl: filePart.url,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (part.type === "agent") {
|
|
116
|
+
const agentPart = part as MessageAgentPart
|
|
117
|
+
const source = agentPart.source
|
|
118
|
+
if (!source) continue
|
|
119
|
+
inline.push({
|
|
120
|
+
type: "agent",
|
|
121
|
+
start: source.start,
|
|
122
|
+
end: source.end,
|
|
123
|
+
value: source.value,
|
|
124
|
+
name: agentPart.name,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
inline.sort((a, b) => {
|
|
130
|
+
if (a.start !== b.start) return a.start - b.start
|
|
131
|
+
return a.end - b.end
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const result: Prompt = []
|
|
135
|
+
let position = 0
|
|
136
|
+
let cursor = 0
|
|
137
|
+
|
|
138
|
+
const pushText = (content: string) => {
|
|
139
|
+
if (!content) return
|
|
140
|
+
result.push({
|
|
141
|
+
type: "text",
|
|
142
|
+
content,
|
|
143
|
+
start: position,
|
|
144
|
+
end: position + content.length,
|
|
145
|
+
})
|
|
146
|
+
position += content.length
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
|
|
150
|
+
const content = item.value
|
|
151
|
+
const attachment: FileAttachmentPart = {
|
|
152
|
+
type: "file",
|
|
153
|
+
path: item.path,
|
|
154
|
+
content,
|
|
155
|
+
start: position,
|
|
156
|
+
end: position + content.length,
|
|
157
|
+
selection: item.selection,
|
|
158
|
+
}
|
|
159
|
+
result.push(attachment)
|
|
160
|
+
position += content.length
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
|
|
164
|
+
const content = item.value
|
|
165
|
+
const mention: AgentPart = {
|
|
166
|
+
type: "agent",
|
|
167
|
+
name: item.name,
|
|
168
|
+
content,
|
|
169
|
+
start: position,
|
|
170
|
+
end: position + content.length,
|
|
171
|
+
}
|
|
172
|
+
result.push(mention)
|
|
173
|
+
position += content.length
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const item of inline) {
|
|
177
|
+
if (item.start < 0 || item.end < item.start) continue
|
|
178
|
+
|
|
179
|
+
const expected = item.value
|
|
180
|
+
if (!expected) continue
|
|
181
|
+
|
|
182
|
+
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
|
|
183
|
+
const start = mismatch ? text.indexOf(expected, cursor) : item.start
|
|
184
|
+
if (start === -1) continue
|
|
185
|
+
const end = mismatch ? start + expected.length : item.end
|
|
186
|
+
|
|
187
|
+
pushText(text.slice(cursor, start))
|
|
188
|
+
|
|
189
|
+
if (item.type === "file") pushFile(item)
|
|
190
|
+
if (item.type === "agent") pushAgent(item)
|
|
191
|
+
|
|
192
|
+
cursor = end
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
pushText(text.slice(cursor))
|
|
196
|
+
|
|
197
|
+
if (result.length === 0) {
|
|
198
|
+
result.push({ type: "text", content: "", start: 0, end: 0 })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (images.length === 0) return result
|
|
202
|
+
return [...result, ...images]
|
|
203
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useDragDropContext } from "@thisbeyond/solid-dnd"
|
|
2
|
+
import { JSXElement } from "solid-js"
|
|
3
|
+
import type { Transformer } from "@thisbeyond/solid-dnd"
|
|
4
|
+
|
|
5
|
+
export const getDraggableId = (event: unknown): string | undefined => {
|
|
6
|
+
if (typeof event !== "object" || event === null) return undefined
|
|
7
|
+
if (!("draggable" in event)) return undefined
|
|
8
|
+
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
|
9
|
+
if (!draggable) return undefined
|
|
10
|
+
return typeof draggable.id === "string" ? draggable.id : undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ConstrainDragXAxis = (): JSXElement => {
|
|
14
|
+
const context = useDragDropContext()
|
|
15
|
+
if (!context) return <></>
|
|
16
|
+
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
|
17
|
+
const transformer: Transformer = {
|
|
18
|
+
id: "constrain-x-axis",
|
|
19
|
+
order: 100,
|
|
20
|
+
callback: (transform) => ({ ...transform, x: 0 }),
|
|
21
|
+
}
|
|
22
|
+
onDragStart((event) => {
|
|
23
|
+
const id = getDraggableId(event)
|
|
24
|
+
if (!id) return
|
|
25
|
+
addTransformer("draggables", id, transformer)
|
|
26
|
+
})
|
|
27
|
+
onDragEnd((event) => {
|
|
28
|
+
const id = getDraggableId(event)
|
|
29
|
+
if (!id) return
|
|
30
|
+
removeTransformer("draggables", id, transformer.id)
|
|
31
|
+
})
|
|
32
|
+
return <></>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const ConstrainDragYAxis = (): JSXElement => {
|
|
36
|
+
const context = useDragDropContext()
|
|
37
|
+
if (!context) return <></>
|
|
38
|
+
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
|
39
|
+
const transformer: Transformer = {
|
|
40
|
+
id: "constrain-y-axis",
|
|
41
|
+
order: 100,
|
|
42
|
+
callback: (transform) => ({ ...transform, y: 0 }),
|
|
43
|
+
}
|
|
44
|
+
onDragStart((event) => {
|
|
45
|
+
const id = getDraggableId(event)
|
|
46
|
+
if (!id) return
|
|
47
|
+
addTransformer("draggables", id, transformer)
|
|
48
|
+
})
|
|
49
|
+
onDragEnd((event) => {
|
|
50
|
+
const id = getDraggableId(event)
|
|
51
|
+
if (!id) return
|
|
52
|
+
removeTransformer("draggables", id, transformer.id)
|
|
53
|
+
})
|
|
54
|
+
return <></>
|
|
55
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import alert01 from "@jonsoc/ui/audio/alert-01.aac"
|
|
2
|
+
import alert02 from "@jonsoc/ui/audio/alert-02.aac"
|
|
3
|
+
import alert03 from "@jonsoc/ui/audio/alert-03.aac"
|
|
4
|
+
import alert04 from "@jonsoc/ui/audio/alert-04.aac"
|
|
5
|
+
import alert05 from "@jonsoc/ui/audio/alert-05.aac"
|
|
6
|
+
import alert06 from "@jonsoc/ui/audio/alert-06.aac"
|
|
7
|
+
import alert07 from "@jonsoc/ui/audio/alert-07.aac"
|
|
8
|
+
import alert08 from "@jonsoc/ui/audio/alert-08.aac"
|
|
9
|
+
import alert09 from "@jonsoc/ui/audio/alert-09.aac"
|
|
10
|
+
import alert10 from "@jonsoc/ui/audio/alert-10.aac"
|
|
11
|
+
import bipbop01 from "@jonsoc/ui/audio/bip-bop-01.aac"
|
|
12
|
+
import bipbop02 from "@jonsoc/ui/audio/bip-bop-02.aac"
|
|
13
|
+
import bipbop03 from "@jonsoc/ui/audio/bip-bop-03.aac"
|
|
14
|
+
import bipbop04 from "@jonsoc/ui/audio/bip-bop-04.aac"
|
|
15
|
+
import bipbop05 from "@jonsoc/ui/audio/bip-bop-05.aac"
|
|
16
|
+
import bipbop06 from "@jonsoc/ui/audio/bip-bop-06.aac"
|
|
17
|
+
import bipbop07 from "@jonsoc/ui/audio/bip-bop-07.aac"
|
|
18
|
+
import bipbop08 from "@jonsoc/ui/audio/bip-bop-08.aac"
|
|
19
|
+
import bipbop09 from "@jonsoc/ui/audio/bip-bop-09.aac"
|
|
20
|
+
import bipbop10 from "@jonsoc/ui/audio/bip-bop-10.aac"
|
|
21
|
+
import nope01 from "@jonsoc/ui/audio/nope-01.aac"
|
|
22
|
+
import nope02 from "@jonsoc/ui/audio/nope-02.aac"
|
|
23
|
+
import nope03 from "@jonsoc/ui/audio/nope-03.aac"
|
|
24
|
+
import nope04 from "@jonsoc/ui/audio/nope-04.aac"
|
|
25
|
+
import nope05 from "@jonsoc/ui/audio/nope-05.aac"
|
|
26
|
+
import nope06 from "@jonsoc/ui/audio/nope-06.aac"
|
|
27
|
+
import nope07 from "@jonsoc/ui/audio/nope-07.aac"
|
|
28
|
+
import nope08 from "@jonsoc/ui/audio/nope-08.aac"
|
|
29
|
+
import nope09 from "@jonsoc/ui/audio/nope-09.aac"
|
|
30
|
+
import nope10 from "@jonsoc/ui/audio/nope-10.aac"
|
|
31
|
+
import nope11 from "@jonsoc/ui/audio/nope-11.aac"
|
|
32
|
+
import nope12 from "@jonsoc/ui/audio/nope-12.aac"
|
|
33
|
+
import staplebops01 from "@jonsoc/ui/audio/staplebops-01.aac"
|
|
34
|
+
import staplebops02 from "@jonsoc/ui/audio/staplebops-02.aac"
|
|
35
|
+
import staplebops03 from "@jonsoc/ui/audio/staplebops-03.aac"
|
|
36
|
+
import staplebops04 from "@jonsoc/ui/audio/staplebops-04.aac"
|
|
37
|
+
import staplebops05 from "@jonsoc/ui/audio/staplebops-05.aac"
|
|
38
|
+
import staplebops06 from "@jonsoc/ui/audio/staplebops-06.aac"
|
|
39
|
+
import staplebops07 from "@jonsoc/ui/audio/staplebops-07.aac"
|
|
40
|
+
import yup01 from "@jonsoc/ui/audio/yup-01.aac"
|
|
41
|
+
import yup02 from "@jonsoc/ui/audio/yup-02.aac"
|
|
42
|
+
import yup03 from "@jonsoc/ui/audio/yup-03.aac"
|
|
43
|
+
import yup04 from "@jonsoc/ui/audio/yup-04.aac"
|
|
44
|
+
import yup05 from "@jonsoc/ui/audio/yup-05.aac"
|
|
45
|
+
import yup06 from "@jonsoc/ui/audio/yup-06.aac"
|
|
46
|
+
|
|
47
|
+
export const SOUND_OPTIONS = [
|
|
48
|
+
{ id: "alert-01", label: "sound.option.alert01", src: alert01 },
|
|
49
|
+
{ id: "alert-02", label: "sound.option.alert02", src: alert02 },
|
|
50
|
+
{ id: "alert-03", label: "sound.option.alert03", src: alert03 },
|
|
51
|
+
{ id: "alert-04", label: "sound.option.alert04", src: alert04 },
|
|
52
|
+
{ id: "alert-05", label: "sound.option.alert05", src: alert05 },
|
|
53
|
+
{ id: "alert-06", label: "sound.option.alert06", src: alert06 },
|
|
54
|
+
{ id: "alert-07", label: "sound.option.alert07", src: alert07 },
|
|
55
|
+
{ id: "alert-08", label: "sound.option.alert08", src: alert08 },
|
|
56
|
+
{ id: "alert-09", label: "sound.option.alert09", src: alert09 },
|
|
57
|
+
{ id: "alert-10", label: "sound.option.alert10", src: alert10 },
|
|
58
|
+
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 },
|
|
59
|
+
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 },
|
|
60
|
+
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 },
|
|
61
|
+
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 },
|
|
62
|
+
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 },
|
|
63
|
+
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 },
|
|
64
|
+
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 },
|
|
65
|
+
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 },
|
|
66
|
+
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 },
|
|
67
|
+
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 },
|
|
68
|
+
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 },
|
|
69
|
+
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 },
|
|
70
|
+
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 },
|
|
71
|
+
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 },
|
|
72
|
+
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 },
|
|
73
|
+
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 },
|
|
74
|
+
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 },
|
|
75
|
+
{ id: "nope-01", label: "sound.option.nope01", src: nope01 },
|
|
76
|
+
{ id: "nope-02", label: "sound.option.nope02", src: nope02 },
|
|
77
|
+
{ id: "nope-03", label: "sound.option.nope03", src: nope03 },
|
|
78
|
+
{ id: "nope-04", label: "sound.option.nope04", src: nope04 },
|
|
79
|
+
{ id: "nope-05", label: "sound.option.nope05", src: nope05 },
|
|
80
|
+
{ id: "nope-06", label: "sound.option.nope06", src: nope06 },
|
|
81
|
+
{ id: "nope-07", label: "sound.option.nope07", src: nope07 },
|
|
82
|
+
{ id: "nope-08", label: "sound.option.nope08", src: nope08 },
|
|
83
|
+
{ id: "nope-09", label: "sound.option.nope09", src: nope09 },
|
|
84
|
+
{ id: "nope-10", label: "sound.option.nope10", src: nope10 },
|
|
85
|
+
{ id: "nope-11", label: "sound.option.nope11", src: nope11 },
|
|
86
|
+
{ id: "nope-12", label: "sound.option.nope12", src: nope12 },
|
|
87
|
+
{ id: "yup-01", label: "sound.option.yup01", src: yup01 },
|
|
88
|
+
{ id: "yup-02", label: "sound.option.yup02", src: yup02 },
|
|
89
|
+
{ id: "yup-03", label: "sound.option.yup03", src: yup03 },
|
|
90
|
+
{ id: "yup-04", label: "sound.option.yup04", src: yup04 },
|
|
91
|
+
{ id: "yup-05", label: "sound.option.yup05", src: yup05 },
|
|
92
|
+
{ id: "yup-06", label: "sound.option.yup06", src: yup06 },
|
|
93
|
+
] as const
|
|
94
|
+
|
|
95
|
+
export type SoundOption = (typeof SOUND_OPTIONS)[number]
|
|
96
|
+
export type SoundID = SoundOption["id"]
|
|
97
|
+
|
|
98
|
+
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
|
|
99
|
+
|
|
100
|
+
export function soundSrc(id: string | undefined) {
|
|
101
|
+
if (!id) return
|
|
102
|
+
if (!(id in soundById)) return
|
|
103
|
+
return soundById[id as SoundID]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function playSound(src: string | undefined) {
|
|
107
|
+
if (typeof Audio === "undefined") return
|
|
108
|
+
if (!src) return
|
|
109
|
+
void new Audio(src).play().catch(() => undefined)
|
|
110
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { createSignal, onCleanup } from "solid-js"
|
|
2
|
+
|
|
3
|
+
// Minimal types to avoid relying on non-standard DOM typings
|
|
4
|
+
type RecognitionResult = {
|
|
5
|
+
0: { transcript: string }
|
|
6
|
+
isFinal: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type RecognitionEvent = {
|
|
10
|
+
results: RecognitionResult[]
|
|
11
|
+
resultIndex: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Recognition {
|
|
15
|
+
continuous: boolean
|
|
16
|
+
interimResults: boolean
|
|
17
|
+
lang: string
|
|
18
|
+
start: () => void
|
|
19
|
+
stop: () => void
|
|
20
|
+
onresult: ((e: RecognitionEvent) => void) | null
|
|
21
|
+
onerror: ((e: { error: string }) => void) | null
|
|
22
|
+
onend: (() => void) | null
|
|
23
|
+
onstart: (() => void) | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const COMMIT_DELAY = 250
|
|
27
|
+
|
|
28
|
+
const appendSegment = (base: string, addition: string) => {
|
|
29
|
+
const trimmed = addition.trim()
|
|
30
|
+
if (!trimmed) return base
|
|
31
|
+
if (!base) return trimmed
|
|
32
|
+
const needsSpace = /\S$/.test(base) && !/^[,.;!?]/.test(trimmed)
|
|
33
|
+
return `${base}${needsSpace ? " " : ""}${trimmed}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const extractSuffix = (committed: string, hypothesis: string) => {
|
|
37
|
+
const cleanHypothesis = hypothesis.trim()
|
|
38
|
+
if (!cleanHypothesis) return ""
|
|
39
|
+
const baseTokens = committed.trim() ? committed.trim().split(/\s+/) : []
|
|
40
|
+
const hypothesisTokens = cleanHypothesis.split(/\s+/)
|
|
41
|
+
let index = 0
|
|
42
|
+
while (
|
|
43
|
+
index < baseTokens.length &&
|
|
44
|
+
index < hypothesisTokens.length &&
|
|
45
|
+
baseTokens[index] === hypothesisTokens[index]
|
|
46
|
+
) {
|
|
47
|
+
index += 1
|
|
48
|
+
}
|
|
49
|
+
if (index < baseTokens.length) return ""
|
|
50
|
+
return hypothesisTokens.slice(index).join(" ")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createSpeechRecognition(opts?: {
|
|
54
|
+
lang?: string
|
|
55
|
+
onFinal?: (text: string) => void
|
|
56
|
+
onInterim?: (text: string) => void
|
|
57
|
+
}) {
|
|
58
|
+
const hasSupport =
|
|
59
|
+
typeof window !== "undefined" &&
|
|
60
|
+
Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition)
|
|
61
|
+
|
|
62
|
+
const [isRecording, setIsRecording] = createSignal(false)
|
|
63
|
+
const [committed, setCommitted] = createSignal("")
|
|
64
|
+
const [interim, setInterim] = createSignal("")
|
|
65
|
+
|
|
66
|
+
let recognition: Recognition | undefined
|
|
67
|
+
let shouldContinue = false
|
|
68
|
+
let committedText = ""
|
|
69
|
+
let sessionCommitted = ""
|
|
70
|
+
let pendingHypothesis = ""
|
|
71
|
+
let lastInterimSuffix = ""
|
|
72
|
+
let shrinkCandidate: string | undefined
|
|
73
|
+
let commitTimer: number | undefined
|
|
74
|
+
|
|
75
|
+
const cancelPendingCommit = () => {
|
|
76
|
+
if (commitTimer === undefined) return
|
|
77
|
+
clearTimeout(commitTimer)
|
|
78
|
+
commitTimer = undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const commitSegment = (segment: string) => {
|
|
82
|
+
const nextCommitted = appendSegment(committedText, segment)
|
|
83
|
+
if (nextCommitted === committedText) return
|
|
84
|
+
committedText = nextCommitted
|
|
85
|
+
setCommitted(committedText)
|
|
86
|
+
if (opts?.onFinal) opts.onFinal(segment.trim())
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const promotePending = () => {
|
|
90
|
+
if (!pendingHypothesis) return
|
|
91
|
+
const suffix = extractSuffix(sessionCommitted, pendingHypothesis)
|
|
92
|
+
if (!suffix) {
|
|
93
|
+
pendingHypothesis = ""
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
sessionCommitted = appendSegment(sessionCommitted, suffix)
|
|
97
|
+
commitSegment(suffix)
|
|
98
|
+
pendingHypothesis = ""
|
|
99
|
+
lastInterimSuffix = ""
|
|
100
|
+
shrinkCandidate = undefined
|
|
101
|
+
setInterim("")
|
|
102
|
+
if (opts?.onInterim) opts.onInterim("")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const applyInterim = (suffix: string, hypothesis: string) => {
|
|
106
|
+
cancelPendingCommit()
|
|
107
|
+
pendingHypothesis = hypothesis
|
|
108
|
+
lastInterimSuffix = suffix
|
|
109
|
+
shrinkCandidate = undefined
|
|
110
|
+
setInterim(suffix)
|
|
111
|
+
if (opts?.onInterim) {
|
|
112
|
+
opts.onInterim(suffix ? appendSegment(committedText, suffix) : "")
|
|
113
|
+
}
|
|
114
|
+
if (!suffix) return
|
|
115
|
+
const snapshot = hypothesis
|
|
116
|
+
commitTimer = window.setTimeout(() => {
|
|
117
|
+
if (pendingHypothesis !== snapshot) return
|
|
118
|
+
const currentSuffix = extractSuffix(sessionCommitted, pendingHypothesis)
|
|
119
|
+
if (!currentSuffix) return
|
|
120
|
+
sessionCommitted = appendSegment(sessionCommitted, currentSuffix)
|
|
121
|
+
commitSegment(currentSuffix)
|
|
122
|
+
pendingHypothesis = ""
|
|
123
|
+
lastInterimSuffix = ""
|
|
124
|
+
shrinkCandidate = undefined
|
|
125
|
+
setInterim("")
|
|
126
|
+
if (opts?.onInterim) opts.onInterim("")
|
|
127
|
+
}, COMMIT_DELAY)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (hasSupport) {
|
|
131
|
+
const Ctor: new () => Recognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition
|
|
132
|
+
|
|
133
|
+
recognition = new Ctor()
|
|
134
|
+
recognition.continuous = false
|
|
135
|
+
recognition.interimResults = true
|
|
136
|
+
recognition.lang = opts?.lang || (typeof navigator !== "undefined" ? navigator.language : "en-US")
|
|
137
|
+
|
|
138
|
+
recognition.onresult = (event: RecognitionEvent) => {
|
|
139
|
+
if (!event.results.length) return
|
|
140
|
+
|
|
141
|
+
let aggregatedFinal = ""
|
|
142
|
+
let latestHypothesis = ""
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < event.results.length; i += 1) {
|
|
145
|
+
const result = event.results[i]
|
|
146
|
+
const transcript = (result[0]?.transcript || "").trim()
|
|
147
|
+
if (!transcript) continue
|
|
148
|
+
if (result.isFinal) {
|
|
149
|
+
aggregatedFinal = appendSegment(aggregatedFinal, transcript)
|
|
150
|
+
} else {
|
|
151
|
+
latestHypothesis = transcript
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (aggregatedFinal) {
|
|
156
|
+
cancelPendingCommit()
|
|
157
|
+
const finalSuffix = extractSuffix(sessionCommitted, aggregatedFinal)
|
|
158
|
+
if (finalSuffix) {
|
|
159
|
+
sessionCommitted = appendSegment(sessionCommitted, finalSuffix)
|
|
160
|
+
commitSegment(finalSuffix)
|
|
161
|
+
}
|
|
162
|
+
pendingHypothesis = ""
|
|
163
|
+
lastInterimSuffix = ""
|
|
164
|
+
shrinkCandidate = undefined
|
|
165
|
+
setInterim("")
|
|
166
|
+
if (opts?.onInterim) opts.onInterim("")
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
cancelPendingCommit()
|
|
171
|
+
|
|
172
|
+
if (!latestHypothesis) {
|
|
173
|
+
shrinkCandidate = undefined
|
|
174
|
+
applyInterim("", "")
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const suffix = extractSuffix(sessionCommitted, latestHypothesis)
|
|
179
|
+
|
|
180
|
+
if (!suffix) {
|
|
181
|
+
if (!lastInterimSuffix) {
|
|
182
|
+
shrinkCandidate = undefined
|
|
183
|
+
applyInterim("", latestHypothesis)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (shrinkCandidate === "") {
|
|
187
|
+
applyInterim("", latestHypothesis)
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
shrinkCandidate = ""
|
|
191
|
+
pendingHypothesis = latestHypothesis
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (lastInterimSuffix && suffix.length < lastInterimSuffix.length) {
|
|
196
|
+
if (shrinkCandidate === suffix) {
|
|
197
|
+
applyInterim(suffix, latestHypothesis)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
shrinkCandidate = suffix
|
|
201
|
+
pendingHypothesis = latestHypothesis
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
shrinkCandidate = undefined
|
|
206
|
+
applyInterim(suffix, latestHypothesis)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
recognition.onerror = (e: { error: string }) => {
|
|
210
|
+
cancelPendingCommit()
|
|
211
|
+
lastInterimSuffix = ""
|
|
212
|
+
shrinkCandidate = undefined
|
|
213
|
+
if (e.error === "no-speech" && shouldContinue) {
|
|
214
|
+
setInterim("")
|
|
215
|
+
if (opts?.onInterim) opts.onInterim("")
|
|
216
|
+
setTimeout(() => {
|
|
217
|
+
try {
|
|
218
|
+
recognition?.start()
|
|
219
|
+
} catch {}
|
|
220
|
+
}, 150)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
shouldContinue = false
|
|
224
|
+
setIsRecording(false)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
recognition.onstart = () => {
|
|
228
|
+
sessionCommitted = ""
|
|
229
|
+
pendingHypothesis = ""
|
|
230
|
+
cancelPendingCommit()
|
|
231
|
+
lastInterimSuffix = ""
|
|
232
|
+
shrinkCandidate = undefined
|
|
233
|
+
setInterim("")
|
|
234
|
+
if (opts?.onInterim) opts.onInterim("")
|
|
235
|
+
setIsRecording(true)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
recognition.onend = () => {
|
|
239
|
+
cancelPendingCommit()
|
|
240
|
+
lastInterimSuffix = ""
|
|
241
|
+
shrinkCandidate = undefined
|
|
242
|
+
setIsRecording(false)
|
|
243
|
+
if (shouldContinue) {
|
|
244
|
+
setTimeout(() => {
|
|
245
|
+
try {
|
|
246
|
+
recognition?.start()
|
|
247
|
+
} catch {}
|
|
248
|
+
}, 150)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const start = () => {
|
|
254
|
+
if (!recognition) return
|
|
255
|
+
shouldContinue = true
|
|
256
|
+
sessionCommitted = ""
|
|
257
|
+
pendingHypothesis = ""
|
|
258
|
+
cancelPendingCommit()
|
|
259
|
+
lastInterimSuffix = ""
|
|
260
|
+
shrinkCandidate = undefined
|
|
261
|
+
setInterim("")
|
|
262
|
+
try {
|
|
263
|
+
recognition.start()
|
|
264
|
+
} catch {}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const stop = () => {
|
|
268
|
+
if (!recognition) return
|
|
269
|
+
shouldContinue = false
|
|
270
|
+
promotePending()
|
|
271
|
+
cancelPendingCommit()
|
|
272
|
+
lastInterimSuffix = ""
|
|
273
|
+
shrinkCandidate = undefined
|
|
274
|
+
setInterim("")
|
|
275
|
+
if (opts?.onInterim) opts.onInterim("")
|
|
276
|
+
try {
|
|
277
|
+
recognition.stop()
|
|
278
|
+
} catch {}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
onCleanup(() => {
|
|
282
|
+
shouldContinue = false
|
|
283
|
+
promotePending()
|
|
284
|
+
cancelPendingCommit()
|
|
285
|
+
lastInterimSuffix = ""
|
|
286
|
+
shrinkCandidate = undefined
|
|
287
|
+
setInterim("")
|
|
288
|
+
if (opts?.onInterim) opts.onInterim("")
|
|
289
|
+
try {
|
|
290
|
+
recognition?.stop()
|
|
291
|
+
} catch {}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
isSupported: () => hasSupport,
|
|
296
|
+
isRecording,
|
|
297
|
+
committed,
|
|
298
|
+
interim,
|
|
299
|
+
start,
|
|
300
|
+
stop,
|
|
301
|
+
}
|
|
302
|
+
}
|