🎉 Initial commit: PiAgentUI — AI Agent Desktop & Web UI
Build & Push Frontend Docker Image / build-and-push (push) Canceled after 0s
Build Validation / validate (push) Canceled after 6s
Build & Push Backend Docker Image / build-and-push (push) Failing after 10m10s
Build & Push Gateway Docker Image / build-and-push (push) Failing after 10m9s

A professional workspace for AI coding agents with:
- Real-time streaming chat with SSE
- Session management (fork, clone, tree navigation)
- MCP tools browser & metadata panel
- Skills browser (global, project, package sources)
- Voice input & STT provider configuration
- Integrated terminal & file explorer
- Model selector with capability metadata
- Keyboard-first UI with command palette
- 8 languages (en, pt-BR, es, ja, zh-CN, ar, bn, hi)
- Tauri 2 desktop app
- React 19 + TypeScript + Vite 8 + Tailwind CSS 4
- Pi Agent extension runtime

Originally forked from OpenCodeUI, now an independent project.
License: GPL-3.0-only
This commit is contained in:
Evandro committed 2026-06-01 00:50:06 -03:00
commit b3489e1ede
983 files changed
+158475

No files matched your search

+631
View File
@@ -0,0 +1,631 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { invoke } from '@tauri-apps/api/core'
import { Sidebar } from './features/chat'
import { ChatPane } from './features/chat/ChatPane'
import { SplitContainer } from './features/chat/SplitContainer'
import type { CommandItem } from './components/CommandPalette'
import { ToastContainer } from './components/ToastContainer'
import { RightPanel } from './components/RightPanel'
import { BottomPanel } from './components/BottomPanel'
import { DesktopTitlebar } from './components/DesktopTitlebar'
import { useDirectory, useGlobalEvents, useGlobalKeybindings, useRouter } from './hooks'
import { useViewportHeight } from './hooks/useViewportHeight'
import { useCloseServiceDialog } from './hooks/useCloseServiceDialog'
import { useWakeLock } from './hooks/useWakeLock'
import type { KeybindingHandlers } from './hooks/useKeybindings'
import { keybindingStore } from './store/keybindingStore'
import {
layoutStore,
paneLayoutStore,
useLayoutStore,
usePaneController,
usePaneControllers,
usePaneLayout,
updateStore,
} from './store'
import {
ChatViewportProvider,
CHAT_SURFACE_MIN_WIDTH,
canUseSplitPane,
useChatViewportController,
} from './features/chat/chatViewport'
import { uiErrorHandler, isSameDirectory, collectActiveDirectories } from './utils'
import { initNotificationSound } from './utils/notificationSoundBridge'
import { createPtySession } from './api/pty'
import type { TerminalTab } from './store/layoutStore'
import type { SettingsTab } from './features/settings/SettingsDialog'
import { isTauri, isTauriMobile } from './utils/tauri'
const SettingsDialog = lazy(() =>
import('./features/settings/SettingsDialog').then(module => ({ default: module.SettingsDialog })),
)
const CommandPalette = lazy(() =>
import('./components/CommandPalette').then(module => ({ default: module.CommandPalette })),
)
const CloseServiceDialog = lazy(() =>
import('./components/CloseServiceDialog').then(module => ({ default: module.CloseServiceDialog })),
)
function App() {
const { t } = useTranslation(['commands', 'chat', 'common', 'components'])
const router = useRouter()
const {
sessionId: routeSessionId,
directory: routeDirectory,
navigateToSession: navigateRouteToSession,
navigateHome: navigateRouteHome,
replaceSession,
} = router
const { currentDirectory, savedDirectories, sidebarExpanded, setSidebarExpanded } = useDirectory()
const { rightPanelOpen, rightPanelWidth, wakeLock } = useLayoutStore()
const { surfaceRef, value: chatViewport } = useChatViewportController({
sidebarExpanded,
rightPanelOpen,
requestedRightPanelWidth: rightPanelWidth,
})
const splitPaneEnabled = canUseSplitPane(chatViewport)
const paneLayout = usePaneLayout()
const focusedController = usePaneController(paneLayout.focusedPaneId)
const paneControllers = usePaneControllers()
const syncingFromRouteRef = useRef(false)
const lastRouteSessionIdRef = useRef<string | null | undefined>(undefined)
// 当 currentDirectory 为 undefined 时表示全局模式,
// 不应 fallback 到 session 自身的 directory,否则 replaceSession 会把 dir 参数写回 URL
const focusedRouteDirectory =
currentDirectory !== undefined
? paneLayout.focusedSessionId === routeSessionId
? routeDirectory || focusedController?.effectiveDirectory || currentDirectory
: focusedController?.effectiveDirectory || currentDirectory
: undefined
useEffect(() => {
const cleanup = initNotificationSound()
return cleanup
}, [])
useEffect(() => {
if (!isTauri() || isTauriMobile()) return
void invoke('desktop_window_ready').catch(() => {
// best effort only
})
}, [])
useEffect(() => {
if (import.meta.env.DEV) return
void updateStore.checkForUpdates()
}, [])
useViewportHeight()
useWakeLock(wakeLock)
const activeDirectories = useMemo(
() =>
collectActiveDirectories({
routeDirectory,
currentDirectory,
paneDirectories: paneControllers
.map(controller => controller.effectiveDirectory)
.filter((directory): directory is string => Boolean(directory)),
projectDirectories: savedDirectories.map(directory => directory.path),
}),
[routeDirectory, currentDirectory, paneControllers, savedDirectories],
)
// 全局唯一 SSE 连接。所有 pane 通过 consumer 机制接收自己的 session 事件。
useGlobalEvents(activeDirectories)
// URL -> focused pane session
useEffect(() => {
if (lastRouteSessionIdRef.current === routeSessionId) return
lastRouteSessionIdRef.current = routeSessionId
if (paneLayoutStore.getFocusedSessionId() === routeSessionId) return
syncingFromRouteRef.current = true
paneLayoutStore.setFocusedSession(routeSessionId)
}, [routeSessionId])
// focused pane session -> URL(路由只反映当前 focused pane)
useEffect(() => {
if (syncingFromRouteRef.current) {
syncingFromRouteRef.current = false
return
}
if (paneLayoutStore.getFocusedSessionId() !== paneLayout.focusedSessionId) return
if (paneLayout.focusedSessionId === routeSessionId && isSameDirectory(routeDirectory, focusedRouteDirectory)) return
replaceSession(paneLayout.focusedSessionId, focusedRouteDirectory)
}, [
paneLayout.focusedPaneId,
paneLayout.focusedSessionId,
routeSessionId,
routeDirectory,
replaceSession,
focusedRouteDirectory,
])
const navigatePaneToSession = useCallback(
(paneId: string, sessionId: string, directory?: string) => {
paneLayoutStore.focusPane(paneId)
paneLayoutStore.setPaneSession(paneId, sessionId)
navigateRouteToSession(sessionId, directory)
},
[navigateRouteToSession],
)
const navigatePaneHome = useCallback(
(paneId: string) => {
paneLayoutStore.focusPane(paneId)
paneLayoutStore.setPaneSession(paneId, null)
navigateRouteHome()
},
[navigateRouteHome],
)
const handleSelectSession = useCallback(
(session: { id: string; directory?: string }) => {
const paneId = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (!paneId) return
navigatePaneToSession(paneId, session.id, session.directory)
},
[paneLayout.focusedPaneId, navigatePaneToSession],
)
const handleNewSession = useCallback(() => {
const paneId = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (!paneId) return
navigatePaneHome(paneId)
}, [paneLayout.focusedPaneId, navigatePaneHome])
const handleEnterSplitMode = useCallback(() => {
paneLayoutStore.enterSplitMode(paneLayout.focusedSessionId)
}, [paneLayout.focusedSessionId])
const handleToggleFocusedPaneFullscreen = useCallback(() => {
const paneId = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (!paneId) return
paneLayoutStore.togglePaneFullscreen(paneId)
}, [paneLayout.focusedPaneId])
const handleOpenSidebar = useCallback(() => setSidebarExpanded(true), [setSidebarExpanded])
const renderPaneLeaf = useCallback(
(paneId: string, paneSessionId: string | null) => (
<ChatPane
key={paneId}
paneId={paneId}
sessionId={paneSessionId}
isFocused={paneLayout.focusedPaneId === paneId}
paneCount={paneLayout.paneCount}
displayMode={paneLayout.isSplit && paneLayout.fullscreenPaneId !== paneId ? 'split' : 'single'}
isPaneFullscreen={paneLayout.fullscreenPaneId === paneId}
onOpenSidebar={handleOpenSidebar}
showSidebarButton={chatViewport.interaction.sidebarBehavior === 'overlay'}
onSplitPane={splitPaneEnabled && !paneLayout.fullscreenPaneId ? handleEnterSplitMode : undefined}
onTogglePaneFullscreen={paneLayout.isSplit ? handleToggleFocusedPaneFullscreen : undefined}
navigatePaneToSession={navigatePaneToSession}
navigatePaneHome={navigatePaneHome}
/>
),
[
paneLayout.focusedPaneId,
paneLayout.paneCount,
paneLayout.isSplit,
paneLayout.fullscreenPaneId,
chatViewport.interaction.sidebarBehavior,
splitPaneEnabled,
handleOpenSidebar,
handleEnterSplitMode,
handleToggleFocusedPaneFullscreen,
navigatePaneToSession,
navigatePaneHome,
],
)
const focusedDirectory = focusedRouteDirectory || ''
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false)
const [settingsInitialTab, setSettingsInitialTab] = useState<SettingsTab>('servers')
const openSettingsTab = useCallback((tab: SettingsTab) => {
setSettingsInitialTab(tab)
setSettingsDialogOpen(true)
}, [])
const openSettings = useCallback(() => {
openSettingsTab('servers')
}, [openSettingsTab])
const openAboutSettings = useCallback(() => {
openSettingsTab('about')
}, [openSettingsTab])
const closeSettings = useCallback(() => setSettingsDialogOpen(false), [])
const [projectDialogOpen, setProjectDialogOpen] = useState(false)
const openProject = useCallback(() => setProjectDialogOpen(true), [])
const closeProjectDialog = useCallback(() => setProjectDialogOpen(false), [])
// 桌面标题栏通过 CustomEvent 触发打开项目/设置
useEffect(() => {
const onOpenProject = () => openProject()
const onOpenSettings = () => openSettings()
window.addEventListener('titlebar:open-project', onOpenProject)
window.addEventListener('titlebar:open-settings', onOpenSettings)
return () => {
window.removeEventListener('titlebar:open-project', onOpenProject)
window.removeEventListener('titlebar:open-settings', onOpenSettings)
}
}, [openProject, openSettings])
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const handleNewTerminal = useCallback(async () => {
try {
const pty = await createPtySession({ cwd: focusedDirectory }, focusedDirectory)
const tab: TerminalTab = {
id: pty.id,
title: pty.title || t('components:terminal.terminal'),
status: 'connecting',
}
layoutStore.addTerminalTab(tab, true)
} catch (error) {
uiErrorHandler('create terminal', error)
}
}, [focusedDirectory, t])
const keybindingHandlers = useMemo<KeybindingHandlers>(
() => ({
openSettings,
openProject,
commandPalette: () => setCommandPaletteOpen(true),
toggleSidebar: () => setSidebarExpanded(!sidebarExpanded),
toggleRightPanel: () => layoutStore.toggleRightPanel(),
focusInput: () => {
const input = document.querySelector<HTMLTextAreaElement>('[data-input-box] textarea')
input?.focus()
},
newSession: () => focusedController?.newSession(),
archiveSession: () => focusedController?.archiveSession(),
previousSession: () => focusedController?.previousSession(),
nextSession: () => focusedController?.nextSession(),
toggleTerminal: () => layoutStore.toggleBottomPanel(),
newTerminal: handleNewTerminal,
selectModel: () => focusedController?.openModelSelector(),
toggleAgent: () => focusedController?.toggleAgent(),
cancelMessage: () => focusedController?.cancelMessage(),
copyLastResponse: () => focusedController?.copyLastResponse(),
toggleFullAuto: () => focusedController?.toggleFullAuto(),
// Pane
focusNextPane: () => {
paneLayoutStore.focusNextPane()
requestAnimationFrame(() => {
const pid = paneLayoutStore.getFocusedPaneId()
if (pid) {
const input = document.querySelector<HTMLTextAreaElement>(`[data-pane-id="${pid}"] textarea`)
input?.focus()
}
})
},
focusPrevPane: () => {
paneLayoutStore.focusPrevPane()
requestAnimationFrame(() => {
const pid = paneLayoutStore.getFocusedPaneId()
if (pid) {
const input = document.querySelector<HTMLTextAreaElement>(`[data-pane-id="${pid}"] textarea`)
input?.focus()
}
})
},
splitRight: () => {
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (pid && splitPaneEnabled) paneLayoutStore.splitPane(pid, 'horizontal')
},
splitDown: () => {
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (pid && splitPaneEnabled) paneLayoutStore.splitPane(pid, 'vertical')
},
closePane: () => {
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (pid && paneLayout.isSplit) paneLayoutStore.closePane(pid)
},
togglePaneFullscreen: () => {
if (paneLayout.isSplit) handleToggleFocusedPaneFullscreen()
},
}),
[
openSettings,
openProject,
sidebarExpanded,
setSidebarExpanded,
focusedController,
handleNewTerminal,
paneLayout.focusedPaneId,
paneLayout.isSplit,
splitPaneEnabled,
handleToggleFocusedPaneFullscreen,
],
)
useGlobalKeybindings(keybindingHandlers)
const commands = useMemo<CommandItem[]>(() => {
const getShortcut = (action: string) =>
keybindingStore.getKey(action as import('./store/keybindingStore').KeybindingAction)
return [
{
id: 'openSettings',
label: t('commands:openSettings'),
description: t('commands:openSettingsDesc'),
category: t('commands:categories.general'),
shortcut: getShortcut('openSettings'),
action: openSettings,
},
{
id: 'openProject',
label: t('commands:openProject'),
description: t('commands:openProjectDesc'),
category: t('commands:categories.general'),
shortcut: getShortcut('openProject'),
action: openProject,
},
{
id: 'openSettingsShortcuts',
label: t('commands:openShortcutsSettings'),
description: t('commands:openShortcutsSettingsDesc'),
category: t('commands:categories.general'),
action: () => {
openSettingsTab('keybindings')
},
},
{
id: 'toggleSidebar',
label: t('commands:toggleSidebar'),
description: t('commands:toggleSidebarDesc'),
category: t('commands:categories.general'),
shortcut: getShortcut('toggleSidebar'),
action: () => setSidebarExpanded(!sidebarExpanded),
},
{
id: 'toggleRightPanel',
label: t('commands:toggleRightPanel'),
description: t('commands:toggleRightPanelDesc'),
category: t('commands:categories.general'),
shortcut: getShortcut('toggleRightPanel'),
action: () => layoutStore.toggleRightPanel(),
},
{
id: 'focusInput',
label: t('commands:focusInput'),
description: t('commands:focusInputDesc'),
category: t('commands:categories.general'),
shortcut: getShortcut('focusInput'),
action: () => {
const input = document.querySelector<HTMLTextAreaElement>('[data-input-box] textarea')
input?.focus()
},
},
{
id: 'newSession',
label: t('commands:newSession'),
description: t('commands:newSessionDesc'),
category: t('commands:categories.session'),
shortcut: getShortcut('newSession'),
action: () => focusedController?.newSession(),
},
{
id: 'archiveSession',
label: t('commands:archiveSession'),
description: t('commands:archiveSessionDesc'),
category: t('commands:categories.session'),
shortcut: getShortcut('archiveSession'),
action: () => focusedController?.archiveSession(),
},
{
id: 'previousSession',
label: t('commands:previousSession'),
description: t('commands:previousSessionDesc'),
category: t('commands:categories.session'),
shortcut: getShortcut('previousSession'),
action: () => focusedController?.previousSession(),
},
{
id: 'nextSession',
label: t('commands:nextSession'),
description: t('commands:nextSessionDesc'),
category: t('commands:categories.session'),
shortcut: getShortcut('nextSession'),
action: () => focusedController?.nextSession(),
},
{
id: 'toggleTerminal',
label: t('commands:toggleTerminal'),
description: t('commands:toggleTerminalDesc'),
category: t('commands:categories.terminal'),
shortcut: getShortcut('toggleTerminal'),
action: () => layoutStore.toggleBottomPanel(),
},
{
id: 'newTerminal',
label: t('commands:newTerminal'),
description: t('commands:newTerminalDesc'),
category: t('commands:categories.terminal'),
shortcut: getShortcut('newTerminal'),
action: handleNewTerminal,
},
{
id: 'selectModel',
label: t('commands:selectModel'),
description: t('commands:selectModelDesc'),
category: t('commands:categories.model'),
shortcut: getShortcut('selectModel'),
action: () => focusedController?.openModelSelector(),
},
{
id: 'toggleAgent',
label: t('commands:toggleAgent'),
description: t('commands:toggleAgentDesc'),
category: t('commands:categories.model'),
shortcut: getShortcut('toggleAgent'),
action: () => focusedController?.toggleAgent(),
},
{
id: 'copyLastResponse',
label: t('commands:copyLastResponse'),
description: t('commands:copyLastResponseDesc'),
category: t('commands:categories.message'),
shortcut: getShortcut('copyLastResponse'),
action: () => focusedController?.copyLastResponse(),
},
{
id: 'cancelMessage',
label: t('commands:cancelMessage'),
description: t('commands:cancelMessageDesc'),
category: t('commands:categories.message'),
shortcut: getShortcut('cancelMessage'),
action: () => focusedController?.cancelMessage(),
when: () => !!focusedController?.isStreaming,
},
// Pane
{
id: 'focusNextPane',
label: t('commands:focusNextPane'),
description: t('commands:focusNextPaneDesc'),
category: t('commands:categories.pane'),
shortcut: getShortcut('focusNextPane'),
action: () => paneLayoutStore.focusNextPane(),
},
{
id: 'focusPrevPane',
label: t('commands:focusPrevPane'),
description: t('commands:focusPrevPaneDesc'),
category: t('commands:categories.pane'),
shortcut: getShortcut('focusPrevPane'),
action: () => paneLayoutStore.focusPrevPane(),
},
{
id: 'splitRight',
label: t('commands:splitRight'),
description: t('commands:splitRightDesc'),
category: t('commands:categories.pane'),
shortcut: getShortcut('splitRight'),
action: () => {
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (pid && splitPaneEnabled) paneLayoutStore.splitPane(pid, 'horizontal')
},
},
{
id: 'splitDown',
label: t('commands:splitDown'),
description: t('commands:splitDownDesc'),
category: t('commands:categories.pane'),
shortcut: getShortcut('splitDown'),
action: () => {
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (pid && splitPaneEnabled) paneLayoutStore.splitPane(pid, 'vertical')
},
},
{
id: 'closePane',
label: t('commands:closePane'),
description: t('commands:closePaneDesc'),
category: t('commands:categories.pane'),
shortcut: getShortcut('closePane'),
action: () => {
const pid = paneLayout.focusedPaneId ?? paneLayoutStore.getFocusedPaneId()
if (pid && paneLayout.isSplit) paneLayoutStore.closePane(pid)
},
when: () => paneLayout.isSplit,
},
{
id: 'togglePaneFullscreen',
label: t('commands:togglePaneFullscreen'),
description: t('commands:togglePaneFullscreenDesc'),
category: t('commands:categories.pane'),
shortcut: getShortcut('togglePaneFullscreen'),
action: () => {
if (paneLayout.isSplit) handleToggleFocusedPaneFullscreen()
},
when: () => paneLayout.isSplit,
},
]
}, [
t,
openSettings,
openProject,
openSettingsTab,
sidebarExpanded,
setSidebarExpanded,
focusedController,
handleNewTerminal,
paneLayout.focusedPaneId,
paneLayout.isSplit,
splitPaneEnabled,
handleToggleFocusedPaneFullscreen,
])
const { showCloseDialog, handleCloseDialogConfirm, handleCloseDialogCancel } = useCloseServiceDialog()
return (
<div className="relative flex h-full flex-col bg-bg-100 overflow-hidden">
<DesktopTitlebar />
<ChatViewportProvider value={chatViewport}>
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<Sidebar
isOpen={sidebarExpanded}
selectedSessionId={paneLayout.focusedSessionId}
onSelectSession={handleSelectSession}
onNewSession={handleNewSession}
onOpen={handleOpenSidebar}
onClose={() => setSidebarExpanded(false)}
contextLimit={focusedController?.contextLimit}
onOpenSettings={openSettings}
projectDialogOpen={projectDialogOpen}
onProjectDialogClose={closeProjectDialog}
/>
<div className="flex-1 flex min-w-0 h-full overflow-hidden">
<div
ref={surfaceRef}
className="flex-1 flex flex-col min-w-0 overflow-hidden"
style={{
minWidth:
chatViewport.interaction.sidebarBehavior === 'overlay' ? undefined : `${CHAT_SURFACE_MIN_WIDTH}px`,
}}
>
<div
className={paneLayout.isSplit && !paneLayout.fullscreenPaneId ? 'flex-1 min-h-0 p-2' : 'flex-1 min-h-0'}
>
<SplitContainer
node={paneLayout.root}
renderLeaf={renderPaneLeaf}
fullscreenPaneId={paneLayout.fullscreenPaneId}
/>
</div>
<BottomPanel directory={focusedDirectory} />
</div>
<RightPanel directory={focusedDirectory} sessionId={paneLayout.focusedSessionId} />
</div>
<ToastContainer onOpenAbout={openAboutSettings} />
</div>
<Suspense fallback={null}>
<SettingsDialog isOpen={settingsDialogOpen} onClose={closeSettings} initialTab={settingsInitialTab} />
<CommandPalette
isOpen={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
commands={commands}
/>
</Suspense>
<Suspense fallback={null}>
<CloseServiceDialog
isOpen={showCloseDialog}
onConfirm={handleCloseDialogConfirm}
onCancel={handleCloseDialogCancel}
/>
</Suspense>
</ChatViewportProvider>
</div>
)
}
export default App
+29
View File
@@ -0,0 +1,29 @@
// ============================================
// Agent API Functions - Decoupled from SDK
// ============================================
import { piClient } from './piClient'
import type { ApiAgent } from './types'
/**
* 获取 agent 列表
*/
export async function getAgents(_directory?: string): Promise<ApiAgent[]> {
try {
const data = await piClient.get<{ agents: ApiAgent[] }>('/api/agents')
return data.agents || []
} catch {
return [
{ id: 'build', name: 'Build', description: 'Main coder agent', hidden: false, mode: 'chat' },
{ id: 'research', name: 'Research', description: 'Code researcher', hidden: false, mode: 'chat' },
]
}
}
/**
* 获取可选择的 agent 列表(过滤掉 hidden 的)
*/
export async function getSelectableAgents(directory?: string): Promise<ApiAgent[]> {
const agents = await getAgents(directory)
return agents.filter(agent => !agent.hidden)
}
+73
View File
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getActiveModels } from './client'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
},
}))
describe('getActiveModels', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
})
it('preserves real context and output limits returned by the backend', async () => {
vi.mocked(piClient.get).mockResolvedValue({
models: [
{
id: 'anthropic/claude-opus-4.8',
name: 'Claude Opus 4.8',
provider: 'anthropic',
contextLimit: 1_000_000,
outputLimit: 128_000,
supportsReasoning: true,
supportsImages: true,
},
],
})
const models = await getActiveModels()
expect(models).toHaveLength(1)
expect(models[0]).toMatchObject({
id: 'anthropic/claude-opus-4.8',
name: 'Claude Opus 4.8',
providerId: 'anthropic',
providerName: 'anthropic',
contextLimit: 1_000_000,
outputLimit: 128_000,
supportsReasoning: true,
supportsImages: true,
})
})
it('uses Pi model metadata aliases before generic fallbacks', async () => {
vi.mocked(piClient.get).mockResolvedValue({
models: [
{
id: 'custom-model',
providerId: 'custom-provider',
providerName: 'Custom Provider',
contextWindow: 200_000,
maxOutputTokens: 64_000,
reasoning: true,
input: ['text', 'image'],
},
],
})
const models = await getActiveModels()
expect(models[0]).toMatchObject({
id: 'custom-model',
providerId: 'custom-provider',
providerName: 'Custom Provider',
contextLimit: 200_000,
outputLimit: 64_000,
supportsReasoning: true,
supportsImages: true,
})
})
})
+140
View File
@@ -0,0 +1,140 @@
// ============================================
// API Client for Pi Agent Backend
// ============================================
import { piClient } from './piClient'
import { formatPathForApi } from '../utils/directoryUtils'
import type { ModelInfo, ApiProject, ApiPath } from './types'
// Re-export all types
export * from './types'
// Re-export from Attachment feature
export { fromFilePart, fromAgentPart } from '../features/attachment'
// Re-export from sub-modules
export * from './session'
export * from './message'
export * from './permission'
export * from './file'
export * from './agent'
export * from './skill'
export * from './events'
export * from './config'
export * from './vcs'
export * from './mcp'
export * from './pty'
export * from './worktree'
export * from './command'
export * from './global'
export * from './tool'
export * from './lsp'
export * from './extension'
// ============================================
// Model API Functions
// ============================================
export async function getActiveModels(_directory?: string): Promise<ModelInfo[]> {
try {
const data = await piClient.get<{ models: any[] }>('/api/models')
return data.models.map(model => ({
id: model.id,
name: model.name || model.id,
providerId: model.providerId ?? model.provider,
providerName: model.providerName ?? model.provider,
family: model.family ?? '',
contextLimit: model.contextLimit ?? model.contextWindow ?? 128000,
outputLimit: model.outputLimit ?? model.maxOutputTokens ?? model.maxTokens ?? 4096,
supportsReasoning: model.supportsReasoning ?? model.reasoning ?? false,
supportsImages: model.supportsImages ?? model.input?.includes('image') ?? false,
supportsPdf: model.supportsPdf ?? false,
supportsAudio: model.supportsAudio ?? false,
supportsVideo: model.supportsVideo ?? false,
supportsToolcall: model.supportsToolcall ?? true,
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: [],
},
]
}
}
export async function getDefaultModels(_directory?: string): Promise<Record<string, string>> {
return {
google: 'gemini-2.5-flash',
anthropic: 'claude-3-5-sonnet',
}
}
// ============================================
// Project API Functions
// ============================================
export async function getCurrentProject(directory?: string): Promise<ApiProject> {
return {
id: 'default',
name: 'Default Project',
directory: formatPathForApi(directory),
}
}
export async function getProjects(directory?: string): Promise<ApiProject[]> {
return [
{
id: 'default',
name: 'Default Project',
directory: formatPathForApi(directory),
},
]
}
export async function initGitProject(directory?: string): Promise<ApiProject> {
return {
id: 'default',
name: 'Default Project',
directory: formatPathForApi(directory),
}
}
export async function updateProject(
projectId: string,
params: {
name?: string
icon?: { url?: string; override?: string; color?: string }
},
directory?: string,
): Promise<ApiProject> {
return {
id: projectId,
name: params.name || 'Default Project',
directory: formatPathForApi(directory),
}
}
// ============================================
// Path API Functions
// ============================================
export async function getPath(): Promise<ApiPath> {
return {
home: '',
path: '',
}
}
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCommands } from './command'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
},
}))
vi.mock('../store/serverStore', () => ({
serverStore: {
getActiveServerId: () => 'test-server',
},
}))
describe('getCommands', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
})
it('marks frontend and api commands with stable sources', async () => {
vi.mocked(piClient.get).mockResolvedValue({
commands: [{ name: 'review', description: 'Run project review' }],
})
const commands = await getCommands('/workspace/project')
expect(commands).toEqual([
{ name: 'review', description: 'Run project review', source: 'api' },
{ name: 'new', description: 'Create a new chat session', source: 'frontend' },
{ name: 'compact', description: 'Compact session by summarizing conversation history', source: 'frontend' },
])
})
it('keeps API commands as api commands even if names overlap frontend commands', async () => {
vi.mocked(piClient.get).mockResolvedValue({
commands: [{ name: 'compact', description: 'Native compact command' }],
})
const commands = await getCommands('/workspace/project-overlap')
expect(commands).toEqual([
{ name: 'compact', description: 'Native compact command', source: 'api' },
{ name: 'new', description: 'Create a new chat session', source: 'frontend' },
])
})
it('keeps local UI handlers for built-in commands that are already implemented in the frontend', async () => {
vi.mocked(piClient.get).mockResolvedValue({
commands: [
{ name: 'new', description: 'Native new command', apiSource: 'builtin' },
{ name: 'compact', description: 'Native compact command', apiSource: 'builtin' },
{ name: 'model', description: 'Select model', apiSource: 'builtin' },
],
})
const commands = await getCommands('/workspace/project-builtins')
expect(commands).toEqual([
{ name: 'model', description: 'Select model', apiSource: 'builtin', source: 'api' },
{ name: 'new', description: 'Create a new chat session', source: 'frontend' },
{ name: 'compact', description: 'Compact session by summarizing conversation history', source: 'frontend' },
])
})
})
+107
View File
@@ -0,0 +1,107 @@
// ============================================
// Command API - Decoupled
// ============================================
import { piClient } from './piClient'
import { formatPathForApi } from '../utils/directoryUtils'
import { serverStore } from '../store/serverStore'
import i18n from '../i18n'
export type CommandApiSource = 'builtin' | 'extension' | 'prompt' | 'skill' | 'unknown'
export interface Command {
name: string
description?: string
keybind?: string
source: 'frontend' | 'api'
apiSource?: CommandApiSource
category?: string
sourceInfo?: unknown
requiresArgs?: boolean
dangerous?: boolean
}
type ApiCommand = Omit<Command, 'source'> & { source?: unknown }
// Frontend-added slash commands that do not come from GET /command.
// These are executed locally or via dedicated session actions.
function getFrontendCommands(): Command[] {
return [
{ name: 'new', description: i18n.t('commands:slashCommand.newSessionDesc'), source: 'frontend' },
{ name: 'compact', description: i18n.t('commands:slashCommand.compactDesc'), source: 'frontend' },
]
}
const COMMAND_CACHE_TTL_MS = 10_000
const commandCache = new Map<string, { data: Command[]; expiresAt: number }>()
const commandInflight = new Map<string, Promise<Command[]>>()
function getCommandCacheKey(directory?: string): string {
return `${serverStore.getActiveServerId()}::${i18n.resolvedLanguage || i18n.language}::${formatPathForApi(directory) ?? ''}`
}
async function fetchCommands(directory?: string): Promise<Command[]> {
let apiCommands: ApiCommand[] = []
try {
const q = `?directory=${encodeURIComponent(formatPathForApi(directory) || '')}`
const data = await piClient.get<{ commands: ApiCommand[] }>(`/api/commands/list${q}`)
apiCommands = data.commands || []
} catch {
// Backend unreachable — frontend commands still available
}
const frontendCommands = getFrontendCommands()
const frontendNames = new Set(frontendCommands.map(c => c.name))
const commandsFromApi: Command[] = apiCommands
.map(command => ({ ...command, source: 'api' as const }))
// Built-in Pi commands that already have dedicated frontend behavior should keep
// their local handler; otherwise selecting `/new` or `/compact` would route to
// the generic API-command path instead of the existing UI action.
.filter(command => !(command.apiSource === 'builtin' && frontendNames.has(command.name)))
const apiNames = new Set(commandsFromApi.map(c => c.name))
return [...commandsFromApi, ...frontendCommands.filter(c => !apiNames.has(c.name))]
}
export async function getCommands(directory?: string): Promise<Command[]> {
const key = getCommandCacheKey(directory)
const now = Date.now()
const cached = commandCache.get(key)
if (cached && cached.expiresAt > now) {
return cached.data
}
const inflight = commandInflight.get(key)
if (inflight) {
return inflight
}
const request = fetchCommands(directory)
.then(data => {
commandCache.set(key, { data, expiresAt: Date.now() + COMMAND_CACHE_TTL_MS })
return data
})
.finally(() => {
commandInflight.delete(key)
})
commandInflight.set(key, request)
return request
}
export async function prefetchCommands(directory?: string): Promise<void> {
await getCommands(directory)
}
export async function executeCommand(
sessionId: string,
command: string,
args: string = '',
directory?: string,
): Promise<unknown> {
return await piClient.post('/api/sessions/command', {
sessionID: sessionId,
directory: formatPathForApi(directory),
command,
arguments: args,
})
}
+58
View File
@@ -0,0 +1,58 @@
// ============================================
// Config API - 配置管理
// ============================================
import type { Config } from '../types/api/config'
import type { ProvidersResponse } from '../types/api/model'
/**
* 获取当前配置
*/
export async function getConfig(_directory?: string): Promise<Config> {
return {
models: {},
providers: {},
} as unknown as Config
}
/**
* 更新配置
*/
export async function updateConfig(config: Config, _directory?: string): Promise<Config> {
return config
}
/**
* 获取 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"
}
}
}
+317
View File
@@ -0,0 +1,317 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EventTypes } from '../types/api/event'
vi.mock('./http', () => ({
getApiBaseUrl: () => 'http://example.test',
getAuthHeader: () => ({}),
}))
vi.mock('../utils/tauri', () => ({
isTauri: () => false,
}))
const encoder = new TextEncoder()
function concatBytes(...parts: Uint8Array[]): Uint8Array {
const total = parts.reduce((sum, part) => sum + part.length, 0)
const result = new Uint8Array(total)
let offset = 0
for (const part of parts) {
result.set(part, offset)
offset += part.length
}
return result
}
function createEventChunks(delta: string, splitAt: number): Uint8Array[] {
const marker = '__DELTA__'
const raw = `data: ${JSON.stringify({
directory: 'global',
payload: {
type: EventTypes.MESSAGE_PART_DELTA,
properties: {
messageID: 'session-1',
partID: 'part-1',
field: 'text',
delta: marker,
},
},
})}\n\n`
const [before, after] = raw.split(marker)
const deltaBytes = encoder.encode(delta)
return [
concatBytes(encoder.encode(before), deltaBytes.slice(0, splitAt)),
concatBytes(deltaBytes.slice(splitAt), encoder.encode(after)),
]
}
function createStream(chunks: Uint8Array[]): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk)
}
controller.close()
},
})
}
function createFetchResponse(chunks: Uint8Array[]): Pick<Response, 'ok' | 'body'> {
return {
ok: true,
body: createStream(chunks) as Response['body'],
}
}
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function createEventChunk(payload: object): Uint8Array[] {
return [encoder.encode(`data: ${JSON.stringify({ directory: 'global', payload })}\n\n`)]
}
describe('subscribeToEvents', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('preserves Chinese text when UTF-8 bytes are split across chunks', async () => {
const fetchMock = vi.fn().mockResolvedValue(createFetchResponse(createEventChunks('中文', 2)))
vi.stubGlobal('fetch', fetchMock)
const { subscribeToEvents } = await import('./events')
const received = await new Promise<string>((resolve, reject) => {
const unsubscribe = subscribeToEvents({
onPartDelta(data) {
unsubscribe()
resolve(data.delta)
},
onError(error) {
unsubscribe()
reject(error)
},
})
})
expect(received).toBe('中文')
})
it('preserves four-byte characters when split in the middle', async () => {
const fetchMock = vi.fn().mockResolvedValue(createFetchResponse(createEventChunks('𠮷😀', 3)))
vi.stubGlobal('fetch', fetchMock)
const { subscribeToEvents } = await import('./events')
const received = await new Promise<string>((resolve, reject) => {
const unsubscribe = subscribeToEvents({
onPartDelta(data) {
unsubscribe()
resolve(data.delta)
},
onError(error) {
unsubscribe()
reject(error)
},
})
})
expect(received).toBe('𠮷😀')
})
it('dispatches server.connected payloads with timestamp', async () => {
const fetchMock = vi.fn().mockResolvedValue(
createFetchResponse(
createEventChunk({
type: EventTypes.SERVER_CONNECTED,
properties: { timestamp: '2026-04-22T15:00:00.000Z' },
}),
),
)
vi.stubGlobal('fetch', fetchMock)
const { subscribeToEvents } = await import('./events')
const received = await new Promise<unknown>((resolve, reject) => {
const unsubscribe = subscribeToEvents({
onServerConnected(data) {
unsubscribe()
resolve(data.timestamp)
},
onError(error) {
unsubscribe()
reject(error)
},
})
})
expect(received).toBe('2026-04-22T15:00:00.000Z')
})
it('dispatches direct message.updated payloads from the extension server', async () => {
const fetchMock = vi.fn().mockResolvedValue(
createFetchResponse(
createEventChunk({
type: EventTypes.MESSAGE_UPDATED,
properties: {
id: 'message-1',
sessionID: 'session-1',
role: 'assistant',
time: { created: 1 },
text: '',
},
}),
),
)
vi.stubGlobal('fetch', fetchMock)
const { subscribeToEvents } = await import('./events')
const received = await new Promise<unknown>((resolve, reject) => {
const unsubscribe = subscribeToEvents({
onMessageUpdated(data) {
unsubscribe()
resolve(data)
},
onError(error) {
unsubscribe()
reject(error)
},
})
})
expect(received).toMatchObject({ id: 'message-1', sessionID: 'session-1', role: 'assistant' })
})
it('dispatches direct message.part.updated payloads from the extension server', async () => {
const fetchMock = vi.fn().mockResolvedValue(
createFetchResponse(
createEventChunk({
type: EventTypes.MESSAGE_PART_UPDATED,
properties: {
id: 'message-1-text',
sessionID: 'session-1',
messageID: 'message-1',
type: 'text',
text: '',
},
}),
),
)
vi.stubGlobal('fetch', fetchMock)
const { subscribeToEvents } = await import('./events')
const received = await new Promise<unknown>((resolve, reject) => {
const unsubscribe = subscribeToEvents({
onPartUpdated(data) {
unsubscribe()
resolve(data)
},
onError(error) {
unsubscribe()
reject(error)
},
})
})
expect(received).toMatchObject({ id: 'message-1-text', sessionID: 'session-1', messageID: 'message-1' })
})
it('ignores stale server.connected events from an old browser SSE generation after reconnect', async () => {
const firstFetch = createDeferred<Pick<Response, 'ok' | 'body'>>()
const secondFetch = createDeferred<Pick<Response, 'ok' | 'body'>>()
const fetchMock = vi
.fn()
.mockImplementationOnce(() => firstFetch.promise)
.mockImplementationOnce(() => secondFetch.promise)
vi.stubGlobal('fetch', fetchMock)
const { subscribeToEvents, reconnectSSE } = await import('./events')
const received: string[] = []
const unsubscribe = subscribeToEvents({
onServerConnected(data) {
if (typeof data.timestamp === 'string') {
received.push(data.timestamp)
}
},
})
reconnectSSE()
secondFetch.resolve(
createFetchResponse(
createEventChunk({
type: EventTypes.SERVER_CONNECTED,
properties: { timestamp: 'new-server-time' },
}),
),
)
await vi.waitFor(() => {
expect(received).toEqual(['new-server-time'])
})
firstFetch.resolve(
createFetchResponse(
createEventChunk({
type: EventTypes.SERVER_CONNECTED,
properties: { timestamp: 'stale-server-time' },
}),
),
)
await Promise.resolve()
await Promise.resolve()
expect(received).toEqual(['new-server-time'])
unsubscribe()
})
it('ignores stale browser fetch failures from an old SSE generation after reconnect', async () => {
const firstFetch = createDeferred<Pick<Response, 'ok' | 'body'>>()
const secondFetch = createDeferred<Pick<Response, 'ok' | 'body'>>()
const fetchMock = vi
.fn()
.mockImplementationOnce(() => firstFetch.promise)
.mockImplementationOnce(() => secondFetch.promise)
vi.stubGlobal('fetch', fetchMock)
const { subscribeToEvents, reconnectSSE, getConnectionInfo } = await import('./events')
const onError = vi.fn()
const unsubscribe = subscribeToEvents({
onError,
})
reconnectSSE()
firstFetch.reject(new Error('stale failure'))
await Promise.resolve()
await Promise.resolve()
expect(onError).not.toHaveBeenCalled()
expect(getConnectionInfo().state).toBe('connecting')
secondFetch.reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
unsubscribe()
})
})
+896
View File
@@ -0,0 +1,896 @@
// ============================================
// Global Event Subscription (SSE) - Singleton Pattern
// ============================================
import { getApiBaseUrl, getAuthHeader } from './http'
import { createSseTextParser } from './sse'
import { normalizeTodoItems } from './todo'
import { isTauri } from '../utils/tauri'
import type {
ApiMessage,
EventCallbacks,
EventType,
GlobalEvent,
ServerConnectedPayload,
SessionErrorPayload,
TodoUpdatedPayload,
} from './types'
import { EventTypes } from '../types/api/event'
// ============================================
// Connection State
// ============================================
export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
export interface ConnectionInfo {
state: ConnectionState
lastEventTime: number
reconnectAttempt: number
error?: string
}
// 全局连接状态(可以被外部订阅)
let connectionInfo: ConnectionInfo = {
state: 'disconnected',
lastEventTime: 0,
reconnectAttempt: 0,
}
const connectionListeners = new Set<(info: ConnectionInfo) => void>()
function updateConnectionState(update: Partial<ConnectionInfo>) {
connectionInfo = { ...connectionInfo, ...update }
connectionListeners.forEach(fn => {
fn(connectionInfo)
})
}
export function getConnectionInfo(): ConnectionInfo {
return connectionInfo
}
export function subscribeToConnectionState(fn: (info: ConnectionInfo) => void): () => void {
connectionListeners.add(fn)
// 立即发送当前状态
fn(connectionInfo)
return () => connectionListeners.delete(fn)
}
// ============================================
// Singleton SSE Connection
// ============================================
const RECONNECT_DELAYS = [1000, 2000, 3000, 5000, 10000, 30000]
/** 后台时使用更激进的重连延迟,确保尽快恢复连接 */
const BACKGROUND_RECONNECT_DELAYS = [500, 1000, 2000, 3000, 5000, 10000]
const HEARTBEAT_TIMEOUT = 60000
/** 后台时的心跳超时(更宽松,因为后台 timer 可能不准) */
const BACKGROUND_HEARTBEAT_TIMEOUT = 120000
/** 后台 keepalive 间隔:定期检查连接是否还活着 */
const BACKGROUND_KEEPALIVE_INTERVAL = 30000
// 所有订阅者的 callbacks
const allSubscribers = new Set<EventCallbacks>()
const EVENT_TYPE_SET = new Set<string>(Object.values(EventTypes))
// 单例连接状态
let singletonController: AbortController | null = null
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let keepaliveTimer: ReturnType<typeof setInterval> | null = null
let isConnecting = false
let lifecycleListenersRegistered = false
/** 连接代次,每次 reconnectSSE() 递增,旧代次的事件会被丢弃 */
let connectionGeneration = 0
/** 当前是否在后台 */
let isInBackground = false
/** 是否因为切换服务器而触发的重连 */
let isServerSwitch = false
/** 上一次 bridge_disconnect 的 Promise,用于串行化 Tauri 侧的 disconnect → connect */
let pendingDisconnect: Promise<void> = Promise.resolve()
/** Cooldown: 上一次 onReconnected 广播的时间戳 */
let lastReconnectedBroadcast = 0
const RECONNECTED_COOLDOWN = 2000
function finalizeConnectionAttempt(generation: number): boolean {
if (generation !== connectionGeneration) {
return false
}
isConnecting = false
return true
}
/**
* 广播 onReconnected,带 cooldown 防止 SSE 快速重连时密集触发数据拉取
*/
function broadcastReconnected(reason: 'network' | 'server-switch') {
const now = Date.now()
if (reason !== 'server-switch' && now - lastReconnectedBroadcast < RECONNECTED_COOLDOWN) {
if (import.meta.env.DEV) {
console.log('[SSE] onReconnected skipped (cooldown)')
}
return
}
lastReconnectedBroadcast = now
allSubscribers.forEach(cb => {
cb.onReconnected?.(reason)
})
}
/**
* 请求 Tauri 侧断开 SSE 连接
* 返回 Promise,调用方可以 await 确保断开完成后再发起新连接
* 多次并发调用会自动串行化
*/
function disconnectTauri(): Promise<void> {
if (!isTauri()) return Promise.resolve()
const p = pendingDisconnect.then(() =>
import('@tauri-apps/api/core')
.then(({ invoke }) => invoke('bridge_disconnect', { args: { bridgeId: 'sse' } }).then(() => undefined))
.catch(() => {}),
)
pendingDisconnect = p
return p
}
function resetHeartbeat() {
if (heartbeatTimer) clearTimeout(heartbeatTimer)
updateConnectionState({ lastEventTime: Date.now() })
// 后台时使用更宽松的超时,因为移动端后台 timer 可能被冻结/延迟
const timeout = isInBackground ? BACKGROUND_HEARTBEAT_TIMEOUT : HEARTBEAT_TIMEOUT
heartbeatTimer = setTimeout(() => {
console.warn(`[SSE] No events received for ${timeout / 1000}s, reconnecting...`)
updateConnectionState({ state: 'disconnected', error: 'Heartbeat timeout' })
forceReconnectNow()
}, timeout)
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer)
if (allSubscribers.size === 0) return // 没有订阅者就不重连
const attempt = connectionInfo.reconnectAttempt
// 后台时使用更激进的重连策略
const delays = isInBackground ? BACKGROUND_RECONNECT_DELAYS : RECONNECT_DELAYS
const delay = delays[Math.min(attempt, delays.length - 1)]
if (import.meta.env.DEV) {
console.log(`[SSE] Reconnecting in ${delay}ms (attempt ${attempt + 1}, background: ${isInBackground})...`)
}
reconnectTimer = setTimeout(() => {
updateConnectionState({ reconnectAttempt: attempt + 1 })
connectSingleton()
}, delay)
}
function connectSingleton() {
if (isConnecting || allSubscribers.size === 0) return
// 如果状态声称 connected,验证连接是否真的活着
if (connectionInfo.state === 'connected') {
const timeSinceLastEvent = Date.now() - connectionInfo.lastEventTime
// 后台时使用更宽松的超时判断
const staleTimeout = isInBackground ? BACKGROUND_HEARTBEAT_TIMEOUT : HEARTBEAT_TIMEOUT
if (timeSinceLastEvent > staleTimeout) {
// 太久没收到事件,连接可能已死,强制断开再重连
if (import.meta.env.DEV) {
console.log(
`[SSE] connectSingleton: state=connected but stale (${Math.round(timeSinceLastEvent / 1000)}s), forcing disconnect`,
)
}
connectionGeneration++
disconnectTauri()
if (singletonController) {
singletonController.abort()
singletonController = null
}
updateConnectionState({ state: 'disconnected' })
} else {
return // 连接确实还活着
}
}
isConnecting = true
updateConnectionState({ state: 'connecting' })
if (import.meta.env.DEV) {
console.log('[SSE] Connecting singleton...')
}
// 注册生命周期监听器(首次连接时)
registerLifecycleListeners()
if (isTauri()) {
connectViaTauri()
} else {
connectViaBrowser()
}
}
// ============================================
// Tauri SSE Bridge (via Rust reqwest + Channel)
// ============================================
/** Unified bridge event from Rust (transparent proxy) */
interface BridgeEvent {
event: 'connected' | 'data' | 'disconnected' | 'error'
data?: {
data?: string
code?: number
reason?: string
message?: string
}
}
async function connectViaTauri() {
const myGeneration = connectionGeneration
try {
// 等待上一次 disconnect 完成,避免 Rust 侧 connect/disconnect 竞争
await pendingDisconnect
const { invoke, Channel } = await import('@tauri-apps/api/core')
const url = `${getApiBaseUrl()}/global/event`
const authHeaders = getAuthHeader()
const authHeader = authHeaders['Authorization'] || null
const sseParser = createSseTextParser()
const onEvent = new Channel<BridgeEvent>()
onEvent.onmessage = (msg: BridgeEvent) => {
// 代次不匹配,说明已经 reconnect 过了,忽略旧连接的事件
if (myGeneration !== connectionGeneration) return
switch (msg.event) {
case 'connected': {
isConnecting = false
updateConnectionState({
state: 'connected',
reconnectAttempt: 0,
error: undefined,
})
resetHeartbeat()
if (import.meta.env.DEV) {
console.log('[SSE/Tauri] Connected')
}
// 每次连接成功都通知订阅者刷新数据
// 覆盖场景:首次连接(先开 UI 后开 server)、网络重连、服务器切换
const reason = isServerSwitch ? ('server-switch' as const) : ('network' as const)
isServerSwitch = false
broadcastReconnected(reason)
break
}
case 'data': {
resetHeartbeat()
if (!msg.data?.data) break
for (const eventData of sseParser.push(msg.data.data)) {
const globalEvent = parseGlobalEvent(eventData)
if (globalEvent) {
broadcastEvent(globalEvent)
}
}
break
}
case 'disconnected': {
isConnecting = false
if (import.meta.env.DEV) {
console.log('[SSE/Tauri] Disconnected:', msg.data?.reason)
}
updateConnectionState({ state: 'disconnected' })
scheduleReconnect()
break
}
case 'error': {
isConnecting = false
const errorMsg = msg.data?.message || 'Unknown error'
if (import.meta.env.DEV) {
console.warn('[SSE/Tauri] Error:', errorMsg)
}
updateConnectionState({
state: 'error',
error: errorMsg,
})
allSubscribers.forEach(cb => {
cb.onError?.(new Error(errorMsg))
})
scheduleReconnect()
break
}
}
}
// 调用统一桥接命令
invoke('bridge_connect', {
args: { bridgeId: 'sse', url, authHeader },
onEvent,
}).catch((error: unknown) => {
if (!finalizeConnectionAttempt(myGeneration)) return
const errorMsg = error instanceof Error ? error.message : String(error)
if (import.meta.env.DEV) {
console.warn('[SSE/Tauri] invoke error:', errorMsg)
}
updateConnectionState({
state: 'error',
error: errorMsg,
})
allSubscribers.forEach(cb => {
cb.onError?.(new Error(errorMsg))
})
scheduleReconnect()
})
} catch (error) {
if (!finalizeConnectionAttempt(myGeneration)) return
const errorMsg = error instanceof Error ? error.message : String(error)
console.warn('[SSE/Tauri] Failed to initialize:', errorMsg)
updateConnectionState({ state: 'error', error: errorMsg })
scheduleReconnect()
}
}
// ============================================
// Browser SSE (via fetch + ReadableStream)
// ============================================
function connectViaBrowser() {
singletonController = new AbortController()
// 捕获当前连接代次
const myGeneration = connectionGeneration
fetch(`${getApiBaseUrl()}/global/event`, {
signal: singletonController.signal,
headers: {
Accept: 'text/event-stream',
...getAuthHeader(),
},
})
.then(async response => {
if (myGeneration !== connectionGeneration) {
await response.body?.cancel?.().catch(() => {})
return
}
finalizeConnectionAttempt(myGeneration)
if (!response.ok) {
throw new Error(`Failed to subscribe: ${response.status}`)
}
updateConnectionState({
state: 'connected',
reconnectAttempt: 0,
error: undefined,
})
resetHeartbeat()
if (import.meta.env.DEV) {
console.log('[SSE] Singleton connected')
}
// 每次连接成功都通知订阅者刷新数据
// 覆盖场景:首次连接(先开 UI 后开 server)、网络重连、服务器切换
const reason = isServerSwitch ? ('server-switch' as const) : ('network' as const)
isServerSwitch = false
broadcastReconnected(reason)
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
const sseParser = createSseTextParser()
while (true) {
// 代次不匹配,说明已经 reconnect 过了,停止读取旧流
if (myGeneration !== connectionGeneration) {
reader.cancel().catch(() => {})
break
}
const { done, value } = await reader.read()
if (myGeneration !== connectionGeneration) {
reader.cancel().catch(() => {})
break
}
if (done) {
if (import.meta.env.DEV) {
console.log('[SSE] Stream ended, reconnecting...')
}
updateConnectionState({ state: 'disconnected' })
scheduleReconnect()
break
}
resetHeartbeat()
for (const eventData of sseParser.push(decoder.decode(value, { stream: true }))) {
const globalEvent = parseGlobalEvent(eventData)
if (globalEvent) {
broadcastEvent(globalEvent)
}
}
}
})
.catch(error => {
if (!finalizeConnectionAttempt(myGeneration)) {
return
}
if (error.name === 'AbortError') {
return
}
// SSE stream error - logged for debugging
if (import.meta.env.DEV) {
console.warn('[SSE] Event stream error:', error)
}
updateConnectionState({
state: 'error',
error: error.message || 'Connection failed',
})
// 通知所有订阅者出错
allSubscribers.forEach(cb => {
cb.onError?.(error)
})
scheduleReconnect()
})
}
function parseGlobalEvent(raw: string): GlobalEvent | null {
try {
const parsed: unknown = JSON.parse(raw)
return isGlobalEvent(parsed) ? parsed : null
} catch (error) {
if (import.meta.env.DEV) {
console.warn('[SSE] Failed to parse event:', error, raw)
}
return null
}
}
function isGlobalEvent(value: unknown): value is GlobalEvent {
if (!isRecord(value)) return false
if (typeof value.directory !== 'string') return false
if (!isRecord(value.payload)) return false
if (!isEventType(value.payload.type)) return false
return 'properties' in value.payload
}
function isEventType(value: unknown): value is EventType {
return typeof value === 'string' && EVENT_TYPE_SET.has(value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'
}
function getMessageInfo(properties: unknown): ApiMessage | undefined {
if (!isRecord(properties)) return undefined
const message = properties.info ?? properties.message
return isRecord(message) ? (message as unknown as ApiMessage) : undefined
}
// ============================================
// Background Keepalive
// ============================================
/**
* 后台 keepalive:定期检查连接是否还活着
* 移动端后台时 SSE 连接可能静默断开,timer 也可能被冻结
* 这个轮询机制可以在 timer 恢复执行时及时发现连接已死
*/
function startBackgroundKeepalive() {
stopBackgroundKeepalive()
keepaliveTimer = setInterval(() => {
const now = Date.now()
const timeSinceLastEvent = now - connectionInfo.lastEventTime
const timeout = BACKGROUND_HEARTBEAT_TIMEOUT
if (import.meta.env.DEV) {
console.log(
`[SSE] Background keepalive check: last event ${Math.round(timeSinceLastEvent / 1000)}s ago, state=${connectionInfo.state}`,
)
}
if (connectionInfo.state === 'connected' && timeSinceLastEvent > timeout) {
// 连接声称是 connected,但已经太久没收到事件了 — 连接可能已经静默断开
console.warn('[SSE] Background keepalive: connection appears dead, forcing reconnect')
// 断开旧连接
disconnectTauri()
if (singletonController) {
singletonController.abort()
singletonController = null
}
isConnecting = false
connectionGeneration++
updateConnectionState({ state: 'disconnected', error: 'Background keepalive timeout' })
scheduleReconnect()
} else if (connectionInfo.state === 'disconnected' || connectionInfo.state === 'error') {
// 已知断连状态,但可能 reconnectTimer 被后台冻结了 — 主动触发重连
if (!reconnectTimer && !isConnecting) {
console.warn('[SSE] Background keepalive: detected stale disconnect, forcing reconnect')
updateConnectionState({ reconnectAttempt: 0 })
connectSingleton()
}
}
}, BACKGROUND_KEEPALIVE_INTERVAL)
}
function stopBackgroundKeepalive() {
if (keepaliveTimer) {
clearInterval(keepaliveTimer)
keepaliveTimer = null
}
}
function disconnectSingleton() {
if (heartbeatTimer) clearTimeout(heartbeatTimer)
if (reconnectTimer) clearTimeout(reconnectTimer)
stopBackgroundKeepalive()
// Tauri: 调用 Rust 侧断开命令
disconnectTauri()
// Browser: abort fetch
if (singletonController) {
singletonController.abort()
singletonController = null
}
isConnecting = false
updateConnectionState({ state: 'disconnected' })
}
// ============================================
// Lifecycle Listeners (Visibility + Network)
// ============================================
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// 页面恢复前台
isInBackground = false
stopBackgroundKeepalive()
if (import.meta.env.DEV) {
console.log(
`[SSE] Page became visible, state=${connectionInfo.state}, lastEvent=${Math.round((Date.now() - connectionInfo.lastEventTime) / 1000)}s ago`,
)
}
if (allSubscribers.size === 0) return
if (connectionInfo.state !== 'connected') {
// 明确断连,立即重连
if (import.meta.env.DEV) {
console.log('[SSE] Page visible: not connected, forcing reconnect...')
}
forceReconnectNow()
} else {
// 状态是 connected,但连接可能已经在后台静默断开
// 检查最后一次收到事件的时间
const timeSinceLastEvent = Date.now() - connectionInfo.lastEventTime
if (timeSinceLastEvent > HEARTBEAT_TIMEOUT) {
// 太久没收到事件了,连接大概率已死
console.warn(
`[SSE] Page visible: connection may be stale (last event ${Math.round(timeSinceLastEvent / 1000)}s ago), forcing reconnect`,
)
forceReconnectNow()
} else {
// 连接看起来还活着,重置心跳为前台模式
resetHeartbeat()
}
}
} else {
// 页面进入后台
isInBackground = true
if (import.meta.env.DEV) {
console.log('[SSE] Page entering background, switching to background mode')
}
// 不再清除心跳!保持心跳运行,但切换为后台模式(更长超时)
// 心跳 timer 可能在后台被冻结,但 keepalive 轮询会在 timer 恢复时补上
resetHeartbeat()
// 启动后台 keepalive 轮询
if (allSubscribers.size > 0) {
startBackgroundKeepalive()
}
}
}
/**
* 强制立即重连:断开旧连接、重置计数器、立即发起新连接
*/
function forceReconnectNow() {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = null
updateConnectionState({ reconnectAttempt: 0 })
// 断开旧连接
connectionGeneration++
disconnectTauri()
if (singletonController) {
singletonController.abort()
singletonController = null
}
isConnecting = false
connectSingleton()
}
function handleOnline() {
if (import.meta.env.DEV) {
console.log('[SSE] Network online, forcing reconnect...')
}
if (connectionInfo.state !== 'connected' && allSubscribers.size > 0) {
forceReconnectNow()
}
}
function handleOffline() {
if (import.meta.env.DEV) {
console.log('[SSE] Network offline')
}
// 标记为断连,但不尝试重连(没网重连也没用)
if (connectionInfo.state === 'connected' || connectionInfo.state === 'connecting') {
connectionGeneration++
disconnectTauri()
if (singletonController) {
singletonController.abort()
singletonController = null
}
isConnecting = false
if (heartbeatTimer) clearTimeout(heartbeatTimer)
if (reconnectTimer) clearTimeout(reconnectTimer)
stopBackgroundKeepalive()
updateConnectionState({ state: 'disconnected', error: 'Network offline' })
}
}
function registerLifecycleListeners() {
if (lifecycleListenersRegistered) return
lifecycleListenersRegistered = true
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
}
function unregisterLifecycleListeners() {
if (!lifecycleListenersRegistered) return
lifecycleListenersRegistered = false
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
function shouldLogSseEvents(): boolean {
if (typeof window === 'undefined') return false
return window.localStorage.getItem('piagentui.debug.sse') === '1'
}
// 广播事件给所有订阅者
function broadcastEvent(globalEvent: GlobalEvent) {
if (shouldLogSseEvents()) {
console.debug(
`%c[SSE Event] Received: ${globalEvent.payload.type}`,
'color: #00bcd4; font-weight: bold; background-color: rgba(0, 188, 212, 0.1); padding: 2px 4px; border-radius: 3px;',
globalEvent.payload.properties,
)
}
// 广播给所有订阅者
allSubscribers.forEach(callbacks => {
handleEventForSubscriber(globalEvent.payload, callbacks)
})
}
function handleEventForSubscriber(payload: GlobalEvent['payload'], callbacks: EventCallbacks) {
switch (payload.type) {
case EventTypes.MESSAGE_UPDATED: {
// Server sends message data directly in properties (no .info wrapper).
// Fall back to payload.properties directly if getMessageInfo returns nothing.
const message =
getMessageInfo(payload.properties) ?? (isRecord(payload.properties) ? payload.properties : undefined)
if (message) callbacks.onMessageUpdated?.(message as ApiMessage)
break
}
case EventTypes.MESSAGE_PART_UPDATED: {
// Server sends part data directly in properties (no .part wrapper).
// Use payload.properties directly instead of payload.properties.part.
callbacks.onPartUpdated?.(payload.properties)
break
}
case EventTypes.MESSAGE_PART_DELTA: {
callbacks.onPartDelta?.(payload.properties)
break
}
case EventTypes.MESSAGE_PART_REMOVED:
callbacks.onPartRemoved?.(payload.properties)
break
case EventTypes.SESSION_UPDATED: {
callbacks.onSessionUpdated?.(payload.properties.info)
break
}
case EventTypes.SESSION_CREATED: {
callbacks.onSessionCreated?.(payload.properties.info)
break
}
case EventTypes.SESSION_DELETED: {
callbacks.onSessionDeleted?.(payload.properties.sessionID)
break
}
case EventTypes.PROJECT_UPDATED: {
callbacks.onProjectUpdated?.(payload.properties)
break
}
case EventTypes.SESSION_ERROR:
callbacks.onSessionError?.(normalizeSessionError(payload.properties))
break
case EventTypes.SESSION_IDLE:
callbacks.onSessionIdle?.(payload.properties)
break
case EventTypes.SESSION_STATUS:
callbacks.onSessionStatus?.(payload.properties)
break
case EventTypes.PERMISSION_ASKED:
callbacks.onPermissionAsked?.(payload.properties)
break
case EventTypes.PERMISSION_REPLIED:
callbacks.onPermissionReplied?.(payload.properties)
break
case EventTypes.QUESTION_ASKED:
callbacks.onQuestionAsked?.(payload.properties)
break
case EventTypes.QUESTION_REPLIED:
callbacks.onQuestionReplied?.(payload.properties)
break
case EventTypes.QUESTION_REJECTED:
callbacks.onQuestionRejected?.(payload.properties)
break
case EventTypes.WORKTREE_READY:
callbacks.onWorktreeReady?.(payload.properties)
break
case EventTypes.WORKTREE_FAILED:
callbacks.onWorktreeFailed?.(payload.properties)
break
case EventTypes.VCS_BRANCH_UPDATED:
callbacks.onVcsBranchUpdated?.(payload.properties)
break
case EventTypes.TODO_UPDATED: {
callbacks.onTodoUpdated?.({
sessionID: payload.properties.sessionID,
todos: normalizeTodoItems(payload.properties.todos),
} satisfies TodoUpdatedPayload)
break
}
case EventTypes.SERVER_CONNECTED:
callbacks.onServerConnected?.(normalizeServerConnected(payload.properties))
break
default:
// 忽略其他事件类型
break
}
}
function normalizeServerConnected(properties: unknown): ServerConnectedPayload {
if (!isRecord(properties)) return {}
return {
timestamp: properties.timestamp,
}
}
function normalizeSessionError(properties: unknown): SessionErrorPayload {
if (!isRecord(properties)) {
return { sessionID: '', name: 'UnknownError', data: properties }
}
const sessionID = typeof properties.sessionID === 'string' ? properties.sessionID : ''
if (typeof properties.name === 'string') {
return {
sessionID,
name: properties.name,
data: properties.data,
}
}
const sdkError = properties.error
if (isRecord(sdkError)) {
return {
sessionID,
name: typeof sdkError.name === 'string' ? sdkError.name : 'UnknownError',
data: 'data' in sdkError ? sdkError.data : sdkError,
}
}
return {
sessionID,
name: 'UnknownError',
data: sdkError,
}
}
// ============================================
// Public API
// ============================================
/**
* 强制重连 SSE(用于切换服务器等场景)
* 断开当前连接 → 重置状态 → 立即重连(新 URL 由 getApiBaseUrl() 动态解析)
*/
export function reconnectSSE() {
if (allSubscribers.size === 0) return // 没有订阅者不需要重连
if (import.meta.env.DEV) {
console.log('[SSE] reconnectSSE() called, forcing reconnect to new server...')
}
// 断开现有连接
if (heartbeatTimer) clearTimeout(heartbeatTimer)
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectTimer = null
stopBackgroundKeepalive()
// 标记为服务器切换,重连成功时 onReconnected 会携带 'server-switch' reason
isServerSwitch = true
// 递增连接代次,使旧连接的事件回调自动失效
connectionGeneration++
disconnectTauri()
if (singletonController) {
singletonController.abort()
singletonController = null
}
isConnecting = false
// 重置重连计数
updateConnectionState({
state: 'disconnected',
reconnectAttempt: 0,
error: undefined,
})
// 立即重连(getApiBaseUrl() 会读取新的 activeServer)
connectSingleton()
}
/**
* 订阅 SSE 事件(单例模式,多个订阅者共享一个连接)
*/
export function subscribeToEvents(callbacks: EventCallbacks): () => void {
allSubscribers.add(callbacks)
// 如果是第一个订阅者,启动连接
if (allSubscribers.size === 1) {
connectSingleton()
}
// 返回取消订阅函数
return () => {
allSubscribers.delete(callbacks)
// 如果没有订阅者了,断开连接并清理监听器
if (allSubscribers.size === 0) {
disconnectSingleton()
unregisterLifecycleListeners()
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getExtensions, installExtension, removeExtensionPackage } from './extension'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
describe('extension api', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
vi.mocked(piClient.post).mockReset()
vi.mocked(piClient.delete).mockReset()
})
it('loads extension catalog and sends safe package operations to backend', async () => {
vi.mocked(piClient.get).mockResolvedValue({
packages: [],
summary: { total: 0, active: 0, inactive: 0, missing: 0, error: 0 },
})
vi.mocked(piClient.post).mockResolvedValue({ source: 'npm:pi-hud', scope: 'user', reloadRequired: true })
vi.mocked(piClient.delete).mockResolvedValue({ source: 'npm:pi-hud', scope: 'user', reloadRequired: true })
await expect(getExtensions('/repo')).resolves.toMatchObject({ summary: { total: 0 } })
await expect(installExtension('pi install npm:pi-hud', '/repo')).resolves.toMatchObject({ reloadRequired: true })
await expect(removeExtensionPackage('npm:pi-hud', 'user', '/repo')).resolves.toMatchObject({ reloadRequired: true })
expect(piClient.get).toHaveBeenCalledWith('/api/extensions?directory=%2Frepo')
expect(piClient.post).toHaveBeenCalledWith('/api/extensions/install', {
command: 'pi install npm:pi-hud',
directory: '/repo',
})
expect(piClient.delete).toHaveBeenCalledWith('/api/extensions/package', {
source: 'npm:pi-hud',
scope: 'user',
directory: '/repo',
})
})
})
+36
View File
@@ -0,0 +1,36 @@
import { formatPathForApi } from '../utils/directoryUtils'
import { piClient } from './piClient'
import type {
ExtensionCatalog,
ExtensionInstallResult,
ExtensionPackageScope,
ExtensionRemoveResult,
} from '../types/api/extension'
function withDirectoryQuery(path: string, directory?: string): string {
const formatted = formatPathForApi(directory)
return formatted ? `${path}?directory=${encodeURIComponent(formatted)}` : path
}
export async function getExtensions(directory?: string): Promise<ExtensionCatalog> {
return await piClient.get<ExtensionCatalog>(withDirectoryQuery('/api/extensions', directory))
}
export async function installExtension(command: string, directory?: string): Promise<ExtensionInstallResult> {
return await piClient.post<ExtensionInstallResult>('/api/extensions/install', {
command,
directory: formatPathForApi(directory),
})
}
export async function removeExtensionPackage(
source: string,
scope: ExtensionPackageScope,
directory?: string,
): Promise<ExtensionRemoveResult> {
return await piClient.delete<ExtensionRemoveResult>('/api/extensions/package', {
source,
scope,
directory: formatPathForApi(directory),
})
}
+151
View File
@@ -0,0 +1,151 @@
// ============================================
// File Search API Functions - Decoupled from SDK
// ============================================
import { piClient } from './piClient'
import { formatPathForApi } from '../utils/directoryUtils'
import type { FileNode, FileContent, FileStatusItem, SymbolInfo } from './types'
import { serverStore } from '../store/serverStore'
const ROOT_DIRECTORY_CACHE_TTL_MS = 10_000
const rootDirectoryCache = new Map<string, { data: FileNode[]; expiresAt: number }>()
const rootDirectoryInflight = new Map<string, Promise<FileNode[]>>()
function isRootDirectoryPath(path: string): boolean {
return path === '' || path === '.' || path === './'
}
function getRootDirectoryCacheKey(directory?: string): string {
return `${serverStore.getActiveServerId()}::${formatPathForApi(directory) ?? ''}`
}
async function fetchDirectory(path: string, directory?: string): Promise<FileNode[]> {
const isAbsolute = /^[a-zA-Z]:/.test(path) || path.startsWith('/')
const targetDir = isAbsolute ? path : (directory || '')
const targetPath = isAbsolute ? '' : path
try {
const query = `?path=${encodeURIComponent(targetPath)}&directory=${encodeURIComponent(targetDir)}`
const data = await piClient.get<{ files: FileNode[] }>(`/api/files/list${query}`)
return data.files || []
} catch {
return []
}
}
/**
* 搜索文件或目录
*/
export async function searchFiles(
query: string,
options: {
directory?: string
type?: 'file' | 'directory'
limit?: number
} = {},
): Promise<string[]> {
try {
const q = `?query=${encodeURIComponent(query)}&directory=${encodeURIComponent(options.directory || '')}&type=${options.type || ''}&limit=${options.limit || ''}`
const data = await piClient.get<{ files: string[] }>(`/api/find/files${q}`)
return data.files || []
} catch {
return []
}
}
/**
* 列出目录内容
*/
export async function listDirectory(path: string, directory?: string): Promise<FileNode[]> {
if (!isRootDirectoryPath(path)) {
return fetchDirectory(path, directory)
}
const key = getRootDirectoryCacheKey(directory)
const now = Date.now()
const cached = rootDirectoryCache.get(key)
if (cached && cached.expiresAt > now) {
return cached.data
}
const inflight = rootDirectoryInflight.get(key)
if (inflight) {
return inflight
}
const request = fetchDirectory(path === '' ? '.' : path, directory)
.then(data => {
rootDirectoryCache.set(key, { data, expiresAt: Date.now() + ROOT_DIRECTORY_CACHE_TTL_MS })
return data
})
.finally(() => {
rootDirectoryInflight.delete(key)
})
rootDirectoryInflight.set(key, request)
return request
}
export async function prefetchRootDirectory(directory?: string): Promise<void> {
await listDirectory('.', directory)
}
/**
* 读取文件内容
*/
export async function getFileContent(path: string, directory?: string): Promise<FileContent> {
try {
const q = `?path=${encodeURIComponent(path)}&directory=${encodeURIComponent(directory || '')}`
const data = await piClient.get<FileContent>(`/api/files/read${q}`)
return {
...data,
text: data.text ?? data.content ?? '',
content: data.content ?? data.text ?? '',
}
} catch (e) {
const errText = `Failed to load file content: ${e instanceof Error ? e.message : String(e)}`
return {
path,
text: errText,
content: errText,
}
}
}
/**
* 获取文件 git 状态
*/
export async function getFileStatus(directory?: string): Promise<FileStatusItem[]> {
try {
const q = `?directory=${encodeURIComponent(directory || '')}`
const data = await piClient.get<{ status: FileStatusItem[] }>(`/api/files/status${q}`)
return data.status || []
} catch {
return []
}
}
/**
* 搜索代码符号
*/
export async function searchSymbols(query: string, directory?: string): Promise<SymbolInfo[]> {
try {
const q = `?query=${encodeURIComponent(query)}&directory=${encodeURIComponent(directory || '')}`
const data = await piClient.get<{ symbols: SymbolInfo[] }>(`/api/find/symbols${q}`)
return data.symbols || []
} catch {
return []
}
}
/**
* 搜索目录(便捷方法)
*/
export async function searchDirectories(query: string, baseDirectory?: string, limit: number = 50): Promise<string[]> {
return searchFiles(query, {
directory: baseDirectory,
type: 'directory',
limit,
})
}
+46
View File
@@ -0,0 +1,46 @@
// ============================================
// Global API - Decoupled from SDK
// ============================================
import { piClient } from './piClient'
import { formatPathForApi } from '../utils/directoryUtils'
export interface HealthInfo {
status: string
version?: string
}
/**
* 获取服务器健康状态
*/
export async function getHealth(): Promise<HealthInfo> {
try {
return await piClient.get<HealthInfo>('/api/health')
} catch {
return { status: 'ok', version: '1.0.0-pi' }
}
}
/**
* 释放所有资源
*/
export async function disposeGlobal(): Promise<boolean> {
try {
await piClient.post('/api/global/dispose')
return true
} catch {
return true
}
}
/**
* 释放当前实例
*/
export async function disposeInstance(directory?: string): Promise<boolean> {
try {
await piClient.post('/api/instance/dispose', { directory: formatPathForApi(directory) })
return true
} catch {
return true
}
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { buildQueryString } from './http'
// ============================================
// buildQueryString 测试
// ============================================
describe('buildQueryString', () => {
it('returns empty string for empty params', () => {
expect(buildQueryString({})).toBe('')
})
it('skips undefined values', () => {
expect(buildQueryString({ a: 'hello', b: undefined })).toBe('?a=hello')
})
it('handles string, number, boolean values', () => {
const result = buildQueryString({ name: 'test', page: 1, active: true })
expect(result).toBe('?name=test&page=1&active=true')
})
it('encodes special characters in values', () => {
const result = buildQueryString({ directory: 'C:\\Program Files\\app' })
expect(result).toBe('?directory=C%3A%5CProgram%20Files%5Capp')
})
it('encodes special characters in keys', () => {
const result = buildQueryString({ 'key with spaces': 'value' })
expect(result).toBe('?key%20with%20spaces=value')
})
it('encodes ampersand and equals in values', () => {
const result = buildQueryString({ q: 'a=1&b=2' })
expect(result).toBe('?q=a%3D1%26b%3D2')
})
it('encodes unicode characters', () => {
const result = buildQueryString({ path: '/home/用户/project' })
expect(result).toBe('?path=%2Fhome%2F%E7%94%A8%E6%88%B7%2Fproject')
})
})
+47
View File
@@ -0,0 +1,47 @@
// ============================================
// HTTP Utilities
// 仅保留给 SSE / PTY WebSocket 使用的 URL 与 auth 辅助函数
// ============================================
import { serverStore, makeBasicAuthHeader } from '../store/serverStore'
/**
* 获取当前 API Base URL
* 优先使用 serverStore 中的活动服务器,回退到常量
*/
export function getApiBaseUrl(): string {
return serverStore.getActiveBaseUrl()
}
/**
* 获取当前活动服务器的 Authorization header
* 如果服务器配置了密码则返回 Basic Auth header,否则返回 undefined
*/
export function getAuthHeader(): Record<string, string> {
const token = serverStore.getActiveToken()
if (token) {
return { Authorization: `Bearer ${token}` }
}
const auth = serverStore.getActiveAuth()
if (auth?.password) {
return { Authorization: makeBasicAuthHeader(auth) }
}
return {}
}
type QueryValue = string | number | boolean | undefined
/**
* 构建查询字符串
* 值会进行 URL 编码以安全处理空格、特殊字符等
* (Go 后端的 r.URL.Query().Get() 会自动解码)
*/
export function buildQueryString(params: Record<string, QueryValue>): string {
const parts: string[] = []
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
}
}
return parts.length > 0 ? '?' + parts.join('&') : ''
}
+3
View File
@@ -0,0 +1,3 @@
// API 模块入口
// 从 client.ts 统一导出所有内容(client.ts 会 re-export 其他子模块)
export * from './client'
+29
View File
@@ -0,0 +1,29 @@
// ============================================
// LSP API Stub - Language Server Protocol
// Gracefully stubbed out for Pi Agent MVP
// ============================================
export interface LSPStatus {
running: boolean
language?: string
capabilities?: string[]
}
/**
* 获取 LSP 服务状态
*/
export async function getLspStatus(_directory?: string): Promise<LSPStatus> {
return { running: false }
}
export interface FormatterStatus {
available: boolean
name?: string
}
/**
* 获取格式化器状态
*/
export async function getFormatterStatus(_directory?: string): Promise<FormatterStatus> {
return { available: false }
}
+40
View File
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getMcpStatus } from './mcp'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
},
}))
describe('getMcpStatus', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
})
it('loads real MCP status from the backend and preserves metadata', async () => {
vi.mocked(piClient.get).mockResolvedValue({
servers: {
context7: {
status: 'configured',
transport: 'http',
source: 'global',
url: 'https://mcp.context7.com/mcp',
directTools: true,
},
},
configPaths: ['C:/Users/Evandro/.pi/agent/mcp.json'],
})
const status = await getMcpStatus('D:/repo')
expect(piClient.get).toHaveBeenCalledWith('/api/mcp/status')
expect(status.servers.context7).toMatchObject({
status: 'configured',
transport: 'http',
source: 'global',
directTools: true,
})
})
})
+62
View File
@@ -0,0 +1,62 @@
// ============================================
// MCP API - Model Context Protocol
// ============================================
import type { MCPStatusResponse, McpServerConfig } from '../types/api/mcp'
import { piClient } from './piClient'
/**
* 获取所有 MCP 服务器状态
*/
export async function getMcpStatus(_directory?: string): Promise<MCPStatusResponse> {
return piClient.get<MCPStatusResponse>('/api/mcp/status')
}
/**
* 添加 MCP 服务器
*/
export async function addMcpServer(_name: string, _config: McpServerConfig, _directory?: string): Promise<void> {
throw new Error('MCP is not supported in the Pi Agent MVP.')
}
/**
* 连接到 MCP 服务器
*/
export async function connectMcpServer(_name: string, _directory?: string): Promise<void> {
throw new Error('MCP is not supported in the Pi Agent MVP.')
}
/**
* 断开 MCP 服务器连接
*/
export async function disconnectMcpServer(_name: string, _directory?: string): Promise<void> {
throw new Error('MCP is not supported in the Pi Agent MVP.')
}
/**
* 开始 MCP 认证流程
*/
export function startMcpAuth(_name: string, _directory?: string): Promise<{ url: string }> {
return Promise.reject(new Error('MCP authentication is not supported yet.'))
}
/**
* 移除 MCP 认证
*/
export async function removeMcpAuth(_name: string, _directory?: string): Promise<void> {
throw new Error('MCP is not supported in the Pi Agent MVP.')
}
/**
* 完成 MCP OAuth 认证(使用授权码)
*/
export async function completeMcpAuth(_name: string, _code: string, _directory?: string): Promise<void> {
throw new Error('MCP is not supported in the Pi Agent MVP.')
}
/**
* 启动完整的 OAuth 认证流程
*/
export async function authenticateMcp(_name: string, _directory?: string): Promise<void> {
throw new Error('MCP is not supported in the Pi Agent MVP.')
}
+156
View File
@@ -0,0 +1,156 @@
// ============================================
// Message API Functions
// ============================================
import { piClient } from './piClient'
import { formatPathForApi } from '../utils/directoryUtils'
import type {
ApiMessageWithParts,
ApiAgentPart,
ApiTextPart,
ApiFilePart,
Attachment,
RevertedMessage,
SendMessageParams,
SendMessageResponse,
} from './types'
type UserContentSource = {
parts: Array<
| ApiTextPart
| ApiFilePart
| ApiAgentPart
| {
type: string
}
>
}
function isTextUserContentPart(part: UserContentSource['parts'][number]): part is ApiTextPart {
return part.type === 'text' && 'text' in part
}
function isFileUserContentPart(part: UserContentSource['parts'][number]): part is ApiFilePart {
return part.type === 'file' && 'mime' in part && 'url' in part
}
function isAgentUserContentPart(part: UserContentSource['parts'][number]): part is ApiAgentPart {
return part.type === 'agent' && 'name' in part
}
// ============================================
// Message Query
// ============================================
/**
* 获取 session 的消息列表
*/
export async function getSessionMessages(
sessionId: string,
limit?: number,
directory?: string,
): Promise<ApiMessageWithParts[]> {
try {
const data = await piClient.get<{ messages: ApiMessageWithParts[] }>(
`/api/sessions/${sessionId}/messages?directory=${formatPathForApi(directory)}&limit=${limit || ''}`
)
return data.messages || []
} catch {
return []
}
}
/**
* 获取 session 的消息数量
*/
export async function getSessionMessageCount(sessionId: string): Promise<number> {
const messages = await getSessionMessages(sessionId)
return messages.length
}
// ============================================
// Message Content Extraction
// ============================================
/**
* 从 API 消息中提取用户消息内容(文本+附件)
*/
export function extractUserMessageContent(message: UserContentSource): RevertedMessage {
const { parts } = message
const textParts = parts.filter((part): part is ApiTextPart => isTextUserContentPart(part) && !part.synthetic)
const text = textParts.map(p => p.text).join('\n')
const attachments: Attachment[] = []
const getSourcePath = (source: ApiFilePart['source']): string | undefined => {
if (!source || !('path' in source)) return undefined
return source.path
}
for (const part of parts) {
if (isFileUserContentPart(part)) {
const isFolder = part.mime === 'application/x-directory'
const sourcePath = getSourcePath(part.source)
attachments.push({
id: part.id || crypto.randomUUID(),
type: isFolder ? 'folder' : 'file',
displayName: part.filename || sourcePath || 'file',
url: part.url,
mime: part.mime,
relativePath: sourcePath,
textRange: part.source?.text
? {
value: part.source.text.value,
start: part.source.text.start ?? 0,
end: part.source.text.end ?? 0,
}
: undefined,
})
} else if (isAgentUserContentPart(part)) {
attachments.push({
id: part.id || crypto.randomUUID(),
type: 'agent',
displayName: part.name,
agentName: part.name,
textRange: part.source
? {
value: part.source.value,
start: part.source.start ?? 0,
end: part.source.end ?? 0,
}
: undefined,
})
}
}
return { text, attachments }
}
// ============================================
// Send Message
// ============================================
/**
* 同步发送消息(等待完成)
*/
export async function sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
return await piClient.post<SendMessageResponse>('/api/messages/send', {
sessionId: params.sessionId,
text: params.text,
model: params.model,
directory: params.directory,
})
}
/**
* 异步发送消息 — 立即返回, AI 响应通过 SSE/WS 推送
*/
export async function sendMessageAsync(params: SendMessageParams): Promise<void> {
await piClient.post('/api/messages/send', {
sessionId: params.sessionId,
text: params.text,
model: params.model,
directory: params.directory,
})
}
+47
View File
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getNetworkAccess, updateNetworkAccess } from './network'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
post: vi.fn(),
},
}))
describe('network api', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
vi.mocked(piClient.post).mockReset()
})
it('loads LAN access info', async () => {
vi.mocked(piClient.get).mockResolvedValue({
lanAccessEnabled: false,
port: 58785,
localUrl: 'http://127.0.0.1:58785',
lanUrls: ['http://192.168.1.25:58785'],
primaryLanUrl: 'http://192.168.1.25:58785',
})
const info = await getNetworkAccess()
expect(piClient.get).toHaveBeenCalledWith('/api/network/access')
expect(info.primaryLanUrl).toBe('http://192.168.1.25:58785')
})
it('updates LAN access info', async () => {
vi.mocked(piClient.post).mockResolvedValue({
lanAccessEnabled: true,
port: 58785,
localUrl: 'http://127.0.0.1:58785',
lanUrls: ['http://192.168.1.25:58785'],
primaryLanUrl: 'http://192.168.1.25:58785',
})
const info = await updateNetworkAccess(true)
expect(piClient.post).toHaveBeenCalledWith('/api/network/access', { lanAccessEnabled: true })
expect(info.lanAccessEnabled).toBe(true)
})
})
+10
View File
@@ -0,0 +1,10 @@
import { piClient } from './piClient'
import type { NetworkAccessInfo } from '../types/api/network'
export async function getNetworkAccess(): Promise<NetworkAccessInfo> {
return await piClient.get<NetworkAccessInfo>('/api/network/access')
}
export async function updateNetworkAccess(lanAccessEnabled: boolean): Promise<NetworkAccessInfo> {
return await piClient.post<NetworkAccessInfo>('/api/network/access', { lanAccessEnabled })
}
+111
View File
@@ -0,0 +1,111 @@
// ============================================
// Permission & Question API Functions - Decoupled
// ============================================
import { piClient } from './piClient'
import { formatPathForApi } from '../utils/directoryUtils'
import type { ApiPermissionRequest, PermissionReply, ApiQuestionRequest, QuestionAnswer } from './types'
// ============================================
// Permission API
// ============================================
/**
* 获取待处理的权限请求列表
*/
export async function getPendingPermissions(sessionId?: string, directory?: string): Promise<ApiPermissionRequest[]> {
try {
const q = `?directory=${encodeURIComponent(directory || '')}`
const data = await piClient.get<{ permissions: ApiPermissionRequest[] }>(`/api/permissions/list${q}`)
const permissions = data.permissions || []
return sessionId ? permissions.filter(p => p.sessionID === sessionId) : permissions
} catch {
return []
}
}
/**
* 回复权限请求
*/
export async function replyPermission(
requestId: string,
reply: PermissionReply,
message?: string,
directory?: string,
sessionId?: string,
): Promise<boolean> {
try {
if (sessionId) {
await piClient.post('/api/permissions/respond', {
sessionID: sessionId,
permissionID: requestId,
directory: formatPathForApi(directory),
response: reply,
})
return true
}
await piClient.post('/api/permissions/reply', {
requestID: requestId,
directory: formatPathForApi(directory),
reply,
message,
})
return true
} catch {
return true
}
}
// ============================================
// Question API
// ============================================
/**
* 获取待处理的问题请求列表
*/
export async function getPendingQuestions(sessionId?: string, directory?: string): Promise<ApiQuestionRequest[]> {
try {
const q = `?directory=${encodeURIComponent(directory || '')}`
const data = await piClient.get<{ questions: ApiQuestionRequest[] }>(`/api/questions/list${q}`)
const questions = data.questions || []
return sessionId ? questions.filter(q => q.sessionID === sessionId) : questions
} catch {
return []
}
}
/**
* 回复问题请求
*/
export async function replyQuestion(
requestId: string,
answers: QuestionAnswer[],
directory?: string,
): Promise<boolean> {
try {
await piClient.post('/api/questions/reply', {
requestID: requestId,
directory: formatPathForApi(directory),
answers,
})
return true
} catch {
return true
}
}
/**
* 拒绝问题请求
*/
export async function rejectQuestion(requestId: string, directory?: string): Promise<boolean> {
try {
await piClient.post('/api/questions/reject', {
requestID: requestId,
directory: formatPathForApi(directory),
})
return true
} catch {
return true
}
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { piClient } from './piClient'
import { serverStore } from '../store/serverStore'
describe('piClient Authenticated Fetch Client', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.resetModules()
localStorage.clear()
sessionStorage.clear()
})
it('builds headers with Bearer token if running in Pi Mode', async () => {
vi.spyOn(serverStore, 'isPiMode').mockReturnValue(true)
vi.spyOn(serverStore, 'getActiveToken').mockReturnValue('my-test-bearer-token')
vi.spyOn(serverStore, 'getActiveBaseUrl').mockReturnValue('http://127.0.0.1:4000')
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(() => Promise.resolve(new Response(JSON.stringify({ ok: true }))))
const result = await piClient.get<{ ok: boolean }>('/api/test')
expect(result.ok).toBe(true)
expect(fetchSpy).toHaveBeenCalledWith(
'http://127.0.0.1:4000/api/test',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer my-test-bearer-token',
'Content-Type': 'application/json',
}),
}),
)
})
it('throws backend JSON error messages instead of generic HTTP status text', async () => {
vi.spyOn(serverStore, 'getActiveToken').mockReturnValue('my-test-bearer-token')
vi.spyOn(serverStore, 'getActiveBaseUrl').mockReturnValue('http://127.0.0.1:4000')
vi.spyOn(globalThis, 'fetch').mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ error: 'fatal: not a git repository' }), {
status: 400,
statusText: 'Bad Request',
headers: { 'Content-Type': 'application/json' },
}),
),
)
await expect(piClient.get('/api/vcs/status')).rejects.toThrow('fatal: not a git repository')
})
})
+115
View File
@@ -0,0 +1,115 @@
import { serverStore } from '../store/serverStore'
import { isTauri } from '../utils/tauri'
let _tauriFetch: typeof globalThis.fetch | null = null
async function getTauriFetch(): Promise<typeof globalThis.fetch> {
if (_tauriFetch) return _tauriFetch
const mod = await import('@tauri-apps/plugin-http')
_tauriFetch = mod.fetch as unknown as typeof globalThis.fetch
return _tauriFetch
}
async function getFetch(): Promise<typeof globalThis.fetch> {
if (isTauri()) {
try {
return await getTauriFetch()
} catch {
return globalThis.fetch
}
}
return globalThis.fetch
}
function buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const token = serverStore.getActiveToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
} else {
const auth = serverStore.getActiveAuth()
if (auth?.password) {
headers['Authorization'] = 'Basic ' + btoa(`${auth.username}:${auth.password}`)
}
}
return headers
}
async function getResponseErrorMessage(response: Response): Promise<string> {
const fallback = `HTTP Error ${response.status}: ${response.statusText}`
try {
const text = await response.text()
if (!text) return fallback
try {
const data = JSON.parse(text) as { error?: unknown; message?: unknown }
if (typeof data.error === 'string' && data.error.trim()) return data.error
if (typeof data.message === 'string' && data.message.trim()) return data.message
} catch {
// Plain-text backend errors are still more useful than the generic status text.
}
return text.trim() || fallback
} catch {
return fallback
}
}
async function throwIfNotOk(response: Response): Promise<void> {
if (response.ok) return
throw new Error(await getResponseErrorMessage(response))
}
export const piClient = {
async get<T>(path: string): Promise<T> {
const baseUrl = serverStore.getActiveBaseUrl()
const headers = buildHeaders()
const f = await getFetch()
const response = await f(`${baseUrl}${path}`, {
method: 'GET',
headers,
})
await throwIfNotOk(response)
return (await response.json()) as T
},
async post<T>(path: string, body?: unknown): Promise<T> {
const baseUrl = serverStore.getActiveBaseUrl()
const headers = buildHeaders()
const f = await getFetch()
const response = await f(`${baseUrl}${path}`, {
method: 'POST',
headers,
body: body ? JSON.stringify(body) : undefined,
})
await throwIfNotOk(response)
return (await response.json()) as T
},
async delete<T>(path: string, body?: unknown): Promise<T> {
const baseUrl = serverStore.getActiveBaseUrl()
const headers = buildHeaders()
const f = await getFetch()
const response = await f(`${baseUrl}${path}`, {
method: 'DELETE',
headers,
body: body ? JSON.stringify(body) : undefined,
})
await throwIfNotOk(response)
return (await response.json()) as T
},
}
+51
View File
@@ -0,0 +1,51 @@
import type { Session, Model } from '../types/api/compat'
export interface SessionListResponse {
sessions: Session[]
}
export interface SessionCreateRequest {
title?: string
directory?: string
}
export interface SessionUpdateRequest {
title?: string
}
export interface SessionForkRequest {
messageID?: string
directory?: string
}
export interface SendMessageRequest {
sessionId: string
text: string
model?: { providerID: string; modelID: string }
directory?: string
}
export interface SendMessageResponse {
messageID: string
sessionID: string
}
export interface ModelsResponse {
models: Model[]
}
export interface PartDeltaPayload {
sessionID: string
messageID: string
partID: string
field: 'text' | 'reasoning' | 'tool'
delta: string
}
export interface PiGlobalEvent {
directory?: string
payload: {
type: string
properties: unknown
}
}
+53
View File
@@ -0,0 +1,53 @@
// ============================================
// PTY API Stub - 终端管理
// Gracefully stubbed out for Pi Agent MVP
// ============================================
import type { Pty, PtyCreateParams, PtyUpdateParams } from '../types/api/pty'
interface PtyConnectUrlOptions {
includeAuthInUrl?: boolean
cursor?: number
}
/**
* 获取所有 PTY 会话列表
*/
export async function listPtySessions(_directory?: string): Promise<Pty[]> {
return []
}
/**
* 创建新的 PTY 会话
*/
export async function createPtySession(_params: PtyCreateParams, _directory?: string): Promise<Pty> {
throw new Error('PTY terminals are not supported in the Pi Agent MVP.')
}
/**
* 获取单个 PTY 会话信息
*/
export async function getPtySession(_ptyId: string, _directory?: string): Promise<Pty> {
throw new Error('PTY terminals are not supported in the Pi Agent MVP.')
}
/**
* 更新 PTY 会话
*/
export async function updatePtySession(_ptyId: string, _params: PtyUpdateParams, _directory?: string): Promise<Pty> {
throw new Error('PTY terminals are not supported in the Pi Agent MVP.')
}
/**
* 删除 PTY 会话
*/
export async function removePtySession(_ptyId: string, _directory?: string): Promise<boolean> {
return true
}
/**
* 获取 PTY 连接 WebSocket URL
*/
export function getPtyConnectUrl(_ptyId: string, _directory?: string, _options?: PtyConnectUrlOptions): string {
return ''
}
+25
View File
@@ -0,0 +1,25 @@
interface ConnectTauriPtyParams {
ptyId: string
directory?: string
cursor?: number
onConnected: () => void
onMessage: (chunk: string) => void
onDisconnected: (info: { code?: number; reason?: string }) => void
onError: (message: string) => void
}
export interface TauriPtyConnection {
send: (data: string) => void
close: () => void
}
export async function connectTauriPty({
onError,
}: ConnectTauriPtyParams): Promise<TauriPtyConnection> {
const msg = 'PTY terminals are not supported in the Pi Agent MVP.'
setTimeout(() => onError(msg), 0)
return {
send(_data: string) {},
close() {},
}
}
+44
View File
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSessionContextUsage } from './session'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
},
}))
describe('getSessionContextUsage', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
})
it('fetches runtime context usage for the requested session', async () => {
vi.mocked(piClient.get).mockResolvedValue({
available: true,
sessionId: 'session-1',
activeSessionId: 'session-1',
tokens: 42000,
contextWindow: 1000000,
percent: 4.2,
providerID: 'opencode-go',
modelID: 'deepseek-v4-flash',
modelName: 'DeepSeek V4 Flash',
outputLimit: 384000,
})
const usage = await getSessionContextUsage('session-1')
expect(piClient.get).toHaveBeenCalledWith('/api/sessions/session-1/context')
expect(usage).toMatchObject({
available: true,
tokens: 42000,
contextWindow: 1000000,
percent: 4.2,
providerID: 'opencode-go',
modelID: 'deepseek-v4-flash',
modelName: 'DeepSeek V4 Flash',
outputLimit: 384000,
})
})
})
+215
View File
@@ -0,0 +1,215 @@
// ============================================
// Session API Functions
// ============================================
import { piClient } from './piClient'
import { formatPathForApi } from '../utils/directoryUtils'
import type { ApiSession, SessionListParams, FileDiff } from './types'
import type { SessionStatusMap } from '../types/api/session'
export interface ApiSessionContextUsage {
available: boolean
sessionId?: string
activeSessionId?: string
tokens?: number | null
contextWindow?: number | null
percent?: number | null
providerID?: string
modelID?: string
modelName?: string
outputLimit?: number | null
reason?: 'runtime_unavailable' | 'session_not_active' | 'context_usage_unavailable' | string
}
import type { TodoItem } from '../types/api/event'
function fallbackSessionTitle(sessionId?: string): string {
return sessionId ? `Session ${sessionId.slice(0, 8)}` : 'Untitled Session'
}
// ============================================
// Session Status & Diff
// ============================================
export async function getSessionStatus(_directory?: string): Promise<SessionStatusMap> {
return {}
}
export async function getSessionDiff(
_sessionId: string,
_directory?: string,
_messageId?: string,
): Promise<FileDiff[]> {
return []
}
export async function getLastTurnDiff(_sessionId: string, _directory?: string): Promise<FileDiff[]> {
return []
}
export async function getSessionContextUsage(sessionId: string): Promise<ApiSessionContextUsage> {
return await piClient.get<ApiSessionContextUsage>(`/api/sessions/${encodeURIComponent(sessionId)}/context`)
}
// ============================================
// Session CRUD
// ============================================
export async function getSessions(params: SessionListParams = {}): Promise<ApiSession[]> {
try {
const data = await piClient.get<{ sessions: ApiSession[] }>('/api/sessions')
return data.sessions || []
} catch {
return [
{
id: 'active-session',
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 },
},
]
}
}
export async function getSession(sessionId: string, directory?: string): Promise<ApiSession> {
return {
id: sessionId,
title: fallbackSessionTitle(sessionId),
directory: formatPathForApi(directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
export async function createSession(
params: {
directory?: string
title?: string
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 },
}
}
}
export async function updateSession(
sessionId: string,
params: { title?: string; time?: { archived?: number } },
directory?: string,
): Promise<ApiSession> {
return {
id: sessionId,
title: params.title || fallbackSessionTitle(sessionId),
directory: formatPathForApi(directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
export async function deleteSession(_sessionId: string, _directory?: string): Promise<boolean> {
return true
}
// ============================================
// Session Actions
// ============================================
export async function abortSession(sessionId: string, directory?: string): Promise<boolean> {
try {
await piClient.post('/api/sessions/abort', { sessionId, directory })
return true
} catch {
return true
}
}
export async function revertMessage(
sessionId: string,
_messageId: string,
_partId?: string,
directory?: string,
): Promise<ApiSession> {
return {
id: sessionId,
title: fallbackSessionTitle(sessionId),
directory: formatPathForApi(directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
export async function unrevertSession(sessionId: string, directory?: string): Promise<ApiSession> {
return {
id: sessionId,
title: fallbackSessionTitle(sessionId),
directory: formatPathForApi(directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
export async function shareSession(sessionId: string, directory?: string): Promise<ApiSession> {
return {
id: sessionId,
title: fallbackSessionTitle(sessionId),
directory: formatPathForApi(directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
export async function unshareSession(sessionId: string, directory?: string): Promise<ApiSession> {
return {
id: sessionId,
title: fallbackSessionTitle(sessionId),
directory: formatPathForApi(directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
export async function forkSession(sessionId: string, _messageId?: string, directory?: string): Promise<ApiSession> {
return {
id: sessionId,
title: fallbackSessionTitle(sessionId),
directory: formatPathForApi(directory) ?? '',
time: { created: Date.now(), updated: Date.now() },
status: { type: 'idle' },
summary: { deletions: 0, files: 0, additions: 0 },
}
}
export async function summarizeSession(
_sessionId: string,
_params: { providerID: string; modelID: string; auto?: boolean },
_directory?: string,
): Promise<boolean> {
return true
}
export async function getSessionChildren(_sessionId: string, _directory?: string): Promise<ApiSession[]> {
return []
}
export type ApiTodo = TodoItem
export async function getSessionTodos(_sessionId: string, _directory?: string): Promise<ApiTodo[]> {
return []
}
+35
View File
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSkills } from './skill'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
},
}))
describe('getSkills', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
})
it('preserves real skill source metadata from the backend', async () => {
vi.mocked(piClient.get).mockResolvedValue({
skills: [
{
name: 'global-skill',
description: 'Global skill',
location: '/home/user/.agents/skills/global-skill/SKILL.md',
source: 'global',
content: 'global content',
},
],
groups: { global: 1, project: 0, package: 0, other: 0 },
})
const skills = await getSkills('/workspace/demo')
expect(piClient.get).toHaveBeenCalledWith('/api/skills')
expect(skills[0]).toMatchObject({ name: 'global-skill', source: 'global' })
})
})
+18
View File
@@ -0,0 +1,18 @@
// ============================================
// Skill API - Decoupled
// ============================================
import { piClient } from './piClient'
import type { SkillList } from '../types/api/skill'
/**
* 获取所有可用 Skills
*/
export async function getSkills(_directory?: string): Promise<SkillList> {
try {
const data = await piClient.get<{ skills: SkillList }>('/api/skills')
return data.skills || []
} catch {
return []
}
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { createSseTextParser } from './sse'
describe('createSseTextParser', () => {
it('keeps multiline event data across chunk boundaries', () => {
const parser = createSseTextParser()
expect(parser.push('data: {"text":"中')).toEqual([])
expect(parser.push('文"}\n')).toEqual([])
expect(parser.push('data: 第二行\n\n')).toEqual(['{"text":"中文"}\n第二行'])
})
it('normalizes CRLF-delimited SSE blocks', () => {
const parser = createSseTextParser()
expect(parser.push('data: 你好\r\n')).toEqual([])
expect(parser.push('data: 世界\r\n\r\n')).toEqual(['你好\n世界'])
})
})
+36
View File
@@ -0,0 +1,36 @@
export interface SseTextParser {
push(chunk: string): string[]
}
export function createSseTextParser(): SseTextParser {
let buffer = ''
return {
push(chunk: string) {
if (!chunk) return []
buffer += chunk
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const blocks = buffer.split('\n\n')
buffer = blocks.pop() ?? ''
const events: string[] = []
for (const block of blocks) {
const dataLines: string[] = []
for (const line of block.split('\n')) {
if (!line.startsWith('data:')) continue
dataLines.push(line[5] === ' ' ? line.slice(6) : line.slice(5))
}
if (dataLines.length > 0) {
events.push(dataLines.join('\n'))
}
}
return events
},
}
}
+93
View File
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getSttSettings, transcribeAudio, updateSttSettings } from './stt'
import { piClient } from './piClient'
import { serverStore } from '../store/serverStore'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
post: vi.fn(),
},
}))
vi.mock('../store/serverStore', () => ({
serverStore: {
getActiveBaseUrl: vi.fn(() => 'http://127.0.0.1:58785'),
getActiveToken: vi.fn(() => 'token-123'),
},
}))
describe('stt api', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
vi.mocked(piClient.post).mockReset()
vi.mocked(serverStore.getActiveBaseUrl).mockReturnValue('http://127.0.0.1:58785')
vi.mocked(serverStore.getActiveToken).mockReturnValue('token-123')
})
it('loads and updates redacted STT settings', async () => {
vi.mocked(piClient.get).mockResolvedValue({
enabled: true,
providerKind: 'openai-compatible',
mode: 'file',
baseUrl: 'https://api.openai.com/v1',
apiKeyConfigured: true,
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
insertMode: 'append',
})
vi.mocked(piClient.post).mockResolvedValue({
enabled: true,
providerKind: 'openai-compatible',
mode: 'file',
baseUrl: 'https://api.openai.com/v1',
apiKeyConfigured: true,
apiKeyRequired: true,
transcriptionEndpoint: '/audio/transcriptions',
transcriptionModel: 'gpt-4o-mini-transcribe',
language: 'pt',
insertMode: 'append',
})
expect((await getSttSettings()).apiKeyConfigured).toBe(true)
const updated = await updateSttSettings({ language: 'pt' })
expect(piClient.get).toHaveBeenCalledWith('/api/settings/stt')
expect(piClient.post).toHaveBeenCalledWith('/api/settings/stt', { language: 'pt' })
expect(updated.language).toBe('pt')
})
it('uploads audio as multipart form data with bearer auth', async () => {
const originalFetch = globalThis.fetch
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
text: 'hello voice',
durationMs: 123,
provider: 'openai-compatible',
model: 'gpt-4o-mini-transcribe',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as typeof fetch
try {
const file = new File(['audio'], 'voice.webm', { type: 'audio/webm' })
const result = await transcribeAudio(file)
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:58785/api/stt/transcriptions',
expect.objectContaining({
method: 'POST',
headers: { Authorization: 'Bearer token-123' },
body: expect.any(FormData),
}),
)
expect(result.text).toBe('hello voice')
} finally {
globalThis.fetch = originalFetch
}
})
})
+44
View File
@@ -0,0 +1,44 @@
import { piClient } from './piClient'
import { serverStore } from '../store/serverStore'
import type { SttSettings, SttTranscriptionResponse, UpdateSttSettingsRequest } from '../types/api/stt'
export async function getSttSettings(): Promise<SttSettings> {
return await piClient.get<SttSettings>('/api/settings/stt')
}
export async function updateSttSettings(settings: UpdateSttSettingsRequest): Promise<SttSettings> {
return await piClient.post<SttSettings>('/api/settings/stt', settings)
}
function buildUploadHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
const token = serverStore.getActiveToken()
if (token) {
headers.Authorization = `Bearer ${token}`
return headers
}
const auth = serverStore.getActiveAuth?.()
if (auth?.password) {
headers.Authorization = 'Basic ' + btoa(`${auth.username}:${auth.password}`)
}
return headers
}
export async function transcribeAudio(file: Blob, filename = 'voice.webm'): Promise<SttTranscriptionResponse> {
const baseUrl = serverStore.getActiveBaseUrl()
const form = new FormData()
form.set('file', file, filename)
const response = await fetch(`${baseUrl}/api/stt/transcriptions`, {
method: 'POST',
headers: buildUploadHeaders(),
body: form,
})
if (!response.ok) {
throw new Error(`HTTP Error ${response.status}: ${response.statusText}`)
}
return (await response.json()) as SttTranscriptionResponse
}
+39
View File
@@ -0,0 +1,39 @@
import type { TodoItem } from '../types/api/event'
export interface SDKTodo {
content: string
status: string
priority: string
}
function buildTodoId(todo: SDKTodo, index: number): string {
const content = String(todo.content ?? '').slice(0, 32)
const status = String(todo.status ?? '')
const priority = String(todo.priority ?? '')
return `todo-${index}-${content}-${status}-${priority}`
}
function isTodoStatus(status: string): status is TodoItem['status'] {
return status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled'
}
function isTodoPriority(priority: string): priority is TodoItem['priority'] {
return priority === 'high' || priority === 'medium' || priority === 'low'
}
function normalizeTodoStatus(status: string): TodoItem['status'] {
return isTodoStatus(status) ? status : 'pending'
}
function normalizeTodoPriority(priority: string): TodoItem['priority'] {
return isTodoPriority(priority) ? priority : 'medium'
}
export function normalizeTodoItems(todos: SDKTodo[] | null | undefined): TodoItem[] {
return (todos ?? []).map((todo, index) => ({
id: buildTodoId(todo, index),
content: todo.content,
status: normalizeTodoStatus(todo.status),
priority: normalizeTodoPriority(todo.priority),
}))
}
+32
View File
@@ -0,0 +1,32 @@
// ============================================
// Tool API - Decoupled
// ============================================
import { piClient } from './piClient'
import type { ToolIDs, ToolList } from '../types/api/tool'
/**
* 获取工具 ID 列表
*/
export async function getToolIds(directory?: string): Promise<ToolIDs> {
try {
const q = `?directory=${encodeURIComponent(directory || '')}`
const data = await piClient.get<{ ids: ToolIDs }>(`/api/tools/ids${q}`)
return data.ids || []
} catch {
return []
}
}
/**
* 获取工具列表(带详细信息)
*/
export async function getTools(provider: string, model: string, directory?: string): Promise<ToolList> {
try {
const q = `?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}&directory=${encodeURIComponent(directory || '')}`
const data = await piClient.get<{ tools: ToolList }>(`/api/tools/list${q}`)
return data.tools || []
} catch {
return []
}
}
+38
View File
@@ -0,0 +1,38 @@
import { piClient } from './piClient'
import { serverStore, makeBasicAuthHeader } from '../store/serverStore'
import type { TtsSettings, UpdateTtsSettingsRequest } from '../types/api/tts'
export async function getTtsSettings(): Promise<TtsSettings> {
return await piClient.get<TtsSettings>('/api/settings/tts')
}
export async function updateTtsSettings(settings: UpdateTtsSettingsRequest): Promise<TtsSettings> {
return await piClient.post<TtsSettings>('/api/settings/tts', settings)
}
function buildSpeechHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const token = serverStore.getActiveToken()
if (token) {
headers.Authorization = `Bearer ${token}`
return headers
}
const auth = serverStore.getActiveAuth?.()
if (auth?.password) headers.Authorization = makeBasicAuthHeader(auth)
return headers
}
export async function synthesizeSpeech(input: string): Promise<Blob> {
const response = await fetch(`${serverStore.getActiveBaseUrl()}/api/tts/speech`, {
method: 'POST',
headers: buildSpeechHeaders(),
body: JSON.stringify({ input }),
})
if (!response.ok) {
throw new Error(`HTTP Error ${response.status}: ${response.statusText}`)
}
return await response.blob()
}
+67
View File
@@ -0,0 +1,67 @@
// API Types - 向后兼容层
export type * from '../types/api'
export type { ModelInfo, FileCapabilities, Attachment, AttachmentType } from '../types/ui'
export type { Model as ApiModel, Provider as ApiProvider, ProvidersResponse } from '../types/api/model'
export type { Project as ApiProject, PathResponse as ApiPath } from '../types/api/project'
export type {
Session as ApiSession,
SessionListParams,
SessionRevert as SessionRevertState,
} from '../types/api/session'
export type {
Message as ApiMessage,
UserMessage as ApiUserMessage,
AssistantMessage as ApiAssistantMessage,
MessageWithParts as ApiMessageWithParts,
Part as ApiPart,
TextPart as ApiTextPart,
ReasoningPart as ApiReasoningPart,
ToolPart as ApiToolPart,
FilePart as ApiFilePart,
AgentPart as ApiAgentPart,
StepStartPart as ApiStepStartPart,
StepFinishPart as ApiStepFinishPart,
SnapshotPart as ApiSnapshotPart,
PatchPart as ApiPatchPart,
RetryPart as ApiRetryPart,
CompactionPart as ApiCompactionPart,
SubtaskPart as ApiSubtaskPart,
} from '../types/api/message'
export type {
PermissionRequest as ApiPermissionRequest,
PermissionReply,
QuestionOption as ApiQuestionOption,
QuestionInfo as ApiQuestionInfo,
QuestionRequest as ApiQuestionRequest,
QuestionAnswer,
} from '../types/api/permission'
export type { Agent as ApiAgent, AgentPermission as ApiAgentPermission } from '../types/api/agent'
export type { Symbol as SymbolInfo } from '../types/api/file'
import type { Attachment } from '../types/ui'
export interface RevertedMessage {
text: string
attachments: Attachment[]
}
export interface SendMessageParams {
sessionId: string
text: string
attachments: Attachment[]
model: {
providerID: string
modelID: string
}
agent?: string
variant?: string
directory?: string
}
export interface SendMessageResponse {
info: import('../types/api/message').AssistantMessage
parts: import('../types/api/message').Part[]
}
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
checkoutGitBranch,
commitGitChanges,
createGitBranch,
getVcsBranches,
getVcsStatus,
pullGitBranch,
pushGitBranch,
stageGitPaths,
unstageGitPaths,
} from './vcs'
import { piClient } from './piClient'
vi.mock('./piClient', () => ({
piClient: {
get: vi.fn(),
post: vi.fn(),
},
}))
describe('vcs api', () => {
beforeEach(() => {
vi.mocked(piClient.get).mockReset()
vi.mocked(piClient.post).mockReset()
})
it('loads Git status and branches for a directory', async () => {
vi.mocked(piClient.get)
.mockResolvedValueOnce({ branch: 'main', dirty: true, ahead: 1, behind: 0, files: [] })
.mockResolvedValueOnce({ current: 'main', branches: [] })
await getVcsStatus('/repo')
await getVcsBranches('/repo')
expect(piClient.get).toHaveBeenNthCalledWith(1, '/api/vcs/status?directory=%2Frepo')
expect(piClient.get).toHaveBeenNthCalledWith(2, '/api/vcs/branches?directory=%2Frepo')
})
it('posts safe Git action bodies', async () => {
vi.mocked(piClient.post).mockResolvedValue({ ok: true })
await checkoutGitBranch({ branch: 'feature/ui' }, '/repo')
await createGitBranch({ name: 'feature/new' }, '/repo')
await stageGitPaths({ paths: ['src/app.tsx'] }, '/repo')
await unstageGitPaths({ paths: ['src/app.tsx'] }, '/repo')
await commitGitChanges({ message: 'feat: add git ui' }, '/repo')
await pullGitBranch('/repo')
await pushGitBranch('/repo')
expect(piClient.post).toHaveBeenNthCalledWith(1, '/api/vcs/checkout?directory=%2Frepo', { branch: 'feature/ui' })
expect(piClient.post).toHaveBeenNthCalledWith(2, '/api/vcs/branch?directory=%2Frepo', { name: 'feature/new' })
expect(piClient.post).toHaveBeenNthCalledWith(3, '/api/vcs/stage?directory=%2Frepo', { paths: ['src/app.tsx'] })
expect(piClient.post).toHaveBeenNthCalledWith(4, '/api/vcs/unstage?directory=%2Frepo', { paths: ['src/app.tsx'] })
expect(piClient.post).toHaveBeenNthCalledWith(5, '/api/vcs/commit?directory=%2Frepo', {
message: 'feat: add git ui',
})
expect(piClient.post).toHaveBeenNthCalledWith(6, '/api/vcs/pull?directory=%2Frepo', undefined)
expect(piClient.post).toHaveBeenNthCalledWith(7, '/api/vcs/push?directory=%2Frepo', undefined)
})
})
+89
View File
@@ -0,0 +1,89 @@
// ============================================
// VCS API - Decoupled
// ============================================
import { piClient } from './piClient'
import type { FileDiff } from './types'
import type {
CheckoutGitBranchRequest,
CommitGitChangesRequest,
CreateGitBranchRequest,
GitActionResponse,
GitBranchesResponse,
GitPathsRequest,
GitStatusResponse,
VcsDiffMode,
VcsInfo,
} from '../types/api/vcs'
import { formatPathForApi } from '../utils/directoryUtils'
function directoryQuery(directory?: string): string {
return `?directory=${encodeURIComponent(formatPathForApi(directory) || '')}`
}
/**
* 获取 VCS 信息
*/
export async function getVcsInfo(directory?: string): Promise<VcsInfo | null> {
try {
return await piClient.get<VcsInfo>(`/api/vcs/info${directoryQuery(directory)}`)
} catch {
// VCS 不可用时返回 null
return null
}
}
/**
* 获取 Git 或分支维度的 diff
*/
export async function getVcsDiff(mode: VcsDiffMode, directory?: string): Promise<FileDiff[]> {
try {
const q = `?mode=${mode}&directory=${encodeURIComponent(formatPathForApi(directory) || '')}`
const data = await piClient.get<{ diff: FileDiff[] }>(`/api/vcs/diff${q}`)
return data.diff || []
} catch {
return []
}
}
export async function getVcsStatus(directory?: string): Promise<GitStatusResponse> {
return await piClient.get<GitStatusResponse>(`/api/vcs/status${directoryQuery(directory)}`)
}
export async function getVcsBranches(directory?: string): Promise<GitBranchesResponse> {
return await piClient.get<GitBranchesResponse>(`/api/vcs/branches${directoryQuery(directory)}`)
}
export async function checkoutGitBranch(
request: CheckoutGitBranchRequest,
directory?: string,
): Promise<GitActionResponse> {
return await piClient.post<GitActionResponse>(`/api/vcs/checkout${directoryQuery(directory)}`, request)
}
export async function createGitBranch(request: CreateGitBranchRequest, directory?: string): Promise<GitActionResponse> {
return await piClient.post<GitActionResponse>(`/api/vcs/branch${directoryQuery(directory)}`, request)
}
export async function stageGitPaths(request: GitPathsRequest, directory?: string): Promise<GitActionResponse> {
return await piClient.post<GitActionResponse>(`/api/vcs/stage${directoryQuery(directory)}`, request)
}
export async function unstageGitPaths(request: GitPathsRequest, directory?: string): Promise<GitActionResponse> {
return await piClient.post<GitActionResponse>(`/api/vcs/unstage${directoryQuery(directory)}`, request)
}
export async function commitGitChanges(
request: CommitGitChangesRequest,
directory?: string,
): Promise<GitActionResponse> {
return await piClient.post<GitActionResponse>(`/api/vcs/commit${directoryQuery(directory)}`, request)
}
export async function pullGitBranch(directory?: string): Promise<GitActionResponse> {
return await piClient.post<GitActionResponse>(`/api/vcs/pull${directoryQuery(directory)}`, undefined)
}
export async function pushGitBranch(directory?: string): Promise<GitActionResponse> {
return await piClient.post<GitActionResponse>(`/api/vcs/push${directoryQuery(directory)}`, undefined)
}
+34
View File
@@ -0,0 +1,34 @@
// ============================================
// Worktree API Stub - Git Worktree
// Gracefully stubbed out for Pi Agent MVP
// ============================================
import type { Worktree, WorktreeCreateInput, WorktreeRemoveInput, WorktreeResetInput } from '../types/api/worktree'
/**
* 获取所有 worktree 列表
*/
export async function listWorktrees(_directory?: string): Promise<string[]> {
return []
}
/**
* 创建新的 worktree
*/
export async function createWorktree(_params: WorktreeCreateInput, _directory?: string): Promise<Worktree> {
throw new Error('Git worktrees are not supported in the Pi Agent MVP.')
}
/**
* 删除 worktree
*/
export async function removeWorktree(_params: WorktreeRemoveInput, _directory?: string): Promise<boolean> {
return true
}
/**
* 重置 worktree
*/
export async function resetWorktree(_params: WorktreeResetInput, _directory?: string): Promise<boolean> {
return true
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z"/>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 542 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 19V5"/>
<path d="m5 12 7-7 7 7"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m3 17 2 2 4-4"/>
<path d="m3 7 2 2 4-4"/>
<path d="M13 6h8"/>
<path d="M13 12h8"/>
<path d="M13 18h8"/>
</svg>

After

Width:  |  Height:  |  Size: 311 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>

After

Width:  |  Height:  |  Size: 216 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
</svg>

After

Width:  |  Height:  |  Size: 223 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4"/>
<path d="M12 16h4"/>
<path d="M8 11h.01"/>
<path d="M8 16h.01"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>

After

Width:  |  Height:  |  Size: 263 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>

After

Width:  |  Height:  |  Size: 238 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12h16"/>
<path d="M4 6h16"/>
<path d="M4 18h16"/>
<path d="M8 3v3"/>
<path d="M16 3v3"/>
<path d="M8 18v3"/>
<path d="M16 18v3"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>

After

Width:  |  Height:  |  Size: 317 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="16" height="16" x="4" y="4" rx="2"/>
<path d="M9 2v2"/><path d="M15 2v2"/>
<path d="M9 20v2"/><path d="M15 20v2"/>
<path d="M2 9h2"/><path d="M2 15h2"/>
<path d="M20 9h2"/><path d="M20 15h2"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" x2="12" y1="2" y2="22"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>

After

Width:  |  Height:  |  Size: 293 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>

After

Width:  |  Height:  |  Size: 316 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6"/>
<path d="M10 14 21 3"/>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>

After

Width:  |  Height:  |  Size: 281 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" x2="8" y1="13" y2="13"/>
<line x1="16" x2="8" y1="17" y2="17"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 2H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>

After

Width:  |  Height:  |  Size: 369 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>

After

Width:  |  Height:  |  Size: 392 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/>
<path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/>
<path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/>
<path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>
</svg>

After

Width:  |  Height:  |  Size: 467 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="9" cy="9" r="2"/>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>

After

Width:  |  Height:  |  Size: 335 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 2-10 5 10 5 10-5-10-5z"/>
<path d="m2 17 10 5 10-5"/>
<path d="m2 12 10 5 10-5"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-1 1.5-2 1.5-3.5 0-3-2-5.5-6-5.5S6 5 6 8c0 1.5.5 2.5 1.5 3.5.8.8 1.3 1.5 1.5 2.5"/>
<path d="M9 18h6"/>
<path d="M10 22h4"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>

After

Width:  |  Height:  |  Size: 337 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
</svg>

After

Width:  |  Height:  |  Size: 241 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/>
<circle cx="19" cy="12" r="1"/>
<circle cx="5" cy="12" r="1"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>

After

Width:  |  Height:  |  Size: 264 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M10 14l-7 7"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.375 2.625a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4Z"/>
</svg>

After

Width:  |  Height:  |  Size: 327 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v18"/>
<path d="M3 12h18"/>
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 4l10 16"/>
</svg>

After

Width:  |  Height:  |  Size: 213 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 4L7 20"/>
</svg>

After

Width:  |  Height:  |  Size: 213 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
<path d="m15 5 4 4"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 5 5 7 9 3"/>
<path d="M12 5h9"/>
<rect x="3" y="10" width="4" height="4" rx="1"/>
<path d="M12 12h9"/>
<rect x="3" y="17" width="4" height="4" rx="1"/>
<path d="M12 19h9"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 8a4 4 0 1 1 8 0c0 2-4 3-4 5v1"/>
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 305 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 14 5-5-5-5"/>
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>
</svg>

After

Width:  |  Height:  |  Size: 288 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
<path d="M3 21v-5h5"/>
</svg>

After

Width:  |  Height:  |  Size: 369 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 10 4 15 9 20"/>
<path d="M20 4v7a4 4 0 0 1-4 4H4"/>
</svg>

After

Width:  |  Height:  |  Size: 264 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>

After

Width:  |  Height:  |  Size: 251 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m22 2-7 20-4-9-9-4Z"/>
<path d="M22 2 11 13"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1.51 1H15a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>

After

Width:  |  Height:  |  Size: 974 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
<polyline points="16 6 12 2 8 6"/>
<line x1="12" y1="2" x2="12" y2="15"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" stroke-opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10"/>
</svg>

After

Width:  |  Height:  |  Size: 283 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 176 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2"/>
<path d="M12 20v2"/>
<path d="m4.93 4.93 1.41 1.41"/>
<path d="m17.66 17.66 1.41 1.41"/>
<path d="M2 12h2"/>
<path d="M20 12h2"/>
<path d="m6.34 17.66-1.41 1.41"/>
<path d="m19.07 4.93-1.41 1.41"/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

Loaded 100 of 983 files, more files were not shown because too many files have changed in this diff. Show more