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:
1 parent
b3489e1ede
commit
98b5db029f
70 files changed
+9466
-9191
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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')])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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() }))
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 元信息管理
|
||||
// ============================================
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user