@mercury-fw/channel-http 0.10.1 → 0.11.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 +32 -0
- package/README.md +9 -5
- package/dist/http-provider.d.ts +3 -1
- package/dist/http-server.d.ts +12 -6
- package/dist/messages.d.ts +10 -0
- package/http-provider.ts +4 -1
- package/http-server.ts +79 -41
- package/index.ts +4 -1
- package/messages.ts +20 -0
- package/openapi.yaml +69 -8
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @mercury-fw/channel-http
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e00ff73: The channels' texts come out in the person's language too, and the language their client asks for counts. Channel contract version 6:
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- 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)`.
|
|
11
|
+
- 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).
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- The terminal's replies to a confirmation read in the terminal person's language, like its other lines.
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
22
|
+
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.
|
|
23
|
+
|
|
24
|
+
An existing app keeps working, with these changes:
|
|
25
|
+
|
|
26
|
+
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 } }`.
|
|
27
|
+
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).
|
|
28
|
+
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.
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
3
35
|
## 0.10.1
|
|
4
36
|
|
|
5
37
|
### 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.
|
|
6
|
+
|
|
5
7
|
```bash
|
|
6
8
|
bun add @mercury-fw/channel-http
|
|
7
9
|
```
|
|
@@ -52,6 +54,8 @@ 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
|
+
|
|
55
59
|
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
60
|
|
|
57
61
|
**SSE events** — each is `event: <name>` followed by `data: <json>`:
|
|
@@ -63,9 +67,9 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
|
|
|
63
67
|
| `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
68
|
| `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
|
|
65
69
|
| `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
|
|
70
|
+
| `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
71
|
| `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) |
|
|
72
|
+
| `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
73
|
| `error` | `{ message }` | the turn failed mid-stream |
|
|
70
74
|
|
|
71
75
|
```bash
|
|
@@ -77,7 +81,7 @@ curl -N -X POST http://localhost:4100/turn \
|
|
|
77
81
|
|
|
78
82
|
## `GET /login/callback`
|
|
79
83
|
|
|
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.
|
|
84
|
+
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
85
|
|
|
82
86
|
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
87
|
|
|
@@ -87,7 +91,7 @@ A code for linking another of the caller's accounts to them, for the UI to show:
|
|
|
87
91
|
|
|
88
92
|
## `POST /confirm`
|
|
89
93
|
|
|
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.
|
|
94
|
+
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
95
|
|
|
92
96
|
## Read-only introspection
|
|
93
97
|
|
|
@@ -99,7 +103,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
|
|
|
99
103
|
| `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
|
|
100
104
|
| `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
|
|
101
105
|
| `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;
|
|
106
|
+
| `GET /me` | `{ name, language }` — how you asked the assistant to call you and the language you asked it to use, `null` when you never said; `language` is missing on a core that doesn't know languages, and the route isn't there on a core older than this channel |
|
|
103
107
|
| `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
|
|
104
108
|
| `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
|
|
105
109
|
| `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 } from "@mercury-fw/channel-types";
|
|
15
15
|
export type HttpProviderDeps = {
|
|
16
16
|
port: number;
|
|
17
17
|
/** Bare-token interception before the model. */
|
|
@@ -38,6 +38,8 @@ export type HttpProviderDeps = {
|
|
|
38
38
|
resumeMaxChars?: number;
|
|
39
39
|
/** What the pages people read call the assistant. */
|
|
40
40
|
assistantName?: string;
|
|
41
|
+
/** The texts in each caller's language (`ctx.i18n`). */
|
|
42
|
+
i18n?: ChannelI18n;
|
|
41
43
|
};
|
|
42
44
|
export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
|
|
43
45
|
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 } 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,6 +122,7 @@ 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
127
|
/** A single read route: its `GET` handler plus the shared `OPTIONS` preflight. */
|
|
122
128
|
type ReadRoute = {
|
|
@@ -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 } from "@mercury-fw/channel-types";
|
|
15
15
|
|
|
16
16
|
export type HttpProviderDeps = {
|
|
17
17
|
port: number;
|
|
@@ -35,6 +35,8 @@ export type HttpProviderDeps = {
|
|
|
35
35
|
resumeMaxChars?: number;
|
|
36
36
|
/** What the pages people read call the assistant. */
|
|
37
37
|
assistantName?: string;
|
|
38
|
+
/** The texts in each caller's language (`ctx.i18n`). */
|
|
39
|
+
i18n?: ChannelI18n;
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
|
|
@@ -54,6 +56,7 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
|
|
|
54
56
|
...(deps.linking === undefined ? {} : { linking: deps.linking }),
|
|
55
57
|
...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
|
|
56
58
|
...(deps.assistantName === undefined ? {} : { assistantName: deps.assistantName }),
|
|
59
|
+
...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
|
|
57
60
|
});
|
|
58
61
|
deps.logins?.accept(deps.logins.callbackUrl);
|
|
59
62
|
},
|
package/http-server.ts
CHANGED
|
@@ -25,18 +25,18 @@
|
|
|
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,
|
|
39
38
|
} from "@mercury-fw/channel-types";
|
|
39
|
+
import { englishHttp } from "./messages.ts";
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* A client's conversation id becomes part of the session key and the log
|
|
@@ -51,13 +51,24 @@ function forLog(text: string): string {
|
|
|
51
51
|
return text.replace(/[\u0000-\u001f\u007f]/g, "?");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** Resolves a bare confirmation token to
|
|
55
|
-
export type ConfirmFn =
|
|
54
|
+
/** 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`). */
|
|
55
|
+
export type ConfirmFn = ChannelRuntimeContext["confirm"];
|
|
56
56
|
/** The structured sibling of {@link ConfirmFn}, for the `/confirm` `resolved` flag. Injected by the core (`ctx.resolveConfirmation`). */
|
|
57
|
-
export type ResolveConfirmationFn =
|
|
57
|
+
export type ResolveConfirmationFn = NonNullable<ChannelRuntimeContext["resolveConfirmation"]>;
|
|
58
58
|
|
|
59
59
|
/** 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>;
|
|
60
|
+
export type AdmitFn = (principal: Principal, hint?: string) => Promise<Admission>;
|
|
61
|
+
|
|
62
|
+
/** The language the caller's client asks for (`Accept-Language`), when it says. */
|
|
63
|
+
function languageHint(req: Request): string | undefined {
|
|
64
|
+
const value = req.headers.get("accept-language")?.trim();
|
|
65
|
+
return value ? value : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `Content-Language` for a response to `principal` asking for `hint`, when the core says which language its texts are in. */
|
|
69
|
+
async function contentLanguage(i18n: ChannelI18n | undefined, principal: Principal | null, hint: string | undefined): Promise<Record<string, string>> {
|
|
70
|
+
return i18n ? { "content-language": (await i18n.forRequest(principal, hint)).locale } : {};
|
|
71
|
+
}
|
|
61
72
|
|
|
62
73
|
export type TurnRequestDeps = {
|
|
63
74
|
handleTurn: HandleTurn;
|
|
@@ -66,6 +77,8 @@ export type TurnRequestDeps = {
|
|
|
66
77
|
admit?: AdmitFn;
|
|
67
78
|
/** Bare-token interception before the model, injected by the core. */
|
|
68
79
|
confirm: ConfirmFn;
|
|
80
|
+
/** The texts in the caller's language, injected by the core (`ctx.i18n`): what `Content-Language` says. */
|
|
81
|
+
i18n?: ChannelI18n;
|
|
69
82
|
/** Allowed CORS origin echoed back to a browser UI; defaults to `*`. */
|
|
70
83
|
corsOrigin?: string;
|
|
71
84
|
/** Test seam for the ephemeral session key when the client sends no conversationId. */
|
|
@@ -92,7 +105,7 @@ function corsHeaders(origin: string): Record<string, string> {
|
|
|
92
105
|
return {
|
|
93
106
|
"access-control-allow-origin": origin,
|
|
94
107
|
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
95
|
-
"access-control-allow-headers": "content-type, authorization",
|
|
108
|
+
"access-control-allow-headers": "content-type, authorization, accept-language",
|
|
96
109
|
};
|
|
97
110
|
}
|
|
98
111
|
|
|
@@ -104,20 +117,26 @@ function unauthorized(origin: string): Response {
|
|
|
104
117
|
);
|
|
105
118
|
}
|
|
106
119
|
|
|
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 {
|
|
120
|
+
/** 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. */
|
|
121
|
+
function notAdmitted(admission: Exclude<Admission, { ok: true }>, origin: string, language: Record<string, string>): Response {
|
|
109
122
|
return Response.json(
|
|
110
123
|
{ ok: false, error: admission.message, reason: admission.reason },
|
|
111
|
-
{ status: admission.reason === "unknown" ? 403 : 503, headers: corsHeaders(origin) },
|
|
124
|
+
{ status: admission.reason === "unknown" ? 403 : 503, headers: { ...corsHeaders(origin), ...language } },
|
|
112
125
|
);
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
/** The caller of `req`, authenticated and admitted, or the response refusing them. */
|
|
116
|
-
async function caller(
|
|
129
|
+
async function caller(
|
|
130
|
+
req: Request,
|
|
131
|
+
deps: { authenticate: Authenticate; admit?: AdmitFn; i18n?: ChannelI18n },
|
|
132
|
+
origin: string,
|
|
133
|
+
): Promise<Principal | Response> {
|
|
117
134
|
const principal = await deps.authenticate(req);
|
|
118
135
|
if (principal === null) return unauthorized(origin);
|
|
119
|
-
const
|
|
120
|
-
|
|
136
|
+
const hint = languageHint(req);
|
|
137
|
+
const admission = deps.admit ? await deps.admit(principal, hint) : { ok: true as const };
|
|
138
|
+
// A refused caller is nobody the core knows: their client's language, else the default.
|
|
139
|
+
return admission.ok ? principal : notAdmitted(admission, origin, await contentLanguage(deps.i18n, null, hint));
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
/** 204 preflight response for an `OPTIONS` request, carrying only CORS headers. */
|
|
@@ -187,6 +206,8 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
187
206
|
return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
|
|
188
207
|
}
|
|
189
208
|
const sessionKey = `${principal.id}:${conversationId}`;
|
|
209
|
+
const hint = languageHint(req);
|
|
210
|
+
const language = await contentLanguage(deps.i18n, principal, hint);
|
|
190
211
|
|
|
191
212
|
// Aborted when the client disconnects (see the stream's `cancel` below) so
|
|
192
213
|
// the in-flight turn stops instead of running to completion — the "stop"
|
|
@@ -215,21 +236,19 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
215
236
|
|
|
216
237
|
// Same deterministic interception as every other channel — a
|
|
217
238
|
// previously-approved mutation must never depend on the model.
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
220
|
-
send("final", { text:
|
|
239
|
+
const reply = opening || resume ? null : await deps.confirm(text, sessionKey, principal, hint);
|
|
240
|
+
if (reply !== null && !reply.resume) {
|
|
241
|
+
send("final", { text: reply.text });
|
|
221
242
|
close();
|
|
222
243
|
return;
|
|
223
244
|
}
|
|
224
245
|
// The confirmed action's result goes back to the model: the turn runs again, on this stream.
|
|
225
|
-
const resuming = resume ||
|
|
246
|
+
const resuming = resume || reply?.resume === true;
|
|
226
247
|
|
|
227
248
|
const sink: TurnSink = {
|
|
228
249
|
onToolStart: (label, detail, toolCallId, toolName) => send("tool", { label, detail, toolCallId, name: toolName }),
|
|
229
250
|
onToolFinish: (toolCallId, outcome) => send("tool_finish", { toolCallId, outcome }),
|
|
230
|
-
onTextChunk: (chunk) => {
|
|
231
|
-
if (chunk !== PENDING_CONFIRMATION_NOTE) send("text", { chunk });
|
|
232
|
-
},
|
|
251
|
+
onTextChunk: (chunk) => send("text", { chunk }),
|
|
233
252
|
onReasoningChunk: (chunk, id) => send("reasoning", { chunk, id }),
|
|
234
253
|
onReasoningEnd: (id, failed) => send("reasoning_end", { id, failed }),
|
|
235
254
|
onStep: (step) => {
|
|
@@ -239,7 +258,10 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
239
258
|
if (login) send("login", { service: login.service, url: login.url });
|
|
240
259
|
},
|
|
241
260
|
onUsage: () => {},
|
|
242
|
-
|
|
261
|
+
// The core says when the text is its stand-in for an answer the
|
|
262
|
+
// model didn't write: a UI showing its own confirmation hides it.
|
|
263
|
+
finalize: async (finalText, info) =>
|
|
264
|
+
send("final", info?.pendingNote === undefined ? { text: finalText } : { text: finalText, pendingNote: info.pendingNote }),
|
|
243
265
|
dispose: () => {},
|
|
244
266
|
};
|
|
245
267
|
|
|
@@ -257,6 +279,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
257
279
|
...(named ? { resume: deps.resumeMaxChars === undefined ? {} : { maxChars: deps.resumeMaxChars } } : {}),
|
|
258
280
|
...(opening ? { opening: true as const } : {}),
|
|
259
281
|
...(resuming ? { resumeAfterConfirm: true as const } : {}),
|
|
282
|
+
...(hint === undefined ? {} : { languageHint: hint }),
|
|
260
283
|
},
|
|
261
284
|
sink,
|
|
262
285
|
);
|
|
@@ -276,7 +299,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
276
299
|
},
|
|
277
300
|
});
|
|
278
301
|
|
|
279
|
-
return new Response(stream, { headers: { ...SSE_HEADERS, ...cors } });
|
|
302
|
+
return new Response(stream, { headers: { ...SSE_HEADERS, ...cors, ...language } });
|
|
280
303
|
}
|
|
281
304
|
|
|
282
305
|
export type ConfirmRequestDeps = {
|
|
@@ -284,6 +307,8 @@ export type ConfirmRequestDeps = {
|
|
|
284
307
|
admit?: AdmitFn;
|
|
285
308
|
resolveConfirmation: ResolveConfirmationFn;
|
|
286
309
|
corsOrigin?: string;
|
|
310
|
+
/** The texts in the caller's language, injected by the core: what `Content-Language` says. */
|
|
311
|
+
i18n?: ChannelI18n;
|
|
287
312
|
};
|
|
288
313
|
|
|
289
314
|
/**
|
|
@@ -294,7 +319,8 @@ export type ConfirmRequestDeps = {
|
|
|
294
319
|
* keyed on the caller's own session (`<principal.id>:<conversationId>`), so a
|
|
295
320
|
* token staged in someone else's conversation is never found. `resolved: true` means the token matched a
|
|
296
321
|
* pending confirmation and its staged action was consumed and run; the `text`
|
|
297
|
-
* then reports whether that execution
|
|
322
|
+
* (the core's, in the caller's language) then reports whether that execution
|
|
323
|
+
* succeeded. `resolved: false` means the
|
|
298
324
|
* token was not a pending confirmation (unknown, expired, already used, or not
|
|
299
325
|
* even token-shaped). `resume: true` (with no `text`) means the action handed
|
|
300
326
|
* its result back to the model: the client hears its answer by opening
|
|
@@ -319,18 +345,19 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
|
|
|
319
345
|
if (!CONVERSATION_ID.test(conversationId)) {
|
|
320
346
|
return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
|
|
321
347
|
}
|
|
322
|
-
const
|
|
348
|
+
const hint = languageHint(req);
|
|
349
|
+
const headers = { ...cors, ...(await contentLanguage(deps.i18n, principal, hint)) };
|
|
350
|
+
const outcome = await deps.resolveConfirmation(token, `${principal.id}:${conversationId}`, principal, hint);
|
|
323
351
|
switch (outcome.status) {
|
|
324
352
|
case "not-a-token":
|
|
325
353
|
case "not-found":
|
|
326
|
-
return Response.json({ ok: true, resolved: false }, { headers
|
|
354
|
+
return Response.json({ ok: true, resolved: false }, { headers });
|
|
327
355
|
case "ok":
|
|
328
|
-
return Response.json({ ok: true, resolved: true, text: `Confermato ed eseguito: ${JSON.stringify(outcome.data)}` }, { headers: cors });
|
|
329
356
|
case "failed":
|
|
330
|
-
return Response.json({ ok: true, resolved: true, text:
|
|
357
|
+
return Response.json({ ok: true, resolved: true, text: outcome.text }, { headers });
|
|
331
358
|
case "resume":
|
|
332
359
|
// 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
|
|
360
|
+
return Response.json({ ok: true, resolved: true, resume: true }, { headers });
|
|
334
361
|
}
|
|
335
362
|
}
|
|
336
363
|
|
|
@@ -365,11 +392,11 @@ function linkRoutes(deps: LinkRequestDeps): Record<string, { POST: (req: Request
|
|
|
365
392
|
export type CompleteLoginFn = (state: string, code: string) => Promise<LoginOutcome>;
|
|
366
393
|
|
|
367
394
|
/** A plain-text page for the person's browser, at the end of their login. */
|
|
368
|
-
function loginPage(text: string, status: number): Response {
|
|
395
|
+
function loginPage(text: string, status: number, language: Record<string, string> = {}): Response {
|
|
369
396
|
// The URL carried the code and the state: neither kept nor passed on.
|
|
370
397
|
return new Response(text, {
|
|
371
398
|
status,
|
|
372
|
-
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" },
|
|
399
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", ...language },
|
|
373
400
|
});
|
|
374
401
|
}
|
|
375
402
|
|
|
@@ -383,32 +410,41 @@ function loginPage(text: string, status: number): Response {
|
|
|
383
410
|
*/
|
|
384
411
|
export async function handleLoginCallback(
|
|
385
412
|
req: Request,
|
|
386
|
-
deps: { complete: CompleteLoginFn; assistantName?: string },
|
|
413
|
+
deps: { complete: CompleteLoginFn; assistantName?: string; i18n?: ChannelI18n },
|
|
387
414
|
): Promise<Response> {
|
|
388
415
|
const assistant = deps.assistantName ?? "Mercury";
|
|
416
|
+
// Nobody is known on this page: the browser's language, then the default.
|
|
417
|
+
const texts = deps.i18n ? await deps.i18n.forRequest(null, languageHint(req)) : undefined;
|
|
418
|
+
const t = texts?.t ?? englishHttp;
|
|
419
|
+
const language: Record<string, string> = texts ? { "content-language": texts.locale } : {};
|
|
389
420
|
const params = new URL(req.url).searchParams;
|
|
390
421
|
const error = params.get("error");
|
|
391
422
|
if (error !== null) {
|
|
392
423
|
const reason = error.replace(/[^A-Za-z0-9_.-]/g, "").slice(0, 64);
|
|
393
|
-
return loginPage(
|
|
424
|
+
return loginPage(t("http.login.denied", { reason, assistant }), 400, language);
|
|
394
425
|
}
|
|
395
426
|
const code = params.get("code");
|
|
396
427
|
const state = params.get("state");
|
|
397
|
-
if (!code || !state) return loginPage(
|
|
428
|
+
if (!code || !state) return loginPage(t("http.login.incomplete", { assistant }), 400, language);
|
|
398
429
|
const outcome = await deps.complete(state, code);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
: loginPage(outcome.error, 400);
|
|
430
|
+
// A failed login's text is the core's, in the language of whoever started it: no header claims otherwise.
|
|
431
|
+
return outcome.ok ? loginPage(t("http.login.done", { service: outcome.service }), 200, language) : loginPage(outcome.error, 400);
|
|
402
432
|
}
|
|
403
433
|
|
|
404
434
|
/** The login callback route, mounted when the server can complete logins. */
|
|
405
435
|
function loginRoutes(
|
|
406
436
|
complete: CompleteLoginFn,
|
|
407
437
|
assistantName: string | undefined,
|
|
438
|
+
i18n: ChannelI18n | undefined,
|
|
408
439
|
): Record<string, { GET: (req: Request) => Promise<Response> }> {
|
|
409
440
|
return {
|
|
410
441
|
"/login/callback": {
|
|
411
|
-
GET: (req) =>
|
|
442
|
+
GET: (req) =>
|
|
443
|
+
handleLoginCallback(req, {
|
|
444
|
+
complete,
|
|
445
|
+
...(assistantName === undefined ? {} : { assistantName }),
|
|
446
|
+
...(i18n === undefined ? {} : { i18n }),
|
|
447
|
+
}),
|
|
412
448
|
},
|
|
413
449
|
};
|
|
414
450
|
}
|
|
@@ -481,8 +517,9 @@ export function readRoutes(
|
|
|
481
517
|
: {
|
|
482
518
|
"/me": route(async (_req, principal) => {
|
|
483
519
|
// Null when the core no longer admits the caller: still a name, null.
|
|
484
|
-
const me = (await reads.me!(principal)) as { name: string | null } | null;
|
|
485
|
-
|
|
520
|
+
const me = (await reads.me!(principal)) as { name: string | null; language?: string | null } | null;
|
|
521
|
+
// An older core says no language: the reply then has none either.
|
|
522
|
+
return json({ ok: true, name: me?.name ?? null, ...(me?.language === undefined ? {} : { language: me.language }) });
|
|
486
523
|
}),
|
|
487
524
|
}),
|
|
488
525
|
"/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
|
|
@@ -547,12 +584,13 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
|
|
|
547
584
|
...(deps.admit === undefined ? {} : { admit: deps.admit }),
|
|
548
585
|
resolveConfirmation: deps.resolveConfirmation,
|
|
549
586
|
corsOrigin: origin,
|
|
587
|
+
...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
|
|
550
588
|
}),
|
|
551
589
|
OPTIONS: () => preflight(origin),
|
|
552
590
|
},
|
|
553
591
|
"/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
|
|
554
592
|
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit) : {}),
|
|
555
|
-
...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName) : {}),
|
|
593
|
+
...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName, deps.i18n) : {}),
|
|
556
594
|
...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
|
|
557
595
|
},
|
|
558
596
|
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,6 +46,7 @@ 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 }),
|
|
48
51
|
...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
|
|
49
52
|
...(ctx.assistantName === undefined ? {} : { assistantName: ctx.assistantName }),
|
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,12 @@ 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.
|
|
108
|
+
parameters:
|
|
109
|
+
- $ref: "#/components/parameters/AcceptLanguage"
|
|
105
110
|
requestBody:
|
|
106
111
|
required: true
|
|
107
112
|
content:
|
|
@@ -125,6 +130,9 @@ paths:
|
|
|
125
130
|
service the caller has to log in to first, with the link to open),
|
|
126
131
|
`final` (the
|
|
127
132
|
authoritative complete answer), and `error`.
|
|
133
|
+
headers:
|
|
134
|
+
Content-Language:
|
|
135
|
+
$ref: "#/components/headers/ContentLanguage"
|
|
128
136
|
content:
|
|
129
137
|
text/event-stream:
|
|
130
138
|
schema:
|
|
@@ -141,7 +149,10 @@ paths:
|
|
|
141
149
|
description: >
|
|
142
150
|
Runs a one-time confirmation token through the same mechanism the
|
|
143
151
|
bare-token-in-`text` path uses, keyed on the caller's own conversation
|
|
144
|
-
(`<caller id>:<conversationId>`). Never touches the model.
|
|
152
|
+
(`<caller id>:<conversationId>`). Never touches the model. The reply
|
|
153
|
+
is in the caller's language, as on `/turn`.
|
|
154
|
+
parameters:
|
|
155
|
+
- $ref: "#/components/parameters/AcceptLanguage"
|
|
145
156
|
requestBody:
|
|
146
157
|
required: true
|
|
147
158
|
content:
|
|
@@ -162,6 +173,9 @@ paths:
|
|
|
162
173
|
already used). `resolved: true` with `resume: true` and no `text`
|
|
163
174
|
when the action handed its result back to the model: the answer
|
|
164
175
|
comes from `POST /turn { resume: true, conversationId }`.
|
|
176
|
+
headers:
|
|
177
|
+
Content-Language:
|
|
178
|
+
$ref: "#/components/headers/ContentLanguage"
|
|
165
179
|
content:
|
|
166
180
|
application/json:
|
|
167
181
|
schema:
|
|
@@ -329,8 +343,10 @@ paths:
|
|
|
329
343
|
summary: Who the caller is to the assistant
|
|
330
344
|
description: >
|
|
331
345
|
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
|
-
|
|
346
|
+
asks for an opening turn after a sign-in only then. Also the language
|
|
347
|
+
they asked the assistant to use, `null` when they never said (missing
|
|
348
|
+
when the core doesn't know languages). Not there on an older core,
|
|
349
|
+
which can't say.
|
|
334
350
|
responses:
|
|
335
351
|
"401":
|
|
336
352
|
$ref: "#/components/responses/Unauthorized"
|
|
@@ -339,7 +355,7 @@ paths:
|
|
|
339
355
|
"503":
|
|
340
356
|
$ref: "#/components/responses/NotAdmitted"
|
|
341
357
|
"200":
|
|
342
|
-
description: The caller's name, or null.
|
|
358
|
+
description: The caller's name and language, or null.
|
|
343
359
|
content:
|
|
344
360
|
application/json:
|
|
345
361
|
schema:
|
|
@@ -350,6 +366,9 @@ paths:
|
|
|
350
366
|
type: boolean
|
|
351
367
|
name:
|
|
352
368
|
type: [string, "null"]
|
|
369
|
+
language:
|
|
370
|
+
type: [string, "null"]
|
|
371
|
+
description: A language tag such as `it` or `pt-br`, one the instance ships.
|
|
353
372
|
/tool-log:
|
|
354
373
|
get:
|
|
355
374
|
summary: The tool calls made in the caller's recent turns
|
|
@@ -493,20 +512,31 @@ paths:
|
|
|
493
512
|
Mounted when `HTTP_SURFACE_PUBLIC_URL` is set. The service sends the
|
|
494
513
|
person's browser here with `code` and `state` (or `error`); the
|
|
495
514
|
single-use state is the credential, so no bearer token is needed.
|
|
496
|
-
Answers with a plain-text page
|
|
515
|
+
Answers with a plain-text page, in the language the browser asks for
|
|
516
|
+
(`Accept-Language`), else the instance's default; a failed login reads
|
|
517
|
+
in the language of the person who started it.
|
|
497
518
|
security: []
|
|
498
519
|
parameters:
|
|
520
|
+
- $ref: "#/components/parameters/AcceptLanguage"
|
|
499
521
|
- { name: code, in: query, schema: { type: string } }
|
|
500
522
|
- { name: state, in: query, schema: { type: string } }
|
|
501
523
|
- { name: error, in: query, schema: { type: string } }
|
|
502
524
|
responses:
|
|
503
525
|
"200":
|
|
504
526
|
description: Logged in.
|
|
527
|
+
headers:
|
|
528
|
+
Content-Language:
|
|
529
|
+
$ref: "#/components/headers/ContentLanguage"
|
|
505
530
|
content:
|
|
506
531
|
text/plain:
|
|
507
532
|
schema: { type: string }
|
|
508
533
|
"400":
|
|
509
534
|
description: Refused, expired, already used, or missing code/state.
|
|
535
|
+
headers:
|
|
536
|
+
Content-Language:
|
|
537
|
+
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.
|
|
538
|
+
schema:
|
|
539
|
+
type: string
|
|
510
540
|
content:
|
|
511
541
|
text/plain:
|
|
512
542
|
schema: { type: string }
|
|
@@ -522,6 +552,26 @@ paths:
|
|
|
522
552
|
schema:
|
|
523
553
|
type: string
|
|
524
554
|
components:
|
|
555
|
+
parameters:
|
|
556
|
+
AcceptLanguage:
|
|
557
|
+
name: Accept-Language
|
|
558
|
+
in: header
|
|
559
|
+
required: false
|
|
560
|
+
description: >
|
|
561
|
+
The languages the client prefers, as browsers send them
|
|
562
|
+
(`it-IT,it;q=0.9,en;q=0.8`). Used for the texts the person reads when
|
|
563
|
+
they saved no language of their own; a language the instance doesn't
|
|
564
|
+
ship is passed over.
|
|
565
|
+
schema:
|
|
566
|
+
type: string
|
|
567
|
+
headers:
|
|
568
|
+
ContentLanguage:
|
|
569
|
+
description: >
|
|
570
|
+
The language of the caller's texts as it stood when the response
|
|
571
|
+
started, one the instance ships (`en`, `it`); a person who picks
|
|
572
|
+
another language during a turn gets it from the next one.
|
|
573
|
+
schema:
|
|
574
|
+
type: string
|
|
525
575
|
securitySchemes:
|
|
526
576
|
bearerAuth:
|
|
527
577
|
type: http
|
|
@@ -545,7 +595,11 @@ components:
|
|
|
545
595
|
description: >
|
|
546
596
|
An authenticated caller the core won't talk to: `403` when the app's
|
|
547
597
|
directory doesn't know them, `503` when it can't be reached. Nothing
|
|
548
|
-
behind the route runs.
|
|
598
|
+
behind the route runs. `error` is in the language `Accept-Language`
|
|
599
|
+
asks for, which `Content-Language` names.
|
|
600
|
+
headers:
|
|
601
|
+
Content-Language:
|
|
602
|
+
$ref: "#/components/headers/ContentLanguage"
|
|
549
603
|
content:
|
|
550
604
|
application/json:
|
|
551
605
|
schema:
|
|
@@ -616,7 +670,7 @@ components:
|
|
|
616
670
|
description: On `pending` — the one-time confirmation token (the first choice's).
|
|
617
671
|
choices:
|
|
618
672
|
type: array
|
|
619
|
-
description: On `pending` — every way to answer, in order; a plain staging has one,
|
|
673
|
+
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
674
|
items:
|
|
621
675
|
type: object
|
|
622
676
|
required: [token, label]
|
|
@@ -634,6 +688,13 @@ components:
|
|
|
634
688
|
text:
|
|
635
689
|
type: string
|
|
636
690
|
description: On `final` — the authoritative complete answer.
|
|
691
|
+
pendingNote:
|
|
692
|
+
type: string
|
|
693
|
+
description: >
|
|
694
|
+
On `final`, when the model wrote nothing because the turn staged an
|
|
695
|
+
action: the stand-in sentence `text` begins with (followed by any
|
|
696
|
+
list the turn surfaced). A UI showing its own confirmation hides
|
|
697
|
+
it; the conversation's transcript keeps it.
|
|
637
698
|
message:
|
|
638
699
|
type: string
|
|
639
700
|
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.11.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.54.0",
|
|
25
|
+
"@mercury-fw/plugin-types": "0.54.0",
|
|
26
26
|
"@mercury-fw/typescript-config": "*",
|
|
27
27
|
"@types/bun": "^1.4.2",
|
|
28
28
|
"typescript": "^6.0.3"
|