Archived
refactor: split client api modules
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# 03. Split client API into feature clients
|
# 03. Split client API into feature clients
|
||||||
|
|
||||||
Status: pending
|
Status: completed
|
||||||
|
|
||||||
Decompose `src/client/src/api.ts` into composable feature clients, shared HTTP helper, parsers, URL builders, and socket helpers.
|
Decompose `src/client/src/api.ts` into composable feature clients, shared HTTP helper, parsers, URL builders, and socket helpers.
|
||||||
|
|||||||
+2
-331
@@ -1,332 +1,3 @@
|
|||||||
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, Workspace } from "../../shared/apiTypes";
|
export { api, filesApi, gitApi, projectsApi, sessionsApi, workspacesApi } from "./api/clients";
|
||||||
|
export { globalSessionEvents, sessionEvents } from "./api/sockets";
|
||||||
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, Workspace } from "../../shared/apiTypes";
|
export type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionActivity, SessionInfo, SessionStatus, SlashCommand, SessionUiEvent, Workspace } from "../../shared/apiTypes";
|
||||||
|
|
||||||
async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
|
||||||
const headers = new Headers(init?.headers);
|
|
||||||
if (init?.body !== undefined) headers.set("content-type", "application/json");
|
|
||||||
const response = await fetch(url, { ...init, headers });
|
|
||||||
if (!response.ok) {
|
|
||||||
const body: unknown = await response.json().catch((): unknown => ({}));
|
|
||||||
throw new Error(errorMessage(body) ?? response.statusText);
|
|
||||||
}
|
|
||||||
const body: unknown = await response.json();
|
|
||||||
return parse(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(value: unknown): string | undefined {
|
|
||||||
if (!isRecord(value)) return undefined;
|
|
||||||
return typeof value["error"] === "string" ? value["error"] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
projects: () => request("/api/projects", arrayOf(parseProject)),
|
|
||||||
addProject: (path: string, name?: string) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name }) }),
|
|
||||||
workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
|
||||||
sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
|
||||||
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
|
||||||
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
|
||||||
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
|
||||||
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
|
||||||
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
|
|
||||||
workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
|
||||||
workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
|
||||||
gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
|
||||||
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse),
|
|
||||||
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
|
||||||
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
|
||||||
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
|
||||||
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
|
||||||
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
|
||||||
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
|
||||||
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
|
||||||
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function sessionEvents(sessionId: string): WebSocket {
|
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function globalSessionEvents(): WebSocket {
|
|
||||||
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function gitDiffUrl(projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options?.path !== undefined) params.set("path", options.path);
|
|
||||||
if (options?.staged === true) params.set("staged", "true");
|
|
||||||
const query = params.toString();
|
|
||||||
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
|
||||||
if (options?.before !== undefined) params.set("before", String(options.before));
|
|
||||||
const query = params.toString();
|
|
||||||
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function webSocketBaseUrl(): string {
|
|
||||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
return `${protocol}//${location.host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireRecord(value: unknown): Record<string, unknown> {
|
|
||||||
if (!isRecord(value)) throw new Error("Expected object response");
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireString(record: Record<string, unknown>, key: string): string {
|
|
||||||
const value = record[key];
|
|
||||||
if (typeof value !== "string") throw new Error(`Expected string field: ${key}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
|
||||||
const value = record[key];
|
|
||||||
if (value === undefined) return undefined;
|
|
||||||
if (typeof value !== "string") throw new Error(`Expected optional string field: ${key}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireNumber(record: Record<string, unknown>, key: string): number {
|
|
||||||
const value = record[key];
|
|
||||||
if (typeof value !== "number") throw new Error(`Expected number field: ${key}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireBoolean(record: Record<string, unknown>, key: string): boolean {
|
|
||||||
const value = record[key];
|
|
||||||
if (typeof value !== "boolean") throw new Error(`Expected boolean field: ${key}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayOf<T>(parse: (value: unknown) => T): (value: unknown) => T[] {
|
|
||||||
return (value) => {
|
|
||||||
if (!Array.isArray(value)) throw new Error("Expected array response");
|
|
||||||
return value.map(parse);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseUnknownArray(value: unknown): unknown[] {
|
|
||||||
if (!Array.isArray(value)) throw new Error("Expected array response");
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMessagePage(value: unknown): MessagePage {
|
|
||||||
if (Array.isArray(value)) return { messages: value, start: 0, total: value.length };
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProject(value: unknown): Project {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWorkspace(value: unknown): Workspace {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
const branch = optionalString(record, "branch");
|
|
||||||
return {
|
|
||||||
id: requireString(record, "id"),
|
|
||||||
projectId: requireString(record, "projectId"),
|
|
||||||
path: requireString(record, "path"),
|
|
||||||
label: requireString(record, "label"),
|
|
||||||
...(branch === undefined ? {} : { branch }),
|
|
||||||
isMain: requireBoolean(record, "isMain"),
|
|
||||||
isGitWorktree: requireBoolean(record, "isGitWorktree"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSessionInfo(value: unknown): SessionInfo {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
const name = optionalString(record, "name");
|
|
||||||
const archivedAt = optionalString(record, "archivedAt");
|
|
||||||
return {
|
|
||||||
id: requireString(record, "id"),
|
|
||||||
path: requireString(record, "path"),
|
|
||||||
cwd: requireString(record, "cwd"),
|
|
||||||
...(name === undefined ? {} : { name }),
|
|
||||||
created: requireString(record, "created"),
|
|
||||||
modified: requireString(record, "modified"),
|
|
||||||
messageCount: requireNumber(record, "messageCount"),
|
|
||||||
firstMessage: requireString(record, "firstMessage"),
|
|
||||||
...(record["archived"] === true ? { archived: true } : {}),
|
|
||||||
...(archivedAt === undefined ? {} : { archivedAt }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSessionStatus(value: unknown): SessionStatus {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return {
|
|
||||||
sessionId: requireString(record, "sessionId"),
|
|
||||||
isStreaming: requireBoolean(record, "isStreaming"),
|
|
||||||
isCompacting: requireBoolean(record, "isCompacting"),
|
|
||||||
isBashRunning: requireBoolean(record, "isBashRunning"),
|
|
||||||
pendingMessageCount: requireNumber(record, "pendingMessageCount"),
|
|
||||||
tokens: parseTokens(record["tokens"]),
|
|
||||||
cost: requireNumber(record, "cost"),
|
|
||||||
...optionalModel(record["model"]),
|
|
||||||
...optionalContextUsage(record["contextUsage"]),
|
|
||||||
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTokens(value: unknown): SessionStatus["tokens"] {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return {
|
|
||||||
input: requireNumber(record, "input"),
|
|
||||||
output: requireNumber(record, "output"),
|
|
||||||
cacheRead: requireNumber(record, "cacheRead"),
|
|
||||||
cacheWrite: requireNumber(record, "cacheWrite"),
|
|
||||||
total: requireNumber(record, "total"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalModel(value: unknown): Pick<SessionStatus, "model"> | object {
|
|
||||||
if (value === undefined) return {};
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { model: { ...optionalField("provider", optionalString(record, "provider")), ...optionalField("id", optionalString(record, "id")), ...optionalField("name", optionalString(record, "name")), ...optionalField("contextWindow", optionalNumber(record, "contextWindow")), ...optionalField("reasoning", record["reasoning"]) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
|
|
||||||
if (value === undefined) return {};
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { contextUsage: { tokens: numberOrNull(record, "tokens"), contextWindow: requireNumber(record, "contextWindow"), percent: numberOrNull(record, "percent") } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSlashCommand(value: unknown): SlashCommand {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
const source = requireString(record, "source");
|
|
||||||
if (source !== "extension" && source !== "prompt" && source !== "skill" && source !== "builtin") throw new Error("Invalid command source");
|
|
||||||
return { name: requireString(record, "name"), source, ...optionalField("description", optionalString(record, "description")) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFileSuggestion(value: unknown): FileSuggestion {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
const kind = requireString(record, "kind");
|
|
||||||
if (kind !== "tracked" && kind !== "untracked" && kind !== "other") throw new Error("Invalid file kind");
|
|
||||||
return { path: requireString(record, "path"), kind };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFileTreeResponse(value: unknown): FileTreeResponse {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { path: requireString(record, "path"), entries: arrayOf(parseFileTreeEntry)(record["entries"]), scannedAt: requireString(record, "scannedAt"), truncated: requireBoolean(record, "truncated") };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFileTreeEntry(value: unknown): FileTreeEntry {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
const type = requireString(record, "type");
|
|
||||||
if (type !== "file" && type !== "directory" && type !== "symlink") throw new Error("Invalid file tree entry type");
|
|
||||||
return { name: requireString(record, "name"), path: requireString(record, "path"), type, ...optionalField("size", optionalNumber(record, "size")), ...optionalField("modifiedAt", optionalString(record, "modifiedAt")) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFileContentResponse(value: unknown): FileContentResponse {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
const encoding = requireString(record, "encoding");
|
|
||||||
if (encoding !== "utf8") throw new Error("Invalid file encoding");
|
|
||||||
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseGitStatusResponse(value: unknown): GitStatusResponse {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseGitStatusFile(value: unknown): GitStatusFile {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseGitFileState(value: unknown): GitFileState {
|
|
||||||
switch (value) {
|
|
||||||
case "unmodified":
|
|
||||||
case "modified":
|
|
||||||
case "added":
|
|
||||||
case "deleted":
|
|
||||||
case "renamed":
|
|
||||||
case "copied":
|
|
||||||
case "untracked":
|
|
||||||
case "ignored":
|
|
||||||
case "conflicted":
|
|
||||||
return value;
|
|
||||||
default:
|
|
||||||
throw new Error("Invalid git file state");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseGitDiffResponse(value: unknown): GitDiffResponse {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { ...optionalField("path", optionalString(record, "path")), staged: requireBoolean(record, "staged"), hash: requireString(record, "hash"), diff: requireString(record, "diff"), truncated: requireBoolean(record, "truncated") };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCommandResult(value: unknown): CommandResult {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
const type = requireString(record, "type");
|
|
||||||
if (type === "unsupported") return { type, message: requireString(record, "message") };
|
|
||||||
if (type === "select") return { type, requestId: requireString(record, "requestId"), title: requireString(record, "title"), options: arrayOf(parseCommandOption)(record["options"]) };
|
|
||||||
if (type === "done") return { type, ...optionalField("message", optionalString(record, "message")), ...optionalSession(record["session"]) };
|
|
||||||
throw new Error("Invalid command result type");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCommandOption(value: unknown): CommandOption {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
return { value: requireString(record, "value"), label: requireString(record, "label"), ...optionalField("description", optionalString(record, "description")) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalSession(value: unknown): Pick<Extract<CommandResult, { type: "done" }>, "session"> | object {
|
|
||||||
return value === undefined ? {} : { session: parseSessionInfo(value) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAccepted(value: unknown): { accepted: true } {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
if (record["accepted"] !== true) throw new Error("Expected accepted response");
|
|
||||||
return { accepted: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAborted(value: unknown): { aborted: true } {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
if (record["aborted"] !== true) throw new Error("Expected aborted response");
|
|
||||||
return { aborted: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStopped(value: unknown): { stopped: true } {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
if (record["stopped"] !== true) throw new Error("Expected stopped response");
|
|
||||||
return { stopped: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArchived(value: unknown): { archived: true } {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
if (record["archived"] !== true) throw new Error("Expected archived response");
|
|
||||||
return { archived: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseRestored(value: unknown): { restored: true } {
|
|
||||||
const record = requireRecord(value);
|
|
||||||
if (record["restored"] !== true) throw new Error("Expected restored response");
|
|
||||||
return { restored: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
||||||
const value = record[key];
|
|
||||||
if (value === undefined) return undefined;
|
|
||||||
if (typeof value !== "number") throw new Error(`Expected optional number field: ${key}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function numberOrNull(record: Record<string, unknown>, key: string): number | null {
|
|
||||||
const value = record[key];
|
|
||||||
if (value === null) return null;
|
|
||||||
if (typeof value !== "number") throw new Error(`Expected number|null field: ${key}`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalField(key: string, value: unknown): object {
|
|
||||||
return value === undefined ? {} : { [key]: value };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { FileSuggestion } from "../../../shared/apiTypes";
|
||||||
|
import { request } from "./http";
|
||||||
|
import {
|
||||||
|
arrayOf,
|
||||||
|
parseAborted,
|
||||||
|
parseAccepted,
|
||||||
|
parseArchived,
|
||||||
|
parseCommandResult,
|
||||||
|
parseFileContentResponse,
|
||||||
|
parseFileSuggestion,
|
||||||
|
parseFileTreeResponse,
|
||||||
|
parseGitDiffResponse,
|
||||||
|
parseGitStatusResponse,
|
||||||
|
parseMessagePage,
|
||||||
|
parseProject,
|
||||||
|
parseRestored,
|
||||||
|
parseSessionInfo,
|
||||||
|
parseSessionStatus,
|
||||||
|
parseSlashCommand,
|
||||||
|
parseStopped,
|
||||||
|
parseWorkspace,
|
||||||
|
} from "./parsers";
|
||||||
|
import { gitDiffUrl, messageUrl } from "./urls";
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
projects: () => request("/api/projects", arrayOf(parseProject)),
|
||||||
|
addProject: (path: string, name?: string) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name }) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const workspacesApi = {
|
||||||
|
workspaces: (projectId: string) => request(`/api/projects/${projectId}/workspaces`, arrayOf(parseWorkspace)),
|
||||||
|
workspaceTree: (projectId: string, workspaceId: string, path = "") => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/tree?path=${encodeURIComponent(path)}`, parseFileTreeResponse),
|
||||||
|
workspaceFile: (projectId: string, workspaceId: string, path: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/file?path=${encodeURIComponent(path)}`, parseFileContentResponse),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sessionsApi = {
|
||||||
|
sessions: (cwd: string) => request(`/api/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
|
||||||
|
startSession: (cwd: string) => request("/api/sessions", parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||||
|
messages: (sessionId: string, options?: { limit?: number; before?: number }) => request(messageUrl(sessionId, options), parseMessagePage),
|
||||||
|
status: (sessionId: string) => request(`/api/sessions/${sessionId}/status`, parseSessionStatus),
|
||||||
|
commands: (sessionId: string) => request(`/api/sessions/${sessionId}/commands`, arrayOf(parseSlashCommand)),
|
||||||
|
prompt: (sessionId: string, text: string, streamingBehavior?: "steer" | "followUp") => request(`/api/sessions/${sessionId}/prompt`, parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
|
||||||
|
shell: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/shell`, parseAccepted, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
|
runCommand: (sessionId: string, text: string) => request(`/api/sessions/${sessionId}/commands/run`, parseCommandResult, { method: "POST", body: JSON.stringify({ text }) }),
|
||||||
|
respondToCommand: (sessionId: string, requestId: string, value: string) => request(`/api/sessions/${sessionId}/commands/respond`, parseCommandResult, { method: "POST", body: JSON.stringify({ requestId, value }) }),
|
||||||
|
abort: (sessionId: string) => request(`/api/sessions/${sessionId}/abort`, parseAborted, { method: "POST" }),
|
||||||
|
stop: (sessionId: string) => request(`/api/sessions/${sessionId}/stop`, parseStopped, { method: "POST" }),
|
||||||
|
archive: (sessionId: string) => request(`/api/sessions/${sessionId}/archive`, parseArchived, { method: "POST" }),
|
||||||
|
restore: (sessionId: string) => request(`/api/sessions/${sessionId}/restore`, parseRestored, { method: "POST" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filesApi = {
|
||||||
|
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gitApi = {
|
||||||
|
gitStatus: (projectId: string, workspaceId: string) => request(`/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/status`, parseGitStatusResponse),
|
||||||
|
gitDiff: (projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }) => request(gitDiffUrl(projectId, workspaceId, options), parseGitDiffResponse),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
...projectsApi,
|
||||||
|
...workspacesApi,
|
||||||
|
...sessionsApi,
|
||||||
|
...filesApi,
|
||||||
|
...gitApi,
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export async function request<T>(url: string, parse: (value: unknown) => T, init?: RequestInit): Promise<T> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
if (init?.body !== undefined) headers.set("content-type", "application/json");
|
||||||
|
const response = await fetch(url, { ...init, headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
const body: unknown = await response.json().catch((): unknown => ({}));
|
||||||
|
throw new Error(errorMessage(body) ?? response.statusText);
|
||||||
|
}
|
||||||
|
const body: unknown = await response.json();
|
||||||
|
return parse(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(value: unknown): string | undefined {
|
||||||
|
if (!isRecord(value)) return undefined;
|
||||||
|
return typeof value["error"] === "string" ? value["error"] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import type { CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, Project, SessionInfo, SessionStatus, SlashCommand, Workspace } from "../../../shared/apiTypes";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (!isRecord(value)) throw new Error("Expected object response");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(record: Record<string, unknown>, key: string): string {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "string") throw new Error(`Expected string field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== "string") throw new Error(`Expected optional string field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireNumber(record: Record<string, unknown>, key: string): number {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "number") throw new Error(`Expected number field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBoolean(record: Record<string, unknown>, key: string): boolean {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value !== "boolean") throw new Error(`Expected boolean field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function arrayOf<T>(parse: (value: unknown) => T): (value: unknown) => T[] {
|
||||||
|
return (value) => {
|
||||||
|
if (!Array.isArray(value)) throw new Error("Expected array response");
|
||||||
|
return value.map(parse);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnknownArray(value: unknown): unknown[] {
|
||||||
|
if (!Array.isArray(value)) throw new Error("Expected array response");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMessagePage(value: unknown): MessagePage {
|
||||||
|
if (Array.isArray(value)) return { messages: value, start: 0, total: value.length };
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { messages: parseUnknownArray(record["messages"]), start: requireNumber(record, "start"), total: requireNumber(record, "total") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProject(value: unknown): Project {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { id: requireString(record, "id"), name: requireString(record, "name"), path: requireString(record, "path"), createdAt: requireString(record, "createdAt") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspace(value: unknown): Workspace {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const branch = optionalString(record, "branch");
|
||||||
|
return {
|
||||||
|
id: requireString(record, "id"),
|
||||||
|
projectId: requireString(record, "projectId"),
|
||||||
|
path: requireString(record, "path"),
|
||||||
|
label: requireString(record, "label"),
|
||||||
|
...(branch === undefined ? {} : { branch }),
|
||||||
|
isMain: requireBoolean(record, "isMain"),
|
||||||
|
isGitWorktree: requireBoolean(record, "isGitWorktree"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionInfo(value: unknown): SessionInfo {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const name = optionalString(record, "name");
|
||||||
|
const archivedAt = optionalString(record, "archivedAt");
|
||||||
|
return {
|
||||||
|
id: requireString(record, "id"),
|
||||||
|
path: requireString(record, "path"),
|
||||||
|
cwd: requireString(record, "cwd"),
|
||||||
|
...(name === undefined ? {} : { name }),
|
||||||
|
created: requireString(record, "created"),
|
||||||
|
modified: requireString(record, "modified"),
|
||||||
|
messageCount: requireNumber(record, "messageCount"),
|
||||||
|
firstMessage: requireString(record, "firstMessage"),
|
||||||
|
...(record["archived"] === true ? { archived: true } : {}),
|
||||||
|
...(archivedAt === undefined ? {} : { archivedAt }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSessionStatus(value: unknown): SessionStatus {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return {
|
||||||
|
sessionId: requireString(record, "sessionId"),
|
||||||
|
isStreaming: requireBoolean(record, "isStreaming"),
|
||||||
|
isCompacting: requireBoolean(record, "isCompacting"),
|
||||||
|
isBashRunning: requireBoolean(record, "isBashRunning"),
|
||||||
|
pendingMessageCount: requireNumber(record, "pendingMessageCount"),
|
||||||
|
tokens: parseTokens(record["tokens"]),
|
||||||
|
cost: requireNumber(record, "cost"),
|
||||||
|
...optionalModel(record["model"]),
|
||||||
|
...optionalContextUsage(record["contextUsage"]),
|
||||||
|
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokens(value: unknown): SessionStatus["tokens"] {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return {
|
||||||
|
input: requireNumber(record, "input"),
|
||||||
|
output: requireNumber(record, "output"),
|
||||||
|
cacheRead: requireNumber(record, "cacheRead"),
|
||||||
|
cacheWrite: requireNumber(record, "cacheWrite"),
|
||||||
|
total: requireNumber(record, "total"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalModel(value: unknown): Pick<SessionStatus, "model"> | object {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { model: { ...optionalField("provider", optionalString(record, "provider")), ...optionalField("id", optionalString(record, "id")), ...optionalField("name", optionalString(record, "name")), ...optionalField("contextWindow", optionalNumber(record, "contextWindow")), ...optionalField("reasoning", record["reasoning"]) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
|
||||||
|
if (value === undefined) return {};
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { contextUsage: { tokens: numberOrNull(record, "tokens"), contextWindow: requireNumber(record, "contextWindow"), percent: numberOrNull(record, "percent") } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSlashCommand(value: unknown): SlashCommand {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const source = requireString(record, "source");
|
||||||
|
if (source !== "extension" && source !== "prompt" && source !== "skill" && source !== "builtin") throw new Error("Invalid command source");
|
||||||
|
return { name: requireString(record, "name"), source, ...optionalField("description", optionalString(record, "description")) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFileSuggestion(value: unknown): FileSuggestion {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const kind = requireString(record, "kind");
|
||||||
|
if (kind !== "tracked" && kind !== "untracked" && kind !== "other") throw new Error("Invalid file kind");
|
||||||
|
return { path: requireString(record, "path"), kind };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFileTreeResponse(value: unknown): FileTreeResponse {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { path: requireString(record, "path"), entries: arrayOf(parseFileTreeEntry)(record["entries"]), scannedAt: requireString(record, "scannedAt"), truncated: requireBoolean(record, "truncated") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFileTreeEntry(value: unknown): FileTreeEntry {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const type = requireString(record, "type");
|
||||||
|
if (type !== "file" && type !== "directory" && type !== "symlink") throw new Error("Invalid file tree entry type");
|
||||||
|
return { name: requireString(record, "name"), path: requireString(record, "path"), type, ...optionalField("size", optionalNumber(record, "size")), ...optionalField("modifiedAt", optionalString(record, "modifiedAt")) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFileContentResponse(value: unknown): FileContentResponse {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const encoding = requireString(record, "encoding");
|
||||||
|
if (encoding !== "utf8") throw new Error("Invalid file encoding");
|
||||||
|
return { path: requireString(record, "path"), ...optionalField("language", optionalString(record, "language")), encoding, size: requireNumber(record, "size"), modifiedAt: requireString(record, "modifiedAt"), content: requireString(record, "content"), truncated: requireBoolean(record, "truncated"), binary: requireBoolean(record, "binary") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGitStatusResponse(value: unknown): GitStatusResponse {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGitStatusFile(value: unknown): GitStatusFile {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGitFileState(value: unknown): GitFileState {
|
||||||
|
switch (value) {
|
||||||
|
case "unmodified":
|
||||||
|
case "modified":
|
||||||
|
case "added":
|
||||||
|
case "deleted":
|
||||||
|
case "renamed":
|
||||||
|
case "copied":
|
||||||
|
case "untracked":
|
||||||
|
case "ignored":
|
||||||
|
case "conflicted":
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
throw new Error("Invalid git file state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGitDiffResponse(value: unknown): GitDiffResponse {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { ...optionalField("path", optionalString(record, "path")), staged: requireBoolean(record, "staged"), hash: requireString(record, "hash"), diff: requireString(record, "diff"), truncated: requireBoolean(record, "truncated") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCommandResult(value: unknown): CommandResult {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
const type = requireString(record, "type");
|
||||||
|
if (type === "unsupported") return { type, message: requireString(record, "message") };
|
||||||
|
if (type === "select") return { type, requestId: requireString(record, "requestId"), title: requireString(record, "title"), options: arrayOf(parseCommandOption)(record["options"]) };
|
||||||
|
if (type === "done") return { type, ...optionalField("message", optionalString(record, "message")), ...optionalSession(record["session"]) };
|
||||||
|
throw new Error("Invalid command result type");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCommandOption(value: unknown): CommandOption {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
return { value: requireString(record, "value"), label: requireString(record, "label"), ...optionalField("description", optionalString(record, "description")) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalSession(value: unknown): Pick<Extract<CommandResult, { type: "done" }>, "session"> | object {
|
||||||
|
return value === undefined ? {} : { session: parseSessionInfo(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAccepted(value: unknown): { accepted: true } {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
if (record["accepted"] !== true) throw new Error("Expected accepted response");
|
||||||
|
return { accepted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAborted(value: unknown): { aborted: true } {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
if (record["aborted"] !== true) throw new Error("Expected aborted response");
|
||||||
|
return { aborted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStopped(value: unknown): { stopped: true } {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
if (record["stopped"] !== true) throw new Error("Expected stopped response");
|
||||||
|
return { stopped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArchived(value: unknown): { archived: true } {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
if (record["archived"] !== true) throw new Error("Expected archived response");
|
||||||
|
return { archived: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRestored(value: unknown): { restored: true } {
|
||||||
|
const record = requireRecord(value);
|
||||||
|
if (record["restored"] !== true) throw new Error("Expected restored response");
|
||||||
|
return { restored: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== "number") throw new Error(`Expected optional number field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(record: Record<string, unknown>, key: string): number | null {
|
||||||
|
const value = record[key];
|
||||||
|
if (value === null) return null;
|
||||||
|
if (typeof value !== "number") throw new Error(`Expected number|null field: ${key}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalField(key: string, value: unknown): object {
|
||||||
|
return value === undefined ? {} : { [key]: value };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function sessionEvents(sessionId: string): WebSocket {
|
||||||
|
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/${sessionId}/events`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function globalSessionEvents(): WebSocket {
|
||||||
|
return new WebSocket(`${webSocketBaseUrl()}/api/sessions/events`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function webSocketBaseUrl(): string {
|
||||||
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${protocol}//${location.host}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function gitDiffUrl(projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.path !== undefined) params.set("path", options.path);
|
||||||
|
if (options?.staged === true) params.set("staged", "true");
|
||||||
|
const query = params.toString();
|
||||||
|
return `/api/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function messageUrl(sessionId: string, options?: { limit?: number; before?: number }): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||||
|
if (options?.before !== undefined) params.set("before", String(options.before));
|
||||||
|
const query = params.toString();
|
||||||
|
return `/api/sessions/${sessionId}/messages${query ? `?${query}` : ""}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user