Renders VS Code through its own app entrypoint Keeps shared app effects focused and reusable Avoids importing the full desktop app root in VS Code
1258 lines
50 KiB
TypeScript
1258 lines
50 KiB
TypeScript
import { createVSCodeAPIs } from './api';
|
|
import { onCommand, onThemeChange, proxyApiRequest, proxySessionMessageRequest, sendBridgeMessage, startSseProxy, stopSseProxy } from './api/bridge';
|
|
import { vscodeStreamPerfCount, vscodeStreamPerfMeasure, vscodeStreamPerfObserve } from './api/streamPerf';
|
|
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
|
|
import {
|
|
buildVSCodeThemeFromPalette,
|
|
readVSCodeThemePalette,
|
|
type VSCodeThemeKind,
|
|
type VSCodeThemePayload,
|
|
} from '@openchamber/ui/lib/theme/vscode/adapter';
|
|
import type { VSCodeActiveEditorFile } from '@/sync/input-store';
|
|
|
|
type ConnectionStatus = 'connecting' | 'connected' | 'error' | 'disconnected';
|
|
type PanelType = 'chat' | 'agentManager';
|
|
|
|
declare const __OPENCHAMBER_WEBVIEW_BUILD_TIME__: string;
|
|
|
|
declare global {
|
|
interface Window {
|
|
__OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs;
|
|
__VSCODE_CONFIG__?: {
|
|
apiUrl?: string;
|
|
workspaceFolder: string;
|
|
theme: string;
|
|
connectionStatus: string;
|
|
cliAvailable?: boolean;
|
|
extensionVersion?: string;
|
|
platform?: string;
|
|
arch?: string;
|
|
panelType?: PanelType;
|
|
viewMode?: 'sidebar' | 'editor';
|
|
initialSessionId?: string | null;
|
|
};
|
|
__OPENCHAMBER_VSCODE_THEME__?: VSCodeThemePayload['theme'];
|
|
__OPENCHAMBER_VSCODE_SHIKI_THEMES__?: { light?: Record<string, unknown>; dark?: Record<string, unknown> } | null;
|
|
__OPENCHAMBER_CONNECTION__?: { status: ConnectionStatus; error?: string; cliAvailable?: boolean };
|
|
__OPENCHAMBER_HOME__?: string;
|
|
__OPENCHAMBER_PANEL_TYPE__?: PanelType;
|
|
}
|
|
}
|
|
|
|
console.log('[OpenChamber] VS Code webview starting...');
|
|
console.log('[OpenChamber] VS Code webview build:', __OPENCHAMBER_WEBVIEW_BUILD_TIME__);
|
|
console.log('[OpenChamber] Config:', window.__VSCODE_CONFIG__);
|
|
try {
|
|
if (window.localStorage.getItem('openchamber_stream_debug') === '1') {
|
|
console.log('[OpenChamber] Debug: openchamber_stream_debug=1');
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
window.__OPENCHAMBER_RUNTIME_APIS__ = createVSCodeAPIs();
|
|
|
|
const bootstrapConnectionStatus = () => {
|
|
const initialStatus = (window.__VSCODE_CONFIG__?.connectionStatus as ConnectionStatus | undefined) || 'connecting';
|
|
const cliAvailable = window.__VSCODE_CONFIG__?.cliAvailable ?? true;
|
|
window.__OPENCHAMBER_CONNECTION__ = { status: initialStatus, cliAvailable };
|
|
};
|
|
|
|
bootstrapConnectionStatus();
|
|
|
|
// Expose panel type globally for the VS Code app root to conditionally render.
|
|
window.__OPENCHAMBER_PANEL_TYPE__ = (window.__VSCODE_CONFIG__?.panelType as PanelType) || 'chat';
|
|
|
|
const handleConnectionMessage = (event: MessageEvent) => {
|
|
const msg = event.data;
|
|
if (msg?.type === 'connectionStatus') {
|
|
const payload: ConnectionStatus = msg.status;
|
|
const error: string | undefined = msg.error;
|
|
const prevCliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
|
|
window.__OPENCHAMBER_CONNECTION__ = { status: payload, error, cliAvailable: prevCliAvailable };
|
|
window.dispatchEvent(new CustomEvent('openchamber:connection-status', { detail: { status: payload, error } }));
|
|
}
|
|
};
|
|
|
|
window.addEventListener('message', handleConnectionMessage);
|
|
window.addEventListener('openchamber:connection-status', () => {
|
|
maybeHideLoadingOverlay();
|
|
});
|
|
|
|
const fadeOutLoadingScreen = () => {
|
|
const loadingEl = document.getElementById('initial-loading');
|
|
if (!loadingEl) return;
|
|
loadingEl.classList.add('fade-out');
|
|
setTimeout(() => {
|
|
try {
|
|
loadingEl.remove();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, 300);
|
|
};
|
|
|
|
const setLoadingStatusText = (text: string, variant: 'normal' | 'error' = 'normal') => {
|
|
const statusEl = document.getElementById('loading-status');
|
|
if (!statusEl) return;
|
|
statusEl.textContent = text;
|
|
if (variant === 'error') {
|
|
statusEl.classList.add('error-text');
|
|
} else {
|
|
statusEl.classList.remove('error-text');
|
|
}
|
|
};
|
|
|
|
const waitForUiMount = (timeoutMs = 8000): Promise<boolean> => {
|
|
if (typeof document === 'undefined') return Promise.resolve(false);
|
|
const root = document.getElementById('root');
|
|
if (!root) return Promise.resolve(false);
|
|
|
|
const hasContent = () => root.childNodes.length > 0;
|
|
if (hasContent()) return Promise.resolve(true);
|
|
|
|
return new Promise((resolve) => {
|
|
const observer = new MutationObserver(() => {
|
|
if (hasContent()) {
|
|
observer.disconnect();
|
|
clearTimeout(timeout);
|
|
resolve(true);
|
|
}
|
|
});
|
|
|
|
observer.observe(root, { childList: true, subtree: true });
|
|
|
|
const timeout = setTimeout(() => {
|
|
observer.disconnect();
|
|
resolve(false);
|
|
}, timeoutMs);
|
|
});
|
|
};
|
|
|
|
let uiMounted = false;
|
|
let bootstrapProvidersReady = false;
|
|
let bootstrapAgentsReady = false;
|
|
let bootstrapFailed = false;
|
|
|
|
const recordBootstrapFetch = (pathname: string, ok: boolean) => {
|
|
if (!pathname.startsWith('/api/')) return;
|
|
|
|
// Don't mark as failed while still connecting — early 503s are expected
|
|
const isConnected = window.__OPENCHAMBER_CONNECTION__?.status === 'connected';
|
|
|
|
if (pathname.startsWith('/api/config/providers')) {
|
|
if (ok) {
|
|
bootstrapProvidersReady = true;
|
|
// Reset failed flag — a successful retry supersedes earlier 503s
|
|
if (bootstrapAgentsReady || !isConnected) bootstrapFailed = false;
|
|
} else if (isConnected) {
|
|
bootstrapFailed = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (pathname === '/api/agent' || pathname.startsWith('/api/agent?')) {
|
|
if (ok) {
|
|
bootstrapAgentsReady = true;
|
|
if (bootstrapProvidersReady || !isConnected) bootstrapFailed = false;
|
|
} else if (isConnected) {
|
|
bootstrapFailed = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
const maybeHideLoadingOverlay = () => {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status ?? 'connecting';
|
|
|
|
if (!uiMounted) {
|
|
return;
|
|
}
|
|
|
|
if (connectionStatus === 'connected') {
|
|
if (bootstrapFailed) {
|
|
setLoadingStatusText('OpenCode connected, but initial data load failed.', 'error');
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
if (bootstrapProvidersReady && bootstrapAgentsReady) {
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
const providersText = bootstrapProvidersReady ? '✓ Providers' : '… Providers';
|
|
const agentsText = bootstrapAgentsReady ? '✓ Agents' : '… Agents';
|
|
setLoadingStatusText(`Loading data (${providersText}, ${agentsText})…`);
|
|
return;
|
|
}
|
|
|
|
if (connectionStatus === 'error') {
|
|
const error = window.__OPENCHAMBER_CONNECTION__?.error;
|
|
setLoadingStatusText(error || 'Connection error', 'error');
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
if (connectionStatus === 'disconnected') {
|
|
setLoadingStatusText('Disconnected', 'error');
|
|
fadeOutLoadingScreen();
|
|
return;
|
|
}
|
|
|
|
setLoadingStatusText('Starting OpenCode API…');
|
|
};
|
|
|
|
const applyInitialTheme = (theme: { metadata?: { variant?: string }; colors?: { surface?: { background?: string; foreground?: string } } }) => {
|
|
if (typeof document === 'undefined' || !theme) return;
|
|
const variant = theme.metadata?.variant === 'dark' ? 'dark' : 'light';
|
|
const root = document.documentElement;
|
|
root.classList.remove('light', 'dark');
|
|
root.classList.add(variant);
|
|
|
|
const background = theme.colors?.surface?.background;
|
|
if (background) {
|
|
document.body.style.backgroundColor = background;
|
|
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null;
|
|
if (!meta) {
|
|
meta = document.createElement('meta');
|
|
meta.setAttribute('name', 'theme-color');
|
|
document.head.appendChild(meta);
|
|
}
|
|
meta.setAttribute('content', background);
|
|
}
|
|
};
|
|
|
|
const emitVSCodeTheme = (preferredKind?: VSCodeThemeKind) => {
|
|
const palette = readVSCodeThemePalette(preferredKind);
|
|
if (!palette) {
|
|
return;
|
|
}
|
|
const theme = buildVSCodeThemeFromPalette(palette);
|
|
window.__OPENCHAMBER_VSCODE_THEME__ = theme;
|
|
applyInitialTheme(theme);
|
|
window.dispatchEvent(new CustomEvent<VSCodeThemePayload>('openchamber:vscode-theme', {
|
|
detail: { theme, palette },
|
|
}));
|
|
};
|
|
|
|
emitVSCodeTheme(window.__VSCODE_CONFIG__?.theme as VSCodeThemeKind | undefined);
|
|
|
|
const scheduleThemeRecompute = (kind?: VSCodeThemeKind) => {
|
|
// VS Code updates webview CSS variables asynchronously around theme changes.
|
|
// Re-read on the next frames so we don't snapshot the old palette.
|
|
requestAnimationFrame(() => {
|
|
emitVSCodeTheme(kind);
|
|
requestAnimationFrame(() => emitVSCodeTheme(kind));
|
|
});
|
|
};
|
|
|
|
onThemeChange((payload) => {
|
|
const kind = (typeof payload === 'string'
|
|
? payload
|
|
: typeof payload === 'object' && payload
|
|
? payload.kind
|
|
: undefined) as VSCodeThemeKind | undefined;
|
|
|
|
if (typeof payload === 'object' && payload?.shikiThemes !== undefined) {
|
|
window.__OPENCHAMBER_VSCODE_SHIKI_THEMES__ = payload.shikiThemes;
|
|
window.dispatchEvent(
|
|
new CustomEvent('openchamber:vscode-shiki-themes', {
|
|
detail: { shikiThemes: payload.shikiThemes },
|
|
}),
|
|
);
|
|
}
|
|
|
|
scheduleThemeRecompute(kind);
|
|
});
|
|
|
|
const workspaceFolder = window.__VSCODE_CONFIG__?.workspaceFolder;
|
|
if (workspaceFolder) {
|
|
const normalizeWorkspacePath = (value: string) => {
|
|
const normalized = value
|
|
.replace(/\\/g, '/')
|
|
.replace(/^([a-z]):\//, (_, letter: string) => `${letter.toUpperCase()}:/`)
|
|
.replace(/^\/([a-z]):\//, (_, letter: string) => `/${letter.toUpperCase()}:/`);
|
|
if (normalized === '/') {
|
|
return '/';
|
|
}
|
|
return normalized.length > 1 ? normalized.replace(/\/+$/, '') : normalized;
|
|
};
|
|
|
|
const normalizedWorkspaceFolder = normalizeWorkspacePath(workspaceFolder);
|
|
window.__OPENCHAMBER_HOME__ = normalizedWorkspaceFolder;
|
|
try {
|
|
window.localStorage.setItem('lastDirectory', normalizedWorkspaceFolder);
|
|
window.localStorage.setItem('homeDirectory', normalizedWorkspaceFolder);
|
|
|
|
// VS Code defaults: show dotfiles, hide gitignored
|
|
if (window.localStorage.getItem('directoryTreeShowHidden') === null) {
|
|
window.localStorage.setItem('directoryTreeShowHidden', 'true');
|
|
}
|
|
if (window.localStorage.getItem('filesViewShowGitignored') === null) {
|
|
window.localStorage.setItem('filesViewShowGitignored', 'false');
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to persist workspace folder', error);
|
|
}
|
|
}
|
|
|
|
const normalizeUrl = (input: string | URL) => {
|
|
try {
|
|
return typeof input === 'string' ? new URL(input, window.location.href) : new URL(input.toString(), window.location.href);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const headersToRecord = (headers: HeadersInit | undefined): Record<string, string> => {
|
|
if (!headers) return {};
|
|
const normalized = headers instanceof Headers ? headers : new Headers(headers);
|
|
const result: Record<string, string> = {};
|
|
normalized.forEach((value, key) => {
|
|
result[key] = value;
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const decodeBase64 = (value: string): Uint8Array => {
|
|
const binary = atob(value);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i += 1) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
};
|
|
|
|
const isNullBodyStatus = (status: number): boolean => status === 204 || status === 205 || status === 304;
|
|
|
|
const buildProxiedResponse = (
|
|
proxied: { status: number; headers: Record<string, string>; bodyBase64?: string }
|
|
): Response => {
|
|
if (isNullBodyStatus(proxied.status)) {
|
|
return new Response(null, { status: proxied.status, headers: proxied.headers });
|
|
}
|
|
|
|
const body = proxied.bodyBase64 ? decodeBase64(proxied.bodyBase64) : new Uint8Array();
|
|
return new Response(body, { status: proxied.status, headers: proxied.headers });
|
|
};
|
|
|
|
const encodeBase64 = (bytes: Uint8Array): string => {
|
|
const CHUNK = 0x8000;
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
}
|
|
return btoa(binary);
|
|
};
|
|
|
|
const extractBodyBase64 = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string | undefined> => {
|
|
if (method === 'GET' || method === 'HEAD') return undefined;
|
|
|
|
if (input instanceof Request) {
|
|
const cloned = input.clone();
|
|
const buffer = await cloned.arrayBuffer();
|
|
const bytes = new Uint8Array(buffer);
|
|
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
|
|
}
|
|
|
|
const body = init?.body;
|
|
if (!body) return undefined;
|
|
|
|
if (typeof body === 'string') {
|
|
return encodeBase64(new TextEncoder().encode(body));
|
|
}
|
|
|
|
if (body instanceof URLSearchParams) {
|
|
return encodeBase64(new TextEncoder().encode(body.toString()));
|
|
}
|
|
|
|
if (body instanceof Blob) {
|
|
const buffer = await body.arrayBuffer();
|
|
const bytes = new Uint8Array(buffer);
|
|
return bytes.length > 0 ? encodeBase64(bytes) : undefined;
|
|
}
|
|
|
|
console.warn('[OpenChamber] Unsupported request body type for proxy request:', body);
|
|
return undefined;
|
|
};
|
|
|
|
const extractBodyText = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<string> => {
|
|
if (method === 'GET' || method === 'HEAD') return '';
|
|
|
|
if (input instanceof Request) {
|
|
const cloned = input.clone();
|
|
return await cloned.text();
|
|
}
|
|
|
|
const body = init?.body;
|
|
if (!body) return '';
|
|
|
|
if (typeof body === 'string') {
|
|
return body;
|
|
}
|
|
|
|
if (body instanceof URLSearchParams) {
|
|
return body.toString();
|
|
}
|
|
|
|
if (body instanceof Blob) {
|
|
return await body.text();
|
|
}
|
|
|
|
console.warn('[OpenChamber] Unsupported request body type for direct session proxy:', body);
|
|
return '';
|
|
};
|
|
|
|
const isSseApiPath = (pathname: string) => pathname === '/api/event' || pathname === '/api/global/event';
|
|
const isSessionMessageApiPath = (pathname: string) => /^\/api\/session\/[^/]+\/message$/.test(pathname);
|
|
|
|
const handleLocalApiRequest = async (url: URL, init?: RequestInit) => {
|
|
const pathname = url.pathname;
|
|
const normalizedPathname = pathname !== '/' ? pathname.replace(/\/+$/, '') : pathname;
|
|
const method = ((init?.method || 'GET') as string).toUpperCase();
|
|
|
|
if (normalizedPathname === '/api/sessions/snapshot' && method === 'GET') {
|
|
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('api:session-activity:get')
|
|
.catch(() => ({}));
|
|
return new Response(
|
|
JSON.stringify({
|
|
statusSessions: {},
|
|
attentionSessions: {},
|
|
activitySessions: activity || {},
|
|
serverTime: Date.now(),
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (/^\/api\/sessions\/[^/]+\/(view|unview)$/.test(normalizedPathname) && method === 'POST') {
|
|
return new Response(JSON.stringify({ success: true }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (/^\/api\/sessions\/[^/]+\/message-sent$/.test(normalizedPathname) && method === 'POST') {
|
|
const sessionId = normalizedPathname.split('/')[3] || '';
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: true,
|
|
sessionId,
|
|
messageSent: true,
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (normalizedPathname === '/api/session-activity' && method === 'GET') {
|
|
const activity = await sendBridgeMessage<Record<string, { type: 'idle' | 'busy' | 'cooldown' }>>('api:session-activity:get')
|
|
.catch(() => ({}));
|
|
return new Response(JSON.stringify(activity || {}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname === '/api/sessions/status' && method === 'GET') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
sessions: {},
|
|
serverTime: Date.now(),
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (normalizedPathname === '/api/sessions/attention' && method === 'GET') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
sessions: {},
|
|
serverTime: Date.now(),
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (/^\/api\/sessions\/[^/]+\/status$/.test(normalizedPathname) && method === 'GET') {
|
|
const sessionId = normalizedPathname.split('/')[3] || '';
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Session not found or no state available',
|
|
sessionId,
|
|
}),
|
|
{
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (/^\/api\/sessions\/[^/]+\/attention$/.test(normalizedPathname) && method === 'GET') {
|
|
const sessionId = normalizedPathname.split('/')[3] || '';
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: 'Session not found or no attention state available',
|
|
sessionId,
|
|
}),
|
|
{
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
if (normalizedPathname === '/api/tts/status' && method === 'GET') {
|
|
return new Response(JSON.stringify({ available: false }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (normalizedPathname === '/api/tts/say/status' && method === 'GET') {
|
|
return new Response(JSON.stringify({ available: false, voices: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/text/summarize') && method === 'POST') {
|
|
return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), {
|
|
status: 501,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// Health endpoints: reflect actual connection status
|
|
if (pathname === '/health' || pathname === '/api/health') {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
|
|
const isReady = connectionStatus === 'connected';
|
|
const cliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
|
|
return new Response(JSON.stringify({
|
|
status: isReady ? 'ok' : 'connecting',
|
|
isOpenCodeReady: isReady,
|
|
cliAvailable,
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (pathname.startsWith('/api/fs/list')) {
|
|
const targetPath = url.searchParams.get('path') || '';
|
|
const respectGitignore = url.searchParams.get('respectGitignore') === 'true';
|
|
const data = await sendBridgeMessage('api:fs:list', { path: targetPath, respectGitignore });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/fs/mkdir')) {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const data = await sendBridgeMessage('api:fs:mkdir', { path: body.path });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/fs/home')) {
|
|
const data = await sendBridgeMessage('api:fs/home');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/vscode/pick-files')) {
|
|
const data = await sendBridgeMessage('api:files/pick');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/vscode/drop-files') && method === 'POST') {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const uris = Array.isArray((body as { uris?: unknown[] }).uris)
|
|
? (body as { uris: unknown[] }).uris.filter((value): value is string => typeof value === 'string')
|
|
: [];
|
|
const data = await sendBridgeMessage('api:files/drop', { uris });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/vscode/save-image') && method === 'POST') {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
|
|
? (body as { fileName: string }).fileName
|
|
: undefined;
|
|
const dataUrl = typeof (body as { dataUrl?: unknown }).dataUrl === 'string'
|
|
? (body as { dataUrl: string }).dataUrl
|
|
: undefined;
|
|
const data = await sendBridgeMessage('api:files/save-image', { fileName, dataUrl });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/vscode/save-markdown') && method === 'POST') {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const fileName = typeof (body as { fileName?: unknown }).fileName === 'string'
|
|
? (body as { fileName: string }).fileName
|
|
: undefined;
|
|
const content = typeof (body as { content?: unknown }).content === 'string'
|
|
? (body as { content: string }).content
|
|
: undefined;
|
|
const data = await sendBridgeMessage('api:files/save-markdown', { fileName, content });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/agents/')) {
|
|
const encodedName = pathname.slice('/api/config/agents/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
const headerDirectory = (() => {
|
|
const headers = init?.headers;
|
|
if (!headers) return undefined;
|
|
if (headers instanceof Headers) {
|
|
return headers.get('x-opencode-directory') || undefined;
|
|
}
|
|
if (Array.isArray(headers)) {
|
|
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
|
|
return found?.[1] || undefined;
|
|
}
|
|
if (typeof headers === 'object') {
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
})();
|
|
const directory = queryDirectory || headerDirectory;
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/agents', { method: verb, name, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/commands/')) {
|
|
const encodedName = pathname.slice('/api/config/commands/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
const headerDirectory = (() => {
|
|
const headers = init?.headers;
|
|
if (!headers) return undefined;
|
|
if (headers instanceof Headers) {
|
|
return headers.get('x-opencode-directory') || undefined;
|
|
}
|
|
if (Array.isArray(headers)) {
|
|
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
|
|
return found?.[1] || undefined;
|
|
}
|
|
if (typeof headers === 'object') {
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
})();
|
|
const directory = queryDirectory || headerDirectory;
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/commands', { method: verb, name, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/mcp') {
|
|
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
const headerDirectory = (() => {
|
|
const headers = init?.headers;
|
|
if (!headers) return undefined;
|
|
if (headers instanceof Headers) {
|
|
return headers.get('x-opencode-directory') || undefined;
|
|
}
|
|
if (Array.isArray(headers)) {
|
|
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
|
|
return found?.[1] || undefined;
|
|
}
|
|
if (typeof headers === 'object') {
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
})();
|
|
const directory = queryDirectory || headerDirectory;
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/mcp', { method: verb, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/mcp/')) {
|
|
const encodedName = pathname.slice('/api/config/mcp/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
const headerDirectory = (() => {
|
|
const headers = init?.headers;
|
|
if (!headers) return undefined;
|
|
if (headers instanceof Headers) {
|
|
return headers.get('x-opencode-directory') || undefined;
|
|
}
|
|
if (Array.isArray(headers)) {
|
|
const found = headers.find(([key]) => key.toLowerCase() === 'x-opencode-directory');
|
|
return found?.[1] || undefined;
|
|
}
|
|
if (typeof headers === 'object') {
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (key.toLowerCase() === 'x-opencode-directory' && typeof value === 'string') {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
})();
|
|
const directory = queryDirectory || headerDirectory;
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/mcp', { method: verb, name, body, directory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Skills file operations: /api/config/skills/:name/files/:filePath
|
|
const skillsFilesMatch = pathname.match(/^\/api\/config\/skills\/([^/]+)\/files\/(.+)$/);
|
|
if (skillsFilesMatch) {
|
|
const name = decodeURIComponent(skillsFilesMatch[1]);
|
|
const filePath = decodeURIComponent(skillsFilesMatch[2]);
|
|
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills/files', {
|
|
method: verb,
|
|
name,
|
|
filePath,
|
|
content: body.content
|
|
});
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
const skillsCatalogStatusFromPayload = (payload: unknown): number => {
|
|
if (!payload || typeof payload !== 'object') return 200;
|
|
const data = payload as { ok?: boolean; error?: { kind?: string } };
|
|
if (data.ok === false) {
|
|
const kind = data.error?.kind;
|
|
if (kind === 'conflicts') return 409;
|
|
if (kind === 'authRequired') return 401;
|
|
return 400;
|
|
}
|
|
return 200;
|
|
};
|
|
|
|
// Skills catalog: /api/config/skills/catalog
|
|
if (pathname === '/api/config/skills/catalog') {
|
|
const refresh = url.searchParams.get('refresh') === 'true';
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills:catalog', { refresh });
|
|
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Skills scan: /api/config/skills/scan
|
|
if (pathname === '/api/config/skills/scan') {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills:scan', body);
|
|
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Skills install: /api/config/skills/install
|
|
if (pathname === '/api/config/skills/install') {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills:install', body);
|
|
return new Response(JSON.stringify(data), { status: skillsCatalogStatusFromPayload(data), headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ ok: false, error: { kind: 'unknown', message } }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Skills CRUD: /api/config/skills/:name or /api/config/skills
|
|
if (pathname === '/api/config/skills') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills', { method: 'GET' });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/skills/')) {
|
|
const encodedName = pathname.slice('/api/config/skills/'.length);
|
|
const name = decodeURIComponent(encodedName);
|
|
const verb = ((init?.method || 'GET') as string).toUpperCase();
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/skills', { method: verb, name, body });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/settings')) {
|
|
if ((init?.method || 'GET').toUpperCase() === 'GET') {
|
|
const settings = await sendBridgeMessage('api:config/settings:get');
|
|
return new Response(JSON.stringify(settings), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const updated = await sendBridgeMessage('api:config/settings:save', body);
|
|
return new Response(JSON.stringify(updated), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (normalizedPathname === '/api/behavior/agents-md') {
|
|
if (method === 'GET') {
|
|
const data = await sendBridgeMessage('api:behavior/agents-md:get');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
if (method === 'PUT') {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const data = await sendBridgeMessage('api:behavior/agents-md:save', body);
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/magic-prompts') {
|
|
if (method === 'GET') {
|
|
const data = await sendBridgeMessage('api:magic-prompts:get');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
if (method === 'DELETE') {
|
|
const data = await sendBridgeMessage('api:magic-prompts:reset-all');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/magic-prompts/')) {
|
|
const id = decodeURIComponent(pathname.slice('/api/magic-prompts/'.length));
|
|
if (method === 'PUT') {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const data = await sendBridgeMessage('api:magic-prompts:save', { id, text: body?.text });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
if (method === 'DELETE') {
|
|
const data = await sendBridgeMessage('api:magic-prompts:reset', { id });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/config/opencode-resolution' && method === 'GET') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:config/opencode-resolution:get');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/config/reload')) {
|
|
await sendBridgeMessage('api:config/reload');
|
|
return new Response(JSON.stringify({ restarted: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/openchamber/models-metadata')) {
|
|
try {
|
|
const data = await sendBridgeMessage('api:models/metadata');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
console.warn('[OpenChamber] Failed to fetch models metadata via bridge, returning empty set:', error);
|
|
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/api/zen/models' && method === 'GET') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:zen:models');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message, models: [] }), { status: 502, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname.startsWith('/api/openchamber/update-check')) {
|
|
try {
|
|
const currentVersion = url.searchParams.get('currentVersion') || undefined;
|
|
const instanceMode = url.searchParams.get('instanceMode') || 'local';
|
|
const deviceClass = url.searchParams.get('deviceClass') || 'desktop';
|
|
const platform = url.searchParams.get('platform') || window.__VSCODE_CONFIG__?.platform || undefined;
|
|
const arch = url.searchParams.get('arch') || window.__VSCODE_CONFIG__?.arch || undefined;
|
|
const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase();
|
|
const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no');
|
|
const data = await sendBridgeMessage('api:openchamber:update-check', {
|
|
currentVersion,
|
|
instanceMode,
|
|
deviceClass,
|
|
platform,
|
|
arch,
|
|
reportUsage,
|
|
});
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ available: false, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
if (pathname === '/auth/session') {
|
|
// VS Code host is trusted; mirror web server shape to keep UI logic happy
|
|
const body = {
|
|
authenticated: true,
|
|
requireSetup: false,
|
|
authenticatedAt: Date.now(),
|
|
};
|
|
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname.startsWith('/api/opencode/directory')) {
|
|
const body = init?.body ? JSON.parse(init.body as string) : {};
|
|
const result = await sendBridgeMessage('api:opencode/directory', { path: body.path });
|
|
return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
|
|
if (pathname === '/api/quota/providers') {
|
|
try {
|
|
const data = await sendBridgeMessage('api:quota:providers');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
const quotaMatch = pathname.match(/^\/api\/quota\/([^/]+)$/);
|
|
if (quotaMatch && (init?.method || 'GET').toUpperCase() === 'GET') {
|
|
const providerId = decodeURIComponent(quotaMatch[1]);
|
|
try {
|
|
const data = await sendBridgeMessage('api:quota:get', { providerId });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Handle provider auth deletion: DELETE /api/provider/:providerId/auth
|
|
const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/);
|
|
if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') {
|
|
const providerId = decodeURIComponent(providerAuthMatch[1]);
|
|
const scope = url.searchParams.get('scope') || 'auth';
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
try {
|
|
const data = await sendBridgeMessage('api:provider/auth:delete', { providerId, scope, directory: queryDirectory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
// Handle provider source lookup: GET /api/provider/:providerId/source
|
|
const providerSourceMatch = pathname.match(/^\/api\/provider\/([^/]+)\/source$/);
|
|
if (providerSourceMatch && (init?.method || 'GET').toUpperCase() === 'GET') {
|
|
const providerId = decodeURIComponent(providerSourceMatch[1]);
|
|
const queryDirectory = url.searchParams.get('directory') || undefined;
|
|
try {
|
|
const data = await sendBridgeMessage('api:provider/source:get', { providerId, directory: queryDirectory });
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const originalFetch = window.fetch.bind(window);
|
|
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const targetUrl = typeof input === 'string' || input instanceof URL ? normalizeUrl(input) : normalizeUrl((input as Request).url);
|
|
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
|
|
const pathname = targetUrl?.pathname || '';
|
|
const normalizedPathname = pathname.replace(/\/+/, '/');
|
|
if (targetUrl && normalizedPathname === '/health') {
|
|
const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status;
|
|
const isReady = connectionStatus === 'connected';
|
|
const cliAvailable = window.__OPENCHAMBER_CONNECTION__?.cliAvailable ?? true;
|
|
return new Response(JSON.stringify({
|
|
status: isReady ? 'ok' : 'connecting',
|
|
isOpenCodeReady: isReady,
|
|
cliAvailable,
|
|
}), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (targetUrl && targetUrl.pathname.startsWith('/api/')) {
|
|
const localResponse = await handleLocalApiRequest(targetUrl, init);
|
|
if (localResponse) {
|
|
recordBootstrapFetch(targetUrl.pathname, localResponse.ok);
|
|
maybeHideLoadingOverlay();
|
|
return localResponse;
|
|
}
|
|
|
|
const suffixPath = `${targetUrl.pathname.replace(/^\/api/, '')}${targetUrl.search}`;
|
|
|
|
const headersFromRequest = input instanceof Request ? headersToRecord(input.headers) : {};
|
|
const headersFromInit = headersToRecord(init?.headers);
|
|
const headers = { ...headersFromRequest, ...headersFromInit };
|
|
|
|
if (isSseApiPath(targetUrl.pathname)) {
|
|
const start = await vscodeStreamPerfMeasure('vscode.webview.sse_start_ms', () => startSseProxy({ path: suffixPath, headers }));
|
|
if (!start.streamId) {
|
|
return new Response(null, { status: start.status || 503, headers: start.headers || {} });
|
|
}
|
|
|
|
const streamId = start.streamId;
|
|
const signal = (input instanceof Request ? input.signal : init?.signal) as AbortSignal | undefined;
|
|
const encoder = new TextEncoder();
|
|
let unsubscribe: (() => void) | null = null;
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const onMessage = (event: MessageEvent) => {
|
|
const msg = event.data as { type?: string; streamId?: string; chunk?: string; error?: string };
|
|
if (!msg || msg.streamId !== streamId) return;
|
|
|
|
if (msg.type === 'api:sse:chunk' && typeof msg.chunk === 'string') {
|
|
vscodeStreamPerfCount('vscode.webview.sse_chunk');
|
|
vscodeStreamPerfObserve('vscode.webview.sse_chunk_bytes', msg.chunk.length);
|
|
controller.enqueue(encoder.encode(msg.chunk));
|
|
return;
|
|
}
|
|
|
|
if (msg.type === 'api:sse:end') {
|
|
vscodeStreamPerfCount('vscode.webview.sse_end');
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
if (typeof msg.error === 'string' && msg.error.length > 0) {
|
|
controller.error(new Error(msg.error));
|
|
} else {
|
|
controller.close();
|
|
}
|
|
void stopSseProxy({ streamId }).catch(() => {});
|
|
}
|
|
};
|
|
|
|
window.addEventListener('message', onMessage);
|
|
unsubscribe = () => window.removeEventListener('message', onMessage);
|
|
|
|
if (signal) {
|
|
const onAbort = () => {
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
try {
|
|
controller.error(new DOMException('Aborted', 'AbortError'));
|
|
} catch {
|
|
controller.close();
|
|
}
|
|
void stopSseProxy({ streamId }).catch(() => {});
|
|
};
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
signal.addEventListener('abort', onAbort, { once: true });
|
|
}
|
|
},
|
|
cancel() {
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
void stopSseProxy({ streamId }).catch(() => {});
|
|
},
|
|
});
|
|
|
|
return new Response(stream, { status: start.status || 200, headers: start.headers || { 'content-type': 'text/event-stream' } });
|
|
}
|
|
|
|
if (method === 'POST' && isSessionMessageApiPath(targetUrl.pathname)) {
|
|
const bodyText = await extractBodyText(input, init, method);
|
|
const proxied = await proxySessionMessageRequest({ path: suffixPath, headers, bodyText });
|
|
const response = buildProxiedResponse(proxied);
|
|
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
|
maybeHideLoadingOverlay();
|
|
return response;
|
|
}
|
|
|
|
const bodyBase64 = await extractBodyBase64(input, init, method);
|
|
const proxied = await proxyApiRequest({ method, path: suffixPath, headers, bodyBase64 });
|
|
const response = buildProxiedResponse(proxied);
|
|
recordBootstrapFetch(targetUrl.pathname, response.ok);
|
|
maybeHideLoadingOverlay();
|
|
return response;
|
|
}
|
|
|
|
if (targetUrl && targetUrl.hostname.includes('models.dev')) {
|
|
try {
|
|
const data = await sendBridgeMessage('api:models/metadata');
|
|
return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
} catch (error) {
|
|
console.warn('[OpenChamber] models.dev request failed via bridge, returning empty metadata:', error);
|
|
return new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
}
|
|
}
|
|
|
|
return originalFetch(input as RequestInfo, init);
|
|
};
|
|
|
|
// Listen for addToContext command from extension
|
|
onCommand('addToContext', (payload) => {
|
|
const { text } = payload as { text: string };
|
|
|
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
|
useInputStore.getState().setPendingInputText(text, 'append');
|
|
});
|
|
});
|
|
|
|
onCommand('addFileMentions', (payload) => {
|
|
const rawPaths = Array.isArray((payload as { paths?: unknown[] })?.paths)
|
|
? (payload as { paths: unknown[] }).paths
|
|
: [];
|
|
const paths = rawPaths
|
|
.filter((entry): entry is string => typeof entry === 'string')
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0);
|
|
|
|
if (paths.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const mentionText = paths.map((relativePath) => `@${relativePath}`).join(' ');
|
|
|
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
|
useInputStore.getState().setPendingInputText(mentionText, 'append-inline');
|
|
});
|
|
});
|
|
|
|
// Listen for createSessionWithPrompt command from extension (Explain, Improve Code)
|
|
onCommand('createSessionWithPrompt', (payload) => {
|
|
const { prompt } = payload as { prompt: string };
|
|
|
|
Promise.all([
|
|
import('@/sync/session-ui-store'),
|
|
import('@/stores/useConfigStore'),
|
|
import('@/sync/input-store'),
|
|
]).then(([{ useSessionUIStore }, { useConfigStore }, { useInputStore }]) => {
|
|
const sessionStore = useSessionUIStore.getState();
|
|
const configStore = useConfigStore.getState();
|
|
|
|
// Open a new session draft first
|
|
sessionStore.openNewSessionDraft();
|
|
|
|
// Get current provider/model/agent configuration
|
|
const { currentProviderId, currentModelId, currentAgentName } = configStore;
|
|
|
|
if (currentProviderId && currentModelId) {
|
|
// Send the message - this will create the session from the draft and send
|
|
sessionStore.sendMessage(
|
|
prompt,
|
|
currentProviderId,
|
|
currentModelId,
|
|
currentAgentName ?? undefined,
|
|
undefined, // attachments
|
|
undefined, // agentMentionName
|
|
undefined // additionalParts
|
|
).catch((error: unknown) => {
|
|
console.error('[OpenChamber] Failed to send prompt:', error);
|
|
});
|
|
} else {
|
|
// If no provider/model configured, just set the text and let user send manually
|
|
useInputStore.getState().setPendingInputText(prompt);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Listen for newSession command from extension title bar button
|
|
onCommand('newSession', () => {
|
|
import('@/sync/session-ui-store').then(({ useSessionUIStore }) => {
|
|
useSessionUIStore.getState().openNewSessionDraft();
|
|
});
|
|
|
|
// Also dispatch event to navigate to chat view in VSCodeLayout
|
|
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
|
|
});
|
|
|
|
// Listen for showSettings command from extension title bar button
|
|
onCommand('showSettings', () => {
|
|
// Dispatch event to navigate to settings view in VSCodeLayout
|
|
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'settings' } }));
|
|
});
|
|
|
|
// Listen for settings sync command from extension (broadcast to all VS Code webviews)
|
|
onCommand('settingsSynced', () => {
|
|
import('@openchamber/ui/lib/persistence').then(({ syncDesktopSettings }) => {
|
|
void syncDesktopSettings();
|
|
});
|
|
});
|
|
|
|
// Listen for active editor file changes from the extension
|
|
onCommand('activeEditorFile', (payload) => {
|
|
import('@/sync/input-store').then(({ useInputStore }) => {
|
|
useInputStore.getState().setActiveEditorFile((payload as VSCodeActiveEditorFile | null) ?? null);
|
|
});
|
|
});
|
|
|
|
import('@openchamber/ui/apps/renderVSCodeApp')
|
|
.then(async ({ renderVSCodeApp }) => {
|
|
renderVSCodeApp(window.__OPENCHAMBER_RUNTIME_APIS__ ?? createVSCodeAPIs());
|
|
await waitForUiMount();
|
|
uiMounted = true;
|
|
maybeHideLoadingOverlay();
|
|
})
|
|
.catch((error) => {
|
|
console.error('[OpenChamber] Failed to bootstrap UI:', error);
|
|
// If the UI bundle fails to load, remove the overlay so the user at least sees errors in the root.
|
|
uiMounted = true;
|
|
fadeOutLoadingScreen();
|
|
});
|