mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
271
web/lib/api/client.ts
Normal file
271
web/lib/api/client.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { ApiError } from "./types";
|
||||
import type { RequestOptions } from "./types";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return (
|
||||
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) ||
|
||||
"http://localhost:3001"
|
||||
);
|
||||
}
|
||||
|
||||
function getApiKey(): string | undefined {
|
||||
return typeof process !== "undefined"
|
||||
? process.env.NEXT_PUBLIC_API_KEY
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof Error &&
|
||||
(err.name === "AbortError" || err.message.toLowerCase().includes("aborted"))
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function backoffMs(attempt: number): number {
|
||||
return Math.min(500 * Math.pow(2, attempt), 8_000);
|
||||
}
|
||||
|
||||
/** Combine multiple AbortSignals into one. Aborts if any source aborts. */
|
||||
function combineSignals(...signals: (AbortSignal | undefined)[]): {
|
||||
signal: AbortSignal;
|
||||
cleanup: () => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
const listeners: Array<() => void> = [];
|
||||
|
||||
for (const sig of signals) {
|
||||
if (!sig) continue;
|
||||
if (sig.aborted) {
|
||||
controller.abort(sig.reason);
|
||||
break;
|
||||
}
|
||||
const listener = () => controller.abort(sig.reason);
|
||||
sig.addEventListener("abort", listener, { once: true });
|
||||
listeners.push(() => sig.removeEventListener("abort", listener));
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup: () => listeners.forEach((fn) => fn()),
|
||||
};
|
||||
}
|
||||
|
||||
async function toApiError(res: Response): Promise<ApiError> {
|
||||
let message = `Request failed with status ${res.status}`;
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; message?: string };
|
||||
message = body.error ?? body.message ?? message;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
const type =
|
||||
res.status === 401
|
||||
? "auth"
|
||||
: res.status === 404
|
||||
? "not_found"
|
||||
: res.status === 429
|
||||
? "rate_limit"
|
||||
: res.status >= 500
|
||||
? "server"
|
||||
: "server";
|
||||
|
||||
const retryAfterMs =
|
||||
res.status === 429
|
||||
? (parseInt(res.headers.get("Retry-After") ?? "60", 10) || 60) * 1_000
|
||||
: undefined;
|
||||
|
||||
return new ApiError(res.status, message, type, retryAfterMs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ApiClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ApiClient {
|
||||
readonly baseUrl: string;
|
||||
private readonly apiKey: string | undefined;
|
||||
/** In-flight GET requests keyed by path — for deduplication. */
|
||||
private readonly inflight = new Map<string, Promise<Response>>();
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = getBaseUrl();
|
||||
this.apiKey = getApiKey();
|
||||
}
|
||||
|
||||
private buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...extra,
|
||||
};
|
||||
if (this.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
attempt = 0
|
||||
): Promise<Response> {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
console.debug(`[api] retry ${attempt}/${MAX_RETRY_ATTEMPTS - 1} ${init.method ?? "GET"} ${url}`);
|
||||
} else {
|
||||
console.debug(`[api] → ${init.method ?? "GET"} ${url}`);
|
||||
}
|
||||
const res = await fetch(url, init);
|
||||
console.debug(`[api] ← ${res.status} ${url}`);
|
||||
|
||||
if (res.status >= 500 && attempt < MAX_RETRY_ATTEMPTS - 1 && !init.signal?.aborted) {
|
||||
await sleep(backoffMs(attempt));
|
||||
return this.fetchWithRetry(url, init, attempt + 1);
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (!isAbortError(err) && attempt < MAX_RETRY_ATTEMPTS - 1) {
|
||||
await sleep(backoffMs(attempt));
|
||||
return this.fetchWithRetry(url, init, attempt + 1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core fetch method. Applies auth headers, timeout, and retry logic.
|
||||
* Pass `timeout: 0` to disable timeout (e.g. for streaming responses).
|
||||
*/
|
||||
async request(
|
||||
path: string,
|
||||
init: RequestInit & { timeout?: number; extraHeaders?: Record<string, string> } = {}
|
||||
): Promise<Response> {
|
||||
const {
|
||||
timeout = DEFAULT_TIMEOUT_MS,
|
||||
signal: userSignal,
|
||||
extraHeaders,
|
||||
...rest
|
||||
} = init;
|
||||
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const headers = this.buildHeaders(extraHeaders);
|
||||
|
||||
const timeoutSignals: (AbortSignal | undefined)[] = [userSignal];
|
||||
let timeoutController: AbortController | undefined;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (timeout > 0) {
|
||||
timeoutController = new AbortController();
|
||||
timeoutId = setTimeout(() => timeoutController!.abort(), timeout);
|
||||
timeoutSignals.push(timeoutController.signal);
|
||||
}
|
||||
|
||||
const { signal, cleanup } = combineSignals(...timeoutSignals);
|
||||
|
||||
try {
|
||||
return await this.fetchWithRetry(url, { ...rest, headers, signal }, 0);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
const didTimeout = timeoutController?.signal.aborted ?? false;
|
||||
throw new ApiError(
|
||||
408,
|
||||
didTimeout ? "Request timed out" : "Request cancelled",
|
||||
didTimeout ? "timeout" : "abort"
|
||||
);
|
||||
}
|
||||
throw new ApiError(
|
||||
0,
|
||||
err instanceof Error ? err.message : "Network error",
|
||||
"network"
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** GET with request deduplication. */
|
||||
async get<T>(path: string, opts?: RequestOptions): Promise<T> {
|
||||
const key = path;
|
||||
const existing = this.inflight.get(key);
|
||||
if (existing) {
|
||||
const res = await existing;
|
||||
if (!res.ok) throw await toApiError(res.clone());
|
||||
return res.clone().json() as Promise<T>;
|
||||
}
|
||||
|
||||
const promise = this.request(path, { method: "GET", signal: opts?.signal });
|
||||
this.inflight.set(key, promise);
|
||||
promise.finally(() => this.inflight.delete(key));
|
||||
|
||||
const res = await promise;
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: unknown, opts?: RequestOptions): Promise<T> {
|
||||
const res = await this.request(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
signal: opts?.signal,
|
||||
extraHeaders: opts?.headers,
|
||||
});
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async patch<T>(path: string, body: unknown, opts?: RequestOptions): Promise<T> {
|
||||
const res = await this.request(path, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
signal: opts?.signal,
|
||||
});
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async delete(path: string, opts?: RequestOptions): Promise<void> {
|
||||
const res = await this.request(path, {
|
||||
method: "DELETE",
|
||||
signal: opts?.signal,
|
||||
});
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST that returns the raw Response (for streaming). Timeout is disabled
|
||||
* automatically.
|
||||
*/
|
||||
async postStream(
|
||||
path: string,
|
||||
body: unknown,
|
||||
opts?: RequestOptions
|
||||
): Promise<Response> {
|
||||
const res = await this.request(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
signal: opts?.signal,
|
||||
timeout: 0,
|
||||
extraHeaders: opts?.headers,
|
||||
});
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
109
web/lib/api/conversations.ts
Normal file
109
web/lib/api/conversations.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ApiError } from "./types";
|
||||
import { extractTextContent } from "../utils";
|
||||
import type { Conversation } from "../types";
|
||||
|
||||
// Lazy import to avoid circular deps at module init time
|
||||
function getStore() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require("../store").useChatStore as import("../store").UseChatStore;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConversationAPI — backed by the Zustand client-side store.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Conversations are not persisted on the backend; they live in localStorage
|
||||
// via Zustand persist middleware. This API provides a consistent async
|
||||
// interface so callers don't need to know about the store directly.
|
||||
|
||||
export interface ListOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateOptions {
|
||||
title?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface ConversationAPI {
|
||||
list(options?: ListOptions): Promise<Conversation[]>;
|
||||
get(id: string): Promise<Conversation>;
|
||||
create(options?: CreateOptions): Promise<Conversation>;
|
||||
update(id: string, updates: Partial<Pick<Conversation, "title" | "model">>): Promise<Conversation>;
|
||||
delete(id: string): Promise<void>;
|
||||
export(id: string, format: "json" | "markdown"): Promise<Blob>;
|
||||
}
|
||||
|
||||
function findConversation(id: string): Conversation {
|
||||
const store = getStore();
|
||||
const conv = store.getState().conversations.find((c) => c.id === id);
|
||||
if (!conv) throw new ApiError(404, `Conversation ${id} not found`, "not_found");
|
||||
return conv;
|
||||
}
|
||||
|
||||
export const conversationAPI: ConversationAPI = {
|
||||
async list(opts) {
|
||||
const { limit = 20, offset = 0 } = opts ?? {};
|
||||
const { conversations } = getStore().getState();
|
||||
return conversations.slice(offset, offset + limit);
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
return findConversation(id);
|
||||
},
|
||||
|
||||
async create(opts) {
|
||||
const store = getStore();
|
||||
const id = store.getState().createConversation();
|
||||
|
||||
if (opts?.title || opts?.model) {
|
||||
store.getState().updateConversation(id, {
|
||||
...(opts.title ? { title: opts.title } : {}),
|
||||
...(opts.model ? { model: opts.model } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return findConversation(id);
|
||||
},
|
||||
|
||||
async update(id, updates) {
|
||||
findConversation(id); // throws 404 if missing
|
||||
getStore().getState().updateConversation(id, updates);
|
||||
return findConversation(id);
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
findConversation(id); // throws 404 if missing
|
||||
getStore().getState().deleteConversation(id);
|
||||
},
|
||||
|
||||
async export(id, format) {
|
||||
const conv = findConversation(id);
|
||||
|
||||
if (format === "json") {
|
||||
return new Blob([JSON.stringify(conv, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
// Markdown export
|
||||
const lines: string[] = [`# ${conv.title}`, ""];
|
||||
const created = new Date(conv.createdAt).toISOString();
|
||||
lines.push(`> Exported from Claude Code · ${created}`, "");
|
||||
|
||||
for (const msg of conv.messages) {
|
||||
const heading =
|
||||
msg.role === "user"
|
||||
? "**You**"
|
||||
: msg.role === "assistant"
|
||||
? "**Claude**"
|
||||
: `**${msg.role}**`;
|
||||
lines.push(heading, "");
|
||||
lines.push(extractTextContent(msg.content), "");
|
||||
lines.push("---", "");
|
||||
}
|
||||
|
||||
return new Blob([lines.join("\n")], { type: "text/markdown" });
|
||||
},
|
||||
};
|
||||
249
web/lib/api/files.ts
Normal file
249
web/lib/api/files.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { ApiError } from "./types";
|
||||
import type {
|
||||
FileEntry,
|
||||
SearchResult,
|
||||
UploadResult,
|
||||
McpRequest,
|
||||
McpResponse,
|
||||
McpToolResult,
|
||||
} from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP JSON-RPC client for the file system tools
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The MCP server exposes tools at POST /mcp (Streamable HTTP transport).
|
||||
// We maintain a single session per page load and reinitialize if it expires.
|
||||
|
||||
function getBaseUrl(): string {
|
||||
return (
|
||||
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) ||
|
||||
"http://localhost:3001"
|
||||
);
|
||||
}
|
||||
|
||||
function getApiKey(): string | undefined {
|
||||
return typeof process !== "undefined"
|
||||
? process.env.NEXT_PUBLIC_API_KEY
|
||||
: undefined;
|
||||
}
|
||||
|
||||
class McpClient {
|
||||
private sessionId: string | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
private buildHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...extra,
|
||||
};
|
||||
const key = getApiKey();
|
||||
if (key) headers["Authorization"] = `Bearer ${key}`;
|
||||
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
const res = await fetch(`${getBaseUrl()}/mcp`, {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: "init",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "claude-code-web", version: "1.0.0" },
|
||||
},
|
||||
} satisfies McpRequest),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
this.initPromise = null;
|
||||
throw new ApiError(res.status, "MCP session initialization failed");
|
||||
}
|
||||
|
||||
this.sessionId = res.headers.get("mcp-session-id");
|
||||
|
||||
// Send "initialized" notification (fire-and-forget)
|
||||
if (this.sessionId) {
|
||||
fetch(`${getBaseUrl()}/mcp`, {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/initialized",
|
||||
}),
|
||||
}).catch(() => {
|
||||
// non-critical
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private initialize(): Promise<void> {
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.doInitialize().catch((err) => {
|
||||
this.initPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
/** Parse an SSE-streamed MCP response and return the first result text. */
|
||||
private async parseSseResponse(res: Response): Promise<string> {
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let result = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const json = JSON.parse(line.slice(6)) as McpResponse<McpToolResult>;
|
||||
if (json.result?.content?.[0]?.text != null) {
|
||||
result = json.result.content[0].text;
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
const res = await fetch(`${getBaseUrl()}/mcp`, {
|
||||
method: "POST",
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: nanoid(),
|
||||
method: "tools/call",
|
||||
params: { name, arguments: args },
|
||||
} satisfies McpRequest),
|
||||
});
|
||||
|
||||
// Session expired — reinitialize once and retry
|
||||
if (res.status === 400 || res.status === 404) {
|
||||
this.sessionId = null;
|
||||
this.initPromise = null;
|
||||
await this.initialize();
|
||||
return this.callTool(name, args);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, `MCP tool "${name}" failed`);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
return this.parseSseResponse(res);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as McpResponse<McpToolResult>;
|
||||
if (json.error) {
|
||||
throw new ApiError(500, json.error.message);
|
||||
}
|
||||
const toolResult = json.result;
|
||||
if (toolResult?.isError) {
|
||||
throw new ApiError(500, toolResult.content[0]?.text ?? "Tool error");
|
||||
}
|
||||
return toolResult?.content?.[0]?.text ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
const mcpClient = new McpClient();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseDirectoryListing(basePath: string, text: string): FileEntry[] {
|
||||
const entries: FileEntry[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const isDir = trimmed.endsWith("/");
|
||||
const name = isDir ? trimmed.slice(0, -1) : trimmed;
|
||||
const joinedPath = `${basePath.replace(/\/$/, "")}/${name}`;
|
||||
entries.push({ name, path: joinedPath, type: isDir ? "directory" : "file" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Matches: path/to/file.ts:42: some content
|
||||
const GREP_LINE_RE = /^([^:]+):(\d+):(.*)$/;
|
||||
|
||||
function parseSearchResults(text: string): SearchResult[] {
|
||||
const results: SearchResult[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const match = GREP_LINE_RE.exec(line.trim());
|
||||
if (!match) continue;
|
||||
const [, path, lineStr, content] = match;
|
||||
results.push({ path, line: parseInt(lineStr, 10), content: content.trim() });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ReadOptions {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
glob?: string;
|
||||
}
|
||||
|
||||
export interface FileAPI {
|
||||
list(path: string): Promise<FileEntry[]>;
|
||||
read(path: string, options?: ReadOptions): Promise<string>;
|
||||
search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
|
||||
upload(file: File): Promise<UploadResult>;
|
||||
}
|
||||
|
||||
export const fileAPI: FileAPI = {
|
||||
async list(path) {
|
||||
const text = await mcpClient.callTool("list_directory", { path });
|
||||
return parseDirectoryListing(path, text);
|
||||
},
|
||||
|
||||
async read(path, opts) {
|
||||
const args: Record<string, unknown> = { path };
|
||||
if (opts?.offset != null) args.offset = opts.offset;
|
||||
if (opts?.limit != null) args.limit = opts.limit;
|
||||
return mcpClient.callTool("read_source_file", args);
|
||||
},
|
||||
|
||||
async search(query, opts) {
|
||||
const args: Record<string, unknown> = { pattern: query };
|
||||
if (opts?.glob) args.glob = opts.glob;
|
||||
const text = await mcpClient.callTool("search_source", args);
|
||||
return parseSearchResults(text);
|
||||
},
|
||||
|
||||
async upload(_file) {
|
||||
// The MCP server does not expose a file upload endpoint.
|
||||
throw new ApiError(501, "File upload is not supported", "server");
|
||||
},
|
||||
};
|
||||
156
web/lib/api/messages.ts
Normal file
156
web/lib/api/messages.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { apiClient } from "./client";
|
||||
import { parseStream } from "./stream";
|
||||
import { ApiError } from "./types";
|
||||
import type { StreamEvent, StreamProgress } from "./types";
|
||||
import type { Message } from "../types";
|
||||
import { DEFAULT_MODEL } from "../constants";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Combine any number of AbortSignals into one. */
|
||||
function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal {
|
||||
const controller = new AbortController();
|
||||
for (const sig of signals) {
|
||||
if (!sig) continue;
|
||||
if (sig.aborted) {
|
||||
controller.abort(sig.reason);
|
||||
return controller.signal;
|
||||
}
|
||||
sig.addEventListener("abort", () => controller.abort(sig.reason), { once: true });
|
||||
}
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
function toApiMessages(
|
||||
messages: Message[]
|
||||
): Array<{ role: string; content: unknown }> {
|
||||
return messages
|
||||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-conversation abort controllers (for stop())
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const activeControllers = new Map<string, AbortController>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MessageAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SendOptions {
|
||||
model?: string;
|
||||
maxTokens?: number;
|
||||
files?: File[];
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: StreamProgress) => void;
|
||||
}
|
||||
|
||||
export interface MessageAPI {
|
||||
/**
|
||||
* Send a user message in a conversation and stream the assistant response.
|
||||
* The caller is responsible for reading the conversation history from the
|
||||
* store and providing it in `history`.
|
||||
*/
|
||||
send(
|
||||
conversationId: string,
|
||||
content: string,
|
||||
history: Message[],
|
||||
opts?: SendOptions
|
||||
): AsyncGenerator<StreamEvent>;
|
||||
|
||||
/**
|
||||
* Retry an assistant message. Sends everything up to and including the
|
||||
* user message that preceded `messageId`.
|
||||
*/
|
||||
retry(
|
||||
conversationId: string,
|
||||
messagesUpToAssistant: Message[],
|
||||
opts?: SendOptions
|
||||
): AsyncGenerator<StreamEvent>;
|
||||
|
||||
/**
|
||||
* Edit a user message and regenerate the assistant response.
|
||||
* `historyBefore` should contain only the messages before the edited one.
|
||||
*/
|
||||
edit(
|
||||
conversationId: string,
|
||||
newContent: string,
|
||||
historyBefore: Message[],
|
||||
opts?: SendOptions
|
||||
): AsyncGenerator<StreamEvent>;
|
||||
|
||||
/** Cancel any in-progress stream for this conversation. */
|
||||
stop(conversationId: string): Promise<void>;
|
||||
}
|
||||
|
||||
async function* streamRequest(
|
||||
conversationId: string,
|
||||
body: Record<string, unknown>,
|
||||
opts?: SendOptions
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
// Cancel any existing stream for this conversation
|
||||
activeControllers.get(conversationId)?.abort();
|
||||
const controller = new AbortController();
|
||||
activeControllers.set(conversationId, controller);
|
||||
|
||||
const signal = combineSignals(opts?.signal, controller.signal);
|
||||
|
||||
try {
|
||||
const res = await apiClient.postStream("/api/chat", body, { signal });
|
||||
yield* parseStream(res, { signal, onProgress: opts?.onProgress });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.type === "abort") return; // stop() was called
|
||||
throw err;
|
||||
} finally {
|
||||
if (activeControllers.get(conversationId) === controller) {
|
||||
activeControllers.delete(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const messageAPI: MessageAPI = {
|
||||
async *send(conversationId, content, history, opts) {
|
||||
const model = opts?.model ?? DEFAULT_MODEL;
|
||||
const messages = [
|
||||
...toApiMessages(history),
|
||||
{ role: "user", content },
|
||||
];
|
||||
yield* streamRequest(
|
||||
conversationId,
|
||||
{ messages, model, stream: true, max_tokens: opts?.maxTokens },
|
||||
opts
|
||||
);
|
||||
},
|
||||
|
||||
async *retry(conversationId, messagesUpToAssistant, opts) {
|
||||
const model = opts?.model ?? DEFAULT_MODEL;
|
||||
const messages = toApiMessages(messagesUpToAssistant);
|
||||
yield* streamRequest(
|
||||
conversationId,
|
||||
{ messages, model, stream: true },
|
||||
opts
|
||||
);
|
||||
},
|
||||
|
||||
async *edit(conversationId, newContent, historyBefore, opts) {
|
||||
const model = opts?.model ?? DEFAULT_MODEL;
|
||||
const messages = [
|
||||
...toApiMessages(historyBefore),
|
||||
{ role: "user", content: newContent },
|
||||
];
|
||||
yield* streamRequest(
|
||||
conversationId,
|
||||
{ messages, model, stream: true },
|
||||
opts
|
||||
);
|
||||
},
|
||||
|
||||
async stop(conversationId) {
|
||||
activeControllers.get(conversationId)?.abort();
|
||||
activeControllers.delete(conversationId);
|
||||
},
|
||||
};
|
||||
228
web/lib/api/stream.ts
Normal file
228
web/lib/api/stream.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { ApiError } from "./types";
|
||||
import type { StreamEvent, StreamProgress } from "./types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse a fetch streaming response body as SSE events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParseStreamOptions {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (progress: StreamProgress) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a streaming HTTP response body formatted as server-sent events.
|
||||
* Yields typed StreamEvent objects as they arrive. Handles burst buffering —
|
||||
* events are never dropped regardless of how fast they arrive.
|
||||
*/
|
||||
export async function* parseStream(
|
||||
response: Response,
|
||||
opts?: ParseStreamOptions
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new ApiError(0, "No response body", "network");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const startTime = Date.now();
|
||||
let tokensReceived = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (opts?.signal?.aborted) break;
|
||||
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process all complete lines. The last (potentially incomplete) line
|
||||
// stays in the buffer.
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue; // blank line = SSE event separator
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") return;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data) as StreamEvent;
|
||||
|
||||
if (
|
||||
event.type === "content_block_delta" &&
|
||||
event.delta.type === "text_delta"
|
||||
) {
|
||||
tokensReceived += event.delta.text.length;
|
||||
}
|
||||
|
||||
opts?.onProgress?.({
|
||||
tokensReceived,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
isComplete: false,
|
||||
});
|
||||
|
||||
yield event;
|
||||
} catch {
|
||||
// skip malformed JSON — keep going
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
opts?.onProgress?.({
|
||||
tokensReceived,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
isComplete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Long-lived SSE connection with automatic reconnection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SSEConnectionOptions {
|
||||
onEvent: (event: StreamEvent) => void;
|
||||
onError: (error: ApiError) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
/** Max reconnect attempts before giving up (default: 5) */
|
||||
maxReconnects?: number;
|
||||
/** Extra request headers (not supported by EventSource — use fetchSSE instead) */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a long-lived SSE connection to a URL with automatic exponential
|
||||
* backoff reconnection. Uses the native EventSource API.
|
||||
*
|
||||
* For endpoints that require custom headers, use FetchSSEConnection instead.
|
||||
*/
|
||||
export class SSEConnection {
|
||||
private eventSource: EventSource | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private readonly maxReconnects: number;
|
||||
private closed = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly opts: SSEConnectionOptions
|
||||
) {
|
||||
this.maxReconnects = opts.maxReconnects ?? 5;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
this.eventSource = new EventSource(this.url);
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.opts.onConnect?.();
|
||||
};
|
||||
|
||||
this.eventSource.onmessage = (e: MessageEvent) => {
|
||||
if (typeof e.data !== "string") return;
|
||||
if (e.data === "[DONE]") return;
|
||||
try {
|
||||
const event = JSON.parse(e.data as string) as StreamEvent;
|
||||
this.opts.onEvent(event);
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
this.eventSource?.close();
|
||||
this.eventSource = null;
|
||||
this.opts.onDisconnect?.();
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.closed = true;
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.eventSource?.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closed || this.reconnectAttempts >= this.maxReconnects) {
|
||||
if (!this.closed) {
|
||||
this.opts.onError(
|
||||
new ApiError(0, "SSE connection permanently lost", "network")
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(1_000 * Math.pow(2, this.reconnectAttempts), 30_000);
|
||||
this.reconnectAttempts++;
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE connection backed by fetch (supports custom headers and auth).
|
||||
* Streams an async generator of StreamEvents with reconnection.
|
||||
*/
|
||||
export async function* fetchSSE(
|
||||
url: string,
|
||||
opts?: {
|
||||
signal?: AbortSignal;
|
||||
headers?: Record<string, string>;
|
||||
maxReconnects?: number;
|
||||
onProgress?: (progress: StreamProgress) => void;
|
||||
}
|
||||
): AsyncGenerator<StreamEvent> {
|
||||
const maxReconnects = opts?.maxReconnects ?? 5;
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
if (opts?.signal?.aborted) break;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
...opts?.headers,
|
||||
},
|
||||
signal: opts?.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, `SSE connection failed: ${res.status}`);
|
||||
}
|
||||
|
||||
// Reset attempt counter on successful connection
|
||||
attempt = 0;
|
||||
yield* parseStream(res, { signal: opts?.signal, onProgress: opts?.onProgress });
|
||||
|
||||
// Clean disconnect — don't reconnect
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.type === "abort") break;
|
||||
if (opts?.signal?.aborted) break;
|
||||
if (attempt >= maxReconnects) throw err;
|
||||
|
||||
const delay = Math.min(1_000 * Math.pow(2, attempt), 30_000);
|
||||
attempt++;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delay);
|
||||
opts?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new ApiError(0, "Cancelled", "abort"));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
124
web/lib/api/types.ts
Normal file
124
web/lib/api/types.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ContentBlock } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE stream events (Anthropic message streaming protocol)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ContentDelta =
|
||||
| { type: "text_delta"; text: string }
|
||||
| { type: "input_json_delta"; partial_json: string };
|
||||
|
||||
export type StreamEvent =
|
||||
| {
|
||||
type: "message_start";
|
||||
message: {
|
||||
id: string;
|
||||
role: string;
|
||||
model: string;
|
||||
usage: { input_tokens: number; output_tokens: number };
|
||||
};
|
||||
}
|
||||
| { type: "content_block_start"; index: number; content_block: ContentBlock }
|
||||
| { type: "content_block_delta"; index: number; delta: ContentDelta }
|
||||
| { type: "content_block_stop"; index: number }
|
||||
| {
|
||||
type: "message_delta";
|
||||
delta: { stop_reason: string; stop_sequence: string | null };
|
||||
usage: { output_tokens: number };
|
||||
}
|
||||
| { type: "message_stop" }
|
||||
| { type: "error"; error: { type: string; message: string } }
|
||||
| { type: "ping" };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ApiErrorType =
|
||||
| "network"
|
||||
| "auth"
|
||||
| "rate_limit"
|
||||
| "server"
|
||||
| "timeout"
|
||||
| "abort"
|
||||
| "not_found";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
public readonly type: ApiErrorType = "server",
|
||||
/** Retry-After seconds for rate limit errors */
|
||||
public readonly retryAfterMs?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
get isRetryable(): boolean {
|
||||
return this.type === "network" || this.type === "server" || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared request / response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RequestOptions {
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StreamProgress {
|
||||
tokensReceived: number;
|
||||
elapsedMs: number;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File API types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
size?: number;
|
||||
modifiedAt?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
path: string;
|
||||
line: number;
|
||||
content: string;
|
||||
context?: string[];
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP JSON-RPC types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface McpRequest {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface McpResponse<T = unknown> {
|
||||
jsonrpc: "2.0";
|
||||
id?: string | number;
|
||||
result?: T;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
export interface McpToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user