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

+3
View File
@@ -37,6 +37,7 @@ import type { TerminalTab } from './store/layoutStore'
import type { SettingsTab } from './features/settings/SettingsDialog'
import { isTauri, isTauriMobile } from './utils/tauri'
import { ProviderOnboarding } from './features/onboarding/ProviderOnboarding'
const SettingsDialog = lazy(() =>
import('./features/settings/SettingsDialog').then(module => ({ default: module.SettingsDialog })),
)
@@ -623,6 +624,8 @@ function App() {
onCancel={handleCloseDialogCancel}
/>
</Suspense>
<ProviderOnboarding />
</ChatViewportProvider>
</div>
)
+3 -22
View File
@@ -30,6 +30,7 @@ export * from './global'
export * from './tool'
export * from './lsp'
export * from './extension'
export * from './runtime'
// ============================================
// Model API Functions
@@ -55,32 +56,12 @@ export async function getActiveModels(_directory?: string): Promise<ModelInfo[]>
variants: model.variants ?? [],
}))
} catch {
return [
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash',
providerId: 'google',
providerName: 'google',
family: '',
contextLimit: 128000,
outputLimit: 4096,
supportsReasoning: true,
supportsImages: true,
supportsPdf: false,
supportsAudio: false,
supportsVideo: false,
supportsToolcall: true,
variants: [],
},
]
return []
}
}
export async function getDefaultModels(_directory?: string): Promise<Record<string, string>> {
return {
google: 'gemini-2.5-flash',
anthropic: 'claude-3-5-sonnet',
}
return {}
}
// ============================================
+10 -29
View File
@@ -4,6 +4,7 @@
import type { Config } from '../types/api/config'
import type { ProvidersResponse } from '../types/api/model'
import { piClient } from './piClient'
/**
* 获取当前配置
@@ -26,33 +27,13 @@ export async function updateConfig(config: Config, _directory?: string): Promise
* 获取 provider 配置列表
*/
export async function getProviderConfigs(_directory?: string): Promise<ProvidersResponse> {
return {
providers: [
{
id: "google",
name: "Google Gemini",
models: [
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", provider: "google" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", provider: "google" }
],
auth: {
authorization: { configured: true }
}
},
{
id: "anthropic",
name: "Anthropic Claude",
models: [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", provider: "anthropic" }
],
auth: {
authorization: { configured: true }
}
}
],
default: {
"google": "gemini-2.5-flash",
"anthropic": "claude-3-5-sonnet"
}
}
return await piClient.get<ProvidersResponse>('/api/providers')
}
export async function saveProviderApiKey(providerId: string, apiKey: string): Promise<void> {
await piClient.post<{ ok: boolean }>(`/api/providers/${encodeURIComponent(providerId)}/api-key`, { apiKey })
}
export async function clearProviderAuth(providerId: string): Promise<void> {
await piClient.post<{ ok: boolean }>(`/api/providers/${encodeURIComponent(providerId)}/logout`)
}
+22 -7
View File
@@ -52,7 +52,7 @@ export async function getSessionMessages(
): Promise<ApiMessageWithParts[]> {
try {
const data = await piClient.get<{ messages: ApiMessageWithParts[] }>(
`/api/sessions/${sessionId}/messages?directory=${formatPathForApi(directory)}&limit=${limit || ''}`
`/api/sessions/${sessionId}/messages?directory=${formatPathForApi(directory)}&limit=${limit || ''}`,
)
return data.messages || []
} catch {
@@ -135,22 +135,37 @@ export function extractUserMessageContent(message: UserContentSource): RevertedM
* 同步发送消息(等待完成)
*/
export async function sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
return await piClient.post<SendMessageResponse>('/api/messages/send', {
const body: Record<string, unknown> = {
sessionId: params.sessionId,
text: params.text,
model: params.model,
directory: params.directory,
})
parts: [{ type: 'text', text: params.text }],
}
if (params.agent) body.agent = params.agent
if (params.variant) body.variant = params.variant
body.text = params.text
return await piClient.post<SendMessageResponse>('/api/messages/send', body)
}
/**
* 异步发送消息 — 立即返回, AI 响应通过 SSE/WS 推送
*/
export async function sendMessageAsync(params: SendMessageParams): Promise<void> {
await piClient.post('/api/messages/send', {
// The Pi server expects 'parts' array (OpenAPI spec) rather than plain 'text'.
// We send both for backward compatibility, but prefer 'parts' format.
const body: Record<string, unknown> = {
sessionId: params.sessionId,
text: params.text,
model: params.model,
directory: params.directory,
})
parts: [{ type: 'text', text: params.text }],
}
// Only include optional fields if they have values
if (params.agent) body.agent = params.agent
if (params.variant) body.variant = params.variant
// Send text as well for backward compatibility with older servers
body.text = params.text
await piClient.post('/api/messages/send', body)
}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getRuntimeCapabilities, parseRuntimeCapabilities } from './runtime'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
},
}))
describe('runtime api', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
})
it('parses Pi runtime capabilities from backend payloads', () => {
const capabilities = parseRuntimeCapabilities({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: true, source: 'pi.extensions' },
mcp: { available: true },
slashCommands: { available: true },
})
expect(capabilities).toEqual({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: true, source: 'pi.extensions' },
mcp: { available: true },
slashCommands: { available: true },
})
})
it('uses safe unavailable defaults for malformed capability payloads', () => {
const capabilities = parseRuntimeCapabilities({ runtime: 'unexpected', sessionSwitching: null })
expect(capabilities).toEqual({
runtime: 'unknown',
sessionSwitching: { available: false, reason: 'capability_unreported' },
extensionLoading: { available: false, reason: 'capability_unreported' },
mcp: { available: false, reason: 'capability_unreported' },
slashCommands: { available: false, reason: 'capability_unreported' },
})
})
it('loads runtime capabilities from the local backend endpoint', async () => {
vi.mocked(piClient.get).mockResolvedValue({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: false, reason: 'extensions_disabled' },
mcp: { available: true },
slashCommands: { available: true },
})
await expect(getRuntimeCapabilities()).resolves.toMatchObject({
runtime: 'pi',
sessionSwitching: { available: true },
extensionLoading: { available: false, reason: 'extensions_disabled' },
})
expect(piClient.get).toHaveBeenCalledWith('/api/runtime/capabilities')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { piClient } from './piClient'
import type { RuntimeCapabilities, RuntimeCapabilityStatus, RuntimeKind } from '../types/api/runtime'
const UNREPORTED_CAPABILITY: RuntimeCapabilityStatus = {
available: false,
reason: 'capability_unreported',
}
export function parseRuntimeCapabilities(value: unknown): RuntimeCapabilities {
const record = isRecord(value) ? value : {}
return {
runtime: parseRuntimeKind(record.runtime),
sessionSwitching: parseCapabilityStatus(record.sessionSwitching),
extensionLoading: parseCapabilityStatus(record.extensionLoading),
mcp: parseCapabilityStatus(record.mcp),
slashCommands: parseCapabilityStatus(record.slashCommands),
}
}
export async function getRuntimeCapabilities(): Promise<RuntimeCapabilities> {
const payload = await piClient.get<unknown>('/api/runtime/capabilities')
return parseRuntimeCapabilities(payload)
}
function parseRuntimeKind(value: unknown): RuntimeKind {
return value === 'pi' ? 'pi' : 'unknown'
}
function parseCapabilityStatus(value: unknown): RuntimeCapabilityStatus {
if (!isRecord(value) || typeof value.available !== 'boolean') {
return { ...UNREPORTED_CAPABILITY }
}
return {
available: value.available,
...(typeof value.reason === 'string' ? { reason: value.reason } : {}),
...(typeof value.source === 'string' ? { source: value.source } : {}),
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export type { RuntimeCapabilities, RuntimeCapabilityStatus, RuntimeKind }
+1 -12
View File
@@ -90,18 +90,7 @@ export async function createSession(
parentID?: string
} = {},
): Promise<ApiSession> {
try {
return await piClient.post<ApiSession>('/api/sessions', params)
} catch {
return {
id: 'active-session',
title: params.title || fallbackSessionTitle('active-session'),
directory: formatPathForApi(params.directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
return await piClient.post<ApiSession>('/api/sessions', params)
}
export async function updateSession(
+7 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSttSettings, transcribeAudio, updateSttSettings } from './stt'
import { getSttRealtimeUrl, getSttSettings, transcribeAudio, updateSttSettings } from './stt'
import { piClient } from './piClient'
import { serverStore } from '../store/serverStore'
@@ -58,6 +58,12 @@ describe('stt api', () => {
expect(updated.language).toBe('pt')
})
it('builds an authenticated realtime STT websocket URL', () => {
const url = getSttRealtimeUrl()
expect(url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
})
it('uploads audio as multipart form data with bearer auth', async () => {
const originalFetch = globalThis.fetch
const fetchMock = vi.fn(
+15
View File
@@ -10,6 +10,21 @@ export async function updateSttSettings(settings: UpdateSttSettingsRequest): Pro
return await piClient.post<SttSettings>('/api/settings/stt', settings)
}
export function getSttRealtimeUrl(): string {
const baseUrl = serverStore
.getActiveBaseUrl()
.replace(/^http:/, 'ws:')
.replace(/^https:/, 'wss:')
const token = serverStore.getActiveToken()
const url = new URL('/api/stt/realtime', baseUrl)
if (token) {
url.searchParams.set('token', token)
}
return url.toString()
}
function buildUploadHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
const token = serverStore.getActiveToken()
+21
View File
@@ -44,6 +44,12 @@ describe('ExtensionPanel', () => {
prompts: [],
themes: [],
},
compatibility: {
kind: 'pi',
status: 'supported',
reason: 'Package declares Pi extension entrypoints.',
capabilities: ['extensions', 'skills'],
},
},
{
source: 'npm:pi-muted',
@@ -60,6 +66,12 @@ describe('ExtensionPanel', () => {
prompts: [],
themes: [],
},
compatibility: {
kind: 'opencode',
status: 'partial',
reason: 'OpenCode manifest detected; execution is disabled until sandbox permissions are implemented.',
capabilities: ['extensions', 'commands'],
},
},
{
source: 'npm:missing-extension',
@@ -67,6 +79,12 @@ describe('ExtensionPanel', () => {
filtered: false,
status: 'missing',
resources: { extensions: [], skills: [], prompts: [], themes: [] },
compatibility: {
kind: 'unknown',
status: 'unsupported',
reason: 'No Pi or OpenCode extension manifest was detected.',
capabilities: [],
},
},
],
})
@@ -87,6 +105,9 @@ describe('ExtensionPanel', () => {
expect(screen.getAllByText('Extensions 1').length).toBeGreaterThan(0)
expect(screen.getByText('Skills 1')).toBeInTheDocument()
expect(screen.getByText('index.ts')).toBeInTheDocument()
expect(screen.getByText('Pi supported')).toBeInTheDocument()
expect(screen.getByText('OpenCode partial')).toBeInTheDocument()
expect(screen.getByText('Unsupported')).toBeInTheDocument()
const installInput = screen.getByRole('textbox', { name: 'Install command' })
fireEvent.change(installInput, { target: { value: 'pi install npm:pi-new' } })
+26
View File
@@ -346,6 +346,7 @@ function ExtensionPackageCard({
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[length:var(--fs-base)] font-medium text-text-100">{name}</span>
<StatusBadge status={pkg.status} />
{pkg.compatibility && <CompatibilityBadge compatibility={pkg.compatibility} />}
</div>
{pkg.description && (
<p className="mt-0.5 truncate text-[length:var(--fs-sm)] text-text-400">{pkg.description}</p>
@@ -418,6 +419,31 @@ function StatusBadge({ status }: { status: ExtensionPackageStatus }) {
)
}
function CompatibilityBadge({ compatibility }: { compatibility: NonNullable<ExtensionPackageInfo['compatibility']> }) {
const label = getCompatibilityLabel(compatibility)
const className = {
supported: 'border-success-100/20 bg-success-100/10 text-success-100',
partial: 'border-warning-100/20 bg-warning-100/10 text-warning-100',
unsupported: 'border-border-200/50 bg-bg-200/60 text-text-400',
}[compatibility.status]
return (
<span
className={`inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[length:var(--fs-xxs)] ${className}`}
title={compatibility.reason}
>
{label}
</span>
)
}
function getCompatibilityLabel(compatibility: NonNullable<ExtensionPackageInfo['compatibility']>): string {
if (compatibility.kind === 'pi' && compatibility.status === 'supported') return 'Pi supported'
if (compatibility.kind === 'opencode' && compatibility.status === 'partial') return 'OpenCode partial'
if (compatibility.status === 'unsupported') return 'Unsupported'
return `${compatibility.kind} ${compatibility.status}`
}
function countResources(
pkg: ExtensionPackageInfo,
): Array<{ type: keyof ExtensionPackageInfo['resources']; count: number }> {
+106 -3
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildTurnDurationMap } from './ChatArea'
import { createElement, createRef } from 'react'
import { act, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { buildTurnDurationMap, ChatArea, type ChatAreaHandle } from './ChatArea'
import { ChatViewportProvider, type ChatViewportValue } from './chatViewport'
import { buildVisibleMessageEntries, getVisibleMessageForkTargetId } from './chatAreaVisibility'
import type { Message, MessageError, Part, ToolPart, ReasoningPart } from '../../types/message'
@@ -18,7 +21,13 @@ function createUserMessage(id: string, created: number): Message {
}
}
function createAssistantMessage(id: string, parts: Part[], created = 1, completed?: number, error?: MessageError): Message {
function createAssistantMessage(
id: string,
parts: Part[],
created = 1,
completed?: number,
error?: MessageError,
): Message {
return {
info: {
id,
@@ -64,6 +73,100 @@ function createToolPart(id: string, messageID: string): ToolPart {
}
}
const viewportValue: ChatViewportValue = {
presentation: { surfaceVariant: 'desktop', isCompact: false },
interaction: {
mode: 'pointer',
touchCapable: false,
sidebarBehavior: 'docked',
rightPanelBehavior: 'docked',
bottomPanelBehavior: 'docked',
outlineInteraction: 'pointer',
enableCollapsedInputDock: false,
},
layout: {
viewportWidth: 1280,
viewportHeight: 800,
surfaceWidth: 900,
surfaceMinWidth: 380,
sidebar: {
railWidth: 49,
requestedWidth: 288,
openWidth: 288,
dockedWidth: 288,
overlayWidth: 288,
hardMinWidth: 160,
preferredMinWidth: 240,
maxWidth: 480,
resizeMaxWidth: 480,
},
rightPanel: {
requestedWidth: 450,
dockedWidth: 0,
hardMinWidth: 160,
maxWidth: 960,
resizeMaxWidth: 960,
},
bottomPanel: { maxHeight: 448 },
},
actions: { setSidebarRequestedWidth: () => {} },
}
class IntersectionObserverMock {
private readonly callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
}
observe(target: Element) {
this.callback(
[{ isIntersecting: false, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
)
}
disconnect() {}
unobserve() {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
}
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
callback(0)
return 0
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('ChatArea scrolling', () => {
it('scrolls to the bottom when the newest-message button calls scrollToBottom', () => {
const ref = createRef<ChatAreaHandle>()
const chatArea = createElement(ChatArea, {
ref,
messages: [createUserMessage('user-1', 1000), createAssistantMessage('assistant-1', [], 1001, 1200)],
sessionId: 'session-1',
})
const { container } = render(createElement(ChatViewportProvider, { value: viewportValue, children: chatArea }))
const scrollRoot = container.querySelector('.custom-scrollbar') as HTMLDivElement
const scrollTo = vi.fn()
Object.defineProperty(scrollRoot, 'scrollHeight', { value: 1200, configurable: true })
Object.defineProperty(scrollRoot, 'clientHeight', { value: 400, configurable: true })
Object.defineProperty(scrollRoot, 'scrollTo', { value: scrollTo, configurable: true })
act(() => ref.current?.scrollToBottom(true))
expect(scrollTo).toHaveBeenCalledWith({ top: 1200, behavior: 'auto' })
})
})
describe('buildVisibleMessageEntries', () => {
it('keeps source ids for merged assistant tool messages', () => {
const first = createAssistantMessage('assistant-1', [createToolPart('tool-1', 'assistant-1')])
+1 -1
View File
@@ -352,7 +352,7 @@ export const ChatArea = memo(
scrollToBottom: (instant = false) => {
const el = scrollRef.current
if (!el) return
el.scrollTo({ top: 0, behavior: instant ? 'auto' : 'smooth' })
el.scrollTo({ top: el.scrollHeight, behavior: instant ? 'auto' : 'smooth' })
},
scrollToBottomIfAtBottom: () => {
const el = scrollRef.current
+42 -5
View File
@@ -3,10 +3,12 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { InputBox } from './InputBox'
import type { Command } from '../../api/command'
import type { SttInsertMode, SttRealtimeDelta } from '../../types/api/stt'
import type { Message } from '../../types/message'
let slashCommands: Command[] = []
let messagesMock: Message[] = []
let latestVoiceDelta: ((event: SttRealtimeDelta, insertMode: SttInsertMode) => void) | undefined
function createHistoryMessage(text: string): Message {
return {
@@ -74,11 +76,22 @@ vi.mock('../slash-command', () => ({
}))
vi.mock('./input/InputToolbar', () => ({
InputToolbar: ({ onSend, canSend }: { onSend: () => void; canSend: boolean }) => (
<button type="button" onClick={onSend} disabled={!canSend}>
send
</button>
),
InputToolbar: ({
onSend,
canSend,
onVoiceTranscriptionDelta,
}: {
onSend: () => void
canSend: boolean
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
}) => {
latestVoiceDelta = onVoiceTranscriptionDelta
return (
<button type="button" onClick={onSend} disabled={!canSend}>
send
</button>
)
},
}))
vi.mock('./input/InputFooter', () => ({
@@ -109,6 +122,30 @@ describe('InputBox slash command selection', () => {
beforeEach(() => {
slashCommands = []
messagesMock = []
latestVoiceDelta = undefined
})
it('shows realtime voice partials without duplicating previous partial text', () => {
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(textarea, { target: { value: 'Resumo:' } })
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá' }, 'append'))
expect(textarea.value).toBe('Resumo: olá')
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: ' olá mundo' }, 'append'))
expect(textarea.value).toBe('Resumo: olá mundo')
})
it('keeps final realtime transcription editable in the composer', () => {
render(<InputBox paneId="pane-test" onSend={vi.fn()} />)
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement
act(() => latestVoiceDelta?.({ type: 'transcript.partial', text: 'olá' }, 'replace'))
act(() => latestVoiceDelta?.({ type: 'transcript.final', text: 'olá mundo' }, 'replace'))
fireEvent.change(textarea, { target: { value: `${textarea.value}!` } })
expect(textarea.value).toBe('olá mundo!')
})
it('executes frontend commands immediately on selection', async () => {
+47 -1
View File
@@ -23,7 +23,7 @@ import { useChatViewport } from './chatViewport'
import type { ApiAgent } from '../../api/client'
import type { ModelInfo, FileCapabilities } from '../../api'
import type { Command } from '../../api/command'
import type { SttInsertMode } from '../../types/api/stt'
import type { SttInsertMode, SttRealtimeDelta } from '../../types/api/stt'
// ============================================
// Types
@@ -178,6 +178,9 @@ function InputBoxComponent({
const footerRef = useRef<HTMLDivElement>(null)
const isComposingRef = useRef(false)
const compositionEndTimerRef = useRef<number | null>(null)
const voiceDraftBaseRef = useRef<string | null>(null)
const voiceDraftFinalRef = useRef('')
const voiceDraftPartialRef = useRef('')
// 附件横向轨道
const {
@@ -757,10 +760,52 @@ function InputBoxComponent({
[supportsAnyFile, fileCaps, isSubmitting],
)
const composeVoiceDraft = useCallback((insertMode: SttInsertMode) => {
const base = voiceDraftBaseRef.current ?? ''
const body = `${voiceDraftFinalRef.current} ${voiceDraftPartialRef.current}`.trim()
if (!body) return base
if (insertMode === 'replace' || !base.trim()) return body
const needsSpace = !base.endsWith(' ') && !base.endsWith('\n')
return `${base}${needsSpace ? ' ' : ''}${body}`
}, [])
const handleVoiceTranscriptionDelta = useCallback(
(event: SttRealtimeDelta, insertMode: SttInsertMode) => {
if (event.type !== 'transcript.partial' && event.type !== 'transcript.final') return
if (voiceDraftBaseRef.current === null) {
voiceDraftBaseRef.current = insertMode === 'replace' ? '' : text
voiceDraftFinalRef.current = ''
voiceDraftPartialRef.current = ''
}
if (event.type === 'transcript.partial') {
voiceDraftPartialRef.current = event.text
} else {
voiceDraftFinalRef.current = `${voiceDraftFinalRef.current} ${event.text}`.trim()
voiceDraftPartialRef.current = ''
}
setText(composeVoiceDraft(insertMode))
requestAnimationFrame(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.focus()
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
})
},
[composeVoiceDraft, text],
)
const handleVoiceTranscription = useCallback((transcribedText: string, insertMode: SttInsertMode) => {
const cleanText = transcribedText.trim()
if (!cleanText) return
voiceDraftBaseRef.current = null
voiceDraftFinalRef.current = ''
voiceDraftPartialRef.current = ''
setText(current => {
if (insertMode === 'replace' || !current.trim()) return cleanText
const needsSpace = !current.endsWith(' ') && !current.endsWith('\n')
@@ -1130,6 +1175,7 @@ function InputBoxComponent({
fileCapabilities={fileCaps}
onFilesSelected={handleFilesSelected}
onVoiceTranscription={handleVoiceTranscription}
onVoiceTranscriptionDelta={handleVoiceTranscriptionDelta}
isStreaming={isStreaming}
isSending={isSubmitting}
onAbort={onAbort}
+8 -2
View File
@@ -9,7 +9,7 @@ import { GitComposerControl } from './GitComposerControl'
import { isTauri, isTauriMobile, extToMime } from '../../../utils/tauri'
import type { ApiAgent } from '../../../api/client'
import type { ModelInfo, FileCapabilities } from '../../../api'
import type { SttInsertMode } from '../../../types/api/stt'
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
interface InputToolbarProps {
agents: ApiAgent[]
@@ -23,6 +23,7 @@ interface InputToolbarProps {
fileCapabilities?: FileCapabilities
onFilesSelected: (files: File[]) => void
onVoiceTranscription?: (text: string, insertMode: SttInsertMode) => void
onVoiceTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
isStreaming?: boolean
isSending?: boolean
@@ -52,6 +53,7 @@ export function InputToolbar({
fileCapabilities,
onFilesSelected,
onVoiceTranscription,
onVoiceTranscriptionDelta,
isStreaming,
isSending = false,
onAbort,
@@ -536,7 +538,11 @@ export function InputToolbar({
</>
</AnimatedPresence>
{onVoiceTranscription && (
<VoiceInputButton disabled={controlsDisabled} onTranscription={onVoiceTranscription} />
<VoiceInputButton
disabled={controlsDisabled}
onTranscription={onVoiceTranscription}
onTranscriptionDelta={onVoiceTranscriptionDelta}
/>
)}
{!canSend && isStreaming && !isSending ? (
<IconButton aria-label={t('inputToolbar.stopGeneration')} variant="solid" onClick={onAbort}>
@@ -20,9 +20,13 @@ describe('VoiceInputButton', () => {
resetError: vi.fn(),
})
render(<VoiceInputButton onTranscription={vi.fn()} />)
const onTranscription = vi.fn()
const onTranscriptionDelta = vi.fn()
render(<VoiceInputButton onTranscription={onTranscription} onTranscriptionDelta={onTranscriptionDelta} />)
fireEvent.click(screen.getByRole('button', { name: 'Start voice recording' }))
expect(useVoiceTranscription).toHaveBeenCalledWith({ onTranscription, onTranscriptionDelta })
expect(startRecording).toHaveBeenCalled()
})
+7 -3
View File
@@ -1,15 +1,19 @@
import { IconButton } from '../../../components/ui'
import { MicrophoneIcon, SpinnerIcon, StopIcon } from '../../../components/Icons'
import { useVoiceTranscription } from '../../../hooks/useVoiceTranscription'
import type { SttInsertMode } from '../../../types/api/stt'
import type { SttInsertMode, SttRealtimeDelta } from '../../../types/api/stt'
interface VoiceInputButtonProps {
disabled?: boolean
onTranscription: (text: string, insertMode: SttInsertMode) => void
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
}
export function VoiceInputButton({ disabled = false, onTranscription }: VoiceInputButtonProps) {
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({ onTranscription })
export function VoiceInputButton({ disabled = false, onTranscription, onTranscriptionDelta }: VoiceInputButtonProps) {
const { state, isAvailable, error, startRecording, stopRecording } = useVoiceTranscription({
onTranscription,
onTranscriptionDelta,
})
const isRecording = state === 'recording'
const isBusy = state === 'requesting-permission' || state === 'transcribing'
const isDisabled = disabled || !isAvailable || isBusy
@@ -0,0 +1,122 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ProviderOnboarding } from './ProviderOnboarding'
import { getProviderConfigs, saveProviderApiKey } from '../../api/config'
import type { Provider } from '../../types/api/model'
vi.mock('../../api/config', () => ({
getProviderConfigs: vi.fn(),
saveProviderApiKey: vi.fn(),
}))
const getProviderConfigsMock = vi.mocked(getProviderConfigs)
const saveProviderApiKeyMock = vi.mocked(saveProviderApiKey)
const PROVIDERS: Provider[] = [
{
id: 'opencode',
name: 'OpenCode Zen',
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash', provider: 'opencode' }],
auth: { method: 'apikey', authorization: { configured: false } },
} as Provider,
{
id: 'anthropic',
name: 'Anthropic',
models: [{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' }],
auth: { method: 'oauth', authorization: { configured: false } },
} as Provider,
]
function configuredProviders(): Provider[] {
return [
{
...PROVIDERS[0],
auth: { method: 'apikey', authorization: { configured: true, source: 'stored' } },
} as Provider,
PROVIDERS[1],
]
}
describe('ProviderOnboarding', () => {
beforeEach(() => {
getProviderConfigsMock.mockReset()
saveProviderApiKeyMock.mockReset()
saveProviderApiKeyMock.mockResolvedValue()
})
it('renders first-run provider catalog when no provider auth exists', async () => {
getProviderConfigsMock.mockResolvedValue({ providers: PROVIDERS })
render(<ProviderOnboarding />)
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
expect(screen.getByText('First run setup')).toBeInTheDocument()
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
expect(screen.getAllByText('1 models available').length).toBe(2)
expect(screen.getByText('Anthropic')).toBeInTheDocument()
expect(screen.getByText('API key')).toBeInTheDocument()
expect(screen.getAllByText('OAuth').length).toBeGreaterThan(0)
})
it('does not treat configured false auth sources as ready', async () => {
getProviderConfigsMock.mockResolvedValue({
providers: [
{
...PROVIDERS[0],
auth: {
method: 'apikey',
authorization: { configured: false, source: 'environment', label: 'OPENCODE_API_KEY' },
},
} as Provider,
],
})
render(<ProviderOnboarding />)
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
})
it('opens onboarding even when the provider catalog is empty', async () => {
getProviderConfigsMock.mockResolvedValue({ providers: [] })
render(<ProviderOnboarding />)
expect(await screen.findByText('Connect a model provider')).toBeInTheDocument()
expect(screen.getByText(/Loading provider catalog/)).toBeInTheDocument()
})
it('filters providers by search text and auth method', async () => {
getProviderConfigsMock.mockResolvedValue({ providers: PROVIDERS })
render(<ProviderOnboarding />)
const search = await screen.findByPlaceholderText('Search providers or models…')
fireEvent.change(search, { target: { value: 'deepseek' } })
expect(screen.getByText('OpenCode Zen')).toBeInTheDocument()
expect(screen.queryByText('Anthropic')).not.toBeInTheDocument()
fireEvent.change(search, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'OAuth' }))
expect(screen.queryByText('OpenCode Zen')).not.toBeInTheDocument()
expect(screen.getByText('Anthropic')).toBeInTheDocument()
})
it('saves an API key and closes after provider auth exists', async () => {
getProviderConfigsMock.mockResolvedValueOnce({ providers: PROVIDERS })
getProviderConfigsMock.mockResolvedValueOnce({ providers: configuredProviders() })
render(<ProviderOnboarding />)
expect(await screen.findByText('OpenCode Zen')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Add key' }))
fireEvent.change(screen.getByPlaceholderText('Paste API key'), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: 'Save key' }))
await waitFor(() => expect(saveProviderApiKeyMock).toHaveBeenCalledWith('opencode', 'test-key'))
await waitFor(() => expect(screen.queryByText('Connect a model provider')).not.toBeInTheDocument())
})
})
@@ -0,0 +1,260 @@
import { useEffect, useMemo, useState } from 'react'
import { getProviderConfigs, saveProviderApiKey } from '../../api/config'
import type { Provider } from '../../types/api/model'
function providerHasAuth(provider: Provider): boolean {
const authorization = provider.auth?.authorization as { configured?: boolean } | undefined
return authorization?.configured === true
}
function providerModelCount(provider: Provider): number {
return Array.isArray(provider.models) ? provider.models.length : 0
}
export function ProviderOnboarding() {
const [providers, setProviders] = useState<Provider[]>([])
const [loading, setLoading] = useState(true)
const [dismissed, setDismissed] = useState(false)
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<'all' | 'apikey' | 'oauth'>('all')
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
const [apiKey, setApiKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [retryCount, setRetryCount] = useState(0)
async function refresh() {
setLoading(true)
try {
const data = await getProviderConfigs()
setProviders(data.providers)
setLoadError(null)
} catch (err) {
setProviders([])
setLoadError(err instanceof Error ? err.message : 'Failed to load provider catalog')
} finally {
setLoading(false)
}
}
useEffect(() => {
void refresh()
}, [])
useEffect(() => {
if (loading || providers.length > 0 || retryCount >= 5) return
const timeout = window.setTimeout(() => {
setRetryCount(count => count + 1)
void refresh()
}, 1200)
return () => window.clearTimeout(timeout)
}, [loading, providers.length, retryCount])
const needsOnboarding = useMemo(() => !loading && !providers.some(providerHasAuth), [loading, providers])
const filteredProviders = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
return providers
.filter(provider => (filter === 'all' ? true : provider.auth?.method === filter))
.filter(provider => {
if (!normalizedQuery) return true
return (
provider.name.toLowerCase().includes(normalizedQuery) ||
provider.id.toLowerCase().includes(normalizedQuery) ||
provider.models.some(
model => model.name?.toLowerCase().includes(normalizedQuery) || model.id.includes(normalizedQuery),
)
)
})
.sort((a, b) => Number(providerHasAuth(b)) - Number(providerHasAuth(a)) || a.name.localeCompare(b.name))
}, [providers, query, filter])
if (dismissed || !needsOnboarding) return null
const activeProvider = providers.find(provider => provider.id === activeProviderId) ?? null
async function saveActiveKey() {
if (!activeProvider || !apiKey.trim()) return
setSaving(true)
setError(null)
try {
await saveProviderApiKey(activeProvider.id, apiKey)
setApiKey('')
setActiveProviderId(null)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save API key')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-[80] bg-black/55 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-5xl max-h-[86vh] overflow-hidden rounded-[28px] border border-border-200/70 bg-bg-000 shadow-2xl">
<div className="px-7 py-6 border-b border-border-200/60 bg-gradient-to-br from-bg-050 to-bg-100/65">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[length:var(--fs-xs)] uppercase tracking-[0.2em] text-accent-main-100 font-semibold">
First run setup
</div>
<h2 className="mt-2 text-[length:var(--fs-2xl)] font-semibold text-text-100">Connect a model provider</h2>
<p className="mt-2 max-w-2xl text-[length:var(--fs-sm)] leading-6 text-text-400">
Pi Agent UI is using a private Pi runtime for this app. Add an API key or sign in to an OAuth provider
to start a session without touching your system Pi configuration.
</p>
</div>
<button
type="button"
onClick={() => setDismissed(true)}
className="px-3 py-1.5 rounded-full text-[length:var(--fs-xs)] text-text-400 hover:text-text-100 hover:bg-bg-200/70 transition-colors"
>
Skip for now
</button>
</div>
<div className="mt-5 flex flex-col md:flex-row gap-3">
<input
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="Search providers or models…"
className="flex-1 rounded-2xl border border-border-200/60 bg-bg-000/70 px-4 py-3 text-[length:var(--fs-sm)] text-text-100 outline-none focus:border-accent-main-100"
/>
<div className="flex rounded-2xl border border-border-200/60 bg-bg-000/55 p-1">
{(['all', 'apikey', 'oauth'] as const).map(value => (
<button
key={value}
type="button"
onClick={() => setFilter(value)}
className={`px-4 py-2 rounded-xl text-[length:var(--fs-xs)] font-medium transition-colors ${
filter === value ? 'bg-accent-main-100 text-white' : 'text-text-400 hover:text-text-100'
}`}
>
{value === 'all' ? 'All' : value === 'apikey' ? 'API keys' : 'OAuth'}
</button>
))}
</div>
</div>
</div>
{(providers.length === 0 || loadError) && (
<div className="p-6 border-b border-border-200/60 text-[length:var(--fs-sm)] text-text-400 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<span>
{loadError
? `Could not load the provider catalog: ${loadError}`
: 'Loading provider catalog from the private Pi runtime…'}
</span>
<button
type="button"
onClick={() => {
setRetryCount(0)
void refresh()
}}
className="self-start rounded-xl border border-border-200/70 px-3 py-2 text-[length:var(--fs-xs)] font-semibold text-text-100 hover:bg-bg-100"
>
Retry
</button>
</div>
)}
<div className="p-5 overflow-y-auto max-h-[56vh]">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{filteredProviders.map(provider => {
const configured = providerHasAuth(provider)
const method = provider.auth?.method ?? 'apikey'
return (
<div
key={provider.id}
className="rounded-2xl border border-border-200/60 bg-bg-050/60 p-4 hover:border-border-200 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[length:var(--fs-md)] font-semibold text-text-100 truncate">
{provider.name}
</div>
<div className="mt-1 text-[length:var(--fs-xs)] text-text-400 truncate">{provider.id}</div>
</div>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-[length:var(--fs-2xs)] font-semibold ${
configured ? 'bg-green-500/15 text-green-400' : 'bg-bg-200 text-text-400'
}`}
>
{configured ? 'Ready' : method === 'oauth' ? 'OAuth' : 'API key'}
</span>
</div>
<div className="mt-4 text-[length:var(--fs-xs)] text-text-400">
{providerModelCount(provider)} models available
</div>
<div className="mt-4 flex gap-2">
{method === 'oauth' ? (
<button
type="button"
disabled
className="flex-1 rounded-xl border border-border-200/60 px-3 py-2 text-[length:var(--fs-sm)] text-text-400 opacity-70"
title="OAuth login will be enabled when the backend exposes a browser login flow."
>
Login
</button>
) : (
<button
type="button"
onClick={() => {
setActiveProviderId(provider.id)
setError(null)
}}
className="flex-1 rounded-xl bg-accent-main-100 px-3 py-2 text-[length:var(--fs-sm)] font-semibold text-white hover:opacity-90 transition-opacity"
>
Add key
</button>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
{activeProvider && (
<div className="absolute inset-0 z-[81] bg-black/45 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-3xl border border-border-200/70 bg-bg-000 p-6 shadow-2xl">
<h3 className="text-[length:var(--fs-xl)] font-semibold text-text-100">
Add {activeProvider.name} API key
</h3>
<p className="mt-2 text-[length:var(--fs-sm)] text-text-400">
The key is stored only in this app’s private Pi auth.json.
</p>
<input
value={apiKey}
onChange={event => setApiKey(event.target.value)}
type="password"
autoFocus
placeholder="Paste API key"
className="mt-5 w-full rounded-2xl border border-border-200/60 bg-bg-050 px-4 py-3 text-text-100 outline-none focus:border-accent-main-100"
/>
{error && <div className="mt-3 text-[length:var(--fs-xs)] text-red-400">{error}</div>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setActiveProviderId(null)}
className="rounded-xl px-4 py-2 text-[length:var(--fs-sm)] text-text-400 hover:text-text-100"
>
Cancel
</button>
<button
type="button"
disabled={saving || !apiKey.trim()}
onClick={() => void saveActiveKey()}
className="rounded-xl bg-accent-main-100 px-4 py-2 text-[length:var(--fs-sm)] font-semibold text-white disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save key'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { createOptimisticSessionDraft, createOptimisticSessionId, isOptimisticSessionId } from './fastSessionLaunch'
describe('fast session launch helpers', () => {
it('creates deterministic optimistic session IDs with the reserved prefix', () => {
const id = createOptimisticSessionId('seed-123')
expect(id).toBe('optimistic-session:seed-123')
expect(isOptimisticSessionId(id)).toBe(true)
expect(isOptimisticSessionId('real-session-id')).toBe(false)
})
it('creates a pending session draft that can render before the runtime responds', () => {
const draft = createOptimisticSessionDraft({
id: 'optimistic-session:test',
now: 1770000000000,
directory: 'C:\\Users\\Evandro\\project',
title: ' Build fast session launch ',
parentID: 'parent-session',
})
expect(draft).toEqual({
id: 'optimistic-session:test',
title: 'Build fast session launch',
directory: 'C:/Users/Evandro/project',
parentID: 'parent-session',
time: {
created: 1770000000000,
updated: 1770000000000,
},
status: {
type: 'running',
message: 'Creating session…',
},
summary: {
deletions: 0,
files: 0,
additions: 0,
},
})
})
it('falls back to a safe title and removes control characters', () => {
const draft = createOptimisticSessionDraft({
id: 'optimistic-session:safe-title',
now: 1770000000000,
title: ' \u0000\n\t ',
})
expect(draft.title).toBe('New Session')
})
})
@@ -0,0 +1,73 @@
import { normalizeToForwardSlash } from '../../utils/directoryUtils'
import type { ApiSession } from '../../api/types'
export const OPTIMISTIC_SESSION_ID_PREFIX = 'optimistic-session:'
export interface OptimisticSessionDraftInput {
directory?: string
title?: string
parentID?: string
id?: string
now?: number
}
export function createOptimisticSessionId(seed: string = createSessionIdSeed()): string {
if (seed.startsWith(OPTIMISTIC_SESSION_ID_PREFIX)) return seed
return `${OPTIMISTIC_SESSION_ID_PREFIX}${seed}`
}
export function isOptimisticSessionId(sessionId: string): boolean {
return sessionId.startsWith(OPTIMISTIC_SESSION_ID_PREFIX)
}
export function createOptimisticSessionDraft(input: OptimisticSessionDraftInput = {}): ApiSession {
const timestamp = input.now ?? Date.now()
const id = input.id ? createOptimisticSessionId(input.id) : createOptimisticSessionId()
const title = normalizeSessionTitle(input.title)
const directory = normalizeToForwardSlash(input.directory)
const parentID = normalizeOptionalString(input.parentID)
return {
id,
title,
directory,
...(parentID ? { parentID } : {}),
time: {
created: timestamp,
updated: timestamp,
},
status: {
type: 'running',
message: 'Creating session…',
},
summary: {
deletions: 0,
files: 0,
additions: 0,
},
}
}
function createSessionIdSeed(): string {
const randomUUID = globalThis.crypto?.randomUUID?.()
if (randomUUID) return randomUUID
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
}
function normalizeSessionTitle(title: string | undefined): string {
const normalized = normalizeOptionalString(title)
return normalized || 'New Session'
}
function normalizeOptionalString(value: string | undefined): string {
if (!value) return ''
return value
.split('')
.map(char => {
const code = char.charCodeAt(0)
return code < 32 || code === 127 ? ' ' : char
})
.join('')
.replace(/\s+/g, ' ')
.trim()
}
@@ -8,6 +8,19 @@ import { isTauri } from '../../../utils/tauri'
import { apiErrorHandler } from '../../../utils'
import { Toggle, SettingRow, SettingsCard } from './SettingsUI'
type PiAgentConnection = { port: number; token: string }
type PiAgentServiceStart = {
started: boolean
connection?: PiAgentConnection | null
}
function installPiAgentConnection(conn: PiAgentConnection) {
;(globalThis as any).window.PI_BOOT_DATA = {
baseUrl: `http://127.0.0.1:${conn.port}`,
token: conn.token,
}
}
export function ServiceSettings() {
const { t } = useTranslation(['settings', 'common'])
const isMobile = useIsMobile()
@@ -57,13 +70,14 @@ export function ServiceSettings() {
try {
const { invoke } = await import('@tauri-apps/api/core')
serviceStore.setStarting(true)
const weStarted = await invoke<boolean>('start_pi_service', {
const result = await invoke<PiAgentServiceStart>('start_pi_service', {
url: getServerUrl(),
binaryPath: serviceStore.effectiveBinaryPath,
envVars: serviceStore.envVarsRecord,
})
serviceStore.setStartedByUs(weStarted)
serviceStore.setRunning(true)
serviceStore.setStartedByUs(result.started)
serviceStore.setRunning(Boolean(result.connection))
if (result.connection) installPiAgentConnection(result.connection)
} catch (e) {
const msg = String(e)
apiErrorHandler('start service', msg)
@@ -88,7 +102,9 @@ export function ServiceSettings() {
const handleCheckService = async () => {
try {
const { invoke } = await import('@tauri-apps/api/core')
const running = await invoke<boolean>('check_pi_service', { url: getServerUrl() })
const conn = await invoke<PiAgentConnection | null>('get_pi_agent_connection')
const url = conn ? `http://127.0.0.1:${conn.port}` : getServerUrl()
const running = await invoke<boolean>('check_pi_service', { url })
serviceStore.setRunning(running)
if (running) {
const byUs = await invoke<boolean>('get_service_started_by_us')
@@ -17,6 +17,8 @@ const SETTINGS = {
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
language: 'pt',
insertMode: 'append' as const,
}
@@ -75,6 +77,29 @@ describe('SttSettings', () => {
expect(screen.getByLabelText('Model')).toHaveValue('gpt-4o-mini-transcribe')
})
it('edits realtime transcription settings', async () => {
render(<SttSettings />)
fireEvent.change(await screen.findByLabelText('Mode'), { target: { value: 'realtime' } })
fireEvent.change(screen.getByLabelText('Realtime endpoint'), {
target: { value: '/v1/realtime/transcriptions' },
})
fireEvent.change(screen.getByLabelText('Realtime model'), {
target: { value: 'gpt-4o-realtime-preview' },
})
fireEvent.click(screen.getByRole('button', { name: 'Save transcription settings' }))
await waitFor(() =>
expect(updateSttSettings).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'realtime',
realtimeEndpoint: '/v1/realtime/transcriptions',
realtimeModel: 'gpt-4o-realtime-preview',
}),
),
)
})
it('styles dropdown lists with the app select treatment', async () => {
render(<SttSettings />)
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { Button } from '../../../components/ui/Button'
import { getSttSettings, updateSttSettings } from '../../../api/stt'
import type { SttInsertMode, SttProviderKind, SttSettings as SttSettingsData } from '../../../types/api/stt'
import type { SttInsertMode, SttMode, SttProviderKind, SttSettings as SttSettingsData } from '../../../types/api/stt'
import { SettingRow, SettingsCard, SettingsSelect, Toggle } from './SettingsUI'
const inputClass =
@@ -42,6 +42,8 @@ export function SttSettings() {
apiKeyRequired: settings.apiKeyRequired,
transcriptionEndpoint: settings.transcriptionEndpoint,
transcriptionModel: settings.transcriptionModel,
realtimeEndpoint: settings.realtimeEndpoint,
realtimeModel: settings.realtimeModel,
language: settings.language || null,
insertMode: settings.insertMode,
}),
@@ -96,6 +98,17 @@ export function SttSettings() {
placeholder="http://127.0.0.1:8022/v1"
/>
</label>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Mode</span>
<SettingsSelect
wrapperClassName="w-full"
value={settings.mode}
onChange={event => setSettings({ ...settings, mode: event.target.value as SttMode })}
>
<option value="file">File upload</option>
<option value="realtime">Realtime WebSocket</option>
</SettingsSelect>
</label>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Endpoint</span>
<input
@@ -114,6 +127,28 @@ export function SttSettings() {
placeholder="deepdml/faster-whisper-large-v3-turbo-ct2"
/>
</label>
{settings.mode === 'realtime' && (
<>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime endpoint</span>
<input
className={inputClass}
value={settings.realtimeEndpoint}
onChange={event => setSettings({ ...settings, realtimeEndpoint: event.target.value })}
placeholder="/realtime/transcriptions"
/>
</label>
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Realtime model</span>
<input
className={inputClass}
value={settings.realtimeModel}
onChange={event => setSettings({ ...settings, realtimeModel: event.target.value })}
placeholder="gpt-4o-realtime-preview"
/>
</label>
</>
)}
<label className="space-y-1">
<span className="text-[length:var(--fs-xs)] font-medium text-text-300">Language</span>
<input
+183 -1
View File
@@ -14,6 +14,8 @@ const {
errorHandlerMock,
sendMessageAsyncMock,
abortSessionMock,
activeSessionStoreMock,
notificationStoreMock,
} = vi.hoisted(() => ({
createSessionMock: vi.fn(),
summarizeSessionMock: vi.fn(),
@@ -26,6 +28,14 @@ const {
errorHandlerMock: vi.fn(),
sendMessageAsyncMock: vi.fn(),
abortSessionMock: vi.fn(),
activeSessionStoreMock: {
addOptimisticSession: vi.fn(),
reconcileOptimisticSession: vi.fn(),
rollbackOptimisticSession: vi.fn(),
},
notificationStoreMock: {
push: vi.fn(),
},
}))
vi.mock('../store', () => ({
@@ -39,6 +49,7 @@ vi.mock('../store', () => ({
handleMessageUpdated: vi.fn(),
handlePartUpdated: vi.fn(),
setMessages: vi.fn(),
clearSession: vi.fn(),
},
useSessionFamily: () => [],
useSessionState: () => null,
@@ -52,6 +63,8 @@ vi.mock('../store', () => ({
registerChildSession: vi.fn(),
getSessionAndDescendants: vi.fn(() => []),
},
activeSessionStore: activeSessionStoreMock,
notificationStore: notificationStoreMock,
useActiveSessionStore: () => ({ statusMap: {} }),
}))
@@ -295,6 +308,10 @@ describe('useChatSession handleSend and abort', () => {
createSessionMock.mockReset()
navigateToSessionMock.mockReset()
navigateHomeMock.mockReset()
activeSessionStoreMock.addOptimisticSession.mockReset()
activeSessionStoreMock.reconcileOptimisticSession.mockReset()
activeSessionStoreMock.rollbackOptimisticSession.mockReset()
notificationStoreMock.push.mockReset()
createSessionMock.mockResolvedValue({ id: 'new-session-id', directory: '/workspace/demo' })
})
@@ -352,7 +369,19 @@ describe('useChatSession handleSend and abort', () => {
expect(success).toBe(true)
expect(createSessionMock).toHaveBeenCalled()
expect(navigateToSessionMock).toHaveBeenCalledWith('new-session-id', '/workspace/demo')
expect(navigateToSessionMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/^optimistic-session:/),
'/workspace/demo',
)
expect(navigateToSessionMock).toHaveBeenLastCalledWith('new-session-id', '/workspace/demo')
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
{
id: 'new-session-id',
directory: '/workspace/demo',
},
)
expect(sendMessageAsyncMock).toHaveBeenCalledWith({
sessionId: 'new-session-id',
text: 'hello new session',
@@ -366,4 +395,157 @@ describe('useChatSession handleSend and abort', () => {
directory: '/workspace/demo',
})
})
it('navigates to an optimistic session before backend session creation resolves', async () => {
let resolveCreateSession!: (value: { id: string; directory: string }) => void
createSessionMock.mockReturnValue(
new Promise(resolve => {
resolveCreateSession = resolve
}),
)
const { result } = renderHook(() =>
useChatSession({
paneId: 'pane-1',
chatAreaRef: { current: null },
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
refetchModels: vi.fn(async () => {}),
sessionId: null,
navigateToSession: navigateToSessionMock,
navigateHome: navigateHomeMock,
}),
)
let sendPromise!: Promise<boolean>
await act(async () => {
sendPromise = result.current.handleSend('hello new session', [])
await Promise.resolve()
})
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringMatching(/^optimistic-session:/),
directory: '/workspace/demo',
status: { type: 'running', message: 'Creating session…' },
}),
)
expect(navigateToSessionMock).toHaveBeenCalledWith(expect.stringMatching(/^optimistic-session:/), '/workspace/demo')
expect(sendMessageAsyncMock).not.toHaveBeenCalled()
await act(async () => {
resolveCreateSession({ id: 'new-session-id', directory: '/workspace/demo' })
await sendPromise
})
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
{
id: 'new-session-id',
directory: '/workspace/demo',
},
)
expect(sendMessageAsyncMock).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'new-session-id' }))
})
it('uses the optimistic session path for the explicit new session action', async () => {
let resolveCreateSession!: (value: { id: string; directory: string }) => void
createSessionMock.mockReturnValue(
new Promise(resolve => {
resolveCreateSession = resolve
}),
)
const { result } = renderHook(() =>
useChatSession({
paneId: 'pane-1',
chatAreaRef: { current: null },
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
refetchModels: vi.fn(async () => {}),
sessionId: 'current-session',
navigateToSession: navigateToSessionMock,
navigateHome: navigateHomeMock,
}),
)
await act(async () => {
result.current.handleNewSession()
await Promise.resolve()
})
expect(navigateHomeMock).not.toHaveBeenCalled()
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.stringMatching(/^optimistic-session:/), directory: '/workspace/demo' }),
)
expect(navigateToSessionMock).toHaveBeenCalledWith(expect.stringMatching(/^optimistic-session:/), '/workspace/demo')
await act(async () => {
resolveCreateSession({ id: 'new-session-id', directory: '/workspace/demo' })
await Promise.resolve()
})
expect(activeSessionStoreMock.reconcileOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
{
id: 'new-session-id',
directory: '/workspace/demo',
},
)
expect(navigateToSessionMock).toHaveBeenLastCalledWith('new-session-id', '/workspace/demo')
})
it('rolls back the optimistic session and returns false when backend creation fails during send', async () => {
createSessionMock.mockRejectedValue(new Error('runtime unavailable'))
const { result } = renderHook(() =>
useChatSession({
paneId: 'pane-1',
chatAreaRef: { current: null },
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
refetchModels: vi.fn(async () => {}),
sessionId: null,
navigateToSession: navigateToSessionMock,
navigateHome: navigateHomeMock,
}),
)
let success = true
await act(async () => {
success = await result.current.handleSend('recoverable draft', [])
})
expect(success).toBe(false)
expect(activeSessionStoreMock.addOptimisticSession).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.stringMatching(/^optimistic-session:/) }),
)
expect(activeSessionStoreMock.rollbackOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
)
expect(sendMessageAsyncMock).not.toHaveBeenCalled()
})
it('rolls back the optimistic session when explicit new session creation fails', async () => {
createSessionMock.mockRejectedValue(new Error('runtime unavailable'))
const { result } = renderHook(() =>
useChatSession({
paneId: 'pane-1',
chatAreaRef: { current: null },
currentModel: { id: 'model-1', providerId: 'provider-1', name: 'Model 1', variants: [] } as any,
refetchModels: vi.fn(async () => {}),
sessionId: 'current-session',
navigateToSession: navigateToSessionMock,
navigateHome: navigateHomeMock,
}),
)
await act(async () => {
result.current.handleNewSession()
await Promise.resolve()
await Promise.resolve()
})
expect(activeSessionStoreMock.rollbackOptimisticSession).toHaveBeenCalledWith(
expect.stringMatching(/^optimistic-session:/),
)
})
})
+100 -24
View File
@@ -10,6 +10,8 @@ import {
autoApproveStore,
childSessionStore,
useActiveSessionStore,
notificationStore,
activeSessionStore,
type RevertHistoryItem,
} from '../store'
import {
@@ -49,6 +51,7 @@ import type { ChatAreaHandle } from '../features/chat'
import { followupQueueStore, useFollowupQueue } from '../store/followupQueueStore'
import { themeStore } from '../store/themeStore'
import { createOptimisticSessionDraft } from '../features/sessions/fastSessionLaunch'
const handleError = createErrorHandler('session')
/**
@@ -543,6 +546,39 @@ export function useChatSession({
setPendingQuestionRequests,
])
const createSessionWithOptimisticRoute = useCallback(
async (directory: string | undefined) => {
const optimisticSession = createOptimisticSessionDraft({ directory })
activeSessionStore.addOptimisticSession(optimisticSession)
messageStore.setMessages(optimisticSession.id, [], {
directory: optimisticSession.directory || directory || '',
title: optimisticSession.title,
hasMoreHistory: false,
revertState: null,
})
navigateToSession(optimisticSession.id, directory)
try {
const newSession = await createSession()
activeSessionStore.reconcileOptimisticSession(optimisticSession.id, newSession)
messageStore.clearSession(optimisticSession.id)
messageStore.setMessages(newSession.id, [], {
directory: newSession.directory || directory || '',
title: newSession.title || undefined,
hasMoreHistory: false,
revertState: null,
})
navigateToSession(newSession.id, directory)
return { session: newSession, optimisticSessionId: optimisticSession.id }
} catch (error) {
activeSessionStore.rollbackOptimisticSession(optimisticSession.id)
messageStore.clearSession(optimisticSession.id)
throw error
}
},
[createSession, navigateToSession],
)
const sendMessageNow = useCallback(
async (input: {
sessionId?: string | null
@@ -557,6 +593,7 @@ export function useChatSession({
let rollbackSnapshot = sessionId ? messageStore.createSendRollbackSnapshot(sessionId) : null
let isNewSession = false
const allowCreate = input.allowCreateSession ?? true
let optimisticSessionId: string | null = null
try {
if (!sessionId) {
@@ -565,27 +602,17 @@ export function useChatSession({
'%c[Message Flow] Requesting new session creation on extension server...',
'color: #2196f3; font-weight: bold;',
)
const newSession = await createSession()
const { session: newSession, optimisticSessionId: pendingSessionId } = await createSessionWithOptimisticRoute(
input.directory,
)
console.log(
'%c[Message Flow] Session created successfully:',
'color: #4caf50; font-weight: bold;',
newSession,
)
optimisticSessionId = pendingSessionId
sessionId = newSession.id
isNewSession = true
// Pre-initialize the message store for the new session BEFORE navigating.
// This prevents the grey screen: without this, navigating triggers
// useSessionManager.loadSession() which sets loadState='loading' and
// fetches messages (which are empty at this point), showing a blank screen.
// By initializing with streaming=true and loadState='loaded', the UI
// correctly shows the chat surface in its "waiting for response" state.
messageStore.setMessages(sessionId, [], {
directory: newSession.directory || input.directory,
title: newSession.title || undefined,
hasMoreHistory: false,
revertState: null,
})
}
if (rollbackSnapshot) {
@@ -603,11 +630,10 @@ export function useChatSession({
// creating a new one every time.
if (isNewSession) {
console.log(
'%c[Message Flow] Transitioning route navigation to session view:',
'%c[Message Flow] Session route already transitioned to real session:',
'color: #e91e63; font-weight: bold;',
sessionId,
)
navigateToSession(sessionId, input.directory)
}
console.log(
@@ -669,6 +695,18 @@ export function useChatSession({
return true
} catch (error) {
handleError('send message', error)
notificationStore.push(
'error',
'Falha ao enviar mensagem',
error instanceof Error
? error.message
: 'Não foi possível conectar ao servidor de IA. Verifique se o Pi está rodando.',
sessionId || '',
)
if (optimisticSessionId) {
activeSessionStore.rollbackOptimisticSession(optimisticSessionId)
messageStore.clearSession(optimisticSessionId)
}
if (sessionId) {
if (rollbackSnapshot) {
messageStore.restoreSendRollback(sessionId, rollbackSnapshot)
@@ -684,11 +722,50 @@ export function useChatSession({
[routeSessionId, navigateToSession, createSession],
)
// Creates a new session with user-visible feedback when no model is available.
// This prevents the silent-nothing-happens problem: even without a model,
// the user sees a new session created and understands what to do next.
const createSessionWithToast = useCallback(async () => {
try {
const newSession = await createSession()
notificationStore.push(
'error',
'Nenhum modelo disponível',
'Sessão criada, mas não foi possível enviar a mensagem. Selecione um modelo ou verifique se o servidor de IA está conectado.',
newSession.id,
)
navigateToSession(newSession.id, effectiveDirectory || '')
} catch (error) {
handleError('create session', error)
notificationStore.push(
'error',
'Falha ao criar sessão',
'Não foi possível criar uma nova sessão. Verifique se o servidor Pi está rodando.',
'',
)
}
}, [createSession, navigateToSession, effectiveDirectory])
// Send message handler
const handleSend = useCallback(
async (content: string, attachments: Attachment[], options?: { agent?: string; variant?: string }) => {
if (!currentModel) {
handleError('send message', new Error('No model selected'))
refetchModels()
// Create a session anyway so the user sees something happen
// instead of complete silence, but don't try to send the message
// (empty model would cause Bad Request on the server).
if (!routeSessionId) {
createSessionWithToast()
} else {
notificationStore.push(
'error',
'Nenhum modelo disponível',
'Selecione um modelo para enviar a mensagem. Verifique se o servidor de IA está conectado.',
routeSessionId,
)
}
return false
}
@@ -918,8 +995,7 @@ export function useChatSession({
if (!command) return false
if (command === 'new') {
navigateHome()
handleNewChat()
await createSessionWithOptimisticRoute(effectiveDirectory)
return true
}
@@ -928,9 +1004,8 @@ export function useChatSession({
try {
// Create session if needed (like handleSend does)
if (!sessionId) {
const newSession = await createSession()
const { session: newSession } = await createSessionWithOptimisticRoute(effectiveDirectory)
sessionId = newSession.id
navigateToSession(sessionId, newSession.directory)
}
if (command === 'compact') {
@@ -964,7 +1039,7 @@ export function useChatSession({
return false
}
},
[routeSessionId, effectiveDirectory, createSession, navigateToSession, currentModel, navigateHome, handleNewChat],
[routeSessionId, effectiveDirectory, createSessionWithOptimisticRoute, currentModel],
)
// Undo with animation
@@ -997,9 +1072,10 @@ export function useChatSession({
// New session
const handleNewSession = useCallback(() => {
navigateHome()
handleNewChat()
}, [navigateHome, handleNewChat])
void createSessionWithOptimisticRoute(effectiveDirectory).catch(error => {
handleError('create session', error)
})
}, [createSessionWithOptimisticRoute, effectiveDirectory])
// Archive current session
const handleArchiveSession = useCallback(async () => {
+95 -2
View File
@@ -1,9 +1,10 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useVoiceTranscription } from './useVoiceTranscription'
import { getSttSettings, transcribeAudio } from '../api/stt'
import { getSttRealtimeUrl, getSttSettings, transcribeAudio } from '../api/stt'
vi.mock('../api/stt', () => ({
getSttRealtimeUrl: vi.fn(() => 'ws://127.0.0.1:58785/api/stt/realtime?token=token-123'),
getSttSettings: vi.fn(),
transcribeAudio: vi.fn(),
}))
@@ -14,8 +15,13 @@ class MockMediaRecorder extends EventTarget {
ondataavailable: ((event: BlobEvent) => void) | null = null
onstop: (() => void) | null = null
start() {
start(timeslice?: number) {
this.state = 'recording'
if (timeslice) {
void Promise.resolve().then(() => {
this.ondataavailable?.({ data: new Blob(['audio'], { type: 'audio/webm' }) } as BlobEvent)
})
}
}
stop() {
@@ -25,8 +31,46 @@ class MockMediaRecorder extends EventTarget {
}
}
class MockWebSocket extends EventTarget {
static instances: MockWebSocket[] = []
readyState: number = WebSocket.CONNECTING
sent: string[] = []
onopen: (() => void) | null = null
onmessage: ((event: MessageEvent) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
readonly url: string
constructor(url: string) {
super()
this.url = url
MockWebSocket.instances.push(this)
}
send(data: string) {
this.sent.push(data)
}
close() {
this.readyState = WebSocket.CLOSED
this.onclose?.()
}
open() {
this.readyState = WebSocket.OPEN
this.onopen?.()
}
receive(message: unknown) {
this.onmessage?.({ data: JSON.stringify(message) } as MessageEvent)
}
}
describe('useVoiceTranscription', () => {
beforeEach(() => {
MockWebSocket.instances = []
vi.mocked(getSttRealtimeUrl).mockClear()
vi.mocked(getSttSettings).mockReset()
vi.mocked(transcribeAudio).mockReset()
vi.mocked(getSttSettings).mockResolvedValue({
@@ -38,6 +82,8 @@ describe('useVoiceTranscription', () => {
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
insertMode: 'append',
})
vi.mocked(transcribeAudio).mockResolvedValue({
@@ -75,6 +121,47 @@ describe('useVoiceTranscription', () => {
expect(result.current.state).toBe('idle')
})
it('streams realtime transcription deltas while recording', async () => {
vi.mocked(getSttSettings).mockResolvedValue({
enabled: true,
providerKind: 'openai-compatible',
mode: 'realtime',
baseUrl: 'http://127.0.0.1:8022/v1',
apiKeyConfigured: false,
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'model-file',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'model-realtime',
insertMode: 'append',
})
Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: MockWebSocket })
const onTranscription = vi.fn()
const onTranscriptionDelta = vi.fn()
const { result } = renderHook(() => useVoiceTranscription({ onTranscription, onTranscriptionDelta }))
await act(async () => {
await result.current.startRecording()
})
const ws = MockWebSocket.instances[0]
expect(ws.url).toBe('ws://127.0.0.1:58785/api/stt/realtime?token=token-123')
act(() => {
ws.open()
ws.receive({ type: 'ready' })
})
expect(result.current.state).toBe('recording')
await waitFor(() => expect(ws.sent.some(data => JSON.parse(data).type === 'audio.append')).toBe(true))
expect(ws.sent.some(data => JSON.parse(data).type === 'audio.commit')).toBe(true)
act(() => ws.receive({ type: 'transcript.partial', text: 'olá' }))
expect(onTranscriptionDelta).toHaveBeenCalledWith({ type: 'transcript.partial', text: 'olá' }, 'append')
await act(async () => result.current.stopRecording())
expect(onTranscription).not.toHaveBeenCalled()
})
it('keeps the voice button available even when STT is not configured', async () => {
vi.mocked(getSttSettings).mockResolvedValue({
enabled: false,
@@ -85,6 +172,8 @@ describe('useVoiceTranscription', () => {
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'gpt-4o-mini-transcribe',
insertMode: 'append',
})
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
@@ -110,6 +199,8 @@ describe('useVoiceTranscription', () => {
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
insertMode: 'append',
})
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
@@ -133,6 +224,8 @@ describe('useVoiceTranscription', () => {
apiKeyRequired: false,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
realtimeEndpoint: '/realtime/transcriptions',
realtimeModel: 'deepdml/faster-whisper-large-v3-turbo-ct2',
insertMode: 'append',
})
const { result } = renderHook(() => useVoiceTranscription({ onTranscription: vi.fn() }))
+119 -6
View File
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { getSttSettings, transcribeAudio } from '../api/stt'
import type { SttInsertMode, SttSettings } from '../types/api/stt'
import { getSttRealtimeUrl, getSttSettings, transcribeAudio } from '../api/stt'
import type { SttInsertMode, SttRealtimeDelta, SttSettings } from '../types/api/stt'
export type VoiceTranscriptionState = 'idle' | 'requesting-permission' | 'recording' | 'transcribing' | 'error'
interface UseVoiceTranscriptionOptions {
onTranscription: (text: string, insertMode: SttInsertMode) => void
onTranscriptionDelta?: (event: SttRealtimeDelta, insertMode: SttInsertMode) => void
}
function isRecordingSupported(): boolean {
@@ -16,13 +17,22 @@ function isRecordingSupported(): boolean {
)
}
export function useVoiceTranscription({ onTranscription }: UseVoiceTranscriptionOptions) {
async function blobToBase64(blob: Blob): Promise<string> {
const bytes = new Uint8Array(await blob.arrayBuffer())
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
export function useVoiceTranscription({ onTranscription, onTranscriptionDelta }: UseVoiceTranscriptionOptions) {
const [state, setState] = useState<VoiceTranscriptionState>('idle')
const [settings, setSettings] = useState<SttSettings | null>(null)
const [error, setError] = useState<string | null>(null)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const realtimeWsRef = useRef<WebSocket | null>(null)
const realtimeInsertModeRef = useRef<SttInsertMode>('append')
const loadSettings = useCallback(async () => {
const nextSettings = await getSttSettings()
@@ -52,6 +62,81 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
streamRef.current = null
}, [])
const startRealtimeRecording = useCallback(
async (activeSettings: SttSettings) => {
if (typeof WebSocket === 'undefined') {
setError('Realtime voice transcription is not supported in this browser')
setState('error')
return
}
const ws = new WebSocket(getSttRealtimeUrl())
realtimeWsRef.current = ws
realtimeInsertModeRef.current = activeSettings.insertMode
ws.onmessage = event => {
let message: SttRealtimeDelta
try {
message = JSON.parse(String(event.data))
} catch {
return
}
if (message.type === 'error') {
setError(message.error)
setState('error')
return
}
if (message.type === 'transcript.partial' || message.type === 'transcript.final') {
onTranscriptionDelta?.(message, realtimeInsertModeRef.current)
}
}
ws.onerror = () => {
setError('Realtime voice transcription failed')
setState('error')
}
ws.onclose = () => {
if (realtimeWsRef.current === ws) realtimeWsRef.current = null
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
const mimeType = MediaRecorder.isTypeSupported?.('audio/webm') ? 'audio/webm' : undefined
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
recorderRef.current = recorder
recorder.ondataavailable = event => {
if (event.data.size <= 0 || ws.readyState !== WebSocket.OPEN) return
void blobToBase64(event.data).then(audio => {
if (ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'audio.append', audio }))
ws.send(JSON.stringify({ type: 'audio.commit' }))
})
}
recorder.onstop = () => {
cleanupStream()
recorderRef.current = null
window.setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'session.close' }))
ws.close()
if (realtimeWsRef.current === ws) realtimeWsRef.current = null
}, 250)
setState('idle')
}
recorder.start(500)
setState('recording')
} catch {
cleanupStream()
realtimeWsRef.current = null
ws.close()
setError('Microphone permission denied')
setState('error')
}
},
[cleanupStream, onTranscriptionDelta],
)
const startRecording = useCallback(async () => {
if (state === 'recording' || state === 'requesting-permission' || state === 'transcribing') return
setError(null)
@@ -79,6 +164,11 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
return
}
if (activeSettings.mode === 'realtime') {
await startRealtimeRecording(activeSettings)
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
streamRef.current = stream
@@ -112,20 +202,43 @@ export function useVoiceTranscription({ onTranscription }: UseVoiceTranscription
setError('Microphone permission denied')
setState('error')
}
}, [cleanupStream, loadSettings, onTranscription, state])
}, [cleanupStream, loadSettings, onTranscription, startRealtimeRecording, state])
const stopRecording = useCallback(() => {
const recorder = recorderRef.current
const realtimeWs = realtimeWsRef.current
if (realtimeWs) {
if (recorder && recorder.state !== 'inactive') {
recorder.stop()
return
}
cleanupStream()
recorderRef.current = null
if (realtimeWs.readyState === WebSocket.OPEN) realtimeWs.send(JSON.stringify({ type: 'session.close' }))
realtimeWs.close()
realtimeWsRef.current = null
setState('idle')
return
}
if (!recorder || recorder.state === 'inactive') return
recorder.stop()
}, [])
}, [cleanupStream])
const resetError = useCallback(() => {
setError(null)
setState('idle')
}, [])
useEffect(() => cleanupStream, [cleanupStream])
useEffect(
() => () => {
realtimeWsRef.current?.close()
realtimeWsRef.current = null
cleanupStream()
},
[cleanupStream],
)
return {
state,
+48 -44
View File
@@ -1,5 +1,5 @@
import { StrictMode, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { createElement } from 'react'
import 'katex/dist/katex.min.css'
import './index.css'
import './i18n'
@@ -17,7 +17,7 @@ import { serviceStore } from './store/serviceStore'
import { reconnectSSE } from './api/events'
import { resetPathModeCache } from './utils/directoryUtils'
import { isTauri, isTauriMobile } from './utils/tauri'
import { apiErrorHandler, globalErrorHandler } from './utils/errorHandling'
import { globalErrorHandler } from './utils/errorHandling'
// Polyfill: randomUUID 在非 HTTPS 环境可能缺失(如局域网 HTTP)
// 统一补齐,避免业务层 scattered fallback。
@@ -77,6 +77,21 @@ serverStore.onServerChange(() => {
const isNativeTauri = isTauri()
const isNativeTauriMobile = isNativeTauri && isTauriMobile()
type PiAgentConnection = { port: number; token: string }
type PiAgentServiceStart = {
started: boolean
connection?: PiAgentConnection | null
agent_dir?: string
session_dir?: string
}
function installPiAgentConnection(conn: PiAgentConnection) {
;(globalThis as any).window.PI_BOOT_DATA = {
baseUrl: `http://127.0.0.1:${conn.port}`,
token: conn.token,
}
}
// Tauri 原生 app 初始化
if (isNativeTauri) {
// 添加 CSS class 用于 safe-area 适配
@@ -91,29 +106,7 @@ if (isNativeTauri) {
}
}
// Auto-start pi serve(如果设置开启)
if (!isNativeTauriMobile && serviceStore.autoStart) {
const serverUrl = serverStore.getActiveBaseUrl() || 'http://127.0.0.1:4096'
const binaryPath = serviceStore.effectiveBinaryPath
import('@tauri-apps/api/core').then(({ invoke }) => {
serviceStore.setStarting(true)
invoke<boolean>('start_pi_service', { url: serverUrl, binaryPath, envVars: serviceStore.envVarsRecord })
.then(weStarted => {
serviceStore.setStartedByUs(weStarted)
serviceStore.setRunning(true)
serviceStore.setStarting(false)
if (weStarted) {
console.info('[Service] pi serve started by app')
} else {
console.info('[Service] pi serve already running')
}
})
.catch(err => {
serviceStore.setStarting(false)
apiErrorHandler('auto-start pi serve', err)
})
})
}
// The isolated Pi backend is discovered/started inside bootstrap() before React renders.
}
// 全局错误处理 - 防止未捕获错误导致页面刷新
@@ -131,33 +124,44 @@ async function bootstrap() {
if (isTauri()) {
try {
const { invoke } = await import('@tauri-apps/api/core')
const conn = await invoke<{ port: number; token: string } | null>('get_pi_agent_connection')
let conn = await invoke<PiAgentConnection | null>('get_pi_agent_connection')
const shouldAutoStartPi = serviceStore.autoStart || navigator.webdriver === true
if (!conn && !isNativeTauriMobile && shouldAutoStartPi) {
serviceStore.setStarting(true)
const result = await invoke<PiAgentServiceStart>('start_pi_service', {
url: serverStore.getActiveBaseUrl() || 'http://127.0.0.1:4096',
binaryPath: serviceStore.effectiveBinaryPath,
envVars: serviceStore.envVarsRecord,
})
serviceStore.setStartedByUs(result.started)
serviceStore.setRunning(Boolean(result.connection))
conn = result.connection ?? null
}
if (conn) {
console.log('[PiAgentUi] Discovered Pi Agent connection info:', conn)
;(globalThis as any).window.PI_BOOT_DATA = {
baseUrl: `http://127.0.0.1:${conn.port}`,
token: conn.token,
}
console.log('[PiAgentUi] Discovered isolated Pi Agent connection info:', conn)
installPiAgentConnection(conn)
} else {
console.log('[PiAgentUi] No active Pi Agent connection file found. App will run in standalone mode.')
console.log('[PiAgentUi] No isolated Pi Agent backend found. App will run in standalone mode.')
}
} catch (err) {
console.error('[PiAgentUi] Failed to get Pi Agent connection:', err)
console.error('[PiAgentUi] Failed to initialize Pi Agent backend:', err)
serviceStore.setRunning(false)
} finally {
serviceStore.setStarting(false)
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<Suspense fallback={null}>
<DirectoryProvider>
<SessionProvider>
<App />
</SessionProvider>
</DirectoryProvider>
</Suspense>
</ErrorBoundary>
</StrictMode>,
createElement(
ErrorBoundary,
null,
createElement(
DirectoryProvider,
null,
createElement(SessionProvider, null, createElement(App)),
),
),
)
}
+64
View File
@@ -44,4 +44,68 @@ describe('activeSessionStore scoped refresh handling', () => {
description: 'Need approval',
})
})
it('adds an optimistic session as an active running entry', () => {
activeSessionStore.addOptimisticSession({
id: 'optimistic-session:test',
title: 'Pending session',
directory: '/workspace/app',
time: { created: 1, updated: 1 },
status: { type: 'running', message: 'Creating session…' },
summary: { deletions: 0, files: 0, additions: 0 },
})
expect(activeSessionStore.getBusySessions()).toEqual([
{
sessionId: 'optimistic-session:test',
status: { type: 'running', message: 'Creating session…' },
title: 'Pending session',
directory: '/workspace/app',
pendingAction: undefined,
},
])
})
it('replaces an optimistic session with the real runtime session', () => {
activeSessionStore.addOptimisticSession({
id: 'optimistic-session:test',
title: 'Pending session',
directory: '/workspace/app',
time: { created: 1, updated: 1 },
status: { type: 'running', message: 'Creating session…' },
summary: { deletions: 0, files: 0, additions: 0 },
})
activeSessionStore.reconcileOptimisticSession('optimistic-session:test', {
id: 'real-session',
title: 'Real session',
directory: '/workspace/app',
time: { created: 2, updated: 2 },
status: { type: 'busy' },
summary: { deletions: 0, files: 0, additions: 0 },
})
expect(activeSessionStore.getBusySessions().map(entry => entry.sessionId)).toEqual(['real-session'])
expect(activeSessionStore.getSessionMeta('optimistic-session:test')).toBeUndefined()
expect(activeSessionStore.getSessionMeta('real-session')).toEqual({
title: 'Real session',
directory: '/workspace/app',
})
})
it('rolls back an optimistic session when runtime creation fails', () => {
activeSessionStore.addOptimisticSession({
id: 'optimistic-session:test',
title: 'Pending session',
directory: '/workspace/app',
time: { created: 1, updated: 1 },
status: { type: 'running', message: 'Creating session…' },
summary: { deletions: 0, files: 0, additions: 0 },
})
activeSessionStore.rollbackOptimisticSession('optimistic-session:test')
expect(activeSessionStore.getBusySessions()).toEqual([])
expect(activeSessionStore.getSessionMeta('optimistic-session:test')).toBeUndefined()
})
})
+66 -1
View File
@@ -13,6 +13,7 @@
import { useCallback, useSyncExternalStore } from 'react'
import type { SessionStatus, SessionStatusMap } from '../types/api/session'
import type { ApiSession } from '../api/types'
// ============================================
// Types
@@ -88,7 +89,7 @@ class ActiveSessionStore {
private recomputeDerived() {
const entries = Object.entries(this.state.statusMap)
.filter(([, status]) => status.type === 'busy' || status.type === 'retry')
.filter(([, status]) => status.type === 'busy' || status.type === 'running' || status.type === 'retry')
.map(([sessionId, status]) => {
const meta = this.sessionMeta.get(sessionId)
// 从自身 pendingRequests 查 pending action
@@ -292,6 +293,70 @@ class ActiveSessionStore {
this.notify()
}
addOptimisticSession(session: ApiSession) {
const newMap = {
...this.state.statusMap,
[session.id]: session.status ?? { type: 'running' as const, message: 'Creating session…' },
}
this.sessionMeta.set(session.id, {
title: session.title,
directory: session.directory,
})
this.state = { ...this.state, statusMap: newMap }
this.notify()
}
reconcileOptimisticSession(optimisticSessionId: string, realSession: ApiSession) {
const newMap = { ...this.state.statusMap }
delete newMap[optimisticSessionId]
if (realSession.status && realSession.status.type !== 'idle') {
newMap[realSession.id] = realSession.status
}
const optimisticMeta = this.sessionMeta.get(optimisticSessionId)
this.sessionMeta.delete(optimisticSessionId)
this.sessionMeta.set(realSession.id, {
title: realSession.title ?? optimisticMeta?.title,
directory: realSession.directory ?? optimisticMeta?.directory,
})
this.reassignSessionReferences(optimisticSessionId, realSession.id)
this.state = { ...this.state, statusMap: newMap }
this.notify()
}
rollbackOptimisticSession(optimisticSessionId: string) {
const newMap = { ...this.state.statusMap }
delete newMap[optimisticSessionId]
this.sessionMeta.delete(optimisticSessionId)
this.removeSessionReferences(optimisticSessionId)
this.state = { ...this.state, statusMap: newMap }
this.notify()
}
private reassignSessionReferences(fromSessionId: string, toSessionId: string) {
for (const req of this.pendingRequests.values()) {
if (req.sessionId === fromSessionId) {
req.sessionId = toSessionId
}
}
if (this.deferredIdleSessions.delete(fromSessionId)) {
this.deferredIdleSessions.add(toSessionId)
}
}
private removeSessionReferences(sessionId: string) {
for (const [requestId, req] of this.pendingRequests.entries()) {
if (req.sessionId === sessionId) {
this.pendingRequests.delete(requestId)
}
}
this.deferredIdleSessions.delete(sessionId)
}
// ============================================
// Session 元信息管理
// ============================================
+7 -5
View File
@@ -5,6 +5,7 @@
// ============================================
import { useSyncExternalStore } from 'react'
import { isTauri, isTauriMobile } from '../utils/tauri'
const STORAGE_KEY_AUTO_START = 'opencode-auto-start-service'
const STORAGE_KEY_BINARY_PATH = 'opencode-binary-path'
@@ -48,7 +49,8 @@ class ServiceStore {
constructor() {
try {
this._autoStart = localStorage.getItem(STORAGE_KEY_AUTO_START) === 'true'
const storedAutoStart = localStorage.getItem(STORAGE_KEY_AUTO_START)
this._autoStart = storedAutoStart === null ? isTauri() && !isTauriMobile() : storedAutoStart === 'true'
} catch {
this._autoStart = false
}
@@ -89,7 +91,7 @@ class ServiceStore {
/** 返回实际要用的可执行文件路径,空则回退默认值 */
get effectiveBinaryPath() {
return this._binaryPath.trim() || 'opencode'
return this._binaryPath.trim() || 'pi'
}
/** 将 envVars 转为 Record<string, string>,方便传给 Rust */
@@ -109,7 +111,7 @@ class ServiceStore {
try {
localStorage.setItem(STORAGE_KEY_AUTO_START, String(v))
} catch {
/* */
void 0
}
this._notify()
}
@@ -119,7 +121,7 @@ class ServiceStore {
try {
localStorage.setItem(STORAGE_KEY_BINARY_PATH, v)
} catch {
/* */
void 0
}
this._notify()
}
@@ -129,7 +131,7 @@ class ServiceStore {
try {
localStorage.setItem(STORAGE_KEY_ENV_VARS, JSON.stringify(vars))
} catch {
/* */
void 0
}
this._notify()
}
+11
View File
@@ -1,5 +1,15 @@
export type ExtensionPackageScope = 'user' | 'project'
export type ExtensionPackageStatus = 'active' | 'inactive' | 'missing' | 'error'
export type ExtensionCompatibilityKind = 'pi' | 'opencode' | 'unknown'
export type ExtensionCompatibilityStatus = 'supported' | 'partial' | 'unsupported'
export type ExtensionCompatibilityCapability = 'extensions' | 'skills' | 'prompts' | 'themes' | 'commands'
export interface ExtensionCompatibilityDescriptor {
kind: ExtensionCompatibilityKind
status: ExtensionCompatibilityStatus
reason: string
capabilities: ExtensionCompatibilityCapability[]
}
export interface ExtensionResource {
name: string
@@ -27,6 +37,7 @@ export interface ExtensionPackageInfo {
status: ExtensionPackageStatus
error?: string
resources: ExtensionPackageResources
compatibility?: ExtensionCompatibilityDescriptor
}
export interface ExtensionCatalogSummary {
+15
View File
@@ -0,0 +1,15 @@
export type RuntimeKind = 'pi' | 'unknown'
export interface RuntimeCapabilityStatus {
available: boolean
reason?: string
source?: string
}
export interface RuntimeCapabilities {
runtime: RuntimeKind
sessionSwitching: RuntimeCapabilityStatus
extensionLoading: RuntimeCapabilityStatus
mcp: RuntimeCapabilityStatus
slashCommands: RuntimeCapabilityStatus
}
+11
View File
@@ -11,6 +11,8 @@ export interface SttSettings {
apiKeyRequired: boolean
transcriptionEndpoint: string
transcriptionModel: string
realtimeEndpoint: string
realtimeModel: string
language?: string
insertMode: SttInsertMode
}
@@ -24,6 +26,8 @@ export interface UpdateSttSettingsRequest {
apiKeyRequired?: boolean
transcriptionEndpoint?: string
transcriptionModel?: string
realtimeEndpoint?: string
realtimeModel?: string
language?: string | null
insertMode?: SttInsertMode
}
@@ -34,3 +38,10 @@ export interface SttTranscriptionResponse {
provider: SttProviderKind
model: string
}
export type SttRealtimeDelta =
| { type: 'ready' }
| { type: 'transcript.partial'; text: string }
| { type: 'transcript.final'; text: string }
| { type: 'error'; error: string }
| { type: 'closed' }
+7 -1
View File
@@ -7,7 +7,13 @@
* 通过检查 window.__TAURI_INTERNALS__ 来判断
*/
export function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
if (typeof window === 'undefined') return false
return (
'__TAURI_INTERNALS__' in window ||
window.location.protocol === 'tauri:' ||
window.location.hostname === 'tauri.localhost' ||
navigator.userAgent.includes('Tauri')
)
}
export function isTauriMobile(): boolean {