Files
OpenQode/bin/goose-ultra-final/src/web-shim.ts

201 lines
7.8 KiB
TypeScript

// Web Shim for Goose Ultra (Browser Edition)
// Proxies window.electron calls to the local server.js API
const API_BASE = 'http://localhost:15044/api';
// Type definitions for the messages
type ChatMessage = {
role: string;
content: string;
};
// Event listeners storage
const listeners: Record<string, ((...args: any[]) => void)[]> = {};
function addListener(channel: string, callback: (...args: any[]) => void) {
if (!listeners[channel]) listeners[channel] = [];
listeners[channel].push(callback);
}
function removeListeners(channel: string) {
delete listeners[channel];
}
function emit(channel: string, ...args: any[]) {
if (listeners[channel]) {
listeners[channel].forEach(cb => cb(...args));
}
}
// Helper to get or create a session token
// For local web edition, we might not have the CLI token file access.
// We'll try to use a stored token or prompt for one via the API.
async function getAuthToken(): Promise<string | null> {
const stored = localStorage.getItem('openqode_token');
if (stored) return stored;
// If no token, maybe we can auto-login as guest for local?
// Or we expect the user to have authenticated via the /api/auth endpoints.
return null;
}
// Only inject if window.electron is missing
if (!(window as any).electron) {
console.log('🌐 Goose Ultra Web Shim Active');
(window as any).electron = {
getAppPath: async () => {
// Return a virtual path
return '/workspace';
},
getPlatform: async () => 'web',
getServerPort: async () => 15044,
exportProjectZip: async (projectId: string) => {
console.warn('Export ZIP not supported in Web Edition');
return '';
},
// Chat Interface
startChat: async (messages: ChatMessage[], model: string) => {
try {
const token = await getAuthToken();
// We need to construct the prompt from messages
// Simple concatenation for now, as server API expects a single string 'message'
// or we send the last message if the server handles history?
// Based on server.js, it sends 'message' to qwenOAuth.sendMessage.
// We'll assume we send the full conversation or just the latest prompt + context.
// Let's send the last message's content for now, or join them.
const lastMessage = messages[messages.length - 1];
if (!lastMessage) return;
emit('chat-status', 'Connecting to server...');
const response = await fetch(`${API_BASE}/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: lastMessage.content,
model: model,
token: token || 'guest_token' // Fallback to allow server to potentially reject
})
});
if (!response.ok) {
throw new Error(`Server error: ${response.statusText}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error('No response body');
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'chunk') {
emit('chat-chunk', data.content);
} else if (data.type === 'done') {
emit('chat-complete', ''); // Empty string as full response is built by chunks?
// Actually preload.js expect 'chat-complete' with full response?
// Or just 'chat-complete'?
// Reviewing preload: onChatComplete callback(response).
// We might need to accumulate chunks to send full response here?
// But the UI likely builds it from chunks.
// Just emitting DONE is important.
} else if (data.type === 'error') {
emit('chat-error', data.error);
}
} catch (e) {
// ignore parse errors
}
}
}
}
} catch (err: any) {
console.error('Chat Error:', err);
emit('chat-error', err.message || 'Connection failed');
}
},
onChatChunk: (cb: any) => addListener('chat-chunk', cb),
onChatStatus: (cb: any) => addListener('chat-status', cb),
onChatComplete: (cb: any) => addListener('chat-complete', cb),
onChatError: (cb: any) => addListener('chat-error', cb),
removeChatListeners: () => {
removeListeners('chat-chunk');
removeListeners('chat-status');
removeListeners('chat-complete');
removeListeners('chat-error');
},
// File System Interface
fs: {
list: async (path: string) => {
const res = await fetch(`${API_BASE}/files/tree`);
const data = await res.json();
return data.tree;
},
read: async (path: string) => {
const res = await fetch(`${API_BASE}/files/read?path=${encodeURIComponent(path)}`);
const data = await res.json();
return data.content;
},
write: async (path: string, content: string) => {
await fetch(`${API_BASE}/files/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, content })
});
},
delete: async (path: string) => {
await fetch(`${API_BASE}/files/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
}
},
// Skills Interface
skills: {
list: async () => {
const res = await fetch(`${API_BASE}/skills/list`);
const data = await res.json();
return data.skills;
},
import: async (url: string) => {
const res = await fetch(`${API_BASE}/skills/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.skill;
},
delete: async (id: string) => {
const res = await fetch(`${API_BASE}/skills/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
}
}
};
}