Skip to content

Commit 932fd83

Browse files
committed
Add a browser Prompt tab with a PIN-encrypted provider vault and a generic in-page MCP harness.
1 parent 13333cd commit 932fd83

16 files changed

Lines changed: 1544 additions & 9 deletions

‎docs/users/github-pages.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,22 @@ https://samg-coder.github.io/ThreeBrowserStudio/
1010
- The same canvas viewport, bootstrap stage, Follow shot / Review, and HUD
1111
- Stock browser WebGPU (Chrome / Edge)
1212
- Three.js loaded from a CDN import map (`three@0.184.0`)
13+
- A **Prompt** tab (browser only) for connecting HTTP chat APIs through an
14+
abstract provider catalog. Bearer tokens live in `localStorage` only as
15+
AES-GCM ciphertext; a PIN unlocks them for this tab session.
1316

1417
## What stays desktop-only
1518

16-
- MCP named pipe and the nine tools
19+
- MCP named pipe and the Node authoring kernel
1720
- Project files under `%LOCALAPPDATA%`
1821
- Native typeface outlines, RTX, and `three_browser_runtime.node`
1922

23+
The browser Prompt tab talks to the nine `three_studio_*` names through a
24+
generic in-page harness. Until an in-process kernel is attached, tool calls
25+
return `kernel_unavailable`. The page never stores a PIN, and it never puts
26+
API keys in the published HTML. The chat endpoint must allow CORS from this
27+
origin.
28+
2029
The page detects the host in JavaScript. It attaches the desktop kernel only
2130
when ThreeRuntime is present (`__threeBrowserNativeRuntime`, the RTX bridge,
2231
or a `ThreeBrowserRuntime/` user-agent). A generic Chrome user-agent is not

‎docs/users/viewport.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ shot. Do not treat the Review pose as the camera the model is rendering.
2121

2222
Switch modes from the header button or Settings.
2323

24+
On the GitHub Pages preview only, Settings points at a **Prompt** tab for
25+
connecting chat APIs. The native window does not show that tab.
26+
2427
## Explorer
2528

2629
A read-only tree of objects, groups, and collections. Collapse a group in the

‎src/browser/index.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export { createSecretVault, VAULT_STORAGE_KEY, VAULT_VERSION } from './secret-vault.mjs';
2+
export {
3+
PROVIDER_KINDS,
4+
createLlmProvider,
5+
getProviderKind,
6+
listLiveProviderKinds,
7+
normalizeChatCompletion,
8+
normalizeProviderConnection,
9+
resolveChatCompletionsUrl,
10+
} from './llm-providers.mjs';
11+
export {
12+
STUDIO_BROWSER_TOOLS,
13+
STUDIO_TOOL_NAMES,
14+
createBrowserMcpHarness,
15+
createUnavailableStudioDispatch,
16+
} from './mcp-harness.mjs';
17+
export { BROWSER_HARNESS_SYSTEM, createBrowserPromptSession } from './prompt-session.mjs';
18+
export { createBrowserPromptPanel } from './prompt-panel.mjs';

