wip: snapshot das alterações não commitadas (2026-10-20)

Origem: D:/Documents/Github/PiAgentUI/PiAgentUi
Arquivos omitidos (segredos, bancos de dados, logs, >50 MB): 4
This commit is contained in:
Evandro committed 2026-10-20 00:48:28 -03:00
1 parent b3489e1ede
commit 98b5db029f
70 files changed
+9466 -9191

No files matched your search

+284 -27
View File
@@ -12,10 +12,12 @@ import {
isRequestBodySizeAllowed,
getLocalNetworkUrls,
validateSttBaseUrl,
buildRuntimeCapabilities,
} from './piagentui-server-core.js'
import {
createPiAgentUiSettingsStore,
resolvePiAgentUiSettingsPaths,
resolvePiAgentUiAgentDir,
type PiAgentUiLocalSettings,
type StoredSttSettings,
type StoredTtsSettings,
@@ -732,6 +734,8 @@ export default function (pi: ExtensionAPI) {
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
insertMode: 'append',
...getLocalSettings().stt,
}
@@ -746,6 +750,8 @@ export default function (pi: ExtensionAPI) {
baseUrl: effective.baseUrl || 'https://api.openai.com/v1',
transcriptionEndpoint: effective.transcriptionEndpoint || '/audio/transcriptions',
transcriptionModel: effective.transcriptionModel || 'gpt-4o-mini-transcribe',
realtimeEndpoint: effective.realtimeEndpoint || '/realtime/transcriptions',
realtimeModel: effective.realtimeModel || effective.transcriptionModel || 'gpt-4o-mini-transcribe',
insertMode: effective.insertMode === 'replace' ? 'replace' : 'append',
apiKeyRequired: effective.apiKeyRequired === true,
apiKeyConfigured: typeof effective.apiKey === 'string' && effective.apiKey.length > 0,
@@ -806,6 +812,18 @@ export default function (pi: ExtensionAPI) {
}
next.transcriptionModel = update.transcriptionModel.trim()
}
if (update.realtimeEndpoint !== undefined) {
if (typeof update.realtimeEndpoint !== 'string' || !update.realtimeEndpoint.startsWith('/')) {
return { ok: false, error: 'Invalid realtimeEndpoint' }
}
next.realtimeEndpoint = update.realtimeEndpoint
}
if (update.realtimeModel !== undefined) {
if (typeof update.realtimeModel !== 'string' || !update.realtimeModel.trim()) {
return { ok: false, error: 'Invalid realtimeModel' }
}
next.realtimeModel = update.realtimeModel.trim()
}
if (update.language !== undefined) {
if (update.language === null || update.language === '') delete next.language
else if (typeof update.language === 'string') next.language = update.language.trim()
@@ -954,6 +972,160 @@ export default function (pi: ExtensionAPI) {
return form
}
type SttRealtimeClientMessage =
| { type: 'audio.append'; audio: string }
| { type: 'audio.commit' }
| { type: 'session.close' }
function buildRealtimeProviderUrl(settings: StoredSttSettings): string {
const base = (settings.baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '')
const endpoint =
settings.realtimeEndpoint ||
(settings.providerKind === 'openai' ? '/realtime?intent=transcription' : '/realtime/transcriptions')
const wsBase = base.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
return `${wsBase}${endpoint}`
}
function buildRealtimeSessionConfigureMessage(settings: StoredSttSettings): Record<string, unknown> {
if (settings.providerKind === 'openai') {
const transcription: Record<string, unknown> = {
model: settings.realtimeModel || 'gpt-realtime-whisper',
}
if (settings.language) transcription.language = settings.language
return {
type: 'session.update',
session: {
type: 'transcription',
audio: {
input: {
format: { type: 'audio/pcm', rate: 24000 },
transcription,
},
},
},
}
}
return { type: 'session.configure', model: settings.realtimeModel || settings.transcriptionModel }
}
function translateRealtimeClientMessage(
message: SttRealtimeClientMessage,
settings: StoredSttSettings,
): Record<string, unknown> | null {
if (settings.providerKind !== 'openai') return message
if (message.type === 'audio.append') return { type: 'input_audio_buffer.append', audio: message.audio }
if (message.type === 'audio.commit') return { type: 'input_audio_buffer.commit' }
return null
}
function normalizeRealtimeProviderMessage(raw: string): { type: string; text?: string; error?: string } | null {
let event: any
try {
event = JSON.parse(raw)
} catch {
return null
}
const type = typeof event.type === 'string' ? event.type : ''
const isTranscriptionDelta =
type === 'conversation.item.input_audio_transcription.delta' ||
type === 'input_audio_buffer.transcription.delta' ||
type === 'input_audio_transcription.delta'
const isTranscriptionCompleted =
type === 'conversation.item.input_audio_transcription.completed' ||
type === 'conversation.item.input_audio_transcription.done' ||
type === 'input_audio_buffer.transcription.completed' ||
type === 'input_audio_buffer.transcription.done' ||
type === 'input_audio_transcription.completed' ||
type === 'input_audio_transcription.done'
const partialText =
event.text_delta || event.partial || event.transcript?.partial || (isTranscriptionDelta ? event.delta : undefined)
if (typeof partialText === 'string') return { type: 'transcript.partial', text: partialText }
const finalText =
event.text || event.final || event.transcript?.text || (isTranscriptionCompleted ? event.transcript : undefined)
if (typeof finalText === 'string') return { type: 'transcript.final', text: finalText }
const error = event.error?.message || event.error
if (typeof error === 'string') return { type: 'error', error }
return null
}
function handleSttRealtimeWebSocket(client: WebSocket, req: http.IncomingMessage) {
if (!checkAuth(req)) {
client.send(JSON.stringify({ type: 'error', error: 'Unauthorized' }))
client.close()
return
}
const settings = getEffectiveSttSettings()
if (!settings.enabled || settings.mode !== 'realtime') {
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT is not enabled' }))
client.close()
return
}
if (settings.apiKeyRequired && !settings.apiKey) {
client.send(JSON.stringify({ type: 'error', error: 'STT API key is not configured' }))
client.close()
return
}
if (!validateSttBaseUrl(settings.baseUrl)) {
client.send(JSON.stringify({ type: 'error', error: 'Invalid STT baseUrl' }))
client.close()
return
}
const headers: Record<string, string> = {}
if (settings.apiKey) headers.Authorization = `Bearer ${settings.apiKey}`
const provider = new WebSocket(buildRealtimeProviderUrl(settings), { headers })
const closeBoth = () => {
if (provider.readyState === WebSocket.OPEN || provider.readyState === WebSocket.CONNECTING) provider.close()
if (client.readyState === WebSocket.OPEN) client.close()
}
provider.on('open', () => {
provider.send(JSON.stringify(buildRealtimeSessionConfigureMessage(settings)))
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'ready' }))
})
provider.on('message', data => {
const normalized = normalizeRealtimeProviderMessage(String(data))
if (normalized && client.readyState === WebSocket.OPEN) client.send(JSON.stringify(normalized))
})
provider.on('error', () => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'error', error: 'Realtime STT provider failed' }))
}
})
provider.on('close', () => {
if (client.readyState === WebSocket.OPEN) client.send(JSON.stringify({ type: 'closed' }))
})
client.on('message', data => {
if (provider.readyState !== WebSocket.OPEN) return
let message: SttRealtimeClientMessage
try {
message = JSON.parse(String(data))
} catch {
client.send(JSON.stringify({ type: 'error', error: 'Invalid realtime message' }))
return
}
if (message.type === 'session.close') {
closeBoth()
return
}
const providerMessage = translateRealtimeClientMessage(message, settings)
if (providerMessage) provider.send(JSON.stringify(providerMessage))
})
client.on('close', closeBoth)
client.on('error', closeBoth)
}
async function transcribeAudio(req: http.IncomingMessage) {
if (!isRequestBodySizeAllowed(req.headers['content-length'], 25 * 1024 * 1024)) {
return { status: 413, body: { error: 'Audio upload too large' } }
@@ -1149,6 +1321,9 @@ export default function (pi: ExtensionAPI) {
const authHeader = req.headers.authorization
if (validateBearerToken(authHeader, localToken)) return true
const queryToken = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('token')
if (queryToken && queryToken === localToken) return true
const host = req.headers.host
const origin = req.headers.origin as string | undefined
const lanAccessEnabled = getLocalSettings().network?.lanAccessEnabled === true
@@ -1400,10 +1575,107 @@ export default function (pi: ExtensionAPI) {
return
}
if (pathParts[1] === 'runtime' && pathParts[2] === 'capabilities' && method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(buildRuntimeCapabilities(latestCtx ?? undefined)))
return
}
if (pathParts[1] === 'providers' && pathParts[2] && pathParts[3] === 'api-key' && method === 'POST') {
try {
const registry = latestCtx?.modelRegistry as any
if (!registry?.authStorage?.set) {
res.writeHead(503, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Model registry is not ready' }))
return
}
const providerId = decodeURIComponent(pathParts[2])
const body = await readJsonBody(req)
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey.trim() : ''
if (!apiKey) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'apiKey is required' }))
return
}
registry.authStorage.set(providerId, { type: 'api_key', key: apiKey })
registry.refresh?.()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to save provider API key' }))
return
}
}
if (pathParts[1] === 'providers' && pathParts[2] && pathParts[3] === 'logout' && method === 'POST') {
try {
const registry = latestCtx?.modelRegistry as any
if (!registry?.authStorage?.logout) {
res.writeHead(503, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Model registry is not ready' }))
return
}
const providerId = decodeURIComponent(pathParts[2])
registry.authStorage.logout(providerId)
registry.refresh?.()
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ ok: true }))
return
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Failed to clear provider auth' }))
return
}
}
if (pathParts[1] === 'providers' && method === 'GET') {
const registry = latestCtx?.modelRegistry as any
const allModels: any[] = registry?.getAll?.() || []
const providerIds: string[] =
registry?.getProviderIds?.() || Array.from(new Set(allModels.map(model => model.provider).filter(Boolean)))
const providers = new Map<string, any>()
for (const providerId of providerIds) {
const providerModels: any[] =
registry?.getModelsForProvider?.(providerId) || allModels.filter(model => model.provider === providerId)
const authStatus = registry?.getProviderAuthStatus?.(providerId) ?? { configured: false }
const credential = registry?.authStorage?.get?.(providerId)
const usesOAuth =
credential?.type === 'oauth' ||
providerModels.some(model => registry?.isUsingOAuth?.(model)) ||
allModels.some(model => model.provider === providerId && registry?.isUsingOAuth?.(model))
providers.set(providerId, {
id: providerId,
name: registry?.getProviderDisplayName?.(providerId) || providerId,
models: providerModels.map(model => ({
id: model.id,
name: model.name || model.id,
provider: providerId,
family: model.family ?? '',
contextWindow: model.contextWindow ?? model.contextLimit,
maxOutputTokens: model.maxOutputTokens ?? model.maxTokens,
capabilities: {
input: { text: true, image: Array.isArray(model.input) ? model.input.includes('image') : undefined },
output: { text: true },
thinking: model.reasoning ?? model.supportsReasoning,
},
})),
auth: {
method: usesOAuth ? 'oauth' : 'apikey',
authorization: authStatus,
},
})
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ providers: Array.from(providers.values()) }))
return
}
if (pathParts[1] === 'models' && method === 'GET') {
const registry = latestCtx?.modelRegistry as any
const activeModel = latestCtx?.model as any
const registryModels: any[] =
latestCtx?.modelRegistry?.getAvailable() || latestCtx?.modelRegistry?.getAll() || []
const registryModels: any[] = registry?.getAvailable?.() || []
const mapModel = (m: any) => ({
id: m.id,
@@ -1423,7 +1695,9 @@ export default function (pi: ExtensionAPI) {
let models = registryModels.map(mapModel)
if (activeModel) {
const activeModelHasAuth = Boolean(activeModel && registry?.hasConfiguredAuth?.(activeModel))
if (activeModel && activeModelHasAuth) {
const registryActiveModel = registryModels.find(
(m: any) => m.id === activeModel.id && m.provider === activeModel.provider,
)
@@ -1443,26 +1717,6 @@ export default function (pi: ExtensionAPI) {
models = models.filter((m: any) => !(m.id === activeModel.id && m.provider === activeModel.provider))
models.unshift(activeMapped)
} else {
if (models.length === 0) {
models = [
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash',
provider: 'google',
family: '',
contextLimit: 128000,
outputLimit: 4096,
supportsReasoning: true,
supportsImages: true,
supportsPdf: false,
supportsAudio: false,
supportsVideo: false,
supportsToolcall: true,
variants: [],
},
]
}
}
res.writeHead(200, { 'Content-Type': 'application/json' })
@@ -2094,7 +2348,12 @@ export default function (pi: ExtensionAPI) {
}
// Upgrade WS safely
const pathname = new URL(request.url || '/', 'http://127.0.0.1').pathname
wss!.handleUpgrade(request, socket, head, (ws: WebSocket) => {
if (pathname === '/api/stt/realtime') {
handleSttRealtimeWebSocket(ws, request)
return
}
wss!.emit('connection', ws, request)
})
})
@@ -2122,8 +2381,7 @@ export default function (pi: ExtensionAPI) {
setWebLinkWidget(ctx)
// Write connection details to discovery file
const homeDir = process.env.USERPROFILE || process.env.HOME || ''
const piAgentDir = path.join(homeDir, '.pi', 'agent')
const piAgentDir = resolvePiAgentUiAgentDir()
const discoveryPath = path.join(piAgentDir, 'piagentui-port.json')
try {
if (!fs.existsSync(piAgentDir)) {
@@ -2149,8 +2407,7 @@ export default function (pi: ExtensionAPI) {
}
}
// Delete discovery file
const homeDir = process.env.USERPROFILE || process.env.HOME || ''
const discoveryPath = path.join(homeDir, '.pi', 'agent', 'piagentui-port.json')
const discoveryPath = path.join(resolvePiAgentUiAgentDir(), 'piagentui-port.json')
try {
if (fs.existsSync(discoveryPath)) {
fs.unlinkSync(discoveryPath)