@columenlabs/lumen-ai 0.1.77 → 0.1.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -5
- package/dist/assistant/chatgpt.d.ts +61 -0
- package/dist/assistant/chatgpt.js +165 -0
- package/dist/assistant/engine.d.ts +63 -9
- package/dist/assistant/engine.js +424 -85
- package/dist/assistant/index.d.ts +3 -3
- package/dist/assistant/index.js +3 -3
- package/dist/assistant/models.d.ts +16 -2
- package/dist/assistant/models.js +46 -3
- package/dist/assistant/routes.js +50 -33
- package/dist/assistant/state.d.ts +45 -14
- package/dist/assistant/state.js +93 -23
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ Every merge to `main` publishes the next patch of every Lumen package at once.
|
|
|
57
57
|
|
|
58
58
|
## A chat assistant
|
|
59
59
|
|
|
60
|
-
`@columenlabs/lumen-ai/assistant` is the server behind a chat side panel: Claude on the app's own tiers, the app's tools from any MCP server, the web, drafts the person saves, hand off to a higher tier, advice, and a monthly spend cap per person. The panel and Settings sections are `@columenlabs/lumen-ui`'s; the shared types are `@columenlabs/lumen-ui-types/assistant`.
|
|
60
|
+
`@columenlabs/lumen-ai/assistant` is the server behind a chat side panel: Claude or OpenAI models on the app's own tiers, the app's tools from any MCP server, the web, drafts the person saves, hand off to a higher tier, advice, and a monthly spend cap per person. The panel and Settings sections are `@columenlabs/lumen-ui`'s; the shared types are `@columenlabs/lumen-ui-types/assistant`.
|
|
61
61
|
|
|
62
62
|
```ts
|
|
63
63
|
import {
|
|
@@ -83,7 +83,8 @@ const assistant = createAssistant({
|
|
|
83
83
|
web: { fetch: { blockHosts: ['app.example.com'] }, scrapeql: { url: 'https://scrapeql.example.com/mcp' } },
|
|
84
84
|
store: sqlAssistantStore(doSql(ctx.storage.sql)), // or d1Sql(env.DB)
|
|
85
85
|
sealer, // the app's own sealing: keys and chats are stored sealed
|
|
86
|
-
|
|
86
|
+
// A provider's key when none is set in Settings.
|
|
87
|
+
appKey: async (provider) => (provider === 'openai' ? env.OPENAI_API_KEY : env.ANTHROPIC_API_KEY),
|
|
87
88
|
});
|
|
88
89
|
|
|
89
90
|
const routes = assistantRoutes(assistant, {
|
|
@@ -93,9 +94,50 @@ const routes = assistantRoutes(assistant, {
|
|
|
93
94
|
app.all('/api/assistant/*', (c) => routes(c.req.raw));
|
|
94
95
|
```
|
|
95
96
|
|
|
96
|
-
- **
|
|
97
|
-
- **
|
|
98
|
-
- **
|
|
97
|
+
- **Providers.** A tier runs any model in `models` (Claude's and OpenAI's by default, `CLAUDE_MODELS`, `OPENAI_MODELS`): Claude through Anthropic's Messages API, OpenAI through the Responses API, with the same tools, drafts, Stop and caps. A hand-off may cross providers. The advisor and prompt caching marks are Claude's only.
|
|
98
|
+
- **Keys.** Each provider has its own keys (`?provider=anthropic|openai` on the key routes). An admin pastes the shared key everyone uses (`PUT /key`); it is tested with one free call before it is kept, then sealed, and no route returns it. A person may add their own (`PUT /my-key`), with one monthly cap on their own keys (`PUT /my-key/cap`).
|
|
99
|
+
- **ChatGPT sign-in.** In place of their own OpenAI key a person may sign in with their ChatGPT plan (`POST /my-signin`, then `POST /my-signin/finish {url}`): the sign-in opens in a new tab and ends on `http://127.0.0.1:1455/auth/callback`, a page that does not load, whose address the person pastes back. The tokens are sealed as their own key and refreshed before they run out; answers on the plan count tokens and no dollars.
|
|
100
|
+
- **Who pays.** The person's pick (the shared keys by default) while it has a key for the tier's provider and money left this month under its cap (an admin sets the shared keys' cap per person, `PUT /app`), else the other; with neither the panel says why.
|
|
101
|
+
- **Tools.** Each group is Off, Read, Read + draft or Read + write per person. A tool is a read when its MCP `readOnlyHint` is set. On Read + draft a write only drafts a card, and runs as the person when they press Save, after access is checked again. Read + write is offered only for a group with `writes: true`: its writes run at once, and with `undo` set each one's line in the chat has Undo, which calls `undo(caller, { tool, args, result })` (`POST /chats/:id/writes/:step/undo`); the model is told what was undone. Cards drafted one after another show as one stack with Save all. Use `mcpServerTools` (an SDK `McpServer`), `mcpFetchTools` (a server served as a fetch handler, any SDK), `remoteMcpTools` or `plainTools`.
|
|
99
102
|
- **Web.** `fetch` is a plain GET from the Worker (10 s, 2 MB, never the app's own hosts or private addresses). ScrapeQL's own tools join over its MCP API once an admin pastes a Cloudflare Access service token (`PUT /web/scrapeql`).
|
|
100
103
|
- **Hand off and advice.** The `escalate` tool hands one task to a higher tier (the person sets next, any or only when asked); Anthropic's advisor tool lets a higher tier send a plan, at most two an answer.
|
|
101
104
|
- **Chats** stream as server-sent events (`POST /chat`); closing the request stops the answer and keeps what was said. Past 150k tokens, or on `/compact`, the history sent to the model becomes a summary. Run `assistant.purge()` once a day to drop chats 30 days after their last message.
|
|
105
|
+
|
|
106
|
+
### Commands, `@` and `#`
|
|
107
|
+
|
|
108
|
+
The panel's composer lists what can follow three keys as they are typed. The kit's own entries are on for every app; an app only adds its own.
|
|
109
|
+
|
|
110
|
+
- **`/` commands.** Built in: `/compact` (summarise the chat), `/clear` (a new chat), `/model <tier>` (answer on another tier from now on) and `/escalate <task>` (hand one task to the next tier up). An app adds its own on the server with `commands`: the chat shows what the person typed and the model gets `prompt`, with `{args}` replaced by the words after the name (or what the function returns). An app's command wins a built-in's name.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
createAssistant({
|
|
114
|
+
// ...
|
|
115
|
+
commands: [
|
|
116
|
+
{ name: 'story', about: 'Find stories for a question', args: '<question>',
|
|
117
|
+
prompt: 'Search my stories for: {args}. List the best three with why each fits.' },
|
|
118
|
+
{ name: 'prep', about: 'Prep for an interview', args: '<company>',
|
|
119
|
+
prompt: async (company, caller) => prepPrompt(caller.id, company) },
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
A command that acts in the browser (opens a page, sets a filter) goes on the panel instead: `LumenAssistant` (or `useLumenAssistantDock`) takes `commands`, each `{ name, about, args?, options?, run }`; `run` returns text to send, or nothing when it acted itself, and `options` lists choices for its words as they are typed.
|
|
125
|
+
- **`@` a tier.** `@` lists the app's tiers; the pick is a chip and goes with that message only (`tier`). A higher tier takes it as a task in its own section of the chat; the same or a lower one answers it.
|
|
126
|
+
- **`#` a thing.** `#` lists what the person can point at, a group per source; each pick is a chip and goes with the message as a ref (`kind`, `label` and whatever finds it), which the model reads before the message. Standing sources go on the panel (`sources`); a page adds its own while it is shown:
|
|
127
|
+
|
|
128
|
+
```tsx
|
|
129
|
+
import { useAssistantSource } from '@columenlabs/lumen-ui/assistant';
|
|
130
|
+
|
|
131
|
+
useAssistantSource({
|
|
132
|
+
id: 'page',
|
|
133
|
+
label: 'On this page',
|
|
134
|
+
items: (q) =>
|
|
135
|
+
rows
|
|
136
|
+
.filter((r) => r.title.toLowerCase().includes(q.toLowerCase()))
|
|
137
|
+
.map((r) => ({ id: r.id, label: r.title, target: `card:${r.id}`, ref: { kind: 'story', label: r.title, id: r.id } })),
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`target` lights the thing on screen while its row is hovered. A page's source replaces a standing one with the same `id`.
|
|
142
|
+
|
|
143
|
+
Text the person highlights on the page is offered first under `#` (Selected text), with where it sits from the nearest `data-assistant-where` attribute. To hand a highlight over from the page, an Ask button calls `composeAssistant({ refs: [selectionRef({ text, where })], text: '' })` (or any refs and text): the panel opens with the chips in the composer, focused for the person to finish.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign in with ChatGPT: a person runs OpenAI models on their own ChatGPT plan instead of an API key
|
|
3
|
+
* (OpenAI's "token sharing" flow, developers.openai.com/siwc). The sign-in opens in a new tab and
|
|
4
|
+
* ends on a 127.0.0.1 address that loads nothing, since the app is not on the person's machine; the
|
|
5
|
+
* person pastes that address back and the server finishes it. PKCE, a fresh state and nonce each
|
|
6
|
+
* time, no client secret. The first sign-in registers the app (`dynamic_agent_client`) and OpenAI
|
|
7
|
+
* issues a client id kept for later sign-ins.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CHATGPT_RESOURCE = "https://api.openai.com/v1";
|
|
10
|
+
/** Only the port may change between sign-ins; `localhost` is refused. */
|
|
11
|
+
export declare const CHATGPT_REDIRECT = "http://127.0.0.1:1455/auth/callback";
|
|
12
|
+
/** The app's registration with OpenAI for one person, kept across sign-outs. */
|
|
13
|
+
export interface ChatgptRegistration {
|
|
14
|
+
/** An opaque id of this app for this person, the same on every sign-in. */
|
|
15
|
+
host_id: string;
|
|
16
|
+
/** The client id OpenAI issued on the first sign-in. */
|
|
17
|
+
client_id?: string;
|
|
18
|
+
/** A sign-in started and not yet finished. */
|
|
19
|
+
pending?: {
|
|
20
|
+
state: string;
|
|
21
|
+
nonce: string;
|
|
22
|
+
verifier: string;
|
|
23
|
+
at: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** A finished sign-in: kept sealed as the person's own OpenAI key. */
|
|
27
|
+
export interface ChatgptTokens {
|
|
28
|
+
client_id: string;
|
|
29
|
+
access_token: string;
|
|
30
|
+
refresh_token: string;
|
|
31
|
+
/** ms */
|
|
32
|
+
expires_at: number;
|
|
33
|
+
scope: string;
|
|
34
|
+
email?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare class SigninError extends Error {
|
|
37
|
+
}
|
|
38
|
+
/** The sign-in address to open, and the registration with its new pending sign-in. */
|
|
39
|
+
export declare function startChatgpt(reg: ChatgptRegistration | null, appName: string): Promise<{
|
|
40
|
+
url: string;
|
|
41
|
+
reg: ChatgptRegistration;
|
|
42
|
+
}>;
|
|
43
|
+
/**
|
|
44
|
+
* Finishes a sign-in from the address it ended on: checks the state, trades the code for tokens and
|
|
45
|
+
* checks they may run models on the plan. The ID token comes straight from OpenAI's token endpoint
|
|
46
|
+
* over TLS, so its nonce and audience are checked and its signature is not (OIDC Core 3.1.3.7).
|
|
47
|
+
*/
|
|
48
|
+
export declare function finishChatgpt(reg: ChatgptRegistration | null, pasted: string, fetchFn: typeof fetch): Promise<{
|
|
49
|
+
tokens: ChatgptTokens;
|
|
50
|
+
reg: ChatgptRegistration;
|
|
51
|
+
}>;
|
|
52
|
+
/** Whether the access token needs a refresh before the next call. */
|
|
53
|
+
export declare const staleChatgpt: (t: ChatgptTokens) => boolean;
|
|
54
|
+
/** A new access token and the rotated refresh token that replaces the old one. */
|
|
55
|
+
export declare function refreshChatgpt(t: ChatgptTokens, fetchFn: typeof fetch): Promise<ChatgptTokens>;
|
|
56
|
+
/** One free read (the account's models) that says whether the plan sign-in still works. */
|
|
57
|
+
export declare function testChatgpt(t: ChatgptTokens, fetchFn: typeof fetch): Promise<{
|
|
58
|
+
ok: boolean;
|
|
59
|
+
detail: string;
|
|
60
|
+
at: number;
|
|
61
|
+
}>;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign in with ChatGPT: a person runs OpenAI models on their own ChatGPT plan instead of an API key
|
|
3
|
+
* (OpenAI's "token sharing" flow, developers.openai.com/siwc). The sign-in opens in a new tab and
|
|
4
|
+
* ends on a 127.0.0.1 address that loads nothing, since the app is not on the person's machine; the
|
|
5
|
+
* person pastes that address back and the server finishes it. PKCE, a fresh state and nonce each
|
|
6
|
+
* time, no client secret. The first sign-in registers the app (`dynamic_agent_client`) and OpenAI
|
|
7
|
+
* issues a client id kept for later sign-ins.
|
|
8
|
+
*/
|
|
9
|
+
const AUTH = 'https://auth.openai.com/api/accounts';
|
|
10
|
+
export const CHATGPT_RESOURCE = 'https://api.openai.com/v1';
|
|
11
|
+
/** Only the port may change between sign-ins; `localhost` is refused. */
|
|
12
|
+
export const CHATGPT_REDIRECT = 'http://127.0.0.1:1455/auth/callback';
|
|
13
|
+
const SCOPE = 'openid profile email offline_access resource.invoke chatgpt.tokens.use.direct';
|
|
14
|
+
/** The scope that lets the tokens run models on the plan. */
|
|
15
|
+
const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
|
|
16
|
+
/** Refresh this long before the access token runs out. */
|
|
17
|
+
const EARLY_MS = 5 * 60_000;
|
|
18
|
+
/** A started sign-in is finished within this. */
|
|
19
|
+
const PENDING_MS = 15 * 60_000;
|
|
20
|
+
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
21
|
+
.replace(/\+/g, '-')
|
|
22
|
+
.replace(/\//g, '_')
|
|
23
|
+
.replace(/=+$/, '');
|
|
24
|
+
const random = (n = 32) => b64url(crypto.getRandomValues(new Uint8Array(n)));
|
|
25
|
+
export class SigninError extends Error {
|
|
26
|
+
}
|
|
27
|
+
/** The sign-in address to open, and the registration with its new pending sign-in. */
|
|
28
|
+
export async function startChatgpt(reg, appName) {
|
|
29
|
+
const pending = { state: random(), nonce: random(), verifier: random(48), at: Date.now() };
|
|
30
|
+
const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(pending.verifier)));
|
|
31
|
+
const next = { host_id: reg?.host_id ?? random(18), ...reg, pending };
|
|
32
|
+
const q = new URLSearchParams({
|
|
33
|
+
client_id: next.client_id ?? 'dynamic_agent_client',
|
|
34
|
+
...(next.client_id ? {} : { agent_name_hint: appName }),
|
|
35
|
+
ext_agent_host_id: next.host_id,
|
|
36
|
+
response_type: 'code',
|
|
37
|
+
redirect_uri: CHATGPT_REDIRECT,
|
|
38
|
+
scope: SCOPE,
|
|
39
|
+
resource: CHATGPT_RESOURCE,
|
|
40
|
+
state: pending.state,
|
|
41
|
+
nonce: pending.nonce,
|
|
42
|
+
code_challenge_method: 'S256',
|
|
43
|
+
code_challenge: challenge,
|
|
44
|
+
});
|
|
45
|
+
return { url: `${AUTH}/authorize?${q}`, reg: next };
|
|
46
|
+
}
|
|
47
|
+
function claims(idToken) {
|
|
48
|
+
try {
|
|
49
|
+
const part = idToken.split('.')[1] ?? '';
|
|
50
|
+
const pad = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
51
|
+
return JSON.parse(atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function token(fetchFn, form) {
|
|
58
|
+
const res = await fetchFn(`${AUTH}/oauth/token`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
61
|
+
body: new URLSearchParams(form),
|
|
62
|
+
});
|
|
63
|
+
const b = (await res.json().catch(() => ({})));
|
|
64
|
+
if (!res.ok || typeof b.access_token !== 'string' || typeof b.refresh_token !== 'string') {
|
|
65
|
+
const code = typeof b.error === 'string' ? b.error : `HTTP ${res.status}`;
|
|
66
|
+
throw new SigninError(code === 'invalid_grant'
|
|
67
|
+
? 'That sign-in has expired or was used already: sign in again.'
|
|
68
|
+
: `OpenAI refused the sign-in (${code}).`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
access_token: b.access_token,
|
|
72
|
+
refresh_token: b.refresh_token,
|
|
73
|
+
expires_at: Date.now() + (typeof b.expires_in === 'number' ? b.expires_in : 3600) * 1000,
|
|
74
|
+
scope: typeof b.scope === 'string' ? b.scope : '',
|
|
75
|
+
id_token: typeof b.id_token === 'string' ? b.id_token : '',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Finishes a sign-in from the address it ended on: checks the state, trades the code for tokens and
|
|
80
|
+
* checks they may run models on the plan. The ID token comes straight from OpenAI's token endpoint
|
|
81
|
+
* over TLS, so its nonce and audience are checked and its signature is not (OIDC Core 3.1.3.7).
|
|
82
|
+
*/
|
|
83
|
+
export async function finishChatgpt(reg, pasted, fetchFn) {
|
|
84
|
+
const pending = reg?.pending;
|
|
85
|
+
if (!reg || !pending || Date.now() - pending.at > PENDING_MS)
|
|
86
|
+
throw new SigninError('Start the sign-in again: this one is too old.');
|
|
87
|
+
let q;
|
|
88
|
+
try {
|
|
89
|
+
q = new URL(pasted.trim()).searchParams;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new SigninError('Paste the whole address from the tab the sign-in ended on.');
|
|
93
|
+
}
|
|
94
|
+
if (q.get('state') !== pending.state)
|
|
95
|
+
throw new SigninError('That address is from another sign-in: start again.');
|
|
96
|
+
if (q.get('error') === 'access_denied')
|
|
97
|
+
throw new SigninError('ChatGPT said no: plan use may be off for this account.');
|
|
98
|
+
if (q.get('error'))
|
|
99
|
+
throw new SigninError(`ChatGPT stopped the sign-in (${q.get('error')}).`);
|
|
100
|
+
const code = q.get('code');
|
|
101
|
+
if (!code)
|
|
102
|
+
throw new SigninError('That address has no sign-in code.');
|
|
103
|
+
const returned = q.get('client_id');
|
|
104
|
+
if (reg.client_id && returned && returned !== reg.client_id)
|
|
105
|
+
throw new SigninError('That sign-in belongs to another registration: start again.');
|
|
106
|
+
const clientId = reg.client_id ?? returned;
|
|
107
|
+
if (!clientId)
|
|
108
|
+
throw new SigninError('OpenAI sent no client id: start again.');
|
|
109
|
+
const t = await token(fetchFn, {
|
|
110
|
+
grant_type: 'authorization_code',
|
|
111
|
+
client_id: clientId,
|
|
112
|
+
code,
|
|
113
|
+
code_verifier: pending.verifier,
|
|
114
|
+
redirect_uri: CHATGPT_REDIRECT,
|
|
115
|
+
resource: CHATGPT_RESOURCE,
|
|
116
|
+
});
|
|
117
|
+
const c = claims(t.id_token);
|
|
118
|
+
const aud = Array.isArray(c.aud) ? c.aud : [c.aud];
|
|
119
|
+
if (c.nonce !== pending.nonce || !aud.includes(clientId))
|
|
120
|
+
throw new SigninError('The sign-in did not match: start again.');
|
|
121
|
+
if (!t.scope.split(' ').includes(PLAN_SCOPE))
|
|
122
|
+
throw new SigninError('This ChatGPT account may not run models on its plan.');
|
|
123
|
+
const { pending: _, ...kept } = reg;
|
|
124
|
+
return {
|
|
125
|
+
reg: { ...kept, client_id: clientId },
|
|
126
|
+
tokens: {
|
|
127
|
+
client_id: clientId,
|
|
128
|
+
access_token: t.access_token,
|
|
129
|
+
refresh_token: t.refresh_token,
|
|
130
|
+
expires_at: t.expires_at,
|
|
131
|
+
scope: t.scope,
|
|
132
|
+
...(typeof c.email === 'string' ? { email: c.email } : {}),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Whether the access token needs a refresh before the next call. */
|
|
137
|
+
export const staleChatgpt = (t) => t.expires_at - EARLY_MS < Date.now();
|
|
138
|
+
/** A new access token and the rotated refresh token that replaces the old one. */
|
|
139
|
+
export async function refreshChatgpt(t, fetchFn) {
|
|
140
|
+
const n = await token(fetchFn, {
|
|
141
|
+
grant_type: 'refresh_token',
|
|
142
|
+
client_id: t.client_id,
|
|
143
|
+
refresh_token: t.refresh_token,
|
|
144
|
+
resource: CHATGPT_RESOURCE,
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
...t,
|
|
148
|
+
access_token: n.access_token,
|
|
149
|
+
refresh_token: n.refresh_token,
|
|
150
|
+
expires_at: n.expires_at,
|
|
151
|
+
...(n.scope ? { scope: n.scope } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** One free read (the account's models) that says whether the plan sign-in still works. */
|
|
155
|
+
export async function testChatgpt(t, fetchFn) {
|
|
156
|
+
const at = Date.now();
|
|
157
|
+
const res = await fetchFn(`${CHATGPT_RESOURCE}/models`, {
|
|
158
|
+
headers: { authorization: `Bearer ${t.access_token}` },
|
|
159
|
+
}).catch(() => null);
|
|
160
|
+
if (!res)
|
|
161
|
+
return { ok: false, detail: 'OpenAI could not be reached.', at };
|
|
162
|
+
if (!res.ok)
|
|
163
|
+
return { ok: false, detail: `OpenAI refused the sign-in (HTTP ${res.status}).`, at };
|
|
164
|
+
return { ok: true, detail: 'ChatGPT plan sign-in works.', at };
|
|
165
|
+
}
|
|
@@ -1,21 +1,47 @@
|
|
|
1
|
-
import type { AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantStatus, DraftItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
1
|
+
import type { AssistantAccess, AssistantChat, AssistantChatRow, AssistantMessage, AssistantProvider, AssistantStatus, DraftItem, StepItem } from '@columenlabs/lumen-ui-types/assistant';
|
|
2
2
|
import { type ModelDef, type TaskDef, type TierDef } from './models.js';
|
|
3
3
|
import { State } from './state.js';
|
|
4
4
|
import { type AssistantStore, type Sealer } from './store.js';
|
|
5
5
|
import { type ToolSource } from './tools.js';
|
|
6
6
|
import { type AccessToken, type WebFetchOptions } from './web.js';
|
|
7
|
+
/** An app's slash command (`/story <words>`). */
|
|
8
|
+
export interface AssistantCommandDef {
|
|
9
|
+
/** Typed after the slash, letters, digits and `-`: `story`. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** One line in the list. */
|
|
12
|
+
about: string;
|
|
13
|
+
/** What follows the name, shown in the list (`<words>`). Without it, picking it sends it. */
|
|
14
|
+
args?: string;
|
|
15
|
+
/** What the model is asked: `{args}` is the words after the name. */
|
|
16
|
+
prompt: string | ((args: string, caller: Caller) => string | Promise<string>);
|
|
17
|
+
}
|
|
7
18
|
/** Who is asking: their id (the store's scope for their things) and whether they are an admin. */
|
|
8
19
|
export interface Caller {
|
|
9
20
|
id: string;
|
|
10
21
|
admin: boolean;
|
|
11
22
|
}
|
|
12
|
-
/** A
|
|
23
|
+
/** A change a write tool made at once, as `undo` gets it. */
|
|
24
|
+
export interface AssistantWrite {
|
|
25
|
+
/** The tool's own name (not the `group__tool` the model calls). */
|
|
26
|
+
tool: string;
|
|
27
|
+
args: unknown;
|
|
28
|
+
/** What the tool returned. */
|
|
29
|
+
result: unknown;
|
|
30
|
+
}
|
|
31
|
+
/** A group of the app's tools the person sets Off, Read, Read + draft or Read + write. */
|
|
13
32
|
export interface ToolGroup {
|
|
14
33
|
id: string;
|
|
15
34
|
label: string;
|
|
16
35
|
about: string;
|
|
17
36
|
/** Where a person starts: `read` by default. */
|
|
18
37
|
access?: AssistantAccess;
|
|
38
|
+
/**
|
|
39
|
+
* Offers Read + write: its write tools then run at once, without a card, each with Undo in the
|
|
40
|
+
* chat when `undo` is set. Off by default, so a group only drafts.
|
|
41
|
+
*/
|
|
42
|
+
writes?: boolean;
|
|
43
|
+
/** Takes back one change a write made at once (Undo on its line in the chat). */
|
|
44
|
+
undo?: (caller: Caller, write: AssistantWrite) => Promise<void>;
|
|
19
45
|
tools: (caller: Caller) => ToolSource | ToolSource[];
|
|
20
46
|
}
|
|
21
47
|
/** What one step or answer was, for the app's meter: shapes only, never content. */
|
|
@@ -52,11 +78,17 @@ export interface AssistantOptions {
|
|
|
52
78
|
name: string;
|
|
53
79
|
/** The app's instructions: what it is, what its tools are for. */
|
|
54
80
|
system: string | ((caller: Caller) => string | Promise<string>);
|
|
81
|
+
/**
|
|
82
|
+
* The app's slash commands, listed in the composer when the person types `/`. A message
|
|
83
|
+
* `/name words` is sent to the model as `prompt` with `{args}` replaced by the words (or what the
|
|
84
|
+
* function returns); the chat shows what the person typed.
|
|
85
|
+
*/
|
|
86
|
+
commands?: AssistantCommandDef[];
|
|
55
87
|
/** The app's tiers, smallest first. */
|
|
56
88
|
tiers: TierDef[];
|
|
57
89
|
/** Tasks a person picks a starting tier for; the chat on the smallest tier by default. */
|
|
58
90
|
tasks?: TaskDef[];
|
|
59
|
-
/** Models a tier may run; Claude's by default. */
|
|
91
|
+
/** Models a tier may run; every model the kit knows (Claude's and OpenAI's) by default. */
|
|
60
92
|
models?: ModelDef[];
|
|
61
93
|
groups?: ToolGroup[];
|
|
62
94
|
web?: {
|
|
@@ -69,15 +101,15 @@ export interface AssistantOptions {
|
|
|
69
101
|
};
|
|
70
102
|
store: AssistantStore;
|
|
71
103
|
sealer?: Sealer;
|
|
72
|
-
/** The app's key when none is stored in Settings (a Worker secret). */
|
|
73
|
-
appKey?: () => Promise<string | null | undefined>;
|
|
104
|
+
/** The app's key of a provider when none is stored in Settings (a Worker secret). */
|
|
105
|
+
appKey?: (provider: AssistantProvider) => Promise<string | null | undefined>;
|
|
74
106
|
/** USD a person may spend a month when nobody set a cap; 10. */
|
|
75
107
|
defaultCap?: number;
|
|
76
108
|
/** Months and dates in this zone; UTC by default. */
|
|
77
109
|
timeZone?: string;
|
|
78
110
|
/** Chats are deleted this many days after their last message (`purge`); 30. */
|
|
79
111
|
keepDays?: number;
|
|
80
|
-
/** The fetch
|
|
112
|
+
/** The fetch the model providers and ScrapeQL calls go through. */
|
|
81
113
|
fetch?: typeof fetch;
|
|
82
114
|
onStep?: (s: StepRecord) => void | Promise<void>;
|
|
83
115
|
}
|
|
@@ -97,7 +129,11 @@ export declare class Assistant {
|
|
|
97
129
|
readonly state: State;
|
|
98
130
|
private readonly groups;
|
|
99
131
|
constructor(o: AssistantOptions);
|
|
100
|
-
private
|
|
132
|
+
private anthropic;
|
|
133
|
+
private openai;
|
|
134
|
+
private clients;
|
|
135
|
+
/** The providers the app's models run on, in the order the models list them. */
|
|
136
|
+
get providers(): AssistantProvider[];
|
|
101
137
|
private tierById;
|
|
102
138
|
private above;
|
|
103
139
|
private modelById;
|
|
@@ -107,6 +143,7 @@ export declare class Assistant {
|
|
|
107
143
|
/** The tier the person's chat runs on: their pick, else the chat task's start tier. */
|
|
108
144
|
private mainTier;
|
|
109
145
|
private access;
|
|
146
|
+
private groupById;
|
|
110
147
|
status(caller: Caller): Promise<AssistantStatus>;
|
|
111
148
|
listChats(caller: Caller): Promise<AssistantChatRow[]>;
|
|
112
149
|
readChat(caller: Caller, id: string): Promise<AssistantChat | null>;
|
|
@@ -125,6 +162,8 @@ export declare class Assistant {
|
|
|
125
162
|
/** The advisor for this answer, or null when off. Never weaker than the model it advises. */
|
|
126
163
|
private advisorFor;
|
|
127
164
|
private escalateTool;
|
|
165
|
+
/** What the model is asked: an app command's prompt, else the message as typed. */
|
|
166
|
+
private expand;
|
|
128
167
|
/** The tiers the person's message asks for by name ("go deeper" is the next one up). */
|
|
129
168
|
private askedFor;
|
|
130
169
|
private handOff;
|
|
@@ -132,14 +171,27 @@ export declare class Assistant {
|
|
|
132
171
|
private runTool;
|
|
133
172
|
/** Model calls and tool rounds until the model answers; returns its words. */
|
|
134
173
|
private loop;
|
|
174
|
+
/** The loop on OpenAI's Responses API: the same tools, Stop, caps and rounds as Claude's. */
|
|
175
|
+
private loopOpenAI;
|
|
176
|
+
/** The loop on Anthropic's Messages API, with the advisor and prompt caching. */
|
|
177
|
+
private loopClaude;
|
|
135
178
|
/** Swaps the history for a short summary. False with nothing to compact. */
|
|
136
179
|
private compact;
|
|
137
180
|
/** How the person's cards ended since the last answer, once each. */
|
|
138
181
|
private cardNews;
|
|
139
182
|
/** The answer as later turns remember it: with what its tools returned, cut. */
|
|
140
183
|
private remember;
|
|
141
|
-
/**
|
|
142
|
-
|
|
184
|
+
/** Starts a person's ChatGPT plan sign-in: the address to open in a new tab. */
|
|
185
|
+
startSignin(caller: Caller, provider: AssistantProvider): Promise<{
|
|
186
|
+
url: string;
|
|
187
|
+
redirect: string;
|
|
188
|
+
}>;
|
|
189
|
+
/** Finishes it from the address it ended on; the plan then stands in for the person's own key. */
|
|
190
|
+
finishSignin(caller: Caller, provider: AssistantProvider, pasted: string): Promise<void>;
|
|
191
|
+
/** Tests a person's own key, refreshing a plan sign-in first. */
|
|
192
|
+
testOwn(caller: Caller, provider: AssistantProvider): Promise<import("./state.js").KeyTest | null>;
|
|
193
|
+
/** One free call (reading a model) that says whether the provider takes the key. */
|
|
194
|
+
testKey(provider: AssistantProvider, apiKey: string): Promise<{
|
|
143
195
|
ok: boolean;
|
|
144
196
|
detail: string;
|
|
145
197
|
at: number;
|
|
@@ -152,6 +204,8 @@ export declare class Assistant {
|
|
|
152
204
|
}>;
|
|
153
205
|
/** Saves (runs, as the person) or dismisses a drafted card. Access is checked again first. */
|
|
154
206
|
settleDraft(caller: Caller, chatId: string, draftId: string, action: 'save' | 'dismiss'): Promise<DraftItem>;
|
|
207
|
+
/** Takes back one change a write made at once, through its group's `undo`. */
|
|
208
|
+
undoWrite(caller: Caller, chatId: string, stepId: string): Promise<StepItem>;
|
|
155
209
|
}
|
|
156
210
|
export declare function createAssistant(o: AssistantOptions): Assistant;
|
|
157
211
|
export {};
|