🎉 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
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: '',
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('&') : ''
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// API 模块入口
|
||||
// 从 client.ts 统一导出所有内容(client.ts 会 re-export 其他子模块)
|
||||
export * from './client'
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.')
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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() {},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 []
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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世界'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |