@mercury-fw/channel-http 0.10.1 → 0.12.0
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/CHANGELOG.md +49 -0
- package/README.md +15 -5
- package/dist/http-provider.d.ts +5 -1
- package/dist/http-server.d.ts +20 -9
- package/dist/messages.d.ts +10 -0
- package/http-provider.ts +7 -1
- package/http-server.ts +120 -48
- package/index.ts +5 -1
- package/messages.ts +20 -0
- package/openapi.yaml +129 -8
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# @mercury-fw/channel-http
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1126106: Every turn tells the model when and where the person wrote it.
|
|
8
|
+
|
|
9
|
+
- Each message reaches the model after a line with the weekday, date, time and time zone it was written in (`[Wednesday 2026-10-21 14:32 Europe/Rome]`), and the system prompt says what that line is. The conversation's archive and what a UI shows of it keep the message as written.
|
|
10
|
+
- A resumed conversation's messages come back stamped from the archive's timestamps.
|
|
11
|
+
- The Layer 1 summary keeps when things were said; the episodic summary quotes a date only from those lines, never guessing one. The semantic-fact extractor and the capture card's preview leave the line out.
|
|
12
|
+
- The time zone, always an IANA name (`Europe/Rome`), is the one the person saved, else the one their client is in when the channel passes it on, else the new `i18n.timeZone` in `mercury.config.ts`, else the machine's. A `timeZone` that isn't a zone's name stops the start.
|
|
13
|
+
- The person saves theirs by telling the assistant (the new `set_time_zone` tool), or from a channel's settings.
|
|
14
|
+
- Channel contract: `InboundTurn.timeZoneHint`, and `ctx.settings.setTimeZone` for a channel with a settings area; `reads.me` also returns the saved `timeZone`. Both are optional additions: the contract version stays 6.
|
|
15
|
+
- HTTP channel: the `X-Time-Zone` header on `POST /turn` (allowed by CORS), `timeZone` in `GET /me`, and `PATCH /me` with `{ timeZone }` to save it. Needs the framework at 0.56 or later; an older core ignores the header and offers no `PATCH /me`.
|
|
16
|
+
- Google Chat channel: the client's time zone (`common.timeZone`), when the event carries it, on messages and on the turn a click resumes.
|
|
17
|
+
|
|
18
|
+
To set a default for an app's people, add `timeZone: "Europe/Rome"` (their zone) to `i18n` in `mercury.config.ts`; without it the machine's zone is used, often UTC inside a container.
|
|
19
|
+
|
|
20
|
+
## 0.11.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- e00ff73: The channels' texts come out in the person's language too, and the language their client asks for counts. Channel contract version 6:
|
|
25
|
+
|
|
26
|
+
- The person's language is the one they saved, else the one their client asks for, else the app's default. The HTTP channel passes on the request's `Accept-Language` header (what browsers send), Google Chat the language of the person's Chat client when its event carries one (`InboundTurn.languageHint`). Someone the agent refuses gets it too.
|
|
27
|
+
- A channel declares its own texts in English in `messages`, under its name (`google-chat.`, `http.`), checked like a plugin's; it gets them through `ctx.i18n.forRequest(principal, hint)`.
|
|
28
|
+
- The replies to a confirmation are the core's, in the person's language: `ctx.confirm` returns `{ text, resume? }` and `ctx.resolveConfirmation` the outcome with its `text`. A confirmed action reads "Confirmed and done." instead of its raw result as JSON. `confirmReply`, `RESUME_AFTER_CONFIRM` and `PENDING_CONFIRMATION_NOTE` are gone from the contract, and `tryConfirm` from `@mercury-fw/confirm-engine` (`resolveConfirmation` reports the outcome; the core words it).
|
|
29
|
+
- The stand-in for an answer the model didn't write because the turn staged an action (`core.pending.note`, "Action pending, waiting for confirmation.") is no longer streamed as text: `finalize(text, { pendingNote })` says the text begins with it, and channels drop it through that, never by comparing text.
|
|
30
|
+
- A plain staging's single choice has an empty label: each channel words its own button ("Confirm" on Google Chat, in the person's language). `loginLinkText(login, t)` takes the person's texts.
|
|
31
|
+
- HTTP: `Accept-Language` on `/turn`, `/confirm` and `/login/callback`; `Content-Language` on the responses of `/turn` and `/confirm`; the `final` event carries `pendingNote`; the login page reads in the browser's language.
|
|
32
|
+
- Google Chat: the cards' titles, status lines and confirm button are `google-chat.*` texts; the client's `common.userLocale` is the hint when the event carries it.
|
|
33
|
+
- The terminal's replies to a confirmation read in the terminal person's language, like its other lines.
|
|
34
|
+
|
|
35
|
+
The texts that were in Italian (the cards, the replies to a confirmation, the pending note) are now English by default. An app that wants them in Italian adds their keys to `locales/it.json` (`core.pending.note`, `core.confirm.*`, `core.login.link*`, `google-chat.*`, `http.*`; the log lists the missing ones at startup). A UI on the HTTP channel can hide the pending note through `final`'s `pendingNote`, and words the button of a choice with an empty label itself. A channel written for contract 5 isn't loaded by this core, and the log says why: rebuild it against `CHANNEL_API_VERSION` 6.
|
|
36
|
+
|
|
37
|
+
- ebde9e2: The texts a person sees can be in their language. The core's own fixed texts (the refusal of someone the agent doesn't know, the account-linking replies, the login errors, the tool status lines, the terminal's own lines) are English, each under a key (`core.access.unknown`, …), and an app adds a language with a file of its own: `i18n: { defaultLocale, locales: { it } }` in `mercury.config.ts`, each file mapping keys to texts. A person picks their language by asking the agent, which saves it with `set_preferred_language` (only the languages the app ships; not offered when it ships English alone) and answers in it from then on. Whoever hasn't picked one gets `defaultLocale`, then English. A key a language's file lacks shows as the key itself, and the log names the missing keys at startup. `GET /me` on the HTTP channel also says the caller's language.
|
|
38
|
+
|
|
39
|
+
The texts that were in Italian (tool status lines, the terminal's lines) are now English by default, and the language is no longer one of the facts inferred from conversations.
|
|
40
|
+
|
|
41
|
+
An existing app keeps working, with these changes:
|
|
42
|
+
|
|
43
|
+
1. `access.unknownMessage` is gone. To keep a custom refusal, put it in `locales/en.json` as `{ "core.access.unknown": "…" }`, and in `mercury.config.ts` import it (`import en from "./locales/en.json";`) and set `i18n: { locales: { en } }`.
|
|
44
|
+
2. To keep the status and terminal lines in Italian, add `locales/it.json` with the `core.` keys translated (start it as `{}`: the log lists the missing keys at startup), and in `mercury.config.ts` import it (`import it from "./locales/it.json";`) and set `i18n: { defaultLocale: "it", locales: { it } }` (`locales: { en, it }` when you did step 1 too).
|
|
45
|
+
3. Whenever the config imports a file from `locales/` (steps 1 and 2), the image needs the folder: add `COPY --chown=mercury:mercury locales ./locales` after the `mercury.config.ts` line of the `Dockerfile`, or the app fails at startup.
|
|
46
|
+
4. For the terminal (`mfw repl`) to follow the language too, add `texts: app.texts,` to `createTerminalProvider({ … })` in `src/repl.ts`; without it the terminal's own lines stay English.
|
|
47
|
+
|
|
48
|
+
### Patch Changes
|
|
49
|
+
|
|
50
|
+
- 6bb8291: Needs the framework at 0.54.0 or later, the version that brings the contract it's written for (plugin contract 5, channel contract 6): the peer range on `@mercury-fw/plugin-types` and `@mercury-fw/cli-engine` (the plugins) or `@mercury-fw/channel-types` (the channels) starts there.
|
|
51
|
+
|
|
3
52
|
## 0.10.1
|
|
4
53
|
|
|
5
54
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Gives a [Mercury](https://github.com/lucabro81/mercury-fw) agent an HTTP surface to build a custom UI on: one conversational turn per request, streamed back as Server-Sent Events, plus confirmation and read-only routes over what the agent holds (conversations, the wiki, memory, its plugins).
|
|
4
4
|
|
|
5
|
+
This version is written for version 6 of the channel contract, the one that brings the channels' texts in the person's language, and needs the framework at 0.54 or later: a core with an older contract skips it, and the log says why. `X-Time-Zone` and `PATCH /me` need the framework at 0.56 or later: an older core ignores the header and offers no `PATCH /me`.
|
|
6
|
+
|
|
5
7
|
```bash
|
|
6
8
|
bun add @mercury-fw/channel-http
|
|
7
9
|
```
|
|
@@ -52,6 +54,10 @@ Runs one conversational turn; the reply streams back as Server-Sent Events.
|
|
|
52
54
|
|
|
53
55
|
**Responses**: `200 text/event-stream` (the events below); `400` if `text` is missing, an opening has no `conversationId`, the body isn't JSON, or `conversationId` has other characters than the ones above.
|
|
54
56
|
|
|
57
|
+
**Languages.** Every text a person reads that isn't the model's (a refusal, a tool's status line, a choice's label, a confirmation's reply, the login page) is in their language: the one they asked the assistant for, else the one the request's `Accept-Language` header asks for (what browsers send on their own), else the app's default. The responses of `POST /turn` and `POST /confirm`, a refusal and the login page say which with `Content-Language` (as it stood when the response started: a language picked during a turn counts from the next one).
|
|
58
|
+
|
|
59
|
+
**Time zone.** The model reads each message with the time the person wrote it, in their time zone: the one they saved, else the one the request's `X-Time-Zone` header names (an IANA name such as `Europe/Rome`, what `Intl.DateTimeFormat().resolvedOptions().timeZone` gives in a browser), else the app's. A UI saves the person's with `PATCH /me`.
|
|
60
|
+
|
|
55
61
|
Reasoning and answer text arrive as **incremental deltas** — never one finished block — so a UI renders them live; treat the `final` event as the authoritative text. **To cancel** a turn (e.g. a generation that's diverging), close the connection: Mercury aborts the in-flight generation.
|
|
56
62
|
|
|
57
63
|
**SSE events** — each is `event: <name>` followed by `data: <json>`:
|
|
@@ -63,9 +69,9 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
|
|
|
63
69
|
| `tool` | `{ label, detail, toolCallId, name }` | a tool call starts; `name` is the tool the model called, absent when Mercury is saving something to memory |
|
|
64
70
|
| `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
|
|
65
71
|
| `text` | `{ chunk }` | an answer-text delta |
|
|
66
|
-
| `pending` | `{ command, token, choices }` | a confirm-required action was staged; send a token back to confirm it — as a later `/turn` `text`, or via `POST /confirm`. `choices` is every way to answer (`{ token, label }`, in order; one
|
|
72
|
+
| `pending` | `{ command, token, choices }` | a confirm-required action was staged; send a token back to confirm it — as a later `/turn` `text`, or via `POST /confirm`. `choices` is every way to answer (`{ token, label }`, in order, each label in the caller's language; one with an empty `label` for a plain staging, which the UI words itself, a "Confirm" button): the others die with the one sent |
|
|
67
73
|
| `login` | `{ service, url }` | the caller isn't logged in to `service` yet, which a plugin acts on as them: show them `url` to open (the model never gets it) |
|
|
68
|
-
| `final` | `{ text }` | the complete answer (also emitted for a token confirmation, with no model turn) |
|
|
74
|
+
| `final` | `{ text, pendingNote? }` | the complete answer (also emitted for a token confirmation, with no model turn). `pendingNote` is there when the model wrote nothing because the turn staged an action: the core's stand-in sentence `text` begins with (then any list the turn surfaced). A UI showing its own confirmation hides it; the conversation's transcript keeps it |
|
|
69
75
|
| `error` | `{ message }` | the turn failed mid-stream |
|
|
70
76
|
|
|
71
77
|
```bash
|
|
@@ -77,7 +83,7 @@ curl -N -X POST http://localhost:4100/turn \
|
|
|
77
83
|
|
|
78
84
|
## `GET /login/callback`
|
|
79
85
|
|
|
80
|
-
Where a person comes back after logging in to a service a plugin acts on as them (Jira, Bitbucket): the `login` event's `url` sends them to the service, and the service sends their browser here with `code` and `state`. Mounted only with `HTTP_SURFACE_PUBLIC_URL`, at `<HTTP_SURFACE_PUBLIC_URL>/login/callback`, which is the URL to register as a callback on the service's app (the Atlassian 3LO app, the Bitbucket consumer people log in through). No bearer token: the browser has none here, and the `state` Mercury issued for that person, single-use and valid for ten minutes, is what ties the request to their login. Answers with a plain-text page saying how it went.
|
|
86
|
+
Where a person comes back after logging in to a service a plugin acts on as them (Jira, Bitbucket): the `login` event's `url` sends them to the service, and the service sends their browser here with `code` and `state`. Mounted only with `HTTP_SURFACE_PUBLIC_URL`, at `<HTTP_SURFACE_PUBLIC_URL>/login/callback`, which is the URL to register as a callback on the service's app (the Atlassian 3LO app, the Bitbucket consumer people log in through). No bearer token: the browser has none here, and the `state` Mercury issued for that person, single-use and valid for ten minutes, is what ties the request to their login. Answers with a plain-text page saying how it went, in the language the browser asks for (`Accept-Language`), else the app's default; a login that failed reads in the language of the person who started it.
|
|
81
87
|
|
|
82
88
|
Whoever opens the link and accepts decides which account gets linked: a link belongs to the person it was shown to, and someone else accepting it would link their own account to that person's Mercury identity. A UI should say so next to the link. Checking the linked account against the person is [#179](https://github.com/lucabro81/mercury-fw/issues/179).
|
|
83
89
|
|
|
@@ -85,9 +91,13 @@ Whoever opens the link and accepts decides which account gets linked: a link bel
|
|
|
85
91
|
|
|
86
92
|
A code for linking another of the caller's accounts to them, for the UI to show: `{ ok: true, code, expiresAt }`, or `400` with the reason when there's none. The person sends the code from the other account, on any channel, twice: the first time Mercury says whom the account would be linked to, the second time it links it, and from then on both accounts are one person (one private area, the same roles). The code lasts ten minutes, works once, and only the caller sees it; the model never does. It belongs to the first account that sends it, so it should be sent in a one-to-one conversation, never in a shared space where someone else could send it first. Over HTTP only an account the instance already admits can send it (anyone else is refused before the turn), so an account a closed instance doesn't know links from a chat.
|
|
87
93
|
|
|
94
|
+
## `PATCH /me`
|
|
95
|
+
|
|
96
|
+
Saves the caller's time zone, for a UI's settings: body `{ timeZone }`, an IANA name such as `Europe/Rome`; returns `{ ok: true, timeZone }` as saved (`europe/rome` is saved as `Europe/Rome`), or `400` with the reason for what isn't a zone's name (a fixed offset such as `+02:00` isn't one) and for a body without it. From then on the caller's turns are stamped with the time there, whatever `X-Time-Zone` says. Not there on a core without settings.
|
|
97
|
+
|
|
88
98
|
## `POST /confirm`
|
|
89
99
|
|
|
90
|
-
Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation, `{ ok: true, resolved: false }` otherwise. When the confirmed action hands its result back to the model, it returns `{ ok: true, resolved: true, resume: true }` instead: open `POST /turn { resume: true, conversationId }` to hear the model's answer. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
|
|
100
|
+
Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation (`text`: the core's reply in the caller's language, "Confirmed and done." or the failure, never the action's raw result), `{ ok: true, resolved: false }` otherwise. When the confirmed action hands its result back to the model, it returns `{ ok: true, resolved: true, resume: true }` instead: open `POST /turn { resume: true, conversationId }` to hear the model's answer. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
|
|
91
101
|
|
|
92
102
|
## Read-only introspection
|
|
93
103
|
|
|
@@ -99,7 +109,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
|
|
|
99
109
|
| `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
|
|
100
110
|
| `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
|
|
101
111
|
| `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
|
|
102
|
-
| `GET /me` | `{ name }` — how you asked the assistant to call you, `null` when you never said;
|
|
112
|
+
| `GET /me` | `{ name, language, timeZone }` — how you asked the assistant to call you, the language you asked it to use and the time zone you saved, `null` when you never said; `language` and `timeZone` are missing on a core that doesn't know them, and the route isn't there on a core older than this channel |
|
|
103
113
|
| `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
|
|
104
114
|
| `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
|
|
105
115
|
| `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
|
package/dist/http-provider.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
12
12
|
*/
|
|
13
13
|
import { type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
|
-
import type { Authenticate, Provider, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
|
|
14
|
+
import type { Authenticate, Provider, ChannelHostReads, ChannelI18n, ChannelLinking, ChannelSettings } from "@mercury-fw/channel-types";
|
|
15
15
|
export type HttpProviderDeps = {
|
|
16
16
|
port: number;
|
|
17
17
|
/** Bare-token interception before the model. */
|
|
@@ -34,10 +34,14 @@ export type HttpProviderDeps = {
|
|
|
34
34
|
};
|
|
35
35
|
/** Linking another account to the caller (`POST /link`). */
|
|
36
36
|
linking?: ChannelLinking;
|
|
37
|
+
/** The person's own settings (`PATCH /me`). */
|
|
38
|
+
settings?: ChannelSettings;
|
|
37
39
|
/** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
|
|
38
40
|
resumeMaxChars?: number;
|
|
39
41
|
/** What the pages people read call the assistant. */
|
|
40
42
|
assistantName?: string;
|
|
43
|
+
/** The texts in each caller's language (`ctx.i18n`). */
|
|
44
|
+
i18n?: ChannelI18n;
|
|
41
45
|
};
|
|
42
46
|
export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
|
|
43
47
|
stop(): Promise<void>;
|
package/dist/http-server.d.ts
CHANGED
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
* Confirm resolution is injected too (`confirm`/`resolveConfirmation`), so this
|
|
23
23
|
* package never imports the app.
|
|
24
24
|
*/
|
|
25
|
-
import { type HandleTurn, type Authenticate, type ChannelHostReads, type
|
|
26
|
-
/** Resolves a bare confirmation token to
|
|
27
|
-
export type ConfirmFn =
|
|
25
|
+
import { type HandleTurn, type Authenticate, type ChannelHostReads, type ChannelI18n, type ChannelRuntimeContext, type LoginOutcome, type Principal, type Admission, type ChannelLinking, type ChannelSettings } from "@mercury-fw/channel-types";
|
|
26
|
+
/** Resolves a bare confirmation token to what to tell the person, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
|
|
27
|
+
export type ConfirmFn = ChannelRuntimeContext["confirm"];
|
|
28
28
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
29
|
-
export type ResolveConfirmationFn =
|
|
29
|
+
export type ResolveConfirmationFn = NonNullable<ChannelRuntimeContext["resolveConfirmation"]>;
|
|
30
30
|
/** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
|
|
31
|
-
export type AdmitFn = (principal: Principal) => Promise<Admission>;
|
|
31
|
+
export type AdmitFn = (principal: Principal, hint?: string) => Promise<Admission>;
|
|
32
32
|
export type TurnRequestDeps = {
|
|
33
33
|
handleTurn: HandleTurn;
|
|
34
34
|
/** Who is calling, injected from the app's auth provider; `null` = refused. */
|
|
@@ -36,6 +36,8 @@ export type TurnRequestDeps = {
|
|
|
36
36
|
admit?: AdmitFn;
|
|
37
37
|
/** Bare-token interception before the model, injected by the core. */
|
|
38
38
|
confirm: ConfirmFn;
|
|
39
|
+
/** The texts in the caller's language, injected by the core (`ctx.i18n`): what `Content-Language` says. */
|
|
40
|
+
i18n?: ChannelI18n;
|
|
39
41
|
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
40
42
|
corsOrigin?: string;
|
|
41
43
|
/** Test seam for the ephemeral session key when the client sends no conversationId. */
|
|
@@ -74,6 +76,8 @@ export type ConfirmRequestDeps = {
|
|
|
74
76
|
admit?: AdmitFn;
|
|
75
77
|
resolveConfirmation: ResolveConfirmationFn;
|
|
76
78
|
corsOrigin?: string;
|
|
79
|
+
/** The texts in the caller's language, injected by the core: what `Content-Language` says. */
|
|
80
|
+
i18n?: ChannelI18n;
|
|
77
81
|
};
|
|
78
82
|
/**
|
|
79
83
|
* `POST /confirm { token, conversationId }` — the explicit confirmation
|
|
@@ -83,7 +87,8 @@ export type ConfirmRequestDeps = {
|
|
|
83
87
|
* keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
|
|
84
88
|
* token staged in someone else's conversation is never found. `resolved: true` means the token matched a
|
|
85
89
|
* pending confirmation and its staged action was consumed and run; the `text`
|
|
86
|
-
* then reports whether that execution
|
|
90
|
+
* (the core's, in the caller's language) then reports whether that execution
|
|
91
|
+
* succeeded. `resolved: false` means the
|
|
87
92
|
* token was not a pending confirmation (unknown, expired, already used, or not
|
|
88
93
|
* even token-shaped). `resume: true` (with no `text`) means the action handed
|
|
89
94
|
* its result back to the model: the client hears its answer by opening
|
|
@@ -117,10 +122,13 @@ export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutc
|
|
|
117
122
|
export declare function handleLoginCallback(req: Request, deps: {
|
|
118
123
|
complete: CompleteLoginFn;
|
|
119
124
|
assistantName?: string;
|
|
125
|
+
i18n?: ChannelI18n;
|
|
120
126
|
}): Promise<Response>;
|
|
121
|
-
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
127
|
+
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight, and on `/me` the `PATCH` saving a setting. */
|
|
122
128
|
type ReadRoute = {
|
|
123
129
|
GET: (req: Request) => Response | Promise<Response>;
|
|
130
|
+
/** Only on `/me`, when the core offers the person's settings (`PATCH /me`). */
|
|
131
|
+
PATCH?: (req: Request) => Promise<Response>;
|
|
124
132
|
OPTIONS: () => Response;
|
|
125
133
|
};
|
|
126
134
|
/**
|
|
@@ -132,9 +140,10 @@ type ReadRoute = {
|
|
|
132
140
|
* their data only; a conversation is named by its conversationId, as on
|
|
133
141
|
* `/turn`, and its session key is built from the caller.
|
|
134
142
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
135
|
-
* same for a 400.
|
|
143
|
+
* same for a 400. With `settings`, `/me` also takes a `PATCH` saving the
|
|
144
|
+
* caller's time zone (a UI's settings area), asking `authenticate` first too.
|
|
136
145
|
*/
|
|
137
|
-
export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn): Record<string, ReadRoute>;
|
|
146
|
+
export declare function readRoutes(reads: ChannelHostReads, authenticate: Authenticate, corsOrigin?: string, admit?: AdmitFn, settings?: ChannelSettings): Record<string, ReadRoute>;
|
|
138
147
|
export type HttpServerDeps = TurnRequestDeps & {
|
|
139
148
|
port: number;
|
|
140
149
|
reads?: ChannelHostReads;
|
|
@@ -144,6 +153,8 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
144
153
|
completeLogin?: CompleteLoginFn;
|
|
145
154
|
/** Linking another account to the caller; `/link` is mounted only with it. */
|
|
146
155
|
linking?: ChannelLinking;
|
|
156
|
+
/** The person's own settings; `PATCH /me` is mounted only with it. */
|
|
157
|
+
settings?: ChannelSettings;
|
|
147
158
|
/** What the pages people read call the assistant. */
|
|
148
159
|
assistantName?: string;
|
|
149
160
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP channel's texts a person sees, in English: the base every
|
|
3
|
+
* instance's language files translate (`ChannelPlugin.messages`). Only the
|
|
4
|
+
* login page: everything else the surface sends is the core's, or the
|
|
5
|
+
* developer-facing errors of its API, which aren't translated.
|
|
6
|
+
*/
|
|
7
|
+
import type { Messages, Translate } from "@mercury-fw/channel-types";
|
|
8
|
+
export declare const HTTP_MESSAGES: Messages;
|
|
9
|
+
/** These texts in English, for a server built without the core's (`ChannelI18n`), as tests build it. */
|
|
10
|
+
export declare const englishHttp: Translate;
|
package/http-provider.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* injected (`confirm`/`resolveConfirmation`), so this package never imports the app.
|
|
12
12
|
*/
|
|
13
13
|
import { startHttpServer, type AdmitFn, type CompleteLoginFn, type ConfirmFn, type ResolveConfirmationFn } from "./http-server.ts";
|
|
14
|
-
import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelLinking } from "@mercury-fw/channel-types";
|
|
14
|
+
import type { Authenticate, Provider, HandleTurn, ChannelHostReads, ChannelI18n, ChannelLinking, ChannelSettings } from "@mercury-fw/channel-types";
|
|
15
15
|
|
|
16
16
|
export type HttpProviderDeps = {
|
|
17
17
|
port: number;
|
|
@@ -31,10 +31,14 @@ export type HttpProviderDeps = {
|
|
|
31
31
|
logins?: { callbackUrl: string; accept: (callbackUrl: string) => void; complete: CompleteLoginFn };
|
|
32
32
|
/** Linking another account to the caller (`POST /link`). */
|
|
33
33
|
linking?: ChannelLinking;
|
|
34
|
+
/** The person's own settings (`PATCH /me`). */
|
|
35
|
+
settings?: ChannelSettings;
|
|
34
36
|
/** The surface's budget for a reopened conversation (`HTTP_SURFACE_RESUME_MAX_CHARS`). */
|
|
35
37
|
resumeMaxChars?: number;
|
|
36
38
|
/** What the pages people read call the assistant. */
|
|
37
39
|
assistantName?: string;
|
|
40
|
+
/** The texts in each caller's language (`ctx.i18n`). */
|
|
41
|
+
i18n?: ChannelI18n;
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
|
|
@@ -52,8 +56,10 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
|
|
|
52
56
|
corsOrigin: deps.corsOrigin,
|
|
53
57
|
completeLogin: deps.logins?.complete,
|
|
54
58
|
...(deps.linking === undefined ? {} : { linking: deps.linking }),
|
|
59
|
+
...(deps.settings === undefined ? {} : { settings: deps.settings }),
|
|
55
60
|
...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
|
|
56
61
|
...(deps.assistantName === undefined ? {} : { assistantName: deps.assistantName }),
|
|
62
|
+
...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
|
|
57
63
|
});
|
|
58
64
|
deps.logins?.accept(deps.logins.callbackUrl);
|
|
59
65
|
},
|
package/http-server.ts
CHANGED
|
@@ -25,18 +25,19 @@
|
|
|
25
25
|
import {
|
|
26
26
|
detectLoginRequired,
|
|
27
27
|
detectPendingConfirmation,
|
|
28
|
-
PENDING_CONFIRMATION_NOTE,
|
|
29
|
-
RESUME_AFTER_CONFIRM,
|
|
30
28
|
type HandleTurn,
|
|
31
29
|
type TurnSink,
|
|
32
30
|
type Authenticate,
|
|
33
31
|
type ChannelHostReads,
|
|
34
|
-
type
|
|
32
|
+
type ChannelI18n,
|
|
33
|
+
type ChannelRuntimeContext,
|
|
35
34
|
type LoginOutcome,
|
|
36
35
|
type Principal,
|
|
37
36
|
type Admission,
|
|
38
37
|
type ChannelLinking,
|
|
38
|
+
type ChannelSettings,
|
|
39
39
|
} from "@mercury-fw/channel-types";
|
|
40
|
+
import { englishHttp } from "./messages.ts";
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* A client's conversation id becomes part of the session key and the log
|
|
@@ -51,13 +52,30 @@ function forLog(text: string): string {
|
|
|
51
52
|
return text.replace(/[\u0000-\u001f\u007f]/g, "?");
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/** Resolves a bare confirmation token to
|
|
55
|
-
export type ConfirmFn =
|
|
55
|
+
/** Resolves a bare confirmation token to what to tell the person, or `null` if the input isn't a token. Injected by the core (`ctx.confirm`). */
|
|
56
|
+
export type ConfirmFn = ChannelRuntimeContext["confirm"];
|
|
56
57
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
57
|
-
export type ResolveConfirmationFn =
|
|
58
|
+
export type ResolveConfirmationFn = NonNullable<ChannelRuntimeContext["resolveConfirmation"]>;
|
|
58
59
|
|
|
59
60
|
/** Whether the core talks to an authenticated caller at all, injected by the core (`ctx.admit`); everyone when absent. */
|
|
60
|
-
export type AdmitFn = (principal: Principal) => Promise<Admission>;
|
|
61
|
+
export type AdmitFn = (principal: Principal, hint?: string) => Promise<Admission>;
|
|
62
|
+
|
|
63
|
+
/** The language the caller's client asks for (`Accept-Language`), when it says. */
|
|
64
|
+
function languageHint(req: Request): string | undefined {
|
|
65
|
+
const value = req.headers.get("accept-language")?.trim();
|
|
66
|
+
return value ? value : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The time zone the caller's client says it's in (`X-Time-Zone`, an IANA name), when it says; the core ignores a name that isn't a zone. */
|
|
70
|
+
function timeZoneHint(req: Request): string | undefined {
|
|
71
|
+
const value = req.headers.get("x-time-zone")?.trim();
|
|
72
|
+
return value ? value : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `Content-Language` for a response to `principal` asking for `hint`, when the core says which language its texts are in. */
|
|
76
|
+
async function contentLanguage(i18n: ChannelI18n | undefined, principal: Principal | null, hint: string | undefined): Promise<Record<string, string>> {
|
|
77
|
+
return i18n ? { "content-language": (await i18n.forRequest(principal, hint)).locale } : {};
|
|
78
|
+
}
|
|
61
79
|
|
|
62
80
|
export type TurnRequestDeps = {
|
|
63
81
|
handleTurn: HandleTurn;
|
|
@@ -66,6 +84,8 @@ export type TurnRequestDeps = {
|
|
|
66
84
|
admit?: AdmitFn;
|
|
67
85
|
/** Bare-token interception before the model, injected by the core. */
|
|
68
86
|
confirm: ConfirmFn;
|
|
87
|
+
/** The texts in the caller's language, injected by the core (`ctx.i18n`): what `Content-Language` says. */
|
|
88
|
+
i18n?: ChannelI18n;
|
|
69
89
|
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
70
90
|
corsOrigin?: string;
|
|
71
91
|
/** Test seam for the ephemeral session key when the client sends no conversationId. */
|
|
@@ -91,8 +111,8 @@ const SSE_HEADERS = {
|
|
|
91
111
|
function corsHeaders(origin: string): Record<string, string> {
|
|
92
112
|
return {
|
|
93
113
|
"access-control-allow-origin": origin,
|
|
94
|
-
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
95
|
-
"access-control-allow-headers": "content-type, authorization",
|
|
114
|
+
"access-control-allow-methods": "GET, POST, PATCH, OPTIONS",
|
|
115
|
+
"access-control-allow-headers": "content-type, authorization, accept-language, x-time-zone",
|
|
96
116
|
};
|
|
97
117
|
}
|
|
98
118
|
|
|
@@ -104,20 +124,26 @@ function unauthorized(origin: string): Response {
|
|
|
104
124
|
);
|
|
105
125
|
}
|
|
106
126
|
|
|
107
|
-
/** The core's refusal of an authenticated caller: 403 for someone it doesn't know, 503 when it couldn't tell, with what to tell them. */
|
|
108
|
-
function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string): Response {
|
|
127
|
+
/** The core's refusal of an authenticated caller: 403 for someone it doesn't know, 503 when it couldn't tell, with what to tell them, in the language they asked for. */
|
|
128
|
+
function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string, language: Record<string, string>): Response {
|
|
109
129
|
return Response.json(
|
|
110
130
|
{ ok: false, error: admission.message, reason: admission.reason },
|
|
111
|
-
{ status: admission.reason === "unknown" ? 403 : 503, headers: corsHeaders(origin) },
|
|
131
|
+
{ status: admission.reason === "unknown" ? 403 : 503, headers: { ...corsHeaders(origin), ...language } },
|
|
112
132
|
);
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
/** The caller of `req`, authenticated and admitted, or the response refusing them. */
|
|
116
|
-
async function caller(
|
|
136
|
+
async function caller(
|
|
137
|
+
req: Request,
|
|
138
|
+
deps: { authenticate: Authenticate; admit?: AdmitFn; i18n?: ChannelI18n },
|
|
139
|
+
origin: string,
|
|
140
|
+
): Promise<Principal | Response> {
|
|
117
141
|
const principal = await deps.authenticate(req);
|
|
118
142
|
if (principal === null) return unauthorized(origin);
|
|
119
|
-
const
|
|
120
|
-
|
|
143
|
+
const hint = languageHint(req);
|
|
144
|
+
const admission = deps.admit ? await deps.admit(principal, hint) : { ok: true as const };
|
|
145
|
+
// A refused caller is nobody the core knows: their client's language, else the default.
|
|
146
|
+
return admission.ok ? principal : notAdmitted(admission, origin, await contentLanguage(deps.i18n, null, hint));
|
|
121
147
|
}
|
|
122
148
|
|
|
123
149
|
/** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
|
|
@@ -187,6 +213,9 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
187
213
|
return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
|
|
188
214
|
}
|
|
189
215
|
const sessionKey = `${principal.id}:${conversationId}`;
|
|
216
|
+
const hint = languageHint(req);
|
|
217
|
+
const zone = timeZoneHint(req);
|
|
218
|
+
const language = await contentLanguage(deps.i18n, principal, hint);
|
|
190
219
|
|
|
191
220
|
// Aborted when the client disconnects (see the stream's `cancel` below) so
|
|
192
221
|
// the in-flight turn stops instead of running to completion — the "stop"
|
|
@@ -215,21 +244,19 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
215
244
|
|
|
216
245
|
// Same deterministic interception as every other channel — a
|
|
217
246
|
// previously-approved mutation must never depend on the model.
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
send("final", { text:
|
|
247
|
+
const reply = opening || resume ? null : await deps.confirm(text, sessionKey, principal, hint);
|
|
248
|
+
if (reply !== null && !reply.resume) {
|
|
249
|
+
send("final", { text: reply.text });
|
|
221
250
|
close();
|
|
222
251
|
return;
|
|
223
252
|
}
|
|
224
253
|
// The confirmed action's result goes back to the model: the turn runs again, on this stream.
|
|
225
|
-
const resuming = resume ||
|
|
254
|
+
const resuming = resume || reply?.resume === true;
|
|
226
255
|
|
|
227
256
|
const sink: TurnSink = {
|
|
228
257
|
onToolStart: (label, detail, toolCallId, toolName) => send("tool", { label, detail, toolCallId, name: toolName }),
|
|
229
258
|
onToolFinish: (toolCallId, outcome) => send("tool_finish", { toolCallId, outcome }),
|
|
230
|
-
onTextChunk: (chunk) => {
|
|
231
|
-
if (chunk !== PENDING_CONFIRMATION_NOTE) send("text", { chunk });
|
|
232
|
-
},
|
|
259
|
+
onTextChunk: (chunk) => send("text", { chunk }),
|
|
233
260
|
onReasoningChunk: (chunk, id) => send("reasoning", { chunk, id }),
|
|
234
261
|
onReasoningEnd: (id, failed) => send("reasoning_end", { id, failed }),
|
|
235
262
|
onStep: (step) => {
|
|
@@ -239,7 +266,10 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
239
266
|
if (login) send("login", { service: login.service, url: login.url });
|
|
240
267
|
},
|
|
241
268
|
onUsage: () => {},
|
|
242
|
-
|
|
269
|
+
// The core says when the text is its stand-in for an answer the
|
|
270
|
+
// model didn't write: a UI showing its own confirmation hides it.
|
|
271
|
+
finalize: async (finalText, info) =>
|
|
272
|
+
send("final", info?.pendingNote === undefined ? { text: finalText } : { text: finalText, pendingNote: info.pendingNote }),
|
|
243
273
|
dispose: () => {},
|
|
244
274
|
};
|
|
245
275
|
|
|
@@ -257,6 +287,8 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
257
287
|
...(named ? { resume: deps.resumeMaxChars === undefined ? {} : { maxChars: deps.resumeMaxChars } } : {}),
|
|
258
288
|
...(opening ? { opening: true as const } : {}),
|
|
259
289
|
...(resuming ? { resumeAfterConfirm: true as const } : {}),
|
|
290
|
+
...(hint === undefined ? {} : { languageHint: hint }),
|
|
291
|
+
...(zone === undefined ? {} : { timeZoneHint: zone }),
|
|
260
292
|
},
|
|
261
293
|
sink,
|
|
262
294
|
);
|
|
@@ -276,7 +308,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
276
308
|
},
|
|
277
309
|
});
|
|
278
310
|
|
|
279
|
-
return new Response(stream, { headers: { ...SSE_HEADERS, ...cors } });
|
|
311
|
+
return new Response(stream, { headers: { ...SSE_HEADERS, ...cors, ...language } });
|
|
280
312
|
}
|
|
281
313
|
|
|
282
314
|
export type ConfirmRequestDeps = {
|
|
@@ -284,6 +316,8 @@ export type ConfirmRequestDeps = {
|
|
|
284
316
|
admit?: AdmitFn;
|
|
285
317
|
resolveConfirmation: ResolveConfirmationFn;
|
|
286
318
|
corsOrigin?: string;
|
|
319
|
+
/** The texts in the caller's language, injected by the core: what `Content-Language` says. */
|
|
320
|
+
i18n?: ChannelI18n;
|
|
287
321
|
};
|
|
288
322
|
|
|
289
323
|
/**
|
|
@@ -294,7 +328,8 @@ export type ConfirmRequestDeps = {
|
|
|
294
328
|
* keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
|
|
295
329
|
* token staged in someone else's conversation is never found. `resolved: true` means the token matched a
|
|
296
330
|
* pending confirmation and its staged action was consumed and run; the `text`
|
|
297
|
-
* then reports whether that execution
|
|
331
|
+
* (the core's, in the caller's language) then reports whether that execution
|
|
332
|
+
* succeeded. `resolved: false` means the
|
|
298
333
|
* token was not a pending confirmation (unknown, expired, already used, or not
|
|
299
334
|
* even token-shaped). `resume: true` (with no `text`) means the action handed
|
|
300
335
|
* its result back to the model: the client hears its answer by opening
|
|
@@ -319,18 +354,19 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
|
|
|
319
354
|
if (!CONVERSATION_ID.test(conversationId)) {
|
|
320
355
|
return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
|
|
321
356
|
}
|
|
322
|
-
const
|
|
357
|
+
const hint = languageHint(req);
|
|
358
|
+
const headers = { ...cors, ...(await contentLanguage(deps.i18n, principal, hint)) };
|
|
359
|
+
const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal, hint);
|
|
323
360
|
switch (outcome.status) {
|
|
324
361
|
case "not-a-token":
|
|
325
362
|
case "not-found":
|
|
326
|
-
return Response.json({ ok: true, resolved: false }, { headers
|
|
363
|
+
return Response.json({ ok: true, resolved: false }, { headers });
|
|
327
364
|
case "ok":
|
|
328
|
-
return Response.json({ ok: true, resolved: true, text: `Confermato ed eseguito: ${JSON.stringify(outcome.data)}` }, { headers: cors });
|
|
329
365
|
case "failed":
|
|
330
|
-
return Response.json({ ok: true, resolved: true, text:
|
|
366
|
+
return Response.json({ ok: true, resolved: true, text: outcome.text }, { headers });
|
|
331
367
|
case "resume":
|
|
332
368
|
// The result went back to the model: the client opens `/turn { resume: true }` to hear its answer.
|
|
333
|
-
return Response.json({ ok: true, resolved: true, resume: true }, { headers
|
|
369
|
+
return Response.json({ ok: true, resolved: true, resume: true }, { headers });
|
|
334
370
|
}
|
|
335
371
|
}
|
|
336
372
|
|
|
@@ -365,11 +401,11 @@ function linkRoutes(deps: LinkRequestDeps): Record<string, { POST: (req: Request
|
|
|
365
401
|
export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
|
|
366
402
|
|
|
367
403
|
/** A plain-text page for the person's browser, at the end of their login. */
|
|
368
|
-
function loginPage(text: string, status: number): Response {
|
|
404
|
+
function loginPage(text: string, status: number, language: Record<string, string> = {}): Response {
|
|
369
405
|
// The URL carried the code and the state: neither kept nor passed on.
|
|
370
406
|
return new Response(text, {
|
|
371
407
|
status,
|
|
372
|
-
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" },
|
|
408
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", ...language },
|
|
373
409
|
});
|
|
374
410
|
}
|
|
375
411
|
|
|
@@ -383,39 +419,50 @@ function loginPage(text: string, status: number): Response {
|
|
|
383
419
|
*/
|
|
384
420
|
export async function handleLoginCallback(
|
|
385
421
|
req: Request,
|
|
386
|
-
deps: { complete: CompleteLoginFn; assistantName?: string },
|
|
422
|
+
deps: { complete: CompleteLoginFn; assistantName?: string; i18n?: ChannelI18n },
|
|
387
423
|
): Promise<Response> {
|
|
388
424
|
const assistant = deps.assistantName ?? "Mercury";
|
|
425
|
+
// Nobody is known on this page: the browser's language, then the default.
|
|
426
|
+
const texts = deps.i18n ? await deps.i18n.forRequest(null, languageHint(req)) : undefined;
|
|
427
|
+
const t = texts?.t ?? englishHttp;
|
|
428
|
+
const language: Record<string, string> = texts ? { "content-language": texts.locale } : {};
|
|
389
429
|
const params = new URL(req.url).searchParams;
|
|
390
430
|
const error = params.get("error");
|
|
391
431
|
if (error !== null) {
|
|
392
432
|
const reason = error.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
|
|
393
|
-
return loginPage(
|
|
433
|
+
return loginPage(t("http.login.denied", { reason, assistant }), 400, language);
|
|
394
434
|
}
|
|
395
435
|
const code = params.get("code");
|
|
396
436
|
const state = params.get("state");
|
|
397
|
-
if (!code || !state) return loginPage(
|
|
437
|
+
if (!code || !state) return loginPage(t("http.login.incomplete", { assistant }), 400, language);
|
|
398
438
|
const outcome = await deps.complete(state, code);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
: loginPage(outcome.error, 400);
|
|
439
|
+
// A failed login's text is the core's, in the language of whoever started it: no header claims otherwise.
|
|
440
|
+
return outcome.ok ? loginPage(t("http.login.done", { service: outcome.service }), 200, language) : loginPage(outcome.error, 400);
|
|
402
441
|
}
|
|
403
442
|
|
|
404
443
|
/** The login callback route, mounted when the server can complete logins. */
|
|
405
444
|
function loginRoutes(
|
|
406
445
|
complete: CompleteLoginFn,
|
|
407
446
|
assistantName: string | undefined,
|
|
447
|
+
i18n: ChannelI18n | undefined,
|
|
408
448
|
): Record<string, { GET: (req: Request) => Promise<Response> }> {
|
|
409
449
|
return {
|
|
410
450
|
"/login/callback": {
|
|
411
|
-
GET: (req) =>
|
|
451
|
+
GET: (req) =>
|
|
452
|
+
handleLoginCallback(req, {
|
|
453
|
+
complete,
|
|
454
|
+
...(assistantName === undefined ? {} : { assistantName }),
|
|
455
|
+
...(i18n === undefined ? {} : { i18n }),
|
|
456
|
+
}),
|
|
412
457
|
},
|
|
413
458
|
};
|
|
414
459
|
}
|
|
415
460
|
|
|
416
|
-
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
461
|
+
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight, and on `/me` the `PATCH` saving a setting. */
|
|
417
462
|
type ReadRoute = {
|
|
418
463
|
GET: (req: Request) => Response | Promise<Response>;
|
|
464
|
+
/** Only on `/me`, when the core offers the person's settings (`PATCH /me`). */
|
|
465
|
+
PATCH?: (req: Request) => Promise<Response>;
|
|
419
466
|
OPTIONS: () => Response;
|
|
420
467
|
};
|
|
421
468
|
|
|
@@ -428,13 +475,15 @@ type ReadRoute = {
|
|
|
428
475
|
* their data only; a conversation is named by its conversationId, as on
|
|
429
476
|
* `/turn`, and its session key is built from the caller.
|
|
430
477
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
431
|
-
* same for a 400.
|
|
478
|
+
* same for a 400. With `settings`, `/me` also takes a `PATCH` saving the
|
|
479
|
+
* caller's time zone (a UI's settings area), asking `authenticate` first too.
|
|
432
480
|
*/
|
|
433
481
|
export function readRoutes(
|
|
434
482
|
reads: ChannelHostReads,
|
|
435
483
|
authenticate: Authenticate,
|
|
436
484
|
corsOrigin = "*",
|
|
437
485
|
admit?: AdmitFn,
|
|
486
|
+
settings?: ChannelSettings,
|
|
438
487
|
): Record<string, ReadRoute> {
|
|
439
488
|
const cors = corsHeaders(corsOrigin);
|
|
440
489
|
const json = (payload: object, status = 200): Response =>
|
|
@@ -479,11 +528,31 @@ export function readRoutes(
|
|
|
479
528
|
...(reads.me === undefined
|
|
480
529
|
? {}
|
|
481
530
|
: {
|
|
482
|
-
"/me":
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
531
|
+
"/me": {
|
|
532
|
+
...route(async (_req, principal) => {
|
|
533
|
+
// Null when the core no longer admits the caller: still a name, null.
|
|
534
|
+
const me = (await reads.me!(principal)) as { name: string | null; language?: string | null; timeZone?: string | null } | null;
|
|
535
|
+
// An older core says no language or time zone: the reply then has none either.
|
|
536
|
+
return json({
|
|
537
|
+
ok: true,
|
|
538
|
+
name: me?.name ?? null,
|
|
539
|
+
...(me?.language === undefined ? {} : { language: me.language }),
|
|
540
|
+
...(me?.timeZone === undefined ? {} : { timeZone: me.timeZone }),
|
|
541
|
+
});
|
|
542
|
+
}),
|
|
543
|
+
...(settings === undefined
|
|
544
|
+
? {}
|
|
545
|
+
: {
|
|
546
|
+
PATCH: async (req: Request) => {
|
|
547
|
+
const principal = await caller(req, { authenticate, ...(admit === undefined ? {} : { admit }) }, corsOrigin);
|
|
548
|
+
if (principal instanceof Response) return principal;
|
|
549
|
+
const body = (await req.json().catch(() => null)) as { timeZone?: unknown } | null;
|
|
550
|
+
if (typeof body?.timeZone !== "string") return badRequest('the body must be {"timeZone": "<IANA name>"}');
|
|
551
|
+
const saved = await settings.setTimeZone(principal, body.timeZone);
|
|
552
|
+
return saved.ok ? json({ ok: true, timeZone: saved.value }) : badRequest(saved.error);
|
|
553
|
+
},
|
|
554
|
+
}),
|
|
555
|
+
},
|
|
487
556
|
}),
|
|
488
557
|
"/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
|
|
489
558
|
"/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
|
|
@@ -519,6 +588,8 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
519
588
|
completeLogin?: CompleteLoginFn;
|
|
520
589
|
/** Linking another account to the caller; `/link` is mounted only with it. */
|
|
521
590
|
linking?: ChannelLinking;
|
|
591
|
+
/** The person's own settings; `PATCH /me` is mounted only with it. */
|
|
592
|
+
settings?: ChannelSettings;
|
|
522
593
|
/** What the pages people read call the assistant. */
|
|
523
594
|
assistantName?: string;
|
|
524
595
|
};
|
|
@@ -547,12 +618,13 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
|
|
|
547
618
|
...(deps.admit === undefined ? {} : { admit: deps.admit }),
|
|
548
619
|
resolveConfirmation: deps.resolveConfirmation,
|
|
549
620
|
corsOrigin: origin,
|
|
621
|
+
...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
|
|
550
622
|
}),
|
|
551
623
|
OPTIONS: () => preflight(origin),
|
|
552
624
|
},
|
|
553
625
|
"/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
|
|
554
|
-
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
|
|
555
|
-
...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName) : {}),
|
|
626
|
+
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit, deps.settings) : {}),
|
|
627
|
+
...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName, deps.i18n) : {}),
|
|
556
628
|
...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
|
|
557
629
|
},
|
|
558
630
|
error: (err) =>
|
package/index.ts
CHANGED
|
@@ -16,14 +16,16 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { ChannelPlugin } from "@mercury-fw/channel-types";
|
|
18
18
|
import { createHttpProvider } from "./http-provider.ts";
|
|
19
|
+
import { HTTP_MESSAGES } from "./messages.ts";
|
|
19
20
|
|
|
20
21
|
export { createHttpProvider, type HttpProviderDeps } from "./http-provider.ts";
|
|
21
22
|
|
|
22
23
|
export const httpChannel: ChannelPlugin = {
|
|
23
24
|
// The contract this channel is written for, as a literal: importing
|
|
24
25
|
// CHANNEL_API_VERSION would report whichever contract is installed.
|
|
25
|
-
apiVersion:
|
|
26
|
+
apiVersion: 6,
|
|
26
27
|
name: "http",
|
|
28
|
+
messages: HTTP_MESSAGES,
|
|
27
29
|
build: (ctx) => {
|
|
28
30
|
if (!ctx.resolveConfirmation || !ctx.reads) {
|
|
29
31
|
throw new Error("http channel requires the resolveConfirmation and reads capabilities on the runtime context");
|
|
@@ -44,7 +46,9 @@ export const httpChannel: ChannelPlugin = {
|
|
|
44
46
|
reads: ctx.reads,
|
|
45
47
|
authenticate: ctx.authenticate,
|
|
46
48
|
admit: ctx.admit,
|
|
49
|
+
i18n: ctx.i18n,
|
|
47
50
|
...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
|
|
51
|
+
...(ctx.settings === undefined ? {} : { settings: ctx.settings }),
|
|
48
52
|
...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
|
|
49
53
|
...(ctx.assistantName === undefined ? {} : { assistantName: ctx.assistantName }),
|
|
50
54
|
...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
|
package/messages.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP channel's texts a person sees, in English: the base every
|
|
3
|
+
* instance's language files translate (`ChannelPlugin.messages`). Only the
|
|
4
|
+
* login page: everything else the surface sends is the core's, or the
|
|
5
|
+
* developer-facing errors of its API, which aren't translated.
|
|
6
|
+
*/
|
|
7
|
+
import type { Messages, Translate } from "@mercury-fw/channel-types";
|
|
8
|
+
|
|
9
|
+
export const HTTP_MESSAGES: Messages = {
|
|
10
|
+
// The page a person lands on after logging in to a service (`GET /login/callback`).
|
|
11
|
+
"http.login.denied": "The login didn't happen ({reason}). Ask {assistant} again for a new link.",
|
|
12
|
+
"http.login.incomplete": "The login link came back without a code or a state. Ask {assistant} again for a new link.",
|
|
13
|
+
"http.login.done": "You're logged in to {service}. Go back to the conversation and ask again.",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** These texts in English, for a server built without the core's (`ChannelI18n`), as tests build it. */
|
|
17
|
+
export const englishHttp: Translate = (key, params) =>
|
|
18
|
+
(HTTP_MESSAGES[key] ?? key).replace(/\{(\w+)\}/g, (whole, name: string) =>
|
|
19
|
+
params !== undefined && Object.hasOwn(params, name) ? String(params[name]) : whole,
|
|
20
|
+
);
|
package/openapi.yaml
CHANGED
|
@@ -101,7 +101,15 @@ paths:
|
|
|
101
101
|
speaks first in the conversation named (right after someone signs
|
|
102
102
|
in): it does only for someone it doesn't know the name of, asking how
|
|
103
103
|
to call them, and otherwise the stream carries a single `final` with
|
|
104
|
-
an empty text.
|
|
104
|
+
an empty text. Every text the person reads that isn't the model's (a
|
|
105
|
+
refusal, a status line, a confirmation's reply) is in their language:
|
|
106
|
+
the one they saved, else the one `Accept-Language` asks for, else the
|
|
107
|
+
instance's default. The model reads the person's message after when
|
|
108
|
+
and where they wrote it: in the time zone they saved, else the one
|
|
109
|
+
`X-Time-Zone` says, else the instance's.
|
|
110
|
+
parameters:
|
|
111
|
+
- $ref: "#/components/parameters/AcceptLanguage"
|
|
112
|
+
- $ref: "#/components/parameters/TimeZone"
|
|
105
113
|
requestBody:
|
|
106
114
|
required: true
|
|
107
115
|
content:
|
|
@@ -125,6 +133,9 @@ paths:
|
|
|
125
133
|
service the caller has to log in to first, with the link to open),
|
|
126
134
|
`final` (the
|
|
127
135
|
authoritative complete answer), and `error`.
|
|
136
|
+
headers:
|
|
137
|
+
Content-Language:
|
|
138
|
+
$ref: "#/components/headers/ContentLanguage"
|
|
128
139
|
content:
|
|
129
140
|
text/event-stream:
|
|
130
141
|
schema:
|
|
@@ -141,7 +152,10 @@ paths:
|
|
|
141
152
|
description: >
|
|
142
153
|
Runs a one-time confirmation token through the same mechanism the
|
|
143
154
|
bare-token-in-`text` path uses, keyed on the caller's own conversation
|
|
144
|
-
(`<caller id>:<conversationId>`). Never touches the model.
|
|
155
|
+
(`<caller id>:<conversationId>`). Never touches the model. The reply
|
|
156
|
+
is in the caller's language, as on `/turn`.
|
|
157
|
+
parameters:
|
|
158
|
+
- $ref: "#/components/parameters/AcceptLanguage"
|
|
145
159
|
requestBody:
|
|
146
160
|
required: true
|
|
147
161
|
content:
|
|
@@ -162,6 +176,9 @@ paths:
|
|
|
162
176
|
already used). `resolved: true` with `resume: true` and no `text`
|
|
163
177
|
when the action handed its result back to the model: the answer
|
|
164
178
|
comes from `POST /turn { resume: true, conversationId }`.
|
|
179
|
+
headers:
|
|
180
|
+
Content-Language:
|
|
181
|
+
$ref: "#/components/headers/ContentLanguage"
|
|
165
182
|
content:
|
|
166
183
|
application/json:
|
|
167
184
|
schema:
|
|
@@ -329,8 +346,10 @@ paths:
|
|
|
329
346
|
summary: Who the caller is to the assistant
|
|
330
347
|
description: >
|
|
331
348
|
How the caller asked to be called, `null` when they never said: a UI
|
|
332
|
-
asks for an opening turn after a sign-in only then.
|
|
333
|
-
|
|
349
|
+
asks for an opening turn after a sign-in only then. Also the language
|
|
350
|
+
they asked the assistant to use and the time zone they saved, `null`
|
|
351
|
+
when they never said (missing on a core that doesn't know them). Not
|
|
352
|
+
there on an older core, which can't say.
|
|
334
353
|
responses:
|
|
335
354
|
"401":
|
|
336
355
|
$ref: "#/components/responses/Unauthorized"
|
|
@@ -339,7 +358,7 @@ paths:
|
|
|
339
358
|
"503":
|
|
340
359
|
$ref: "#/components/responses/NotAdmitted"
|
|
341
360
|
"200":
|
|
342
|
-
description: The caller's name, or null.
|
|
361
|
+
description: The caller's name, language and time zone, or null.
|
|
343
362
|
content:
|
|
344
363
|
application/json:
|
|
345
364
|
schema:
|
|
@@ -350,6 +369,55 @@ paths:
|
|
|
350
369
|
type: boolean
|
|
351
370
|
name:
|
|
352
371
|
type: [string, "null"]
|
|
372
|
+
language:
|
|
373
|
+
type: [string, "null"]
|
|
374
|
+
description: A language tag such as `it` or `pt-br`, one the instance ships.
|
|
375
|
+
timeZone:
|
|
376
|
+
type: [string, "null"]
|
|
377
|
+
description: The time zone's IANA name, such as `Europe/Rome`.
|
|
378
|
+
patch:
|
|
379
|
+
summary: Saves the caller's time zone
|
|
380
|
+
description: >
|
|
381
|
+
The time zone the caller is in, by its IANA name, for a UI's settings:
|
|
382
|
+
from then on their turns are stamped with the time there, whatever
|
|
383
|
+
`X-Time-Zone` says. Saved as the IANA list writes it (`europe/rome` is
|
|
384
|
+
`Europe/Rome`). Not there on a core without settings.
|
|
385
|
+
requestBody:
|
|
386
|
+
required: true
|
|
387
|
+
content:
|
|
388
|
+
application/json:
|
|
389
|
+
schema:
|
|
390
|
+
type: object
|
|
391
|
+
required: [timeZone]
|
|
392
|
+
properties:
|
|
393
|
+
timeZone:
|
|
394
|
+
type: string
|
|
395
|
+
description: An IANA name such as `Europe/Rome`; a fixed offset (`+02:00`) isn't one.
|
|
396
|
+
responses:
|
|
397
|
+
"400":
|
|
398
|
+
description: The body has no `timeZone`, or it isn't a zone's name.
|
|
399
|
+
content:
|
|
400
|
+
application/json:
|
|
401
|
+
schema:
|
|
402
|
+
$ref: "#/components/schemas/Error"
|
|
403
|
+
"401":
|
|
404
|
+
$ref: "#/components/responses/Unauthorized"
|
|
405
|
+
"403":
|
|
406
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
407
|
+
"503":
|
|
408
|
+
$ref: "#/components/responses/NotAdmitted"
|
|
409
|
+
"200":
|
|
410
|
+
description: The time zone as saved.
|
|
411
|
+
content:
|
|
412
|
+
application/json:
|
|
413
|
+
schema:
|
|
414
|
+
type: object
|
|
415
|
+
required: [ok, timeZone]
|
|
416
|
+
properties:
|
|
417
|
+
ok:
|
|
418
|
+
type: boolean
|
|
419
|
+
timeZone:
|
|
420
|
+
type: string
|
|
353
421
|
/tool-log:
|
|
354
422
|
get:
|
|
355
423
|
summary: The tool calls made in the caller's recent turns
|
|
@@ -493,20 +561,31 @@ paths:
|
|
|
493
561
|
Mounted when `HTTP_SURFACE_PUBLIC_URL` is set. The service sends the
|
|
494
562
|
person's browser here with `code` and `state` (or `error`); the
|
|
495
563
|
single-use state is the credential, so no bearer token is needed.
|
|
496
|
-
Answers with a plain-text page
|
|
564
|
+
Answers with a plain-text page, in the language the browser asks for
|
|
565
|
+
(`Accept-Language`), else the instance's default; a failed login reads
|
|
566
|
+
in the language of the person who started it.
|
|
497
567
|
security: []
|
|
498
568
|
parameters:
|
|
569
|
+
- $ref: "#/components/parameters/AcceptLanguage"
|
|
499
570
|
- { name: code, in: query, schema: { type: string } }
|
|
500
571
|
- { name: state, in: query, schema: { type: string } }
|
|
501
572
|
- { name: error, in: query, schema: { type: string } }
|
|
502
573
|
responses:
|
|
503
574
|
"200":
|
|
504
575
|
description: Logged in.
|
|
576
|
+
headers:
|
|
577
|
+
Content-Language:
|
|
578
|
+
$ref: "#/components/headers/ContentLanguage"
|
|
505
579
|
content:
|
|
506
580
|
text/plain:
|
|
507
581
|
schema: { type: string }
|
|
508
582
|
"400":
|
|
509
583
|
description: Refused, expired, already used, or missing code/state.
|
|
584
|
+
headers:
|
|
585
|
+
Content-Language:
|
|
586
|
+
description: Set on a denied or incomplete login (the browser's language); not on a failed one, which reads in the language of whoever started it.
|
|
587
|
+
schema:
|
|
588
|
+
type: string
|
|
510
589
|
content:
|
|
511
590
|
text/plain:
|
|
512
591
|
schema: { type: string }
|
|
@@ -522,6 +601,37 @@ paths:
|
|
|
522
601
|
schema:
|
|
523
602
|
type: string
|
|
524
603
|
components:
|
|
604
|
+
parameters:
|
|
605
|
+
AcceptLanguage:
|
|
606
|
+
name: Accept-Language
|
|
607
|
+
in: header
|
|
608
|
+
required: false
|
|
609
|
+
description: >
|
|
610
|
+
The languages the client prefers, as browsers send them
|
|
611
|
+
(`it-IT,it;q=0.9,en;q=0.8`). Used for the texts the person reads when
|
|
612
|
+
they saved no language of their own; a language the instance doesn't
|
|
613
|
+
ship is passed over.
|
|
614
|
+
schema:
|
|
615
|
+
type: string
|
|
616
|
+
TimeZone:
|
|
617
|
+
name: X-Time-Zone
|
|
618
|
+
in: header
|
|
619
|
+
required: false
|
|
620
|
+
description: >
|
|
621
|
+
The time zone the client is in, by its IANA name, as a browser gives
|
|
622
|
+
it (`Intl.DateTimeFormat().resolvedOptions().timeZone`). Used for the
|
|
623
|
+
turn's time when the person saved no time zone of their own; a name
|
|
624
|
+
that isn't a zone is passed over.
|
|
625
|
+
schema:
|
|
626
|
+
type: string
|
|
627
|
+
headers:
|
|
628
|
+
ContentLanguage:
|
|
629
|
+
description: >
|
|
630
|
+
The language of the caller's texts as it stood when the response
|
|
631
|
+
started, one the instance ships (`en`, `it`); a person who picks
|
|
632
|
+
another language during a turn gets it from the next one.
|
|
633
|
+
schema:
|
|
634
|
+
type: string
|
|
525
635
|
securitySchemes:
|
|
526
636
|
bearerAuth:
|
|
527
637
|
type: http
|
|
@@ -545,7 +655,11 @@ components:
|
|
|
545
655
|
description: >
|
|
546
656
|
An authenticated caller the core won't talk to: `403` when the app's
|
|
547
657
|
directory doesn't know them, `503` when it can't be reached. Nothing
|
|
548
|
-
behind the route runs.
|
|
658
|
+
behind the route runs. `error` is in the language `Accept-Language`
|
|
659
|
+
asks for, which `Content-Language` names.
|
|
660
|
+
headers:
|
|
661
|
+
Content-Language:
|
|
662
|
+
$ref: "#/components/headers/ContentLanguage"
|
|
549
663
|
content:
|
|
550
664
|
application/json:
|
|
551
665
|
schema:
|
|
@@ -616,7 +730,7 @@ components:
|
|
|
616
730
|
description: On `pending` — the one-time confirmation token (the first choice's).
|
|
617
731
|
choices:
|
|
618
732
|
type: array
|
|
619
|
-
description: On `pending` — every way to answer, in order; a plain staging has one,
|
|
733
|
+
description: On `pending` — every way to answer, in order, each label in the caller's language; a plain staging has one with an empty label, which the UI words itself (a "Confirm" button).
|
|
620
734
|
items:
|
|
621
735
|
type: object
|
|
622
736
|
required: [token, label]
|
|
@@ -634,6 +748,13 @@ components:
|
|
|
634
748
|
text:
|
|
635
749
|
type: string
|
|
636
750
|
description: On `final` — the authoritative complete answer.
|
|
751
|
+
pendingNote:
|
|
752
|
+
type: string
|
|
753
|
+
description: >
|
|
754
|
+
On `final`, when the model wrote nothing because the turn staged an
|
|
755
|
+
action: the stand-in sentence `text` begins with (followed by any
|
|
756
|
+
list the turn surfaced). A UI showing its own confirmation hides
|
|
757
|
+
it; the conversation's transcript keeps it.
|
|
637
758
|
message:
|
|
638
759
|
type: string
|
|
639
760
|
description: On `error` — the failure message.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@mercury-fw/channel-types": ">=0.
|
|
21
|
+
"@mercury-fw/channel-types": ">=0.54.0 <1.0.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@mercury-fw/channel-types": "0.
|
|
25
|
-
"@mercury-fw/plugin-types": "0.
|
|
24
|
+
"@mercury-fw/channel-types": "0.56.0",
|
|
25
|
+
"@mercury-fw/plugin-types": "0.56.0",
|
|
26
26
|
"@mercury-fw/typescript-config": "*",
|
|
27
27
|
"@types/bun": "^1.4.2",
|
|
28
28
|
"typescript": "^6.0.3"
|