@pikku/core 0.12.34 → 0.12.36
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/CHANGELOG.md +18 -0
- package/dist/index.d.ts +1 -1
- package/dist/services/ai-agent-runner-service.d.ts +143 -0
- package/dist/services/index.d.ts +1 -1
- package/dist/services/meta-service.d.ts +0 -1
- package/dist/services/meta-service.js +7 -6
- package/dist/wirings/ai-agent/index.d.ts +2 -0
- package/dist/wirings/ai-agent/index.js +2 -0
- package/dist/wirings/ai-agent/voice-input.d.ts +9 -0
- package/dist/wirings/ai-agent/voice-input.js +113 -0
- package/dist/wirings/ai-agent/voice-output.d.ts +16 -0
- package/dist/wirings/ai-agent/voice-output.js +94 -0
- package/package.json +1 -1
- package/src/index.ts +17 -1
- package/src/services/ai-agent-runner-service.ts +159 -0
- package/src/services/index.ts +14 -0
- package/src/services/meta-service.ts +7 -6
- package/src/wirings/ai-agent/index.ts +2 -0
- package/src/wirings/ai-agent/voice-input.ts +132 -0
- package/src/wirings/ai-agent/voice-output.ts +134 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
AIStreamChannel,
|
|
6
6
|
} from '../wirings/ai-agent/ai-agent.types.js'
|
|
7
7
|
|
|
8
|
+
export type AIProviderOptions = Record<string, Record<string, unknown>>
|
|
9
|
+
|
|
8
10
|
export type AIAgentRunnerParams = {
|
|
9
11
|
model: string
|
|
10
12
|
temperature?: number
|
|
@@ -36,12 +38,169 @@ export type AIAgentStepResult = {
|
|
|
36
38
|
reasoningContent?: string
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
export type AITranscriptionParams = {
|
|
42
|
+
model: string
|
|
43
|
+
audio: Uint8Array
|
|
44
|
+
providerOptions?: AIProviderOptions
|
|
45
|
+
maxRetries?: number
|
|
46
|
+
abortSignal?: AbortSignal
|
|
47
|
+
headers?: Record<string, string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type AITranscriptionResult = {
|
|
51
|
+
text: string
|
|
52
|
+
segments?: Array<{
|
|
53
|
+
text: string
|
|
54
|
+
startSecond: number
|
|
55
|
+
endSecond: number
|
|
56
|
+
}>
|
|
57
|
+
language?: string
|
|
58
|
+
durationInSeconds?: number
|
|
59
|
+
warnings?: unknown[]
|
|
60
|
+
providerMetadata?: Record<string, unknown>
|
|
61
|
+
responses?: unknown[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type AIGenerateSpeechParams = {
|
|
65
|
+
model: string
|
|
66
|
+
text: string
|
|
67
|
+
voice?: string
|
|
68
|
+
outputFormat?: string
|
|
69
|
+
instructions?: string
|
|
70
|
+
speed?: number
|
|
71
|
+
language?: string
|
|
72
|
+
providerOptions?: AIProviderOptions
|
|
73
|
+
maxRetries?: number
|
|
74
|
+
abortSignal?: AbortSignal
|
|
75
|
+
headers?: Record<string, string>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type AIGenerateSpeechResult = {
|
|
79
|
+
audio: {
|
|
80
|
+
uint8Array: Uint8Array
|
|
81
|
+
base64: string
|
|
82
|
+
mediaType: string
|
|
83
|
+
format: string
|
|
84
|
+
}
|
|
85
|
+
warnings?: unknown[]
|
|
86
|
+
providerMetadata?: Record<string, unknown>
|
|
87
|
+
responses?: unknown[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type AIGenerateImagePrompt =
|
|
91
|
+
| string
|
|
92
|
+
| {
|
|
93
|
+
images: Array<Uint8Array | ArrayBuffer | string>
|
|
94
|
+
text?: string
|
|
95
|
+
mask?: Uint8Array | ArrayBuffer | string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type AIGenerateImageParams = {
|
|
99
|
+
model: string
|
|
100
|
+
prompt: AIGenerateImagePrompt
|
|
101
|
+
n?: number
|
|
102
|
+
maxImagesPerCall?: number
|
|
103
|
+
size?: `${number}x${number}`
|
|
104
|
+
aspectRatio?: `${number}:${number}`
|
|
105
|
+
seed?: number
|
|
106
|
+
providerOptions?: AIProviderOptions
|
|
107
|
+
maxRetries?: number
|
|
108
|
+
abortSignal?: AbortSignal
|
|
109
|
+
headers?: Record<string, string>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type AIGenerateImageResult = {
|
|
113
|
+
images: Array<{
|
|
114
|
+
uint8Array: Uint8Array
|
|
115
|
+
base64: string
|
|
116
|
+
mediaType: string
|
|
117
|
+
}>
|
|
118
|
+
warnings?: unknown[]
|
|
119
|
+
providerMetadata?: Record<string, unknown>
|
|
120
|
+
responses?: unknown[]
|
|
121
|
+
usage?: {
|
|
122
|
+
inputTokens?: number
|
|
123
|
+
outputTokens?: number
|
|
124
|
+
totalTokens?: number
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type AIEmbedParams = {
|
|
129
|
+
model: string
|
|
130
|
+
value: string
|
|
131
|
+
providerOptions?: AIProviderOptions
|
|
132
|
+
maxRetries?: number
|
|
133
|
+
abortSignal?: AbortSignal
|
|
134
|
+
headers?: Record<string, string>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type AIEmbedResult = {
|
|
138
|
+
value: string
|
|
139
|
+
embedding: number[]
|
|
140
|
+
usage?: { tokens?: number }
|
|
141
|
+
warnings?: unknown[]
|
|
142
|
+
providerMetadata?: Record<string, unknown>
|
|
143
|
+
response?: unknown
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type AIEmbedManyParams = {
|
|
147
|
+
model: string
|
|
148
|
+
values: string[]
|
|
149
|
+
providerOptions?: AIProviderOptions
|
|
150
|
+
maxRetries?: number
|
|
151
|
+
abortSignal?: AbortSignal
|
|
152
|
+
headers?: Record<string, string>
|
|
153
|
+
maxParallelCalls?: number
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type AIEmbedManyResult = {
|
|
157
|
+
values: string[]
|
|
158
|
+
embeddings: number[][]
|
|
159
|
+
usage?: { tokens?: number }
|
|
160
|
+
warnings?: unknown[]
|
|
161
|
+
providerMetadata?: Record<string, unknown>
|
|
162
|
+
responses?: unknown[]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type AIRerankParams<VALUE extends string | Record<string, unknown>> = {
|
|
166
|
+
model: string
|
|
167
|
+
query: string
|
|
168
|
+
documents: VALUE[]
|
|
169
|
+
topK?: number
|
|
170
|
+
providerOptions?: AIProviderOptions
|
|
171
|
+
maxRetries?: number
|
|
172
|
+
abortSignal?: AbortSignal
|
|
173
|
+
headers?: Record<string, string>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type AIRerankResult<VALUE extends string | Record<string, unknown>> = {
|
|
177
|
+
ranking: Array<{
|
|
178
|
+
index: number
|
|
179
|
+
document: VALUE
|
|
180
|
+
score: number
|
|
181
|
+
}>
|
|
182
|
+
rerankedDocuments: VALUE[]
|
|
183
|
+
originalDocuments: VALUE[]
|
|
184
|
+
providerMetadata?: Record<string, unknown>
|
|
185
|
+
response?: unknown
|
|
186
|
+
}
|
|
187
|
+
|
|
39
188
|
export interface AIAgentRunnerService {
|
|
40
189
|
stream(
|
|
41
190
|
params: AIAgentRunnerParams,
|
|
42
191
|
channel: AIStreamChannel
|
|
43
192
|
): Promise<AIAgentStepResult>
|
|
44
193
|
run(params: AIAgentRunnerParams): Promise<AIAgentStepResult>
|
|
194
|
+
transcribe?(params: AITranscriptionParams): Promise<AITranscriptionResult>
|
|
195
|
+
generateSpeech?(
|
|
196
|
+
params: AIGenerateSpeechParams
|
|
197
|
+
): Promise<AIGenerateSpeechResult>
|
|
198
|
+
generateImage?(params: AIGenerateImageParams): Promise<AIGenerateImageResult>
|
|
199
|
+
embed?(params: AIEmbedParams): Promise<AIEmbedResult>
|
|
200
|
+
embedMany?(params: AIEmbedManyParams): Promise<AIEmbedManyResult>
|
|
201
|
+
rerank?<VALUE extends string | Record<string, unknown>>(
|
|
202
|
+
params: AIRerankParams<VALUE>
|
|
203
|
+
): Promise<AIRerankResult<VALUE>>
|
|
45
204
|
/** Return a new runner that uses the given API key for every LLM call.
|
|
46
205
|
* Optional — runners that don't support per-key scoping leave this undefined. */
|
|
47
206
|
withApiKey?(apiKey: string): AIAgentRunnerService
|
package/src/services/index.ts
CHANGED
|
@@ -69,6 +69,20 @@ export type {
|
|
|
69
69
|
AIAgentRunnerParams,
|
|
70
70
|
AIAgentRunnerResult,
|
|
71
71
|
AIAgentStepResult,
|
|
72
|
+
AIEmbedManyParams,
|
|
73
|
+
AIEmbedManyResult,
|
|
74
|
+
AIEmbedParams,
|
|
75
|
+
AIEmbedResult,
|
|
76
|
+
AIGenerateImageParams,
|
|
77
|
+
AIGenerateImagePrompt,
|
|
78
|
+
AIGenerateImageResult,
|
|
79
|
+
AIGenerateSpeechParams,
|
|
80
|
+
AIGenerateSpeechResult,
|
|
81
|
+
AIProviderOptions,
|
|
82
|
+
AIRerankParams,
|
|
83
|
+
AIRerankResult,
|
|
84
|
+
AITranscriptionParams,
|
|
85
|
+
AITranscriptionResult,
|
|
72
86
|
AIAgentRunnerService,
|
|
73
87
|
} from './ai-agent-runner-service.js'
|
|
74
88
|
export type {
|
|
@@ -212,7 +212,6 @@ export class LocalMetaService implements MetaService {
|
|
|
212
212
|
private secretsMetaCache: SecretDefinitionsMeta | null = null
|
|
213
213
|
private credentialsMetaCache: CredentialDefinitionsMeta | null = null
|
|
214
214
|
private variablesMetaCache: VariableDefinitionsMeta | null = null
|
|
215
|
-
private emailMetaCache: EmailsMeta | null = null
|
|
216
215
|
private middlewareGroupsMetaCache: MiddlewareGroupsMeta | null = null
|
|
217
216
|
private permissionsGroupsMetaCache: PermissionsGroupsMeta | null = null
|
|
218
217
|
private agentsMetaCache: AgentsMeta | null = null
|
|
@@ -262,7 +261,6 @@ export class LocalMetaService implements MetaService {
|
|
|
262
261
|
this.secretsMetaCache = null
|
|
263
262
|
this.credentialsMetaCache = null
|
|
264
263
|
this.variablesMetaCache = null
|
|
265
|
-
this.emailMetaCache = null
|
|
266
264
|
this.middlewareGroupsMetaCache = null
|
|
267
265
|
this.permissionsGroupsMetaCache = null
|
|
268
266
|
this.agentsMetaCache = null
|
|
@@ -509,17 +507,20 @@ export class LocalMetaService implements MetaService {
|
|
|
509
507
|
}
|
|
510
508
|
|
|
511
509
|
async getEmailMeta(): Promise<EmailsMeta> {
|
|
512
|
-
|
|
513
|
-
|
|
510
|
+
// Read fresh every call — never cache. The email meta file is generated by
|
|
511
|
+
// `pikku all`/`pikku emails generate` and is regenerated DURING a long-lived
|
|
512
|
+
// session (sandbox boots the orchestrator before the user project's codegen
|
|
513
|
+
// has written it). Caching here once returned an empty {templates:{}} that
|
|
514
|
+
// then stuck for the whole session, leaving the console emails screen blank
|
|
515
|
+
// even after the file appeared. A local JSON read is essentially free.
|
|
514
516
|
const content = await this.readFile('email/pikku-emails-meta.gen.json')
|
|
515
|
-
|
|
517
|
+
return content
|
|
516
518
|
? JSON.parse(content)
|
|
517
519
|
: {
|
|
518
520
|
src: '',
|
|
519
521
|
themeHash: '',
|
|
520
522
|
templates: {},
|
|
521
523
|
}
|
|
522
|
-
return this.emailMetaCache!
|
|
523
524
|
}
|
|
524
525
|
|
|
525
526
|
async getEmailTemplateAssets(
|
|
@@ -6,6 +6,8 @@ export {
|
|
|
6
6
|
} from './ai-agent-helpers.js'
|
|
7
7
|
export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js'
|
|
8
8
|
export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js'
|
|
9
|
+
export { voiceInput } from './voice-input.js'
|
|
10
|
+
export { voiceOutput } from './voice-output.js'
|
|
9
11
|
export {
|
|
10
12
|
type RunAIAgentParams,
|
|
11
13
|
type StreamAIAgentOptions,
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
|
|
2
|
+
import { pikkuAIMiddleware } from '../../types/core.types.js'
|
|
3
|
+
import type { AIContentPart } from './ai-agent.types.js'
|
|
4
|
+
|
|
5
|
+
function base64ToUint8Array(base64: string): Uint8Array {
|
|
6
|
+
const binary = atob(base64)
|
|
7
|
+
const bytes = new Uint8Array(binary.length)
|
|
8
|
+
for (let i = 0; i < binary.length; i++) {
|
|
9
|
+
bytes[i] = binary.charCodeAt(i)
|
|
10
|
+
}
|
|
11
|
+
return bytes
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MAX_AUDIO_SIZE = 50 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
// Portable SSRF guard: @pikku/core runs in edge runtimes (CF Workers) with no
|
|
17
|
+
// Node `dns`, so we cannot resolve hostnames to check for private targets.
|
|
18
|
+
// Reject the obvious internal literals; callers wanting stricter control pass
|
|
19
|
+
// an explicit `allowedAudioHosts` allowlist. (Does not defend against a public
|
|
20
|
+
// hostname that resolves to a private IP / DNS rebinding — out of reach here.)
|
|
21
|
+
function isPrivateHost(hostname: string): boolean {
|
|
22
|
+
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
23
|
+
if (host === 'localhost' || host === '0.0.0.0' || host === '::1') return true
|
|
24
|
+
if (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
|
|
25
|
+
return true
|
|
26
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/)
|
|
27
|
+
if (v4) {
|
|
28
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])]
|
|
29
|
+
if (a === 127 || a === 10 || a === 0) return true
|
|
30
|
+
if (a === 169 && b === 254) return true // link-local incl. cloud metadata
|
|
31
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
32
|
+
if (a === 192 && b === 168) return true
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchAsUint8Array(
|
|
38
|
+
url: string,
|
|
39
|
+
allowedAudioHosts?: string[]
|
|
40
|
+
): Promise<Uint8Array> {
|
|
41
|
+
const parsed = new URL(url)
|
|
42
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
43
|
+
throw new Error('Only HTTP(S) URLs are supported for audio')
|
|
44
|
+
}
|
|
45
|
+
if (allowedAudioHosts) {
|
|
46
|
+
if (!allowedAudioHosts.includes(parsed.hostname)) {
|
|
47
|
+
throw new Error(`Audio URL host is not allowed: ${parsed.hostname}`)
|
|
48
|
+
}
|
|
49
|
+
} else if (isPrivateHost(parsed.hostname)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Refusing to fetch audio from a private/internal host: ${parsed.hostname}`
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
const response = await fetch(url)
|
|
55
|
+
const contentLength = response.headers.get('content-length')
|
|
56
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_AUDIO_SIZE) {
|
|
57
|
+
throw new Error('Audio file exceeds maximum size')
|
|
58
|
+
}
|
|
59
|
+
const buffer = await response.arrayBuffer()
|
|
60
|
+
if (buffer.byteLength > MAX_AUDIO_SIZE) {
|
|
61
|
+
throw new Error('Audio file exceeds maximum size')
|
|
62
|
+
}
|
|
63
|
+
return new Uint8Array(buffer)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const voiceInput = (config?: {
|
|
67
|
+
language?: string
|
|
68
|
+
model?: string
|
|
69
|
+
allowedAudioHosts?: string[]
|
|
70
|
+
}) =>
|
|
71
|
+
pikkuAIMiddleware({
|
|
72
|
+
modifyInput: async (services, { messages, instructions }) => {
|
|
73
|
+
const transcribeAudio = (services as {
|
|
74
|
+
aiAgentRunner?: AIAgentRunnerService
|
|
75
|
+
}).aiAgentRunner?.transcribe
|
|
76
|
+
if (!transcribeAudio) return { messages, instructions }
|
|
77
|
+
|
|
78
|
+
const last = messages[messages.length - 1]
|
|
79
|
+
if (!last || last.role !== 'user' || typeof last.content === 'string') {
|
|
80
|
+
return { messages, instructions }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parts = last.content as AIContentPart[]
|
|
84
|
+
if (!parts) return { messages, instructions }
|
|
85
|
+
|
|
86
|
+
const hasAudio = parts.some(
|
|
87
|
+
(p) => p.type === 'file' && !!p.mediaType?.startsWith('audio/')
|
|
88
|
+
)
|
|
89
|
+
if (!hasAudio) return { messages, instructions }
|
|
90
|
+
|
|
91
|
+
// Process parts in order, replacing each audio part with its transcription
|
|
92
|
+
// in place. Sequential, so no unbounded parallel downloads/transcriptions
|
|
93
|
+
// and original content ordering is preserved.
|
|
94
|
+
const updatedContent: AIContentPart[] = []
|
|
95
|
+
for (const p of parts) {
|
|
96
|
+
if (!(p.type === 'file' && p.mediaType?.startsWith('audio/'))) {
|
|
97
|
+
updatedContent.push(p)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
if (!config?.model) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
'voiceInput requires a transcription model (e.g. openai/whisper-1)'
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
const audioData = p.data
|
|
106
|
+
? base64ToUint8Array(p.data)
|
|
107
|
+
: await fetchAsUint8Array(p.url!, config.allowedAudioHosts)
|
|
108
|
+
const result = await transcribeAudio({
|
|
109
|
+
model: config.model,
|
|
110
|
+
audio: audioData,
|
|
111
|
+
...(config.language
|
|
112
|
+
? {
|
|
113
|
+
providerOptions: {
|
|
114
|
+
openai: {
|
|
115
|
+
language: config.language,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
120
|
+
})
|
|
121
|
+
updatedContent.push({ type: 'text' as const, text: result.text })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
messages: [
|
|
126
|
+
...messages.slice(0, -1),
|
|
127
|
+
{ ...last, content: updatedContent },
|
|
128
|
+
],
|
|
129
|
+
instructions,
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { AIAgentRunnerService } from '../../services/ai-agent-runner-service.js'
|
|
2
|
+
import { pikkuAIMiddleware } from '../../types/core.types.js'
|
|
3
|
+
|
|
4
|
+
type VoiceOutputState = {
|
|
5
|
+
textBuffer?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function bufferToBase64(data: Uint8Array): string {
|
|
9
|
+
let binary = ''
|
|
10
|
+
for (let i = 0; i < data.length; i++) {
|
|
11
|
+
binary += String.fromCharCode(data[i]!)
|
|
12
|
+
}
|
|
13
|
+
return btoa(binary)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SENTENCE_BOUNDARY = /[.!?]\s*$/
|
|
17
|
+
|
|
18
|
+
function isSentenceBoundary(text: string): boolean {
|
|
19
|
+
return SENTENCE_BOUNDARY.test(text)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function synthesizeAudio(
|
|
23
|
+
aiAgentRunner: AIAgentRunnerService,
|
|
24
|
+
input: {
|
|
25
|
+
model: string
|
|
26
|
+
text: string
|
|
27
|
+
voice?: string
|
|
28
|
+
format?: string
|
|
29
|
+
instructions?: string
|
|
30
|
+
speed?: number
|
|
31
|
+
language?: string
|
|
32
|
+
}
|
|
33
|
+
): Promise<{ bytes: Uint8Array; format: string }> {
|
|
34
|
+
const result = await aiAgentRunner.generateSpeech?.({
|
|
35
|
+
model: input.model,
|
|
36
|
+
text: input.text,
|
|
37
|
+
voice: input.voice,
|
|
38
|
+
outputFormat: input.format,
|
|
39
|
+
instructions: input.instructions,
|
|
40
|
+
speed: input.speed,
|
|
41
|
+
language: input.language,
|
|
42
|
+
})
|
|
43
|
+
if (!result) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
'voiceOutput requires an aiAgentRunner with generateSpeech support'
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
// Label chunks with the format the provider actually returned (config.format
|
|
49
|
+
// is only a request), falling back to the requested format then pcm16.
|
|
50
|
+
return {
|
|
51
|
+
bytes: result.audio.uint8Array,
|
|
52
|
+
format: result.audio.format || input.format || 'pcm16',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const voiceOutput = (config?: {
|
|
57
|
+
model?: string
|
|
58
|
+
format?: string
|
|
59
|
+
voice?: string
|
|
60
|
+
instructions?: string
|
|
61
|
+
speed?: number
|
|
62
|
+
language?: string
|
|
63
|
+
}) =>
|
|
64
|
+
pikkuAIMiddleware<VoiceOutputState>({
|
|
65
|
+
modifyOutputStream: async (services, { event, state }) => {
|
|
66
|
+
const aiAgentRunner = (services as {
|
|
67
|
+
aiAgentRunner?: AIAgentRunnerService
|
|
68
|
+
}).aiAgentRunner
|
|
69
|
+
if (!aiAgentRunner?.generateSpeech) return event
|
|
70
|
+
|
|
71
|
+
if (event.type === 'done') {
|
|
72
|
+
const remaining = state.textBuffer ?? ''
|
|
73
|
+
if (remaining) {
|
|
74
|
+
state.textBuffer = ''
|
|
75
|
+
if (!config?.model) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'voiceOutput requires a speech model (e.g. openai/tts-1)'
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
const audio = await synthesizeAudio(aiAgentRunner, {
|
|
81
|
+
model: config.model,
|
|
82
|
+
text: remaining,
|
|
83
|
+
voice: config?.voice,
|
|
84
|
+
format: config?.format,
|
|
85
|
+
instructions: config?.instructions,
|
|
86
|
+
speed: config?.speed,
|
|
87
|
+
language: config?.language,
|
|
88
|
+
})
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
type: 'audio-delta' as const,
|
|
92
|
+
data: bufferToBase64(audio.bytes),
|
|
93
|
+
format: audio.format,
|
|
94
|
+
},
|
|
95
|
+
{ type: 'audio-done' as const },
|
|
96
|
+
event,
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
return [{ type: 'audio-done' as const }, event]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (event.type !== 'text-delta') return event
|
|
103
|
+
|
|
104
|
+
state.textBuffer = `${state.textBuffer ?? ''}${event.text}`
|
|
105
|
+
if (!isSentenceBoundary(state.textBuffer)) return event
|
|
106
|
+
|
|
107
|
+
const text = state.textBuffer
|
|
108
|
+
state.textBuffer = ''
|
|
109
|
+
|
|
110
|
+
if (!config?.model) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
'voiceOutput requires a speech model (e.g. openai/tts-1)'
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
const audio = await synthesizeAudio(aiAgentRunner, {
|
|
116
|
+
model: config.model,
|
|
117
|
+
text,
|
|
118
|
+
voice: config?.voice,
|
|
119
|
+
format: config?.format,
|
|
120
|
+
instructions: config?.instructions,
|
|
121
|
+
speed: config?.speed,
|
|
122
|
+
language: config?.language,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return [
|
|
126
|
+
event,
|
|
127
|
+
{
|
|
128
|
+
type: 'audio-delta' as const,
|
|
129
|
+
data: bufferToBase64(audio.bytes),
|
|
130
|
+
format: audio.format,
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
})
|