diff --git a/README.md b/README.md index c9c88ee..d4be7e7 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,26 @@ map laid out from a description, an area redone, triggers written and explained, and a briefing, a review, string rewrites, and an assistant beside the map that reads and edits everything in it with you. -There is nothing to set up. The first AI request starts a free trial with no sign-in; -when it is spent you sign in (Discord to start with) for the sign-in credit, and buy -credit at cost when that runs out. The plugin holds no model key and has no field for a -server, a token or a key: it talks to scmjs.dev and to nothing else, and it sends nothing -until you press a button — with no session stored it makes no request at startup. Every -menu item that reaches the network carries the plugin's mark. - -The server decides who gets the AI. When it keeps the AI to some accounts (scmjs.dev -does for now), everyone else sees no Tools ▸ AI menu, assistant, AI buttons or AI page -in Preferences; the account, map storage and shared maps are the same for everybody. -The plugin remembers the server's last answer, so until it has asked once — a sign-in, -or opening the Account dialog — the AI stays hidden. +The AI runs on your own Anthropic API key. Paste one (made at +[console.anthropic.com](https://console.anthropic.com)) under **Your Anthropic key** on +the plugin's page in Edit ▸ Preferences — the Account dialog's *Add your key…* goes +there. The server checks it with Anthropic first, which costs nothing, and only a key +that passes is kept; from then on it goes with each AI request, Anthropic bills the +requests to you, and the Tools ▸ AI menu, the assistant and the AI buttons are there. +The key stays in this browser: scmjs.dev passes it on and keeps no copy. **Forget the +key** on the same page takes it, and the AI features, away again. Replay casts in +Aftermath are not offered on your own key. + +An account whose role the server pays for (an admin on scmjs.dev) gets the AI without a +key, on its balance as before — the free trial and the sign-in credit are that path, on +a server that offers them. Everyone else, without a key, sees no Tools ▸ AI menu, +assistant or AI buttons; the account, map storage and shared maps are the same for +everybody. The plugin remembers the server's last answer, so until it has asked once — a +sign-in, or opening the Account dialog — the key field and the AI stay hidden. + +The plugin has no field for a server or a token: it talks to scmjs.dev and to nothing +else, and it sends nothing until you press a button — with no session stored it makes no +request at startup. Every menu item that reaches the network carries the plugin's mark. ## Install @@ -313,7 +321,8 @@ model answers is the service's business and is never asked. Every AI dialog shows how long it has been waiting, and once the answer is back what it cost, what the session has cost so far, and what is left on the account. Roughly: a map plan is a few tens of cents, a trigger script and a review about the same, a name or a -translation a few cents. The trial and the sign-in credit are what the Account dialog +translation a few cents. On your own key those are Anthropic's charges to you, and the +balance line says so instead. The trial and the sign-in credit are what the Account dialog says; credit bought on top is charged at cost, does not expire, and is spent after any weekly allowance. When the balance is empty the dialog says so and links to the Account dialog — sign in if you were on the trial, top up or wait for the refill if you were not. @@ -342,9 +351,9 @@ api.services.watch("scmjs-dev.account", (account) => { ## What is stored, and where In this browser (Preferences ▸ Browser storage, under the plugin): the session, a random -device id the one free trial is keyed by, the ticks and the AI options, and the name you -last joined a shared map under. On the server: your provider id and display name, a ledger -of what your calls cost, the maps you stored and their links (a map behind a link can be +device id the one free trial is keyed by, the ticks and the AI options, your Anthropic +key if you gave one, and the name you last joined a shared map under. On the server: your provider id and display name, a ledger +of what your calls cost (none for calls on your own key), the maps you stored and their links (a map behind a link can be downloaded by anyone who has the link) — and, only while a map is shared, a copy of it and the changes since in the server's memory. Nothing else — never a prompt, never a card. The account page on scmjs.dev deletes all of it. diff --git a/account.ts b/account.ts index dd7f343..d1a2df8 100644 --- a/account.ts +++ b/account.ts @@ -9,7 +9,8 @@ * * Settings are the plugin's own `api.storage` (listed in Preferences ▸ Browser storage * under the plugin): the session, the device id the one trial is keyed by, the ticks in - * the Account dialog, and the AI options. The server is `https://api.scmjs.dev` and + * the Account dialog, the AI options, and the person's own Anthropic key when they gave + * one — which is what the AI runs on for everyone but an admin. The server is `https://api.scmjs.dev` and * there is no field for it: `serverOverride` is the one way to point a development * build elsewhere, a `?scmjs-server=` query on the editor's address. */ @@ -59,12 +60,19 @@ export interface Settings { * the server nothing; off until the server has said so once. */ aiOffered: boolean; + /** + * The person's own Anthropic key, stored only once the server has checked it with + * Anthropic. Sent with AI requests alone; scmjs.dev forwards it and keeps no copy. + */ + anthropicKey: string; + /** The server takes an own key (`/v1/info`'s `access.byok`), from its last answer. */ + keysAccepted: boolean; } export const DEFAULT_SETTINGS: Settings = { serverUrl: DEFAULT_SERVER_URL, session: "", deviceId: "", statusItem: true, ai: true, quality: "standard", showThinking: true, maxRounds: 24, ceilingUsd: 0.5, scenarioCeilingUsd: 1.5, attachView: false, dockAssistant: false, followMap: true, - shareName: "", aiOffered: false, + shareName: "", aiOffered: false, anthropicKey: "", keysAccepted: false, }; const KEY = "settings"; @@ -155,8 +163,8 @@ export class AccountManager { this.store = store; this.client = client; this.deps = deps; - // An AI request with no session starts the trial first, and the balance follows every result. - client.prepare = () => this.ensureSession(); + // An AI request with no session starts the trial first — unless it runs on the person's own key — and the balance follows every result. + client.prepare = () => this.ownKey() ? Promise.resolve() : this.ensureSession(); client.onRemaining = (r) => this.noteRemaining(r); } @@ -192,11 +200,38 @@ export class AccountManager { casts: { on: boolean; voiced: boolean } | null = null; aiOffered(): boolean { + if (this.ownKey()) return true; if (this.view) return this.view.ai ?? true; if (this.info && !this.store.get().session) return this.info.ai ?? true; return this.store.get().aiOffered; } + /** The AI runs on the person's own Anthropic key: one is stored and the server takes it. */ + ownKey(): boolean { + const s = this.store.get(); + return !!s.anthropicKey && s.keysAccepted; + } + + /** The server takes an own key, as it last said. */ + keysAccepted(): boolean { return this.store.get().keysAccepted; } + + /** + * Check `key` with the server (which asks Anthropic, at no cost) and keep it when it + * passes; a key that fails is not stored, and the error says why. + */ + async useKey(key: string): Promise { + const trimmed = key.trim(); + if (!trimmed) throw new ScmjsError("invalid_input", t("Paste the key first.")); + await this.client.checkKey(trimmed); + this.store.set({ anthropicKey: trimmed, keysAccepted: true }); + this.changed(); + } + + forgetKey(): void { + this.store.set({ anthropicKey: "" }); + this.changed(); + } + private changed() { const offered = this.aiOffered(); if (offered !== this.store.get().aiOffered) this.store.set({ aiOffered: offered }); @@ -208,9 +243,14 @@ export class AccountManager { summary(): string { const v = this.view; switch (this.kind()) { - case "guest": return this.aiOffered() && this.info?.trial !== false ? t("Not signed in · the first AI request starts a free trial") : t("Not signed in"); - case "trial": return v ? t("Free trial · {balance} left · sign in to keep it and get more", { balance: formatUsd(v.balanceUsd) }) : t("Free trial"); + case "guest": + if (this.ownKey()) return t("Not signed in · AI requests use your own Anthropic key"); + return this.aiOffered() && this.info?.trial !== false ? t("Not signed in · the first AI request starts a free trial") : t("Not signed in"); + case "trial": + if (this.ownKey()) return t("Not signed in · AI requests use your own Anthropic key"); + return v ? t("Free trial · {balance} left · sign in to keep it and get more", { balance: formatUsd(v.balanceUsd) }) : t("Free trial"); default: { + if (this.ownKey()) return t("{name} · AI requests use your own Anthropic key", { name: v?.name ?? t("Signed in") }); if (!v) return t("Signed in"); const name = v.name ?? t("Signed in"); if (v.unlimited) return t("{name} · no balance is kept", { name }); @@ -235,6 +275,8 @@ export class AccountManager { const cast = info.recipes?.find((r) => (r.name as string) === "cast"); this.casts = cast ? { on: cast.enabled, voiced: !!(info as { speech?: boolean }).speech } : null; this.serverName = info.name; + const accepted = !!info.access?.byok; + if (accepted !== this.store.get().keysAccepted) this.store.set({ keysAccepted: accepted }); if (info.caller.kind === "user" && info.caller.account) this.view = info.caller.account; else if (this.store.get().session && info.caller.kind === "anonymous") { // The session was sent and not recognised: it has ended. diff --git a/ai/options.ts b/ai/options.ts index 37a8482..8406281 100644 --- a/ai/options.ts +++ b/ai/options.ts @@ -7,11 +7,16 @@ * scmjs.dev's, the account is the Account dialog's — so the page opens on the balance * line and a button to that dialog, which is where a person who came here for the * sign-in wants to be. Registered whether the AI features are on or off — the tick that - * turns them back on lives here — but only while the server offers the AI at all. + * turns them back on lives here — while the server offers the AI or takes an own key. + * + * The AI runs on the person's own Anthropic key unless their role pays for it (an admin), + * so the page ends on that key: pasted, checked with the server (which asks Anthropic, at + * no cost) and kept only once it passes. Until there is one the page shows that alone. */ import type { Disposable, PluginApi } from "@scm-js/plugin-api"; import type { AccountManager, Quality, SettingsStore } from "../account"; -import { h, styled } from "./ui"; +import { describeError, ScmjsError } from "../client"; +import { clear, h, styled } from "./ui"; import { msg, t, translate } from "../i18n"; /** The page id `api.ui.open("preferences", { page })` takes. */ @@ -42,60 +47,125 @@ export function registerOptionsPage(deps: OptionsDeps): Disposable { return api.ui.preferencesPage({ mount(body, page) { const root = styled(body); - const s = store.get(); + const aiBox = h("div", null); + const keyBox = h("div", null); + root.append(aiBox, keyBox); + /** The AI part's own listener, while it is mounted. */ + let offAi: (() => void) | null = null; - /* The account line and the way to it. */ - const balance = h("div", { className: "ai-hint" }, account.summary()); - const offAccount = account.onChange(() => { balance.textContent = account.summary(); }); - const accountBtn = w.button(account.signedIn() ? t("Account…") : t("Sign in…"), { primary: !account.signedIn(), onClick: () => { page.close(); deps.openAccount(); } }); + /* The own key: the field and the check, or the key in use and a way to forget it. */ + const keyStatus = w.statusLine({ text: "" }); + // (Rebuilt only when what it shows changes: an account update must not empty a field being typed in.) + let keyShown: string | null = null; + const renderKey = () => { + const key = store.get().anthropicKey; + const state = `${account.keysAccepted()}:${key}`; + if (state === keyShown) return; + keyShown = state; + clear(keyBox); + if (!account.keysAccepted()) return; + if (key) { + keyBox.append(w.group(t("Your Anthropic key"), + h("div", { className: "ai-btns" }, + h("span", null, t("In use: the key ending {end}.", { end: key.slice(-4) })), + w.button(t("Forget the key"), { onClick: () => { account.forgetKey(); keyStatus.set(t("The key is forgotten.")); } }), + ), + h("div", { className: "ai-hint" }, t("AI requests go to Anthropic on this key and are billed to your Anthropic account. It stays in this browser; scmjs.dev passes it on with each request and keeps no copy.")), + keyStatus, + )); + return; + } + const field = w.text({ placeholder: "sk-ant-…", password: true }); + const check = w.button(t("Check and use"), { primary: true, onClick: async () => { + keyStatus.set(t("Checking the key with Anthropic…")); + try { + await account.useKey(field.value); + keyStatus.set(t("The key works. The AI features are on."), "ok"); + keyStatus.scrollIntoView({ block: "nearest" }); + } catch (err) { + // (A refused key, or a server that takes none, says so in its own words.) + keyStatus.set(err instanceof ScmjsError && (err.code === "forbidden" || err.code === "invalid_input") ? err.message : describeError(err), "error"); + } + } }); + keyBox.append(w.group(t("Your Anthropic key"), + h("div", { className: "ai-btns" }, field, check), + h("div", { className: "ai-hint" }, t("The AI features run on your own Anthropic API key, made at console.anthropic.com. The key is checked with Anthropic first, which costs nothing; after that it goes with each AI request and Anthropic bills the requests to your account. It stays in this browser; scmjs.dev passes it on and keeps no copy.")), + keyStatus, + )); + }; - /* The one switch. */ - const aiBox = w.checkbox(t("Use the AI features"), { value: s.ai, onChange: (v) => { store.set({ ai: v }); } }); + let aiShown: string | null = null; + const renderAi = () => { + const offered = account.aiOffered(); + const state = `${offered}:${account.ownKey()}`; + if (state === aiShown) return; + aiShown = state; + offAi?.(); offAi = null; + clear(aiBox); + if (offered) mountAi(aiBox); + }; - /* Quality. */ - const qualitySelect = w.select(QUALITY_CHOICES.map((c) => ({ value: c.value, label: translate(c.label) })), { value: s.quality, onChange: (v) => { store.set({ quality: v as Quality }); } }); + const offAccount = account.onChange(() => { renderAi(); renderKey(); }); + renderAi(); + renderKey(); + return () => { offAccount(); offAi?.(); }; - /* The assistant's knobs. */ - const thinkingBox = w.checkbox(t("Show the model's reasoning summary while it works"), { value: s.showThinking, onChange: (v) => { store.set({ showThinking: v }); } }); - const dockBox = w.checkbox(t("Dock the assistant at the right, under the Properties panel, instead of floating over the map"), { value: s.dockAssistant, onChange: (v) => { store.set({ dockAssistant: v }); } }); - const roundsField = w.number({ value: s.maxRounds, min: 1, max: 100, step: 1, onChange: (v) => { store.set({ maxRounds: Math.max(1, Math.min(100, Math.round(v || 24))) }); } }); - const usd = (v: number) => Math.max(0, Math.min(100, Math.round((Number.isFinite(v) ? v : 0) * 100) / 100)); - const ceilingField = w.number({ value: s.ceilingUsd, min: 0, max: 100, step: 0.05, onChange: (v) => { store.set({ ceilingUsd: usd(v) }); } }); - const scenarioCeilingField = w.number({ value: s.scenarioCeilingUsd, min: 0, max: 100, step: 0.25, onChange: (v) => { store.set({ scenarioCeilingUsd: usd(v) }); } }); - const attachBox = w.checkbox(t("Send a picture of the visible area with every message"), { value: s.attachView, onChange: (v) => { store.set({ attachView: v }); } }); - const followBox = w.checkbox(t("Follow the assistant's work around the map"), { value: s.followMap, onChange: (v) => { store.set({ followMap: v }); } }); + function mountAi(root: HTMLElement): void { + const s = store.get(); - root.append( - w.group(t("Account"), - h("div", { className: "ai-btns" }, accountBtn, balance), - h("div", { className: "ai-hint" }, t("The first AI request starts a free trial with no sign-in. Signing in keeps the balance across browsers and adds the sign-in credit; the Account dialog has the balance, the top-up and the activity.")), - ), - w.group(t("AI features"), - aiBox, - h("div", { className: "ai-hint" }, t("Off takes the Tools ▸ AI menu, the assistant and the AI buttons in the editor's dialogs away. Your account and the maps stored on it stay.")), - ), - w.group(t("Quality"), - w.form([{ label: t("Quality"), field: qualitySelect }]), - h("div", { className: "ai-hint" }, t("How hard the model works on a request, and so how long it takes and what it costs. Standard gives each feature the setting it was tuned for — laying out maps and writing triggers already work at the highest one. Changing it in the middle of an assistant conversation makes the server re-read the whole conversation once; the next message is a little dearer.")), - ), - w.group(t("Spending"), - w.form([{ label: t("Per assistant message ($)"), field: ceilingField }, { label: t("Per Make Scenario run ($)"), field: scenarioCeilingField }]), - h("div", { className: "ai-hint" }, t("A ceiling on one message with its tool rounds, and on one design or build. At the ceiling the work stops with the map as edited so far, the assistant offers to continue for as much again, and a build says which step it stopped at. 0 is no ceiling. A message usually costs $0.10–0.30 and a build $0.50–1.00; the server holds the ceiling, so the last call can run a little over it, never a whole extra one.")), - ), - h("details", null, - h("summary", null, t("Assistant")), - h("div", { className: "ai-body" }, - w.form([{ label: t("Rounds per message"), field: roundsField }]), - attachBox, - followBox, - dockBox, - thinkingBox, - h("div", { className: "ai-hint" }, t("A round is one answer from the model followed by the tool calls it asked for; the assistant stops at the limit and offers to continue. A picture costs about as much as a page of text each time. Following moves the view to each call's spot and zooms out when the spot is larger than the view, never in; scroll or zoom yourself during a turn and it stops until the next one. The dock setting applies the next time the assistant opens.")), - ), - ), - ); + /* The account line and the way to it. */ + const balance = h("div", { className: "ai-hint" }, account.summary()); + offAi = account.onChange(() => { balance.textContent = account.summary(); }); + const accountBtn = w.button(account.signedIn() ? t("Account…") : t("Sign in…"), { primary: !account.signedIn() && !account.ownKey(), onClick: () => { page.close(); deps.openAccount(); } }); + + /* The one switch. */ + const aiBox = w.checkbox(t("Use the AI features"), { value: s.ai, onChange: (v) => { store.set({ ai: v }); } }); + + /* Quality. */ + const qualitySelect = w.select(QUALITY_CHOICES.map((c) => ({ value: c.value, label: translate(c.label) })), { value: s.quality, onChange: (v) => { store.set({ quality: v as Quality }); } }); - return () => { offAccount(); }; + /* The assistant's knobs. */ + const thinkingBox = w.checkbox(t("Show the model's reasoning summary while it works"), { value: s.showThinking, onChange: (v) => { store.set({ showThinking: v }); } }); + const dockBox = w.checkbox(t("Dock the assistant at the right, under the Properties panel, instead of floating over the map"), { value: s.dockAssistant, onChange: (v) => { store.set({ dockAssistant: v }); } }); + const roundsField = w.number({ value: s.maxRounds, min: 1, max: 100, step: 1, onChange: (v) => { store.set({ maxRounds: Math.max(1, Math.min(100, Math.round(v || 24))) }); } }); + const usd = (v: number) => Math.max(0, Math.min(100, Math.round((Number.isFinite(v) ? v : 0) * 100) / 100)); + const ceilingField = w.number({ value: s.ceilingUsd, min: 0, max: 100, step: 0.05, onChange: (v) => { store.set({ ceilingUsd: usd(v) }); } }); + const scenarioCeilingField = w.number({ value: s.scenarioCeilingUsd, min: 0, max: 100, step: 0.25, onChange: (v) => { store.set({ scenarioCeilingUsd: usd(v) }); } }); + const attachBox = w.checkbox(t("Send a picture of the visible area with every message"), { value: s.attachView, onChange: (v) => { store.set({ attachView: v }); } }); + const followBox = w.checkbox(t("Follow the assistant's work around the map"), { value: s.followMap, onChange: (v) => { store.set({ followMap: v }); } }); + + root.append( + w.group(t("Account"), + h("div", { className: "ai-btns" }, accountBtn, balance), + h("div", { className: "ai-hint" }, account.ownKey() + ? t("AI requests run on your own Anthropic key, below. The account is for the maps you store and the maps you share.") + : t("The first AI request starts a free trial with no sign-in. Signing in keeps the balance across browsers and adds the sign-in credit; the Account dialog has the balance, the top-up and the activity.")), + ), + w.group(t("AI features"), + aiBox, + h("div", { className: "ai-hint" }, t("Off takes the Tools ▸ AI menu, the assistant and the AI buttons in the editor's dialogs away. Your account and the maps stored on it stay.")), + ), + w.group(t("Quality"), + w.form([{ label: t("Quality"), field: qualitySelect }]), + h("div", { className: "ai-hint" }, t("How hard the model works on a request, and so how long it takes and what it costs. Standard gives each feature the setting it was tuned for — laying out maps and writing triggers already work at the highest one. Changing it in the middle of an assistant conversation makes the server re-read the whole conversation once; the next message is a little dearer.")), + ), + w.group(t("Spending"), + w.form([{ label: t("Per assistant message ($)"), field: ceilingField }, { label: t("Per Make Scenario run ($)"), field: scenarioCeilingField }]), + h("div", { className: "ai-hint" }, t("A ceiling on one message with its tool rounds, and on one design or build. At the ceiling the work stops with the map as edited so far, the assistant offers to continue for as much again, and a build says which step it stopped at. 0 is no ceiling. A message usually costs $0.10–0.30 and a build $0.50–1.00; the server holds the ceiling, so the last call can run a little over it, never a whole extra one.")), + ), + h("details", null, + h("summary", null, t("Assistant")), + h("div", { className: "ai-body" }, + w.form([{ label: t("Rounds per message"), field: roundsField }]), + attachBox, + followBox, + dockBox, + thinkingBox, + h("div", { className: "ai-hint" }, t("A round is one answer from the model followed by the tool calls it asked for; the assistant stops at the limit and offers to continue. A picture costs about as much as a page of text each time. Following moves the view to each call's spot and zooms out when the spot is larger than the view, never in; scroll or zoom yourself during a turn and it stops until the next one. The dock setting applies the next time the assistant opens.")), + ), + ), + ); + } }, }); } diff --git a/client.ts b/client.ts index 6d522e2..20d7dc6 100644 --- a/client.ts +++ b/client.ts @@ -1,7 +1,8 @@ /** * The typed client for scmjs.dev's server (an ai-server, https://github.com/scm-js/ai-server): * the account routes, the map storage routes, and the AI *recipes*. Every call is one - * `fetch` under the server address with the session as a bearer; a failure is a + * `fetch` under the server address with the session as a bearer — and a recipe with the + * person's own Anthropic key too, when they gave one; a failure is a * `ScmjsError` carrying the server's error code (or `network`, `aborted`, `protocol` for * what never reached one). A recipe runs over `POST /v1/recipes/` with * `Accept: text/event-stream` and an SSE parser that copes with frames split across @@ -9,7 +10,7 @@ * without a network, and nothing is sent until a method is called. */ import type { - AccountResponse, Allowance, AuthStartResponse, CheckoutResponse, ErrorBody, ErrorCode, InfoResponse, MapLinkResponse, MapListResponse, MapMeta, MapPatch, MapResponse, PublicMapResponse, + AccountResponse, Allowance, AuthStartResponse, CheckoutResponse, ErrorBody, ErrorCode, InfoResponse, KeyCheckResponse, MapLinkResponse, MapListResponse, MapMeta, MapPatch, MapResponse, PublicMapResponse, RecipeEvent, RecipeInputs, RecipeName, RecipeOptions, RecipeOutputs, RecipeRequest, RecipeResponse, RevisionPatch, RoomCreateRequest, RoomCreateResponse, RoomLookupResponse, SharedMapResponse, SharedMapsResponse, KeepDays, StorageResponse, TrialResponse, Usage, } from "./protocol"; @@ -20,6 +21,11 @@ export interface Credentials { serverUrl: string; /** The session the server issued (a trial's or a signed-in account's); empty for a guest. */ session: string; + /** + * The person's own Anthropic key, checked when it was given; empty for none. Sent with + * recipe calls only — never in `headers()`, which other plugins spread into theirs. + */ + anthropicKey?: string; } export class ScmjsError extends Error { @@ -265,6 +271,25 @@ export class ScmjsClient { return info; } + /** + * `POST /v1/key/check`: whether Anthropic takes `key` (the server asks for its model + * list on it, which costs nothing) and the recipes it unlocks there. Sent with the key + * alone, no session. + */ + async checkKey(key: string, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await this.fetchImpl(`${this.base()}/v1/key/check`, { method: "POST", headers: { Accept: "application/json", "X-Anthropic-Key": key.trim() }, signal }); + } catch (err) { + throw toNetworkError(err); + } + if (!res.ok) { + const err = await errorOf(res); + throw err.code === "unauthorized" ? new ScmjsError("forbidden", t("Anthropic did not accept that key."), { status: err.status }) : err; + } + try { return (await res.json()) as KeyCheckResponse; } catch (err) { throw new ScmjsError("protocol", (err as Error).message); } + } + /** `POST /v1/trial`: a session with the free trial on it, once per device id. */ trial(deviceId: string): Promise { return this.request("/v1/trial", { method: "POST", json: { deviceId } }); @@ -436,18 +461,23 @@ export class ScmjsClient { async run(name: N, input: RecipeInputs[N], hooks: RunHooks = {}, options?: RecipeOptions): Promise> { if (this.prepare) await this.prepare(); const body: RecipeRequest = { protocol: PROTOCOL_VERSION, input, options }; + const key = this.credentials().anthropicKey?.trim(); let res: Response; try { res = await this.fetchImpl(`${this.base()}/v1/recipes/${name}`, { method: "POST", - headers: { Accept: "text/event-stream", ...this.headers(), "Content-Type": "application/json" }, + headers: { Accept: "text/event-stream", ...this.headers(), ...(key ? { "X-Anthropic-Key": key } : {}), "Content-Type": "application/json" }, body: JSON.stringify(body), signal: hooks.signal, }); } catch (err) { throw toNetworkError(err); } - if (!res.ok) throw await errorOf(res); + if (!res.ok) { + const err = await errorOf(res); + // (On an own key, a 401 is Anthropic turning the key down, not the session ending.) + throw key && err.code === "unauthorized" ? new ScmjsError("forbidden", t("Anthropic did not accept your key: {message} Check it in Preferences ▸ scmjs.dev.", { message: err.message }), { status: err.status }) : err; + } const type = res.headers.get("content-type") ?? ""; if (!type.includes("text/event-stream")) { // A JSON answer (a server that does not stream): one result. @@ -471,7 +501,9 @@ export class ScmjsClient { case "delta": hooks.onDelta?.(ev.text); break; case "tool_use": hooks.onToolUse?.(ev.id, ev.name); break; case "result": result = { output: ev.output, usage: ev.usage, remaining: ev.remaining }; break; - case "error": throw new ScmjsError(ev.error.code, ev.error.message, { retryAfterSec: ev.error.retryAfterSec }); + case "error": + if (key && ev.error.code === "unauthorized") throw new ScmjsError("forbidden", t("Anthropic did not accept your key: {message} Check it in Preferences ▸ scmjs.dev.", { message: ev.error.message })); + throw new ScmjsError(ev.error.code, ev.error.message, { retryAfterSec: ev.error.retryAfterSec }); case "done": break; } }; diff --git a/dialogs.ts b/dialogs.ts index 5bee77b..61ac611 100644 --- a/dialogs.ts +++ b/dialogs.ts @@ -7,6 +7,7 @@ */ import type { DialogHandle } from "@scm-js/plugin-api"; import { SERVER_QUERY, SITE_URL } from "./account"; +import { openOptions } from "./ai/options"; import { describeError, formatBytes, formatUsd, signInGives } from "./client"; import { sharedMapsList } from "./share/kept"; import { t, tc } from "./i18n"; @@ -102,16 +103,22 @@ export function openAccountDialog(ctx: Ctx): DialogHandle { // What the server offers this person, once it has answered: so nobody is left looking for a feature that is off. if (s.offers) { const signedIn = s.kind === "account"; - const state = (on: boolean, off: string, needsSignIn: boolean): [string, string, string] => + type Row = [string, string, string | Node]; + const state = (on: boolean, off: string | Node, needsSignIn: boolean): Row => !on ? [t("Off"), "sd-bad", off] : needsSignIn && !signedIn ? [t("Sign in to use"), "", ""] : [t("On"), "sd-ok", ""]; - const features: [string, [string, string, string]][] = [ - [t("AI features"), state(account.aiOffered(), t("Off for now while I work out the tooling and costs."), false)], + // The AI runs on the person's own Anthropic key unless their role pays for it. + const ai: Row = account.ownKey() ? [t("On"), "sd-ok", t("On your own Anthropic key.")] + : account.aiOffered() ? [t("On"), "sd-ok", ""] + : account.keysAccepted() ? state(false, h("span", null, t("They run on your own Anthropic key."), " ", h("a", { href: "#", onClick: (e: Event) => { e.preventDefault(); dialog.close(); openOptions(api); } }, t("Add your key…"))), false) + : state(false, t("Off for now while I work out the tooling and costs."), false); + const features: [string, Row][] = [ + [t("AI features"), ai], [t("Map storage"), state(s.offers.maps, t("This server keeps no maps."), true)], [t("Shared editing"), state(!!s.offers.rooms, t("This server has no shared map editing."), true)], - // (Shown only by a server that writes casts at all.) - ...(account.casts ? [[t("Replay casts"), account.casts.on + // (Shown only by a server that writes casts at all, and not to someone on their own key whom it does not offer them: a key never unlocks casts.) + ...(account.casts && (account.casts.on || !account.ownKey()) ? [[t("Replay casts"), account.casts.on ? [t("On"), "sd-ok", account.casts.voiced ? t("In Aftermath: a replay's timeline ▸ Cast…, voiced.") : t("In Aftermath: a replay's timeline ▸ Cast…, as captions.")] - : state(false, t("Not offered to this account for now."), true)] as [string, [string, string, string]]] : []), + : state(false, t("Not offered to this account for now."), true)] as [string, Row]] : []), ]; featuresBox.append(w.group(t("Status"), h("table", { className: "sd-ledger" }, h("tbody", null, ...features.map(([name, [text, cls, note]]) => h("tr", null, diff --git a/dist/plugin.js b/dist/plugin.js index 8e96419..fdb7ebc 100644 --- a/dist/plugin.js +++ b/dist/plugin.js @@ -999,7 +999,25 @@ var KO = { "Replay casts": "\uB9AC\uD50C\uB808\uC774 \uC911\uACC4", "In Aftermath: a replay's timeline \u25B8 Cast\u2026, voiced.": "Aftermath\uC5D0\uC11C: \uB9AC\uD50C\uB808\uC774 \uD0C0\uC784\uB77C\uC778 \u25B8 \uC911\uACC4\u2026, \uC74C\uC131 \uD3EC\uD568.", "In Aftermath: a replay's timeline \u25B8 Cast\u2026, as captions.": "Aftermath\uC5D0\uC11C: \uB9AC\uD50C\uB808\uC774 \uD0C0\uC784\uB77C\uC778 \u25B8 \uC911\uACC4\u2026, \uC790\uB9C9\uC73C\uB85C.", - "Not offered to this account for now.": "\uC9C0\uAE08\uC740 \uC774 \uACC4\uC815\uC5D0 \uC81C\uACF5\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4." + "Not offered to this account for now.": "\uC9C0\uAE08\uC740 \uC774 \uACC4\uC815\uC5D0 \uC81C\uACF5\uB418\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.", + "Paste the key first.": "\uBA3C\uC800 \uD0A4\uB97C \uBD99\uC5EC \uB123\uC73C\uC138\uC694.", + "Not signed in \xB7 AI requests use your own Anthropic key": "\uB85C\uADF8\uC778\uD558\uC9C0 \uC54A\uC74C \xB7 AI \uC694\uCCAD\uC740 \uB0B4 Anthropic \uD0A4\uB97C \uC501\uB2C8\uB2E4", + "{name} \xB7 AI requests use your own Anthropic key": "{name} \xB7 AI \uC694\uCCAD\uC740 \uB0B4 Anthropic \uD0A4\uB97C \uC501\uB2C8\uB2E4", + "Your Anthropic key": "\uB0B4 Anthropic \uD0A4", + "In use: the key ending {end}.": "\uC0AC\uC6A9 \uC911: {end}(\uC73C)\uB85C \uB05D\uB098\uB294 \uD0A4.", + "Forget the key": "\uD0A4 \uC9C0\uC6B0\uAE30", + "The key is forgotten.": "\uD0A4\uB97C \uC9C0\uC6E0\uC2B5\uB2C8\uB2E4.", + "AI requests go to Anthropic on this key and are billed to your Anthropic account. It stays in this browser; scmjs.dev passes it on with each request and keeps no copy.": "AI \uC694\uCCAD\uC740 \uC774 \uD0A4\uB85C Anthropic\uC5D0 \uBCF4\uB0B4\uC9C0\uACE0 \uC694\uAE08\uC740 \uB0B4 Anthropic \uACC4\uC815\uC5D0 \uCCAD\uAD6C\uB429\uB2C8\uB2E4. \uD0A4\uB294 \uC774 \uBE0C\uB77C\uC6B0\uC800\uC5D0\uB9CC \uB0A8\uACE0, scmjs.dev\uB294 \uC694\uCCAD\uB9C8\uB2E4 \uD0A4\uB97C \uC804\uB2EC\uD560 \uBFD0 \uC0AC\uBCF8\uC744 \uBCF4\uAD00\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.", + "Check and use": "\uD655\uC778\uD558\uACE0 \uC0AC\uC6A9", + "Checking the key with Anthropic\u2026": "Anthropic\uC5D0\uC11C \uD0A4\uB97C \uD655\uC778\uD558\uB294 \uC911\u2026", + "The key works. The AI features are on.": "\uD0A4\uAC00 \uC791\uB3D9\uD569\uB2C8\uB2E4. AI \uAE30\uB2A5\uC774 \uCF1C\uC84C\uC2B5\uB2C8\uB2E4.", + "The AI features run on your own Anthropic API key, made at console.anthropic.com. The key is checked with Anthropic first, which costs nothing; after that it goes with each AI request and Anthropic bills the requests to your account. It stays in this browser; scmjs.dev passes it on and keeps no copy.": "AI \uAE30\uB2A5\uC740 console.anthropic.com\uC5D0\uC11C \uB9CC\uB4E0 \uB0B4 Anthropic API \uD0A4\uB85C \uC791\uB3D9\uD569\uB2C8\uB2E4. \uD0A4\uB294 \uBA3C\uC800 Anthropic\uC5D0\uC11C \uD655\uC778\uD558\uBA70 \uC774 \uD655\uC778\uC5D0\uB294 \uBE44\uC6A9\uC774 \uB4E4\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uADF8\uB2E4\uC74C\uBD80\uD130\uB294 AI \uC694\uCCAD\uB9C8\uB2E4 \uD0A4\uAC00 \uD568\uAED8 \uAC00\uACE0, Anthropic\uC774 \uC694\uCCAD \uC694\uAE08\uC744 \uB0B4 \uACC4\uC815\uC5D0 \uCCAD\uAD6C\uD569\uB2C8\uB2E4. \uD0A4\uB294 \uC774 \uBE0C\uB77C\uC6B0\uC800\uC5D0\uB9CC \uB0A8\uACE0, scmjs.dev\uB294 \uC804\uB2EC\uD560 \uBFD0 \uC0AC\uBCF8\uC744 \uBCF4\uAD00\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4.", + "AI requests run on your own Anthropic key, below. The account is for the maps you store and the maps you share.": "AI \uC694\uCCAD\uC740 \uC544\uB798\uC758 \uB0B4 Anthropic \uD0A4\uB85C \uC2E4\uD589\uB429\uB2C8\uB2E4. \uACC4\uC815\uC740 \uBCF4\uAD00\uD558\uB294 \uB9F5\uACFC \uACF5\uC720\uD558\uB294 \uB9F5\uC744 \uC704\uD55C \uAC83\uC785\uB2C8\uB2E4.", + "Anthropic did not accept that key.": "Anthropic\uC774 \uADF8 \uD0A4\uB97C \uBC1B\uC544\uB4E4\uC774\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4.", + "Anthropic did not accept your key: {message} Check it in Preferences \u25B8 scmjs.dev.": "Anthropic\uC774 \uB0B4 \uD0A4\uB97C \uBC1B\uC544\uB4E4\uC774\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4: {message} \uD658\uACBD \uC124\uC815 \u25B8 scmjs.dev\uC5D0\uC11C \uD655\uC778\uD558\uC138\uC694.", + "On your own Anthropic key.": "\uB0B4 Anthropic \uD0A4\uB85C.", + "They run on your own Anthropic key.": "\uB0B4 Anthropic \uD0A4\uB85C \uC791\uB3D9\uD569\uB2C8\uB2E4.", + "Add your key\u2026": "\uD0A4 \uCD94\uAC00\u2026" }; // i18n.ts @@ -1287,6 +1305,28 @@ var ScmjsClient = class { if (info.protocol !== void 0 && info.protocol !== PROTOCOL_VERSION) throw new ScmjsError("protocol", t("it speaks protocol {theirs}, this plugin speaks {ours}.", { theirs: String(info.protocol), ours: PROTOCOL_VERSION })); return info; } + /** + * `POST /v1/key/check`: whether Anthropic takes `key` (the server asks for its model + * list on it, which costs nothing) and the recipes it unlocks there. Sent with the key + * alone, no session. + */ + async checkKey(key, signal) { + let res; + try { + res = await this.fetchImpl(`${this.base()}/v1/key/check`, { method: "POST", headers: { Accept: "application/json", "X-Anthropic-Key": key.trim() }, signal }); + } catch (err) { + throw toNetworkError(err); + } + if (!res.ok) { + const err = await errorOf(res); + throw err.code === "unauthorized" ? new ScmjsError("forbidden", t("Anthropic did not accept that key."), { status: err.status }) : err; + } + try { + return await res.json(); + } catch (err) { + throw new ScmjsError("protocol", err.message); + } + } /** `POST /v1/trial`: a session with the free trial on it, once per device id. */ trial(deviceId) { return this.request("/v1/trial", { method: "POST", json: { deviceId } }); @@ -1426,18 +1466,22 @@ var ScmjsClient = class { async run(name, input, hooks = {}, options) { if (this.prepare) await this.prepare(); const body = { protocol: PROTOCOL_VERSION, input, options }; + const key = this.credentials().anthropicKey?.trim(); let res; try { res = await this.fetchImpl(`${this.base()}/v1/recipes/${name}`, { method: "POST", - headers: { Accept: "text/event-stream", ...this.headers(), "Content-Type": "application/json" }, + headers: { Accept: "text/event-stream", ...this.headers(), ...key ? { "X-Anthropic-Key": key } : {}, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: hooks.signal }); } catch (err) { throw toNetworkError(err); } - if (!res.ok) throw await errorOf(res); + if (!res.ok) { + const err = await errorOf(res); + throw key && err.code === "unauthorized" ? new ScmjsError("forbidden", t("Anthropic did not accept your key: {message} Check it in Preferences \u25B8 scmjs.dev.", { message: err.message }), { status: err.status }) : err; + } const type = res.headers.get("content-type") ?? ""; if (!type.includes("text/event-stream")) { const r2 = await res.json(); @@ -1477,6 +1521,7 @@ var ScmjsClient = class { result = { output: ev.output, usage: ev.usage, remaining: ev.remaining }; break; case "error": + if (key && ev.error.code === "unauthorized") throw new ScmjsError("forbidden", t("Anthropic did not accept your key: {message} Check it in Preferences \u25B8 scmjs.dev.", { message: ev.error.message })); throw new ScmjsError(ev.error.code, ev.error.message, { retryAfterSec: ev.error.retryAfterSec }); case "done": break; @@ -1531,7 +1576,9 @@ var DEFAULT_SETTINGS = { dockAssistant: false, followMap: true, shareName: "", - aiOffered: false + aiOffered: false, + anthropicKey: "", + keysAccepted: false }; var KEY = "settings"; function newDeviceId() { @@ -1588,7 +1635,7 @@ var AccountManager = class { this.store = store; this.client = client; this.deps = deps; - client.prepare = () => this.ensureSession(); + client.prepare = () => this.ownKey() ? Promise.resolve() : this.ensureSession(); client.onRemaining = (r) => this.noteRemaining(r); } /* ── Reading ──────────────────────────────────────────── */ @@ -1637,10 +1684,35 @@ var AccountManager = class { /** Whether the server writes replay casts for this caller (Aftermath makes them), and voices them; null for a server without. */ casts = null; aiOffered() { + if (this.ownKey()) return true; if (this.view) return this.view.ai ?? true; if (this.info && !this.store.get().session) return this.info.ai ?? true; return this.store.get().aiOffered; } + /** The AI runs on the person's own Anthropic key: one is stored and the server takes it. */ + ownKey() { + const s = this.store.get(); + return !!s.anthropicKey && s.keysAccepted; + } + /** The server takes an own key, as it last said. */ + keysAccepted() { + return this.store.get().keysAccepted; + } + /** + * Check `key` with the server (which asks Anthropic, at no cost) and keep it when it + * passes; a key that fails is not stored, and the error says why. + */ + async useKey(key) { + const trimmed = key.trim(); + if (!trimmed) throw new ScmjsError("invalid_input", t("Paste the key first.")); + await this.client.checkKey(trimmed); + this.store.set({ anthropicKey: trimmed, keysAccepted: true }); + this.changed(); + } + forgetKey() { + this.store.set({ anthropicKey: "" }); + this.changed(); + } changed() { const offered = this.aiOffered(); if (offered !== this.store.get().aiOffered) this.store.set({ aiOffered: offered }); @@ -1658,10 +1730,13 @@ var AccountManager = class { const v = this.view; switch (this.kind()) { case "guest": + if (this.ownKey()) return t("Not signed in \xB7 AI requests use your own Anthropic key"); return this.aiOffered() && this.info?.trial !== false ? t("Not signed in \xB7 the first AI request starts a free trial") : t("Not signed in"); case "trial": + if (this.ownKey()) return t("Not signed in \xB7 AI requests use your own Anthropic key"); return v ? t("Free trial \xB7 {balance} left \xB7 sign in to keep it and get more", { balance: formatUsd(v.balanceUsd) }) : t("Free trial"); default: { + if (this.ownKey()) return t("{name} \xB7 AI requests use your own Anthropic key", { name: v?.name ?? t("Signed in") }); if (!v) return t("Signed in"); const name = v.name ?? t("Signed in"); if (v.unlimited) return t("{name} \xB7 no balance is kept", { name }); @@ -1683,6 +1758,8 @@ var AccountManager = class { const cast = info.recipes?.find((r) => r.name === "cast"); this.casts = cast ? { on: cast.enabled, voiced: !!info.speech } : null; this.serverName = info.name; + const accepted = !!info.access?.byok; + if (accepted !== this.store.get().keysAccepted) this.store.set({ keysAccepted: accepted }); if (info.caller.kind === "user" && info.caller.account) this.view = info.caller.account; else if (this.store.get().session && info.caller.kind === "anonymous") { this.store.set({ session: "" }); @@ -11549,83 +11626,151 @@ function registerOptionsPage(deps) { return api.ui.preferencesPage({ mount(body, page) { const root2 = styled(body); - const s = store.get(); - const balance = h("div", { className: "ai-hint" }, account.summary()); + const aiBox = h("div", null); + const keyBox = h("div", null); + root2.append(aiBox, keyBox); + let offAi = null; + const keyStatus = w.statusLine({ text: "" }); + let keyShown = null; + const renderKey = () => { + const key = store.get().anthropicKey; + const state = `${account.keysAccepted()}:${key}`; + if (state === keyShown) return; + keyShown = state; + clear(keyBox); + if (!account.keysAccepted()) return; + if (key) { + keyBox.append(w.group( + t("Your Anthropic key"), + h( + "div", + { className: "ai-btns" }, + h("span", null, t("In use: the key ending {end}.", { end: key.slice(-4) })), + w.button(t("Forget the key"), { onClick: () => { + account.forgetKey(); + keyStatus.set(t("The key is forgotten.")); + } }) + ), + h("div", { className: "ai-hint" }, t("AI requests go to Anthropic on this key and are billed to your Anthropic account. It stays in this browser; scmjs.dev passes it on with each request and keeps no copy.")), + keyStatus + )); + return; + } + const field = w.text({ placeholder: "sk-ant-\u2026", password: true }); + const check = w.button(t("Check and use"), { primary: true, onClick: async () => { + keyStatus.set(t("Checking the key with Anthropic\u2026")); + try { + await account.useKey(field.value); + keyStatus.set(t("The key works. The AI features are on."), "ok"); + keyStatus.scrollIntoView({ block: "nearest" }); + } catch (err) { + keyStatus.set(err instanceof ScmjsError && (err.code === "forbidden" || err.code === "invalid_input") ? err.message : describeError(err), "error"); + } + } }); + keyBox.append(w.group( + t("Your Anthropic key"), + h("div", { className: "ai-btns" }, field, check), + h("div", { className: "ai-hint" }, t("The AI features run on your own Anthropic API key, made at console.anthropic.com. The key is checked with Anthropic first, which costs nothing; after that it goes with each AI request and Anthropic bills the requests to your account. It stays in this browser; scmjs.dev passes it on and keeps no copy.")), + keyStatus + )); + }; + let aiShown = null; + const renderAi = () => { + const offered = account.aiOffered(); + const state = `${offered}:${account.ownKey()}`; + if (state === aiShown) return; + aiShown = state; + offAi?.(); + offAi = null; + clear(aiBox); + if (offered) mountAi(aiBox); + }; const offAccount = account.onChange(() => { - balance.textContent = account.summary(); + renderAi(); + renderKey(); }); - const accountBtn = w.button(account.signedIn() ? t("Account\u2026") : t("Sign in\u2026"), { primary: !account.signedIn(), onClick: () => { - page.close(); - deps.openAccount(); - } }); - const aiBox = w.checkbox(t("Use the AI features"), { value: s.ai, onChange: (v) => { - store.set({ ai: v }); - } }); - const qualitySelect = w.select(QUALITY_CHOICES.map((c2) => ({ value: c2.value, label: translate(c2.label) })), { value: s.quality, onChange: (v) => { - store.set({ quality: v }); - } }); - const thinkingBox = w.checkbox(t("Show the model's reasoning summary while it works"), { value: s.showThinking, onChange: (v) => { - store.set({ showThinking: v }); - } }); - const dockBox = w.checkbox(t("Dock the assistant at the right, under the Properties panel, instead of floating over the map"), { value: s.dockAssistant, onChange: (v) => { - store.set({ dockAssistant: v }); - } }); - const roundsField = w.number({ value: s.maxRounds, min: 1, max: 100, step: 1, onChange: (v) => { - store.set({ maxRounds: Math.max(1, Math.min(100, Math.round(v || 24))) }); - } }); - const usd = (v) => Math.max(0, Math.min(100, Math.round((Number.isFinite(v) ? v : 0) * 100) / 100)); - const ceilingField = w.number({ value: s.ceilingUsd, min: 0, max: 100, step: 0.05, onChange: (v) => { - store.set({ ceilingUsd: usd(v) }); - } }); - const scenarioCeilingField = w.number({ value: s.scenarioCeilingUsd, min: 0, max: 100, step: 0.25, onChange: (v) => { - store.set({ scenarioCeilingUsd: usd(v) }); - } }); - const attachBox = w.checkbox(t("Send a picture of the visible area with every message"), { value: s.attachView, onChange: (v) => { - store.set({ attachView: v }); - } }); - const followBox2 = w.checkbox(t("Follow the assistant's work around the map"), { value: s.followMap, onChange: (v) => { - store.set({ followMap: v }); - } }); - root2.append( - w.group( - t("Account"), - h("div", { className: "ai-btns" }, accountBtn, balance), - h("div", { className: "ai-hint" }, t("The first AI request starts a free trial with no sign-in. Signing in keeps the balance across browsers and adds the sign-in credit; the Account dialog has the balance, the top-up and the activity.")) - ), - w.group( - t("AI features"), - aiBox, - h("div", { className: "ai-hint" }, t("Off takes the Tools \u25B8 AI menu, the assistant and the AI buttons in the editor's dialogs away. Your account and the maps stored on it stay.")) - ), - w.group( - t("Quality"), - w.form([{ label: t("Quality"), field: qualitySelect }]), - h("div", { className: "ai-hint" }, t("How hard the model works on a request, and so how long it takes and what it costs. Standard gives each feature the setting it was tuned for \u2014 laying out maps and writing triggers already work at the highest one. Changing it in the middle of an assistant conversation makes the server re-read the whole conversation once; the next message is a little dearer.")) - ), - w.group( - t("Spending"), - w.form([{ label: t("Per assistant message ($)"), field: ceilingField }, { label: t("Per Make Scenario run ($)"), field: scenarioCeilingField }]), - h("div", { className: "ai-hint" }, t("A ceiling on one message with its tool rounds, and on one design or build. At the ceiling the work stops with the map as edited so far, the assistant offers to continue for as much again, and a build says which step it stopped at. 0 is no ceiling. A message usually costs $0.10\u20130.30 and a build $0.50\u20131.00; the server holds the ceiling, so the last call can run a little over it, never a whole extra one.")) - ), - h( - "details", - null, - h("summary", null, t("Assistant")), - h( - "div", - { className: "ai-body" }, - w.form([{ label: t("Rounds per message"), field: roundsField }]), - attachBox, - followBox2, - dockBox, - thinkingBox, - h("div", { className: "ai-hint" }, t("A round is one answer from the model followed by the tool calls it asked for; the assistant stops at the limit and offers to continue. A picture costs about as much as a page of text each time. Following moves the view to each call's spot and zooms out when the spot is larger than the view, never in; scroll or zoom yourself during a turn and it stops until the next one. The dock setting applies the next time the assistant opens.")) - ) - ) - ); + renderAi(); + renderKey(); return () => { offAccount(); + offAi?.(); }; + function mountAi(root3) { + const s = store.get(); + const balance = h("div", { className: "ai-hint" }, account.summary()); + offAi = account.onChange(() => { + balance.textContent = account.summary(); + }); + const accountBtn = w.button(account.signedIn() ? t("Account\u2026") : t("Sign in\u2026"), { primary: !account.signedIn() && !account.ownKey(), onClick: () => { + page.close(); + deps.openAccount(); + } }); + const aiBox2 = w.checkbox(t("Use the AI features"), { value: s.ai, onChange: (v) => { + store.set({ ai: v }); + } }); + const qualitySelect = w.select(QUALITY_CHOICES.map((c2) => ({ value: c2.value, label: translate(c2.label) })), { value: s.quality, onChange: (v) => { + store.set({ quality: v }); + } }); + const thinkingBox = w.checkbox(t("Show the model's reasoning summary while it works"), { value: s.showThinking, onChange: (v) => { + store.set({ showThinking: v }); + } }); + const dockBox = w.checkbox(t("Dock the assistant at the right, under the Properties panel, instead of floating over the map"), { value: s.dockAssistant, onChange: (v) => { + store.set({ dockAssistant: v }); + } }); + const roundsField = w.number({ value: s.maxRounds, min: 1, max: 100, step: 1, onChange: (v) => { + store.set({ maxRounds: Math.max(1, Math.min(100, Math.round(v || 24))) }); + } }); + const usd = (v) => Math.max(0, Math.min(100, Math.round((Number.isFinite(v) ? v : 0) * 100) / 100)); + const ceilingField = w.number({ value: s.ceilingUsd, min: 0, max: 100, step: 0.05, onChange: (v) => { + store.set({ ceilingUsd: usd(v) }); + } }); + const scenarioCeilingField = w.number({ value: s.scenarioCeilingUsd, min: 0, max: 100, step: 0.25, onChange: (v) => { + store.set({ scenarioCeilingUsd: usd(v) }); + } }); + const attachBox = w.checkbox(t("Send a picture of the visible area with every message"), { value: s.attachView, onChange: (v) => { + store.set({ attachView: v }); + } }); + const followBox2 = w.checkbox(t("Follow the assistant's work around the map"), { value: s.followMap, onChange: (v) => { + store.set({ followMap: v }); + } }); + root3.append( + w.group( + t("Account"), + h("div", { className: "ai-btns" }, accountBtn, balance), + h("div", { className: "ai-hint" }, account.ownKey() ? t("AI requests run on your own Anthropic key, below. The account is for the maps you store and the maps you share.") : t("The first AI request starts a free trial with no sign-in. Signing in keeps the balance across browsers and adds the sign-in credit; the Account dialog has the balance, the top-up and the activity.")) + ), + w.group( + t("AI features"), + aiBox2, + h("div", { className: "ai-hint" }, t("Off takes the Tools \u25B8 AI menu, the assistant and the AI buttons in the editor's dialogs away. Your account and the maps stored on it stay.")) + ), + w.group( + t("Quality"), + w.form([{ label: t("Quality"), field: qualitySelect }]), + h("div", { className: "ai-hint" }, t("How hard the model works on a request, and so how long it takes and what it costs. Standard gives each feature the setting it was tuned for \u2014 laying out maps and writing triggers already work at the highest one. Changing it in the middle of an assistant conversation makes the server re-read the whole conversation once; the next message is a little dearer.")) + ), + w.group( + t("Spending"), + w.form([{ label: t("Per assistant message ($)"), field: ceilingField }, { label: t("Per Make Scenario run ($)"), field: scenarioCeilingField }]), + h("div", { className: "ai-hint" }, t("A ceiling on one message with its tool rounds, and on one design or build. At the ceiling the work stops with the map as edited so far, the assistant offers to continue for as much again, and a build says which step it stopped at. 0 is no ceiling. A message usually costs $0.10\u20130.30 and a build $0.50\u20131.00; the server holds the ceiling, so the last call can run a little over it, never a whole extra one.")) + ), + h( + "details", + null, + h("summary", null, t("Assistant")), + h( + "div", + { className: "ai-body" }, + w.form([{ label: t("Rounds per message"), field: roundsField }]), + attachBox, + followBox2, + dockBox, + thinkingBox, + h("div", { className: "ai-hint" }, t("A round is one answer from the model followed by the tool calls it asked for; the assistant stops at the limit and offers to continue. A picture costs about as much as a page of text each time. Following moves the view to each call's spot and zooms out when the spot is larger than the view, never in; scroll or zoom yourself during a turn and it stops until the next one. The dock setting applies the next time the assistant opens.")) + ) + ) + ); + } } }); } @@ -12338,12 +12483,17 @@ function openAccountDialog(ctx) { if (s.offers) { const signedIn = s.kind === "account"; const state = (on, off2, needsSignIn) => !on ? [t("Off"), "sd-bad", off2] : needsSignIn && !signedIn ? [t("Sign in to use"), "", ""] : [t("On"), "sd-ok", ""]; + const ai = account.ownKey() ? [t("On"), "sd-ok", t("On your own Anthropic key.")] : account.aiOffered() ? [t("On"), "sd-ok", ""] : account.keysAccepted() ? state(false, h2("span", null, t("They run on your own Anthropic key."), " ", h2("a", { href: "#", onClick: (e) => { + e.preventDefault(); + dialog.close(); + openOptions(api); + } }, t("Add your key\u2026"))), false) : state(false, t("Off for now while I work out the tooling and costs."), false); const features = [ - [t("AI features"), state(account.aiOffered(), t("Off for now while I work out the tooling and costs."), false)], + [t("AI features"), ai], [t("Map storage"), state(s.offers.maps, t("This server keeps no maps."), true)], [t("Shared editing"), state(!!s.offers.rooms, t("This server has no shared map editing."), true)], - // (Shown only by a server that writes casts at all.) - ...account.casts ? [[t("Replay casts"), account.casts.on ? [t("On"), "sd-ok", account.casts.voiced ? t("In Aftermath: a replay's timeline \u25B8 Cast\u2026, voiced.") : t("In Aftermath: a replay's timeline \u25B8 Cast\u2026, as captions.")] : state(false, t("Not offered to this account for now."), true)]] : [] + // (Shown only by a server that writes casts at all, and not to someone on their own key whom it does not offer them: a key never unlocks casts.) + ...account.casts && (account.casts.on || !account.ownKey()) ? [[t("Replay casts"), account.casts.on ? [t("On"), "sd-ok", account.casts.voiced ? t("In Aftermath: a replay's timeline \u25B8 Cast\u2026, voiced.") : t("In Aftermath: a replay's timeline \u25B8 Cast\u2026, as captions.")] : state(false, t("Not offered to this account for now."), true)]] : [] ]; featuresBox.append(w.group(t("Status"), h2( "table", @@ -14563,7 +14713,7 @@ var CONTRACT_VERSION = 1; function activate(api) { const unbindI18n = bindI18n(api); const store = settingsStore(api); - const client = new ScmjsClient(() => ({ serverUrl: store.get().serverUrl, session: store.get().session })); + const client = new ScmjsClient(() => ({ serverUrl: store.get().serverUrl, session: store.get().session, anthropicKey: store.get().keysAccepted ? store.get().anthropicKey : "" })); const account = new AccountManager(store, client); let link = null; const links = { get: () => link, set: (l) => { @@ -14681,8 +14831,9 @@ function activate(api) { let optionsPage = null; const syncAi = () => { const offered = store.get().aiOffered; - if (offered && !optionsPage) optionsPage = registerOptionsPage({ api, store, account, openAccount: ctx.openAccount }); - else if (!offered && optionsPage) { + const page = offered || store.get().keysAccepted; + if (page && !optionsPage) optionsPage = registerOptionsPage({ api, store, account, openAccount: ctx.openAccount }); + else if (!page && optionsPage) { optionsPage.dispose(); optionsPage = null; } diff --git a/ko.ts b/ko.ts index cfb2915..3cfb5ad 100644 --- a/ko.ts +++ b/ko.ts @@ -993,4 +993,22 @@ export const KO: Record = { "In Aftermath: a replay's timeline ▸ Cast…, voiced.": "Aftermath에서: 리플레이 타임라인 ▸ 중계…, 음성 포함.", "In Aftermath: a replay's timeline ▸ Cast…, as captions.": "Aftermath에서: 리플레이 타임라인 ▸ 중계…, 자막으로.", "Not offered to this account for now.": "지금은 이 계정에 제공되지 않습니다.", + "Paste the key first.": "먼저 키를 붙여 넣으세요.", + "Not signed in · AI requests use your own Anthropic key": "로그인하지 않음 · AI 요청은 내 Anthropic 키를 씁니다", + "{name} · AI requests use your own Anthropic key": "{name} · AI 요청은 내 Anthropic 키를 씁니다", + "Your Anthropic key": "내 Anthropic 키", + "In use: the key ending {end}.": "사용 중: {end}(으)로 끝나는 키.", + "Forget the key": "키 지우기", + "The key is forgotten.": "키를 지웠습니다.", + "AI requests go to Anthropic on this key and are billed to your Anthropic account. It stays in this browser; scmjs.dev passes it on with each request and keeps no copy.": "AI 요청은 이 키로 Anthropic에 보내지고 요금은 내 Anthropic 계정에 청구됩니다. 키는 이 브라우저에만 남고, scmjs.dev는 요청마다 키를 전달할 뿐 사본을 보관하지 않습니다.", + "Check and use": "확인하고 사용", + "Checking the key with Anthropic…": "Anthropic에서 키를 확인하는 중…", + "The key works. The AI features are on.": "키가 작동합니다. AI 기능이 켜졌습니다.", + "The AI features run on your own Anthropic API key, made at console.anthropic.com. The key is checked with Anthropic first, which costs nothing; after that it goes with each AI request and Anthropic bills the requests to your account. It stays in this browser; scmjs.dev passes it on and keeps no copy.": "AI 기능은 console.anthropic.com에서 만든 내 Anthropic API 키로 작동합니다. 키는 먼저 Anthropic에서 확인하며 이 확인에는 비용이 들지 않습니다. 그다음부터는 AI 요청마다 키가 함께 가고, Anthropic이 요청 요금을 내 계정에 청구합니다. 키는 이 브라우저에만 남고, scmjs.dev는 전달할 뿐 사본을 보관하지 않습니다.", + "AI requests run on your own Anthropic key, below. The account is for the maps you store and the maps you share.": "AI 요청은 아래의 내 Anthropic 키로 실행됩니다. 계정은 보관하는 맵과 공유하는 맵을 위한 것입니다.", + "Anthropic did not accept that key.": "Anthropic이 그 키를 받아들이지 않았습니다.", + "Anthropic did not accept your key: {message} Check it in Preferences ▸ scmjs.dev.": "Anthropic이 내 키를 받아들이지 않았습니다: {message} 환경 설정 ▸ scmjs.dev에서 확인하세요.", + "On your own Anthropic key.": "내 Anthropic 키로.", + "They run on your own Anthropic key.": "내 Anthropic 키로 작동합니다.", + "Add your key…": "키 추가…", }; diff --git a/package-lock.json b/package-lock.json index 5307a2f..05d45e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.37.1", + "version": "1.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.37.1", + "version": "1.38.0", "license": "MIT", "devDependencies": { "@scm-js/plugin-api": "^1.35.0", diff --git a/package.json b/package.json index 6db1a6f..9418189 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@scm-js/plugin-scmjs-dev", - "version": "1.37.1", + "version": "1.38.0", "private": true, "description": "scmjs.dev — a plugin for the scmJS StarCraft map editor: your account, map storage with revisions, and the AI features (scenarios, maps, triggers, an assistant) on one free trial and one sign-in.", "author": "Jeany", diff --git a/plugin.json b/plugin.json index a260a53..8c02dc5 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "scmjs.dev", "id": "scmjs-dev", - "version": "1.37.1", + "version": "1.38.0", "description": "Your scmjs.dev account in the editor. Keep maps on your account with revisions and notes, and share the open map so others can edit it with you at the same time. Where the server offers them, AI tools can build, lay out and review a map with you.", "author": "Jeany", "homepage": "https://github.com/scm-js/plugin-scmjs-dev", diff --git a/plugin.ts b/plugin.ts index cd4c416..5037f99 100644 --- a/plugin.ts +++ b/plugin.ts @@ -52,7 +52,7 @@ export const CONTRACT_VERSION = 1; export function activate(api: PluginApi) { const unbindI18n = bindI18n(api); const store = settingsStore(api); - const client = new ScmjsClient(() => ({ serverUrl: store.get().serverUrl, session: store.get().session })); + const client = new ScmjsClient(() => ({ serverUrl: store.get().serverUrl, session: store.get().session, anthropicKey: store.get().keysAccepted ? store.get().anthropicKey : "" })); const account = new AccountManager(store, client); /** The stored map the open document is linked to, for the session; cleared when another file opens. */ @@ -152,16 +152,18 @@ export function activate(api: PluginApi) { const provided: Disposable = api.services.provide(SERVICE_NAME, account.service(ctx.openAccount), { version: CONTRACT_VERSION }); /* - * The AI features, in while the tick says so and the server offers them. The options - * page in Edit ▸ Preferences follows the offer alone — the tick that turns the rest on - * is on it — so a person the server keeps off the AI sees none of it. + * The AI features, in while the tick says so and they are on offer: the person's role + * pays for them (an admin), or they gave their own Anthropic key. The options page in + * Edit ▸ Preferences is there while they are on offer or the server takes a key — the + * key field and the tick that turns the rest on are on it. */ let ai: (() => void) | null = null; let optionsPage: Disposable | null = null; const syncAi = () => { const offered = store.get().aiOffered; - if (offered && !optionsPage) optionsPage = registerOptionsPage({ api, store, account, openAccount: ctx.openAccount }); - else if (!offered && optionsPage) { optionsPage.dispose(); optionsPage = null; } + const page = offered || store.get().keysAccepted; + if (page && !optionsPage) optionsPage = registerOptionsPage({ api, store, account, openAccount: ctx.openAccount }); + else if (!page && optionsPage) { optionsPage.dispose(); optionsPage = null; } const want = offered && store.get().ai; if (want && !ai) ai = installAi({ api, store, client, account, openAccount: ctx.openAccount }); else if (!want && ai) { ai(); ai = null; } diff --git a/protocol.ts b/protocol.ts index 510774d..b253420 100644 --- a/protocol.ts +++ b/protocol.ts @@ -887,6 +887,12 @@ export interface AdminCallSummary { byCaller: CallStats[]; } +/** `POST /v1/key/check` with `X-Anthropic-Key` (from 0.25.0): Anthropic took the key; the recipes it unlocks on this server (never `cast`). */ +export interface KeyCheckResponse { + ok: true; + recipes: RecipeName[]; +} + export interface InfoResponse { protocol: typeof PROTOCOL_VERSION; /** The server's package version. */ @@ -899,7 +905,7 @@ export interface InfoResponse { access: { /** Requests with no credentials are served. */ anonymous: boolean; - /** `X-Anthropic-Key` is honoured. */ + /** `X-Anthropic-Key` is honoured: every recipe but `cast`, and no `/v1/speech`. `POST /v1/key/check` checks a key. */ byok: boolean; }; /** Present when the server runs accounts (trial sessions, sign-in, balances). */ diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 043b1da..c14c6d4 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -227,6 +227,52 @@ describe("the account manager", () => { expect(store.get().aiOffered).toBe(false); }); + it("unlocks the AI on the person's own key once the server has checked it, and sends it with recipes alone", async () => { + const accounts = { providers: [], trial: false, trialUsd: 0, signupUsd: 0, weeklyUsd: 0, packs: [], accountUrl: "https://api.example/account", maps: true, ai: false }; + const usage = { model: "m", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0, durationMs: 1 }; + const { fetchImpl, calls } = fakeFetch({ + "GET /v1/info": () => json({ name: "Test", version: "1", accounts, access: { anonymous: false, byok: true }, recipes: [], caller: { kind: "anonymous", remaining: {} } }), + "POST /v1/key/check": (init) => ((init.headers as Record)["X-Anthropic-Key"] === "sk-good" + ? json({ ok: true, recipes: ["describe"] }) + : new Response(JSON.stringify({ error: { code: "unauthorized", message: "Anthropic rejected the key you sent (401)." } }), { status: 401 })), + "POST /v1/recipes/describe": () => json({ id: "1", recipe: "describe", output: { name: "N", description: "D", alternatives: [] }, usage }), + }); + const store = memoryStore(); + const client = new ScmjsClient(() => ({ serverUrl: "https://api.example", session: store.get().session, anthropicKey: store.get().keysAccepted ? store.get().anthropicKey : "" }), fetchImpl); + const account = new AccountManager(store, client); + await account.connect(); + expect(account.keysAccepted()).toBe(true); + expect(account.aiOffered()).toBe(false); + + const bad = await account.useKey("sk-bad").catch((e: unknown) => e); + expect(bad).toMatchObject({ code: "forbidden", message: "Anthropic did not accept that key." }); + expect(store.get().anthropicKey).toBe(""); + expect(account.aiOffered()).toBe(false); + + await account.useKey(" sk-good "); + expect(store.get().anthropicKey).toBe("sk-good"); + expect(account.ownKey()).toBe(true); + expect(store.get().aiOffered).toBe(true); + expect(account.summary()).toBe("Not signed in · AI requests use your own Anthropic key"); + + // A recipe goes on the key with no trial first; what other plugins spread into their requests never carries it. + await client.run("describe", { facts: {} as never }); + expect(calls.map((c) => `${c.method} ${c.path}`)).not.toContain("POST /v1/trial"); + expect(calls.at(-1)!.headers["x-anthropic-key"]).toBe("sk-good"); + expect(account.service(() => {}).headers()).toEqual({}); + + account.forgetKey(); + expect(account.aiOffered()).toBe(false); + // A server that stops taking keys turns the key off too. + await account.useKey("sk-good"); + accounts.ai = false; + const closed = fakeFetch({ "GET /v1/info": () => json({ name: "Test", version: "1", accounts, access: { anonymous: false, byok: false }, recipes: [], caller: { kind: "anonymous", remaining: {} } }) }); + const later = new AccountManager(store, new ScmjsClient(() => ({ serverUrl: "https://api.example", session: "" }), closed.fetchImpl)); + await later.connect(); + expect(later.ownKey()).toBe(false); + expect(store.get().aiOffered).toBe(false); + }); + it("signs in through the popup, refreshes, follows a balance note, and signs out", async () => { const state = { session: null as string | null, account: null as AccountView | null, trials: 0 }; const { fetchImpl, calls } = server(state);