| |
|
|
| const AP_ANNOTATOR_KEY = 'astroparse_annotator_v1'; |
|
|
| const AP_PROVIDERS = { |
| builtin: { label: 'Built-in (Claude)', needsKey: false, defaultModel: 'claude-haiku-4-5', |
| hint: 'no key needed \u2014 runs on the prototype\u2019s built-in Claude' }, |
| anthropic: { label: 'Anthropic', needsKey: true, defaultModel: 'claude-haiku-4-5', |
| keyPlaceholder: 'sk-ant-\u2026', hint: 'console.anthropic.com \u2192 API keys' }, |
| openai: { label: 'OpenAI', needsKey: true, defaultModel: 'gpt-4o-mini', |
| keyPlaceholder: 'sk-\u2026', hint: 'platform.openai.com \u2192 API keys' }, |
| gemini: { label: 'Gemini', needsKey: true, defaultModel: 'gemini-2.0-flash', |
| keyPlaceholder: 'AIza\u2026', hint: 'aistudio.google.com \u2192 Get API key' }, |
| }; |
|
|
| function apLoadAnnotator() { |
| try { |
| const raw = localStorage.getItem(AP_ANNOTATOR_KEY); |
| if (raw) { const v = JSON.parse(raw); if (v && AP_PROVIDERS[v.provider]) return v; } |
| } catch (e) {} |
| return { provider: 'builtin', model: AP_PROVIDERS.builtin.defaultModel, key: '' }; |
| } |
|
|
| function apSaveAnnotator(cfg) { |
| try { localStorage.setItem(AP_ANNOTATOR_KEY, JSON.stringify(cfg)); } catch (e) {} |
| } |
|
|
| async function apComplete(cfg, prompt) { |
| const provider = cfg.provider || 'builtin'; |
| const model = (cfg.model || AP_PROVIDERS[provider].defaultModel).trim(); |
| if (provider !== 'builtin' && !(cfg.key || '').trim()) { |
| throw new Error('no API key set \u2014 open the annotator settings'); |
| } |
| if (provider === 'builtin') { |
| return await window.claude.complete(prompt); |
| } |
| if (provider === 'anthropic') { |
| const res = await fetch('https://api.anthropic.com/v1/messages', { |
| method: 'POST', |
| headers: { |
| 'content-type': 'application/json', |
| 'x-api-key': cfg.key.trim(), |
| 'anthropic-version': '2023-06-01', |
| 'anthropic-dangerous-direct-browser-access': 'true', |
| }, |
| body: JSON.stringify({ model: model, max_tokens: 400, messages: [{ role: 'user', content: prompt }] }), |
| }); |
| if (!res.ok) throw new Error('Anthropic ' + res.status + ': ' + (await res.text()).slice(0, 140)); |
| const data = await res.json(); |
| return data.content[0].text; |
| } |
| if (provider === 'openai') { |
| const res = await fetch('https://api.openai.com/v1/chat/completions', { |
| method: 'POST', |
| headers: { 'content-type': 'application/json', authorization: 'Bearer ' + cfg.key.trim() }, |
| body: JSON.stringify({ model: model, max_tokens: 400, messages: [{ role: 'user', content: prompt }] }), |
| }); |
| if (!res.ok) throw new Error('OpenAI ' + res.status + ': ' + (await res.text()).slice(0, 140)); |
| const data = await res.json(); |
| return data.choices[0].message.content; |
| } |
| if (provider === 'gemini') { |
| const url = 'https://generativelanguage.googleapis.com/v1beta/models/' + encodeURIComponent(model) + |
| ':generateContent?key=' + encodeURIComponent(cfg.key.trim()); |
| const res = await fetch(url, { |
| method: 'POST', |
| headers: { 'content-type': 'application/json' }, |
| body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }), |
| }); |
| if (!res.ok) throw new Error('Gemini ' + res.status + ': ' + (await res.text()).slice(0, 140)); |
| const data = await res.json(); |
| return data.candidates[0].content.parts[0].text; |
| } |
| throw new Error('unknown provider'); |
| } |
|
|
| function AnnotatorSettings({ cfg, onChange, onClose }) { |
| const p = AP_PROVIDERS[cfg.provider]; |
| const set = (patch) => onChange({ ...cfg, ...patch }); |
| React.useEffect(() => { |
| const onKey = (e) => { if (e.key === 'Escape') onClose(); }; |
| window.addEventListener('keydown', onKey); |
| return () => window.removeEventListener('keydown', onKey); |
| }, [onClose]); |
| return ( |
| <div> |
| <div className="ap-pop-backdrop" onClick={onClose}></div> |
| <div className="ap-settings" data-screen-label="Annotator settings"> |
| <div className="ap-settings-title">The Annotator</div> |
| <div className="ap-settings-sub">which mind writes in your margins</div> |
| |
| <div className="ap-field"> |
| <label className="ap-field-label">Provider</label> |
| <div className="ap-provider-row"> |
| {Object.entries(AP_PROVIDERS).map(([id, pr]) => ( |
| <button |
| key={id} |
| className={'ap-provider' + (cfg.provider === id ? ' is-active' : '')} |
| onClick={() => set({ provider: id, model: pr.defaultModel })} |
| >{pr.label}</button> |
| ))} |
| </div> |
| </div> |
| |
| <div className="ap-field"> |
| <label className="ap-field-label">Model</label> |
| <input |
| className="ap-input" type="text" value={cfg.model} |
| onChange={(e) => set({ model: e.target.value })} |
| spellCheck="false" |
| /> |
| </div> |
| |
| {p.needsKey ? ( |
| <div className="ap-field"> |
| <label className="ap-field-label">API key</label> |
| <input |
| className="ap-input" type="password" value={cfg.key} |
| placeholder={p.keyPlaceholder} |
| onChange={(e) => set({ key: e.target.value })} |
| spellCheck="false" autoComplete="off" |
| /> |
| <div className="ap-field-hint">{p.hint} · stored only in this browser, sent only to {p.label}</div> |
| </div> |
| ) : ( |
| <div className="ap-field-hint">{p.hint}</div> |
| )} |
| |
| <div className="ap-settings-foot"> |
| <button className="ap-textbtn" onClick={onClose}>done</button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| Object.assign(window, { AnnotatorSettings, apComplete, apLoadAnnotator, apSaveAnnotator, AP_PROVIDERS }); |
|
|