Files
hapi/web/src/api/client.ts
T
weishu 334cebc2da feat: add real-time permission and model mode synchronization close #21
Implements bidirectional sync of permission/model modes between CLI sessions and web app. Adds Codex-specific permission modes (read-only, safe-yolo, yolo) alongside Claude's modes. Web can now control CLI session state via RPC set-session-config handler, while CLI broadcasts state changes through keep-alive payloads. UI controls are flavor-aware, showing appropriate modes for Claude vs Codex vs Gemini. Type centralization in api/types eliminates circular dependencies.
2025-12-28 20:51:18 +08:00

265 lines
9.5 KiB
TypeScript

import type {
AuthResponse,
FileReadResponse,
FileSearchResponse,
GitCommandResponse,
MachinePathsExistsResponse,
MachinesResponse,
MessagesResponse,
SlashCommandsResponse,
SpawnResponse,
SessionResponse,
SessionsResponse
} from '@/types/api'
type ApiClientOptions = {
baseUrl?: string
getToken?: () => string | null
onUnauthorized?: () => Promise<string | null>
}
export class ApiClient {
private token: string
private readonly baseUrl: string | null
private readonly getToken: (() => string | null) | null
private readonly onUnauthorized: (() => Promise<string | null>) | null
constructor(token: string, options?: ApiClientOptions) {
this.token = token
this.baseUrl = options?.baseUrl ?? null
this.getToken = options?.getToken ?? null
this.onUnauthorized = options?.onUnauthorized ?? null
}
private buildUrl(path: string): string {
if (!this.baseUrl) {
return path
}
try {
return new URL(path, this.baseUrl).toString()
} catch {
return path
}
}
private async request<T>(
path: string,
init?: RequestInit,
attempt: number = 0,
overrideToken?: string | null
): Promise<T> {
const headers = new Headers(init?.headers)
const liveToken = this.getToken ? this.getToken() : null
const authToken = overrideToken !== undefined
? (overrideToken ?? (liveToken ?? this.token))
: (liveToken ?? this.token)
if (authToken) {
headers.set('authorization', `Bearer ${authToken}`)
}
if (init?.body !== undefined && !headers.has('content-type')) {
headers.set('content-type', 'application/json')
}
const res = await fetch(this.buildUrl(path), {
...init,
headers
})
if (res.status === 401) {
if (attempt === 0 && this.onUnauthorized) {
const refreshed = await this.onUnauthorized()
if (refreshed) {
this.token = refreshed
return await this.request<T>(path, init, attempt + 1, refreshed)
}
}
throw new Error('Session expired. Please sign in again.')
}
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`HTTP ${res.status} ${res.statusText}: ${body}`)
}
return await res.json() as T
}
async authenticate(auth: { initData: string } | { accessToken: string }): Promise<AuthResponse> {
const res = await fetch(this.buildUrl('/api/auth'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(auth)
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Auth failed: HTTP ${res.status} ${res.statusText}: ${body}`)
}
return await res.json() as AuthResponse
}
async getSessions(): Promise<SessionsResponse> {
return await this.request<SessionsResponse>('/api/sessions')
}
async getSession(sessionId: string): Promise<SessionResponse> {
return await this.request<SessionResponse>(`/api/sessions/${encodeURIComponent(sessionId)}`)
}
async getMessages(sessionId: string, options: { beforeSeq?: number | null; limit?: number }): Promise<MessagesResponse> {
const params = new URLSearchParams()
if (options.beforeSeq !== undefined && options.beforeSeq !== null) {
params.set('beforeSeq', `${options.beforeSeq}`)
}
if (options.limit !== undefined && options.limit !== null) {
params.set('limit', `${options.limit}`)
}
const qs = params.toString()
const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`
return await this.request<MessagesResponse>(url)
}
async getGitStatus(sessionId: string): Promise<GitCommandResponse> {
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-status`)
}
async getGitDiffNumstat(sessionId: string, staged: boolean): Promise<GitCommandResponse> {
const params = new URLSearchParams()
params.set('staged', staged ? 'true' : 'false')
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-numstat?${params.toString()}`)
}
async getGitDiffFile(sessionId: string, path: string, staged?: boolean): Promise<GitCommandResponse> {
const params = new URLSearchParams()
params.set('path', path)
if (staged !== undefined) {
params.set('staged', staged ? 'true' : 'false')
}
return await this.request<GitCommandResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/git-diff-file?${params.toString()}`)
}
async searchSessionFiles(sessionId: string, query: string, limit?: number): Promise<FileSearchResponse> {
const params = new URLSearchParams()
if (query) {
params.set('query', query)
}
if (limit !== undefined) {
params.set('limit', `${limit}`)
}
const qs = params.toString()
return await this.request<FileSearchResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/files${qs ? `?${qs}` : ''}`)
}
async readSessionFile(sessionId: string, path: string): Promise<FileReadResponse> {
const params = new URLSearchParams()
params.set('path', path)
return await this.request<FileReadResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/file?${params.toString()}`)
}
async sendMessage(sessionId: string, text: string, localId?: string | null): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
method: 'POST',
body: JSON.stringify({ text, localId: localId ?? undefined })
})
}
async abortSession(sessionId: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/abort`, {
method: 'POST',
body: JSON.stringify({})
})
}
async switchSession(sessionId: string): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/switch`, {
method: 'POST',
body: JSON.stringify({})
})
}
async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, {
method: 'POST',
body: JSON.stringify({ mode })
})
}
async setModelMode(sessionId: string, model: 'default' | 'sonnet' | 'opus'): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, {
method: 'POST',
body: JSON.stringify({ model })
})
}
async approvePermission(
sessionId: string,
requestId: string,
modeOrOptions?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | {
mode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
allowTools?: string[]
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
answers?: Record<string, string[]>
}
): Promise<void> {
const body = typeof modeOrOptions === 'string' || modeOrOptions === undefined
? { mode: modeOrOptions }
: modeOrOptions
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/approve`, {
method: 'POST',
body: JSON.stringify(body)
})
}
async denyPermission(
sessionId: string,
requestId: string,
options?: {
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'
}
): Promise<void> {
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}/deny`, {
method: 'POST',
body: JSON.stringify(options ?? {})
})
}
async getMachines(): Promise<MachinesResponse> {
return await this.request<MachinesResponse>('/api/machines')
}
async checkMachinePathsExists(
machineId: string,
paths: string[]
): Promise<MachinePathsExistsResponse> {
return await this.request<MachinePathsExistsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/paths/exists`,
{
method: 'POST',
body: JSON.stringify({ paths })
}
)
}
async spawnSession(
machineId: string,
directory: string,
agent?: 'claude' | 'codex' | 'gemini',
yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string
): Promise<SpawnResponse> {
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
method: 'POST',
body: JSON.stringify({ directory, agent, yolo, sessionType, worktreeName })
})
}
async getSlashCommands(sessionId: string): Promise<SlashCommandsResponse> {
return await this.request<SlashCommandsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/slash-commands`
)
}
}