@jkwd/inbase 0.1.21 → 0.1.22
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/README.md +13 -7
- package/apps/explorer/package.json +1 -0
- package/apps/explorer/scripts/explain-store.d.ts +135 -0
- package/apps/explorer/scripts/explain-store.mjs +666 -0
- package/apps/explorer/scripts/patch-lib.mjs +4 -0
- package/apps/explorer/scripts/scan-target.mjs +22 -5
- package/apps/explorer/scripts/session-store.d.ts +86 -11
- package/apps/explorer/scripts/session-store.mjs +371 -58
- package/apps/explorer/scripts/target-config.d.ts +38 -3
- package/apps/explorer/scripts/target-config.mjs +147 -3
- package/apps/explorer/src/App.tsx +1073 -158
- package/apps/explorer/src/agentIntent.ts +61 -7
- package/apps/explorer/src/codebase.ts +1 -1
- package/apps/explorer/src/devTargets.ts +66 -0
- package/apps/explorer/src/explain.ts +312 -0
- package/apps/explorer/src/index.css +874 -222
- package/apps/explorer/src/layout.ts +55 -0
- package/apps/explorer/src/scene/DistantFileBlocks.tsx +4 -2
- package/apps/explorer/src/scene/FileBlock.tsx +95 -72
- package/apps/explorer/src/scene/FolderArea.tsx +55 -32
- package/apps/explorer/src/scene/MapView.tsx +506 -33
- package/apps/explorer/src/scene/RelationLines.tsx +7 -0
- package/apps/explorer/src/scene/World.tsx +146 -32
- package/apps/explorer/src/speech.ts +228 -0
- package/apps/explorer/src/theme.ts +29 -0
- package/apps/explorer/src/types.ts +98 -8
- package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +1 -1
- package/apps/explorer/src/ui/ExplainAskCard.tsx +142 -0
- package/apps/explorer/src/ui/ExplainHud.tsx +524 -0
- package/apps/explorer/src/ui/ExplainInfoPanel.tsx +135 -0
- package/apps/explorer/src/ui/ExplainPointer.tsx +73 -0
- package/apps/explorer/src/ui/EyeIcon.tsx +38 -1
- package/apps/explorer/src/ui/HUD.tsx +1067 -795
- package/apps/explorer/src/ui/NameInput.tsx +114 -5
- package/apps/explorer/src/userContext.ts +0 -11
- package/apps/explorer/src/userCreated.ts +54 -1
- package/apps/explorer/vite.config.ts +173 -26
- package/bin/inbase.mjs +11 -2
- package/bin/project.mjs +1 -1
- package/bin/session.mjs +287 -38
- package/package.json +4 -1
- package/skill/commands/amber.md +23 -0
- package/skill/commands/blue.md +13 -0
- package/skill/commands/coral.md +23 -0
- package/skill/commands/explain.md +77 -0
- package/skill/commands/green.md +23 -0
- package/skill/commands/inbase.md +7 -5
- package/skill/commands/lime.md +23 -0
- package/skill/commands/orange.md +23 -0
- package/skill/commands/purple.md +23 -0
- package/skill/commands/red.md +23 -0
- package/skill/commands/skipinbase.md +1 -1
- package/skill/commands/violet.md +23 -0
- package/skill/commands/yellow.md +23 -0
- package/skill/inbase/SKILL.md +124 -76
- package/apps/explorer/src/scene/BlockPlacer.tsx +0 -78
- package/apps/explorer/src/scene/IslandPlacer.tsx +0 -31
- package/apps/explorer/src/scene/SelectionThumbnail.tsx +0 -1069
|
@@ -17,6 +17,7 @@ type RelationLinesProps = {
|
|
|
17
17
|
layout: WorldLayout
|
|
18
18
|
extras?: Record<string, PlacedFile>
|
|
19
19
|
plannedEdges?: PatchImport[]
|
|
20
|
+
extraEdges?: PatchImport[]
|
|
20
21
|
fromAbove?: boolean
|
|
21
22
|
importedBy?: boolean
|
|
22
23
|
}
|
|
@@ -117,6 +118,7 @@ export function RelationLines({
|
|
|
117
118
|
layout,
|
|
118
119
|
extras = {},
|
|
119
120
|
plannedEdges = [],
|
|
121
|
+
extraEdges = [],
|
|
120
122
|
fromAbove = false,
|
|
121
123
|
importedBy = false,
|
|
122
124
|
}: RelationLinesProps) {
|
|
@@ -146,6 +148,10 @@ export function RelationLines({
|
|
|
146
148
|
addLine(edge.from, edge.to, true, plannedRadius)
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
for (const edge of extraEdges) {
|
|
152
|
+
addLine(edge.from, edge.to, false, selectedRadius)
|
|
153
|
+
}
|
|
154
|
+
|
|
149
155
|
if (selectedId) {
|
|
150
156
|
if (importedBy) {
|
|
151
157
|
for (const file of files) {
|
|
@@ -165,6 +171,7 @@ export function RelationLines({
|
|
|
165
171
|
return lines
|
|
166
172
|
}, [
|
|
167
173
|
extras,
|
|
174
|
+
extraEdges,
|
|
168
175
|
files,
|
|
169
176
|
fromAbove,
|
|
170
177
|
importedBy,
|
|
@@ -5,11 +5,9 @@ import { DistantFileBlocks } from './DistantFileBlocks'
|
|
|
5
5
|
import { Bridge } from './Bridge'
|
|
6
6
|
import { RelationLines } from './RelationLines'
|
|
7
7
|
import { Player } from './Player'
|
|
8
|
-
import { MapView, type MapBlueprintMenu } from './MapView'
|
|
8
|
+
import { MapView, type MapBlueprintMenu, type MapFileLabel, type MapFocusBounds } from './MapView'
|
|
9
9
|
import { SelectionController } from './SelectionController'
|
|
10
10
|
import { UserContextTracker } from './UserContextTracker'
|
|
11
|
-
import { BlockPlacer } from './BlockPlacer'
|
|
12
|
-
import { IslandPlacer } from './IslandPlacer'
|
|
13
11
|
import { WalkLodTracker } from './WalkLodTracker'
|
|
14
12
|
import { computeWalkLod, type WalkLod } from './walkLod'
|
|
15
13
|
import {
|
|
@@ -19,6 +17,13 @@ import {
|
|
|
19
17
|
folderOfFile,
|
|
20
18
|
mapPointOntoFolder,
|
|
21
19
|
} from '../layout'
|
|
20
|
+
import {
|
|
21
|
+
explainFileFocused,
|
|
22
|
+
explainFileHighlighted,
|
|
23
|
+
explainFolderFocused,
|
|
24
|
+
explainHasFocus,
|
|
25
|
+
type ExplainFocus,
|
|
26
|
+
} from '../explain'
|
|
22
27
|
import { WORLD_VOID, CONFIG, fileHeight } from '../theme'
|
|
23
28
|
import type {
|
|
24
29
|
CodebaseGraph,
|
|
@@ -45,6 +50,7 @@ type WorldProps = {
|
|
|
45
50
|
locked: boolean
|
|
46
51
|
onSelect: (fileId: string | null) => void
|
|
47
52
|
onSelectFolder: (folderPath: string | null) => void
|
|
53
|
+
pickingImport?: boolean
|
|
48
54
|
onLockedChange: (locked: boolean) => void
|
|
49
55
|
onLand: (x: number, z: number) => void
|
|
50
56
|
onWalkPosition: (x: number, z: number) => void
|
|
@@ -63,8 +69,6 @@ type WorldProps = {
|
|
|
63
69
|
onTravelTo: (fromId: string, toId: string) => void
|
|
64
70
|
importedBy?: boolean
|
|
65
71
|
namingId?: string | null
|
|
66
|
-
onPlaceBlock?: (spot: { x: number; z: number; folder: string }) => void
|
|
67
|
-
onPlaceIsland?: (parent: string) => void
|
|
68
72
|
onBlueprintMenu?: (menu: MapBlueprintMenu) => void
|
|
69
73
|
onCommitName?: (id: string, name: string) => boolean
|
|
70
74
|
onCancelName?: (id: string) => void
|
|
@@ -74,10 +78,17 @@ type WorldProps = {
|
|
|
74
78
|
userCreatedIslands?: UserCreatedIsland[]
|
|
75
79
|
overlayBlocks?: UserCreatedBlock[]
|
|
76
80
|
pointedFileIds?: string[]
|
|
81
|
+
pointedFileColors?: Record<string, string[]>
|
|
77
82
|
pointedFolderPaths?: string[]
|
|
83
|
+
pointedFolderColors?: Record<string, string[]>
|
|
78
84
|
namingIslandId?: string | null
|
|
79
85
|
mapGraph?: CodebaseGraph | null
|
|
80
86
|
mapLayout?: WorldLayout | null
|
|
87
|
+
explainActive?: boolean
|
|
88
|
+
explainFocus?: ExplainFocus | null
|
|
89
|
+
focusBounds?: MapFocusBounds | null
|
|
90
|
+
focusFlightKey?: string | number
|
|
91
|
+
landEnabled?: boolean
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
export function World({
|
|
@@ -90,6 +101,7 @@ export function World({
|
|
|
90
101
|
locked,
|
|
91
102
|
onSelect,
|
|
92
103
|
onSelectFolder,
|
|
104
|
+
pickingImport = false,
|
|
93
105
|
onLockedChange,
|
|
94
106
|
onLand,
|
|
95
107
|
onWalkPosition,
|
|
@@ -109,8 +121,6 @@ export function World({
|
|
|
109
121
|
importedBy = false,
|
|
110
122
|
namingId = null,
|
|
111
123
|
namingIslandId = null,
|
|
112
|
-
onPlaceBlock,
|
|
113
|
-
onPlaceIsland,
|
|
114
124
|
onBlueprintMenu,
|
|
115
125
|
onCommitName,
|
|
116
126
|
onCancelName,
|
|
@@ -120,9 +130,16 @@ export function World({
|
|
|
120
130
|
userCreatedIslands = [],
|
|
121
131
|
overlayBlocks = [],
|
|
122
132
|
pointedFileIds = [],
|
|
133
|
+
pointedFileColors = {},
|
|
123
134
|
pointedFolderPaths = [],
|
|
135
|
+
pointedFolderColors = {},
|
|
124
136
|
mapGraph = null,
|
|
125
137
|
mapLayout = null,
|
|
138
|
+
explainActive = false,
|
|
139
|
+
explainFocus = null,
|
|
140
|
+
focusBounds = null,
|
|
141
|
+
focusFlightKey = 0,
|
|
142
|
+
landEnabled = true,
|
|
126
143
|
}: WorldProps) {
|
|
127
144
|
const created = new Set(createdIds)
|
|
128
145
|
const deleted = new Set(deletedIds)
|
|
@@ -224,6 +241,68 @@ export function World({
|
|
|
224
241
|
placed: PlacedFile
|
|
225
242
|
dimmed: boolean
|
|
226
243
|
}[] = []
|
|
244
|
+
const mapFileLabels = useMemo(() => {
|
|
245
|
+
if (!mapping) return []
|
|
246
|
+
const pointed = new Set(pointedFileIds)
|
|
247
|
+
const seen = new Set<string>()
|
|
248
|
+
const items: MapFileLabel[] = []
|
|
249
|
+
const push = (id: string, name: string, placed: PlacedFile) => {
|
|
250
|
+
if (!placed || seen.has(id)) return
|
|
251
|
+
seen.add(id)
|
|
252
|
+
const colors = pointedFileColors[id]
|
|
253
|
+
items.push({
|
|
254
|
+
id,
|
|
255
|
+
name,
|
|
256
|
+
x: placed.position[0],
|
|
257
|
+
z: placed.position[2],
|
|
258
|
+
width: placed.size[0],
|
|
259
|
+
depth: placed.size[2],
|
|
260
|
+
outer: -placed.aisleFace as 1 | -1,
|
|
261
|
+
selected: id === selectedId,
|
|
262
|
+
pointed: pointed.has(id),
|
|
263
|
+
pointedColor: colors?.[colors.length - 1],
|
|
264
|
+
dimmed:
|
|
265
|
+
explainActive &&
|
|
266
|
+
!explainFileFocused(explainFocus, id, folderOfFile(id)),
|
|
267
|
+
focused: explainFileHighlighted(explainFocus, id, folderOfFile(id)),
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
for (const file of viewGraph.files) {
|
|
271
|
+
const placed = viewLayout.files[file.id]
|
|
272
|
+
if (placed) push(file.id, file.name, placed)
|
|
273
|
+
}
|
|
274
|
+
for (const placed of Object.values(ghosts)) {
|
|
275
|
+
push(placed.id, placed.id.split('/').pop() ?? placed.id, placed)
|
|
276
|
+
}
|
|
277
|
+
for (const block of overlayBlocks) {
|
|
278
|
+
const height = fileHeight(12)
|
|
279
|
+
push(block.id, block.name, {
|
|
280
|
+
id: block.id,
|
|
281
|
+
position: [block.x, height / 2 + 0.42, block.z],
|
|
282
|
+
size: [CONFIG.fileWidth, height, CONFIG.fileDepth],
|
|
283
|
+
aisleFace: 1,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
return items
|
|
287
|
+
}, [
|
|
288
|
+
ghosts,
|
|
289
|
+
mapping,
|
|
290
|
+
overlayBlocks,
|
|
291
|
+
pointedFileColors,
|
|
292
|
+
pointedFileIds,
|
|
293
|
+
selectedId,
|
|
294
|
+
viewGraph.files,
|
|
295
|
+
viewLayout.files,
|
|
296
|
+
explainActive,
|
|
297
|
+
explainFocus,
|
|
298
|
+
])
|
|
299
|
+
|
|
300
|
+
const dimmedFolderPaths = useMemo(() => {
|
|
301
|
+
if (!explainActive || !explainHasFocus(explainFocus)) return []
|
|
302
|
+
return Object.keys(viewLayout.folders).filter(
|
|
303
|
+
(path) => !explainFolderFocused(explainFocus, path),
|
|
304
|
+
)
|
|
305
|
+
}, [explainActive, explainFocus, viewLayout.folders])
|
|
227
306
|
|
|
228
307
|
return (
|
|
229
308
|
<>
|
|
@@ -238,15 +317,28 @@ export function World({
|
|
|
238
317
|
<MapView
|
|
239
318
|
layout={viewLayout}
|
|
240
319
|
enabled={mapping}
|
|
241
|
-
marker={mapMarker}
|
|
320
|
+
marker={explainActive ? null : mapMarker}
|
|
242
321
|
highlightedFolders={highlightedFolders}
|
|
243
322
|
selectedFolder={selectedFolder}
|
|
244
323
|
namingFolderPath={namingIslandId}
|
|
324
|
+
namingFileId={namingId}
|
|
325
|
+
pointedFolderPaths={pointedFolderPaths}
|
|
326
|
+
pointedFolderColors={pointedFolderColors}
|
|
327
|
+
fileLabels={mapFileLabels}
|
|
328
|
+
focusBounds={focusBounds}
|
|
329
|
+
focusFlightKey={focusFlightKey}
|
|
330
|
+
hudReserve={explainActive ? 24 : 88}
|
|
331
|
+
topReserve={explainActive ? 24 : 28}
|
|
332
|
+
landEnabled={landEnabled}
|
|
333
|
+
dimmedFolderPaths={dimmedFolderPaths}
|
|
245
334
|
onLand={onLand}
|
|
246
335
|
onSelect={onSelect}
|
|
247
336
|
onSelectFolder={onSelectFolder}
|
|
337
|
+
pickingImport={pickingImport}
|
|
248
338
|
onBlueprintMenu={
|
|
249
|
-
mapping && !placing &&
|
|
339
|
+
mapping && !placing && !explainActive && onBlueprintMenu
|
|
340
|
+
? onBlueprintMenu
|
|
341
|
+
: undefined
|
|
250
342
|
}
|
|
251
343
|
/>
|
|
252
344
|
|
|
@@ -263,6 +355,12 @@ export function World({
|
|
|
263
355
|
mapping ? highlightedFolders[folder.path] ?? null : null
|
|
264
356
|
}
|
|
265
357
|
pointed={pointedFolders.has(folder.path)}
|
|
358
|
+
pointedColors={pointedFolderColors[folder.path]}
|
|
359
|
+
opacity={
|
|
360
|
+
explainActive && !explainFolderFocused(explainFocus, folder.path)
|
|
361
|
+
? 0.5
|
|
362
|
+
: 1
|
|
363
|
+
}
|
|
266
364
|
labelVisible={!lod || lod.folderLabels.has(folder.path)}
|
|
267
365
|
onCommitName={
|
|
268
366
|
folder.path === namingIslandId && onCommitIslandName
|
|
@@ -295,6 +393,7 @@ export function World({
|
|
|
295
393
|
const naming = file.id === namingId
|
|
296
394
|
const aimed = file.id === aimedRelation?.flyTo
|
|
297
395
|
const pointed = pointedFiles.has(file.id)
|
|
396
|
+
const focused = explainFileHighlighted(explainFocus, file.id, file.folder)
|
|
298
397
|
const detailed =
|
|
299
398
|
selected ||
|
|
300
399
|
isRelated ||
|
|
@@ -302,16 +401,20 @@ export function World({
|
|
|
302
401
|
naming ||
|
|
303
402
|
aimed ||
|
|
304
403
|
pointed ||
|
|
404
|
+
focused ||
|
|
305
405
|
Boolean(changeKind)
|
|
306
406
|
if (lod && !lod.files.has(file.id)) return null
|
|
307
407
|
const dimmed =
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
408
|
+
explainActive
|
|
409
|
+
? !explainFileFocused(explainFocus, file.id, file.folder)
|
|
410
|
+
: hasFocus &&
|
|
411
|
+
!selected &&
|
|
412
|
+
!isRelated &&
|
|
413
|
+
!changeKind &&
|
|
414
|
+
!pointed &&
|
|
415
|
+
!patchLinked.has(file.id) &&
|
|
416
|
+
!folderFileIds.has(file.id)
|
|
417
|
+
const opacity = explainActive && dimmed ? 0.5 : 1
|
|
315
418
|
if (lod && !detailed && !lod.labels.has(file.id)) {
|
|
316
419
|
distantFiles.push({ file, placed, dimmed })
|
|
317
420
|
return null
|
|
@@ -328,7 +431,10 @@ export function World({
|
|
|
328
431
|
added={created.has(file.id) || file.userCreated}
|
|
329
432
|
aimed={aimed}
|
|
330
433
|
pointed={pointed}
|
|
434
|
+
pointedColors={pointedFileColors[file.id]}
|
|
331
435
|
dimmed={dimmed}
|
|
436
|
+
focused={focused}
|
|
437
|
+
opacity={opacity}
|
|
332
438
|
naming={naming}
|
|
333
439
|
mapMode={mapping}
|
|
334
440
|
labelVisible={!lod || lod.labels.has(file.id) || naming}
|
|
@@ -366,7 +472,18 @@ export function World({
|
|
|
366
472
|
changeKind="add"
|
|
367
473
|
added
|
|
368
474
|
pointed={pointedFiles.has(file.id)}
|
|
369
|
-
|
|
475
|
+
pointedColors={pointedFileColors[file.id]}
|
|
476
|
+
dimmed={
|
|
477
|
+
explainActive &&
|
|
478
|
+
!explainFileFocused(explainFocus, file.id, file.folder)
|
|
479
|
+
}
|
|
480
|
+
focused={explainFileHighlighted(explainFocus, file.id, file.folder)}
|
|
481
|
+
opacity={
|
|
482
|
+
explainActive &&
|
|
483
|
+
!explainFileFocused(explainFocus, file.id, file.folder)
|
|
484
|
+
? 0.5
|
|
485
|
+
: 1
|
|
486
|
+
}
|
|
370
487
|
mapMode={mapping}
|
|
371
488
|
labelVisible
|
|
372
489
|
/>
|
|
@@ -395,7 +512,17 @@ export function World({
|
|
|
395
512
|
planned
|
|
396
513
|
changeKind="add"
|
|
397
514
|
added
|
|
398
|
-
dimmed={
|
|
515
|
+
dimmed={
|
|
516
|
+
explainActive &&
|
|
517
|
+
!explainFileFocused(explainFocus, file.id, file.folder)
|
|
518
|
+
}
|
|
519
|
+
focused={explainFileHighlighted(explainFocus, file.id, file.folder)}
|
|
520
|
+
opacity={
|
|
521
|
+
explainActive &&
|
|
522
|
+
!explainFileFocused(explainFocus, file.id, file.folder)
|
|
523
|
+
? 0.5
|
|
524
|
+
: 1
|
|
525
|
+
}
|
|
399
526
|
mapMode={mapping}
|
|
400
527
|
/>
|
|
401
528
|
)
|
|
@@ -407,6 +534,7 @@ export function World({
|
|
|
407
534
|
layout={viewLayout}
|
|
408
535
|
extras={ghosts}
|
|
409
536
|
plannedEdges={plannedImports}
|
|
537
|
+
extraEdges={explainFocus?.relations ?? []}
|
|
410
538
|
fromAbove={mapping}
|
|
411
539
|
importedBy={importedBy}
|
|
412
540
|
/>
|
|
@@ -431,20 +559,6 @@ export function World({
|
|
|
431
559
|
onChange={setWalkLod}
|
|
432
560
|
/>
|
|
433
561
|
)}
|
|
434
|
-
{onPlaceBlock && (
|
|
435
|
-
<BlockPlacer
|
|
436
|
-
enabled={!mapping && !placing}
|
|
437
|
-
layout={layout}
|
|
438
|
-
onPlace={onPlaceBlock}
|
|
439
|
-
/>
|
|
440
|
-
)}
|
|
441
|
-
{onPlaceIsland && (
|
|
442
|
-
<IslandPlacer
|
|
443
|
-
enabled={!mapping && !placing}
|
|
444
|
-
layout={layout}
|
|
445
|
-
onPlace={onPlaceIsland}
|
|
446
|
-
/>
|
|
447
|
-
)}
|
|
448
562
|
<SelectionController
|
|
449
563
|
locked={locked && !mapping}
|
|
450
564
|
onSelect={onSelect}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { KokoroTTS } from 'kokoro-js'
|
|
2
|
+
|
|
3
|
+
let speakGeneration = 0
|
|
4
|
+
let neuralSpeaking = false
|
|
5
|
+
let currentSource: AudioBufferSourceNode | null = null
|
|
6
|
+
let audioContext: AudioContext | null = null
|
|
7
|
+
let kokoroFailed = false
|
|
8
|
+
let kokoroLoading: Promise<KokoroTTS | null> | null = null
|
|
9
|
+
|
|
10
|
+
export const AUTO_STEP_DELAY_MS = 4000
|
|
11
|
+
export type SpeakStatus = 'idle' | 'loading' | 'speaking'
|
|
12
|
+
|
|
13
|
+
const KOKORO_MODEL = 'onnx-community/Kokoro-82M-v1.0-ONNX'
|
|
14
|
+
const KOKORO_VOICE = 'af_heart' as const
|
|
15
|
+
|
|
16
|
+
let speakStatus: SpeakStatus = 'idle'
|
|
17
|
+
const statusListeners = new Set<(status: SpeakStatus) => void>()
|
|
18
|
+
|
|
19
|
+
function setSpeakStatus(next: SpeakStatus) {
|
|
20
|
+
speakStatus = next
|
|
21
|
+
for (const listener of statusListeners) listener(next)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function subscribeSpeakStatus(listener: (status: SpeakStatus) => void) {
|
|
25
|
+
statusListeners.add(listener)
|
|
26
|
+
listener(speakStatus)
|
|
27
|
+
return () => {
|
|
28
|
+
statusListeners.delete(listener)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canUseWebSpeech() {
|
|
33
|
+
return typeof window !== 'undefined' && 'speechSynthesis' in window
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function canSpeak() {
|
|
37
|
+
return (
|
|
38
|
+
typeof window !== 'undefined' &&
|
|
39
|
+
('AudioContext' in window ||
|
|
40
|
+
'webkitAudioContext' in window ||
|
|
41
|
+
canUseWebSpeech())
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isSpeaking() {
|
|
46
|
+
return (
|
|
47
|
+
neuralSpeaking ||
|
|
48
|
+
(canUseWebSpeech() && window.speechSynthesis.speaking)
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getAudioContext() {
|
|
53
|
+
const Context =
|
|
54
|
+
window.AudioContext ||
|
|
55
|
+
(window as typeof window & { webkitAudioContext?: typeof AudioContext })
|
|
56
|
+
.webkitAudioContext
|
|
57
|
+
if (!Context) return null
|
|
58
|
+
if (!audioContext || audioContext.state === 'closed') {
|
|
59
|
+
audioContext = new Context()
|
|
60
|
+
}
|
|
61
|
+
if (audioContext.state === 'suspended') void audioContext.resume()
|
|
62
|
+
return audioContext
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stopPlayback() {
|
|
66
|
+
neuralSpeaking = false
|
|
67
|
+
if (currentSource) {
|
|
68
|
+
try {
|
|
69
|
+
currentSource.stop()
|
|
70
|
+
} catch {
|
|
71
|
+
// Already stopped.
|
|
72
|
+
}
|
|
73
|
+
currentSource = null
|
|
74
|
+
}
|
|
75
|
+
if (canUseWebSpeech()) window.speechSynthesis.cancel()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function stopSpeaking() {
|
|
79
|
+
speakGeneration += 1
|
|
80
|
+
stopPlayback()
|
|
81
|
+
setSpeakStatus('idle')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function voiceScore(voice: SpeechSynthesisVoice) {
|
|
85
|
+
const label = `${voice.name} ${voice.voiceURI} ${voice.lang}`.toLowerCase()
|
|
86
|
+
let score = 0
|
|
87
|
+
if (voice.lang.toLowerCase().startsWith('en')) score += 10
|
|
88
|
+
if (/neural|premium|enhanced|natural|online|wavenet|studio/.test(label)) {
|
|
89
|
+
score += 50
|
|
90
|
+
}
|
|
91
|
+
if (/google/.test(label)) score += 30
|
|
92
|
+
if (/microsoft/.test(label)) score += 20
|
|
93
|
+
if (/\b(zoe|evan|nolan|samantha|allison|ava|daniel|karen|moira)\b/.test(label)) {
|
|
94
|
+
score += 15
|
|
95
|
+
}
|
|
96
|
+
if (voice.localService) score += 2
|
|
97
|
+
if (voice.default) score += 1
|
|
98
|
+
return score
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function preferredWebVoice() {
|
|
102
|
+
const voices = window.speechSynthesis.getVoices()
|
|
103
|
+
if (voices.length === 0) return undefined
|
|
104
|
+
return [...voices].sort((a, b) => voiceScore(b) - voiceScore(a))[0]
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function detectWebGpu() {
|
|
108
|
+
const gpu = (
|
|
109
|
+
navigator as Navigator & { gpu?: { requestAdapter: () => Promise<unknown> } }
|
|
110
|
+
).gpu
|
|
111
|
+
if (!gpu) return false
|
|
112
|
+
try {
|
|
113
|
+
return Boolean(await gpu.requestAdapter())
|
|
114
|
+
} catch {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function loadKokoro() {
|
|
120
|
+
if (kokoroFailed) return null
|
|
121
|
+
kokoroLoading ??= (async () => {
|
|
122
|
+
try {
|
|
123
|
+
const { KokoroTTS } = await import('kokoro-js')
|
|
124
|
+
const webgpu = await detectWebGpu()
|
|
125
|
+
return await KokoroTTS.from_pretrained(KOKORO_MODEL, webgpu
|
|
126
|
+
? { dtype: 'fp32', device: 'webgpu' }
|
|
127
|
+
: { dtype: 'q8', device: 'wasm' },
|
|
128
|
+
)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
kokoroFailed = true
|
|
131
|
+
console.warn('Natural voice unavailable, using the browser voice', error)
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
})()
|
|
135
|
+
return kokoroLoading
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function playRawAudio(
|
|
139
|
+
input: ArrayLike<number>,
|
|
140
|
+
sampleRate: number,
|
|
141
|
+
generation: number,
|
|
142
|
+
onEnded: () => void,
|
|
143
|
+
) {
|
|
144
|
+
const context = getAudioContext()
|
|
145
|
+
if (!context) {
|
|
146
|
+
onEnded()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const samples = new Float32Array(input.length)
|
|
150
|
+
samples.set(input as ArrayLike<number>)
|
|
151
|
+
const buffer = context.createBuffer(1, samples.length, sampleRate)
|
|
152
|
+
buffer.copyToChannel(samples, 0)
|
|
153
|
+
const source = context.createBufferSource()
|
|
154
|
+
source.buffer = buffer
|
|
155
|
+
source.connect(context.destination)
|
|
156
|
+
currentSource = source
|
|
157
|
+
neuralSpeaking = true
|
|
158
|
+
setSpeakStatus('speaking')
|
|
159
|
+
source.onended = () => {
|
|
160
|
+
if (currentSource === source) currentSource = null
|
|
161
|
+
neuralSpeaking = false
|
|
162
|
+
if (generation !== speakGeneration) return
|
|
163
|
+
setSpeakStatus('idle')
|
|
164
|
+
onEnded()
|
|
165
|
+
}
|
|
166
|
+
source.start()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function speakWithWebSpeech(text: string, generation: number, onEnded: () => void) {
|
|
170
|
+
if (!canUseWebSpeech()) {
|
|
171
|
+
onEnded()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
const utterance = new SpeechSynthesisUtterance(text)
|
|
175
|
+
utterance.rate = 0.98
|
|
176
|
+
const voice = preferredWebVoice()
|
|
177
|
+
if (voice) utterance.voice = voice
|
|
178
|
+
utterance.onend = () => {
|
|
179
|
+
if (generation !== speakGeneration) return
|
|
180
|
+
setSpeakStatus('idle')
|
|
181
|
+
onEnded()
|
|
182
|
+
}
|
|
183
|
+
utterance.onerror = (event) => {
|
|
184
|
+
if (event.error === 'interrupted' || event.error === 'canceled') return
|
|
185
|
+
if (generation !== speakGeneration) return
|
|
186
|
+
setSpeakStatus('idle')
|
|
187
|
+
onEnded()
|
|
188
|
+
}
|
|
189
|
+
setSpeakStatus('speaking')
|
|
190
|
+
window.speechSynthesis.speak(utterance)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function speakText(text: string, onEnd?: () => void) {
|
|
194
|
+
const generation = ++speakGeneration
|
|
195
|
+
stopPlayback()
|
|
196
|
+
const spoken = text.trim()
|
|
197
|
+
const finish = () => {
|
|
198
|
+
if (generation !== speakGeneration) return
|
|
199
|
+
neuralSpeaking = false
|
|
200
|
+
setSpeakStatus('idle')
|
|
201
|
+
onEnd?.()
|
|
202
|
+
}
|
|
203
|
+
if (!spoken) {
|
|
204
|
+
queueMicrotask(finish)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Keep AudioContext in the user-gesture stack so playback can start later.
|
|
209
|
+
getAudioContext()
|
|
210
|
+
setSpeakStatus('loading')
|
|
211
|
+
|
|
212
|
+
void (async () => {
|
|
213
|
+
try {
|
|
214
|
+
const tts = await loadKokoro()
|
|
215
|
+
if (generation !== speakGeneration) return
|
|
216
|
+
if (tts) {
|
|
217
|
+
const audio = await tts.generate(spoken, { voice: KOKORO_VOICE, speed: 1 })
|
|
218
|
+
if (generation !== speakGeneration) return
|
|
219
|
+
playRawAudio(audio.audio, audio.sampling_rate, generation, finish)
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.warn('Natural voice failed, using the browser voice', error)
|
|
224
|
+
}
|
|
225
|
+
if (generation !== speakGeneration) return
|
|
226
|
+
speakWithWebSpeech(spoken, generation, finish)
|
|
227
|
+
})()
|
|
228
|
+
}
|
|
@@ -64,6 +64,10 @@ export const MAP_SELECTION = {
|
|
|
64
64
|
island: FILE_SELECTION.color,
|
|
65
65
|
islandPad: 0.38,
|
|
66
66
|
blockPad: 0.1,
|
|
67
|
+
pointed: '#9ad8ff',
|
|
68
|
+
pointedPad: 0.22,
|
|
69
|
+
explain: '#9ad8ff',
|
|
70
|
+
explainPad: 0.16,
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
export function fileHeight(lines: number) {
|
|
@@ -110,6 +114,31 @@ export function dimColor(hex: string, amount = 0.32) {
|
|
|
110
114
|
return `#${channel(0)}${channel(2)}${channel(4)}`
|
|
111
115
|
}
|
|
112
116
|
|
|
117
|
+
export function lightenColor(hex: string, amount = 0.35) {
|
|
118
|
+
const value = hex.replace('#', '')
|
|
119
|
+
if (value.length !== 6) return hex
|
|
120
|
+
const mix = Math.max(0, Math.min(1, amount))
|
|
121
|
+
const channel = (start: number) => {
|
|
122
|
+
const n = parseInt(value.slice(start, start + 2), 16)
|
|
123
|
+
return Math.round(n + (255 - n) * mix)
|
|
124
|
+
.toString(16)
|
|
125
|
+
.padStart(2, '0')
|
|
126
|
+
}
|
|
127
|
+
return `#${channel(0)}${channel(2)}${channel(4)}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function blueprintPalette(hex?: string | null) {
|
|
131
|
+
const color =
|
|
132
|
+
typeof hex === 'string' && /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : '#38bdf8'
|
|
133
|
+
return {
|
|
134
|
+
color,
|
|
135
|
+
label: lightenColor(color, 0.42),
|
|
136
|
+
emissive: dimColor(color, 0.45),
|
|
137
|
+
floor: dimColor(color, 0.78),
|
|
138
|
+
aisle: dimColor(color, 0.58),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
113
142
|
function folderHue(path: string) {
|
|
114
143
|
let hash = 0
|
|
115
144
|
for (let i = 0; i < path.length; i += 1) {
|