‎src/browser/llm-providers.mjs‎

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import { StudioError } from '../core/errors.mjs';
2+
3+
const HTTP_CHAT_FIELDS = Object.freeze([
4+
Object.freeze({ id: 'baseUrl', label: 'Base URL', kind: 'url', required: true, placeholder: 'https://api.example.com/v1' }),
5+
Object.freeze({ id: 'model', label: 'Model', kind: 'text', required: true, placeholder: 'model-id' }),
6+
Object.freeze({ id: 'authHeader', label: 'Auth header', kind: 'text', required: false, defaultValue: 'Authorization' }),
7+
Object.freeze({ id: 'authScheme', label: 'Auth scheme', kind: 'text', required: false, defaultValue: 'Bearer' }),
8+
Object.freeze({ id: 'secret', label: 'Token', kind: 'secret', required: true }),
9+
]);
10+
11+
/** Provider kinds stay declarative so Gemini, OAuth, or a local server can be added without changing the harness. */
12+
export const PROVIDER_KINDS = Object.freeze([
13+
Object.freeze({
14+
id: 'http-chat',
15+
label: 'HTTP chat API',
16+
status: 'live',
17+
auth: 'bearer',
18+
description: 'OpenAI-compatible /chat/completions. Use for OpenAI, Groq, OpenRouter, or a local server that allows this origin.',
19+
fields: HTTP_CHAT_FIELDS,
20+
}),
21+
Object.freeze({
22+
id: 'gemini',
23+
label: 'Google Gemini',
24+
status: 'planned',
25+
auth: 'oauth-pkce',
26+
description: 'Planned first-party adapter. Do not put a Gemini key in the page; OAuth belongs on a later hosted origin.',
27+
fields: Object.freeze([]),
28+
}),
29+
]);
30+
31+
export function listLiveProviderKinds() {
32+
return PROVIDER_KINDS.filter(kind => kind.status === 'live');
33+
}
34+
35+
export function getProviderKind(id) {
36+
return PROVIDER_KINDS.find(kind => kind.id === id) ?? null;
37+
}
38+
39+
function trimSlash(value) {
40+
return String(value ?? '').trim().replace(/\/+$/, '');
41+
}
42+
43+
export function resolveChatCompletionsUrl(baseUrl) {
44+
const raw = String(baseUrl ?? '').trim();
45+
let parsed;
46+
try {
47+
parsed = new URL(raw);
48+
} catch {
49+
throw new StudioError('invalid_provider_url', 'Base URL must be an absolute http(s) URL.');
50+
}
51+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
52+
throw new StudioError('invalid_provider_url', 'Base URL must use http or https.');
53+
}
54+
if (/\/chat\/completions\/?$/i.test(parsed.pathname)) return parsed.href.replace(/\/+$/, '');
55+
return `${trimSlash(parsed.href)}/chat/completions`;
56+
}
57+
58+
function normalizeConfig(kind, config = {}) {
59+
const next = {};
60+
for (const field of kind.fields) {
61+
if (field.kind === 'secret') continue;
62+
const supplied = config[field.id];
63+
const value = supplied == null || supplied === '' ? field.defaultValue ?? '' : String(supplied).trim();
64+
if (field.required && !value) {
65+
throw new StudioError('invalid_provider_config', `${field.label} is required.`, { field: field.id });
66+
}
67+
if (value) next[field.id] = value;
68+
}
69+
if (kind.id === 'http-chat') resolveChatCompletionsUrl(next.baseUrl);
70+
return next;
71+
}
72+
73+
export function normalizeProviderConnection(connection, { requireSecret = false } = {}) {
74+
if (!connection || typeof connection !== 'object' || Array.isArray(connection)) {
75+
throw new StudioError('invalid_provider_connection', 'Provider connection must be an object.');
76+
}
77+
const kind = getProviderKind(connection.kind);
78+
if (!kind) throw new StudioError('provider_unknown', `Unknown provider kind ${connection.kind}.`);
79+
if (kind.status !== 'live') {
80+
throw new StudioError('provider_not_implemented', `${kind.label} is catalogued but not implemented yet.`, { kind: kind.id });
81+
}
82+
const id = String(connection.id ?? '').trim();
83+
const label = String(connection.label ?? kind.label).trim() || kind.label;
84+
if (!id) throw new StudioError('invalid_provider_connection', 'Provider connection id is required.');
85+
const secret = connection.secret == null ? '' : String(connection.secret);
86+
if (requireSecret && !secret) throw new StudioError('secret_required', 'A bearer token or API key is required.');
87+
return Object.freeze({
88+
id,
89+
kind: kind.id,
90+
label,
91+
config: Object.freeze(normalizeConfig(kind, connection.config)),
92+
secret,
93+
});
94+
}
95+
96+
function toOpenAiMessages(messages) {
97+
if (!Array.isArray(messages)) return [];
98+
return messages.map(message => {
99+
const role = message?.role === 'tool' ? 'tool' : message?.role === 'assistant' ? 'assistant' : message?.role === 'system' ? 'system' : 'user';
100+
const next = { role, content: message?.content == null ? '' : String(message.content) };
101+
if (role === 'tool') {
102+
next.tool_call_id = String(message.toolCallId ?? message.tool_call_id ?? '');
103+
next.name = message.name ? String(message.name) : undefined;
104+
}
105+
if (role === 'assistant' && Array.isArray(message.toolCalls) && message.toolCalls.length > 0) {
106+
next.tool_calls = message.toolCalls.map(call => ({
107+
id: String(call.id),
108+
type: 'function',
109+
function: {
110+
name: String(call.name),
111+
arguments: typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {}),
112+
},
113+
}));
114+
}
115+
return next;
116+
});
117+
}
118+
119+
function parseToolArguments(value) {
120+
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
121+
if (typeof value !== 'string' || value.trim() === '') return {};
122+
try {
123+
const parsed = JSON.parse(value);
124+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
125+
} catch {
126+
throw new StudioError('provider_tool_arguments', 'The model returned tool arguments that are not JSON.');
127+
}
128+
}
129+
130+
export function normalizeChatCompletion(payload) {
131+
const choice = payload?.choices?.[0];
132+
const message = choice?.message ?? {};
133+
const toolCalls = Array.isArray(message.tool_calls)
134+
? message.tool_calls.map(call => Object.freeze({
135+
id: String(call?.id ?? ''),
136+
name: String(call?.function?.name ?? ''),
137+
arguments: parseToolArguments(call?.function?.arguments),
138+
}))
139+
: [];
140+
const finishReason = toolCalls.length > 0 ? 'tool_calls' : 'stop';
141+
return Object.freeze({
142+
message: Object.freeze({
143+
role: 'assistant',
144+
content: message.content == null ? '' : String(message.content),
145+
}),
146+
toolCalls: Object.freeze(toolCalls),
147+
finishReason,
148+
usage: payload?.usage && typeof payload.usage === 'object' ? Object.freeze({ ...payload.usage }) : null,
149+
});
150+
}
151+
152+
function createHttpChatProvider(connection, { fetchImpl }) {
153+
const url = resolveChatCompletionsUrl(connection.config.baseUrl);
154+
const headerName = connection.config.authHeader || 'Authorization';
155+
const scheme = connection.config.authScheme ?? 'Bearer';
156+
const headerValue = scheme ? `${scheme} ${connection.secret}` : connection.secret;
157+
158+
async function request(body, signal) {
159+
let response;
160+
try {
161+
response = await fetchImpl(url, {
162+
method: 'POST',
163+
headers: {
164+
accept: 'application/json',
165+
'content-type': 'application/json',
166+
[headerName]: headerValue,
167+
},
168+
body: JSON.stringify(body),
169+
signal,
170+
});
171+
} catch (error) {
172+
throw new StudioError(
173+
'provider_unreachable',
174+
'The chat API could not be reached from this origin. The endpoint must allow CORS for GitHub Pages.',
175+
{ cause: error },
176+
);
177+
}
178+
if (!response.ok) {
179+
throw new StudioError('provider_http_error', `The chat API returned HTTP ${response.status}.`, {
180+
status: response.status,
181+
});
182+
}
183+
try {
184+
return await response.json();
185+
} catch (error) {
186+
throw new StudioError('provider_bad_response', 'The chat API did not return JSON.', { cause: error });
187+
}
188+
}
189+
190+
return Object.freeze({
191+
id: connection.id,
192+
kind: connection.kind,
193+
label: connection.label,
194+
model: connection.config.model,
195+
async complete({ messages, tools, signal } = {}) {
196+
const body = {
197+
model: connection.config.model,
198+
messages: toOpenAiMessages(messages),
199+
};
200+
if (Array.isArray(tools) && tools.length > 0) {
201+
body.tools = tools.map(tool => ({
202+
type: 'function',
203+
function: {
204+
name: tool.name,
205+
description: tool.description ?? '',
206+
parameters: tool.inputSchema ?? { type: 'object' },
207+
},
208+
}));
209+
}
210+
return normalizeChatCompletion(await request(body, signal));
211+
},
212+
async testConnection({ signal } = {}) {
213+
const result = await this.complete({
214+
messages: [{ role: 'user', content: 'Reply with the single word ok.' }],
215+
signal,
216+
});
217+
return Object.freeze({
218+
ok: true,
219+
model: connection.config.model,
220+
finishReason: result.finishReason,
221+
});
222+
},
223+
});
224+
}
225+
226+
const ADAPTERS = Object.freeze({
227+
'http-chat': createHttpChatProvider,
228+
});
229+
230+
export function createLlmProvider(connection, { fetch: fetchImpl = globalThis.fetch, requireSecret = true } = {}) {
231+
const normalized = normalizeProviderConnection(connection, { requireSecret });
232+
if (typeof fetchImpl !== 'function') {
233+
throw new StudioError('fetch_unavailable', 'fetch is required to call a chat API from the browser.');
234+
}
235+
return ADAPTERS[normalized.kind](normalized, { fetchImpl });
236+
}

0 commit comments

Comments
 (0)