@mercury-fw/channel-http 0.11.0 → 0.13.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 +24 -0
- package/README.md +13 -3
- package/dist/http-provider.d.ts +5 -1
- package/dist/http-server.d.ts +12 -5
- package/dist/index.d.ts +1 -1
- package/http-provider.ts +7 -1
- package/http-server.ts +62 -12
- package/index.ts +3 -1
- package/openapi.yaml +87 -6
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @mercury-fw/channel-http
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7c9a038: - `GET /version` answers the framework's version (`{ ok: true, version: "0.59.0" }`) with no token, for a UI to show which Mercury it's talking to.
|
|
8
|
+
- The channel now needs the framework at 0.59 or later (peer range `>=0.59.0 <1.0.0`), the first whose core gives channels its version.
|
|
9
|
+
|
|
10
|
+
## 0.12.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- 1126106: Every turn tells the model when and where the person wrote it.
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
- A resumed conversation's messages come back stamped from the archive's timestamps.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- The person saves theirs by telling the assistant (the new `set_time_zone` tool), or from a channel's settings.
|
|
21
|
+
- 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.
|
|
22
|
+
- 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`.
|
|
23
|
+
- Google Chat channel: the client's time zone (`common.timeZone`), when the event carries it, on messages and on the turn a click resumes.
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
3
27
|
## 0.11.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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.
|
|
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.59 or later, the first whose core gives channels its version (`GET /version`). A core with an older contract skips it, and the log says why.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
bun add @mercury-fw/channel-http
|
|
@@ -29,7 +29,7 @@ It needs an auth provider next to it, or several (`auth: [a, b]`, tried in order
|
|
|
29
29
|
|
|
30
30
|
It's active whenever it's declared in `mercury.config.ts`'s `channels` with an auth provider; remove it there to turn the surface off. It listens on `HTTP_SURFACE_PORT` (default `4100`). Base URL `http://<host>:<port>`.
|
|
31
31
|
|
|
32
|
-
**Authentication.** Every route except `GET /openapi.yaml`, `GET /login/callback` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth providers decide who the token belongs to: the first that recognizes it. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs. Then the core decides whether it talks to that person at all: someone the app's directory doesn't know gets `403` (unless the app lets unknown people in), and `503` when the directory can't be reached, both with `{ ok: false, error, reason }`, where `error` is what to tell them.
|
|
32
|
+
**Authentication.** Every route except `GET /openapi.yaml`, `GET /version`, `GET /login/callback` and the `OPTIONS` preflights needs `Authorization: Bearer <token>`, and the auth providers decide who the token belongs to: the first that recognizes it. A missing or refused token gets `401` with `WWW-Authenticate: Bearer` before anything runs. Then the core decides whether it talks to that person at all: someone the app's directory doesn't know gets `403` (unless the app lets unknown people in), and `503` when the directory can't be reached, both with `{ ok: false, error, reason }`, where `error` is what to tell them.
|
|
33
33
|
|
|
34
34
|
**Conversations belong to whoever opened them.** The session key is `<caller id>:<conversationId>`, so the same `conversationId` sent by someone else is a conversation of their own, and a confirmation token staged in your conversation can't be confirmed from theirs. Each authenticated caller also gets their own episodic memory and wiki area, as on Google Chat.
|
|
35
35
|
|
|
@@ -56,6 +56,8 @@ Runs one conversational turn; the reply streams back as Server-Sent Events.
|
|
|
56
56
|
|
|
57
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
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
|
+
|
|
59
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.
|
|
60
62
|
|
|
61
63
|
**SSE events** — each is `event: <name>` followed by `data: <json>`:
|
|
@@ -89,6 +91,10 @@ Whoever opens the link and accepts decides which account gets linked: a link bel
|
|
|
89
91
|
|
|
90
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.
|
|
91
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
|
+
|
|
92
98
|
## `POST /confirm`
|
|
93
99
|
|
|
94
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.
|
|
@@ -103,7 +109,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
|
|
|
103
109
|
| `GET /conversations?limit=<n>` | `{ conversations: [{ conversationId, lastTimestamp, preview }] }` — your conversations, most-recently-active first |
|
|
104
110
|
| `GET /manifest` | `{ manifest: { coreApiVersion, plugins: [{ name, apiVersion, active, skills, hasBuild, customStatus }], activeClis, skills } }` |
|
|
105
111
|
| `GET /confirmations` | `{ pending: [{ sessionKey, summary, expiresAt }] }` — your pending confirmations; tokens are deliberately never included |
|
|
106
|
-
| `GET /me` | `{ name, language }` — how you asked the assistant to call you
|
|
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 |
|
|
107
113
|
| `GET /tool-log` | `{ entries: [...] }` — the tool calls of your recent turns |
|
|
108
114
|
| `GET /health` | `{ uptimeSeconds, memory, qdrantReachable, ollamaReachable }` |
|
|
109
115
|
| `GET /wiki/list` | `{ files: [...] }` — `curated/...` and `personal/...` paths |
|
|
@@ -113,3 +119,7 @@ All `GET`, all JSON, all reporting state already held in-process. They need an a
|
|
|
113
119
|
| `GET /openapi.yaml` | the OpenAPI document for this surface (`text/yaml`) |
|
|
114
120
|
|
|
115
121
|
Conversation history and the conversation list are backed by the durable verbatim archive; they degrade to empty when the vector store is unreachable.
|
|
122
|
+
|
|
123
|
+
## `GET /version`
|
|
124
|
+
|
|
125
|
+
Which Mercury this is, for a UI to show: `{ ok: true, version }`, the framework's version as `major.minor.patch` (`"0.59.0"`), the one of `@mercury-fw/core`, not this channel's own. It needs no token.
|
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, ChannelI18n, 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,12 +34,16 @@ 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;
|
|
41
43
|
/** The texts in each caller's language (`ctx.i18n`). */
|
|
42
44
|
i18n?: ChannelI18n;
|
|
45
|
+
/** The framework's version, served on `/version` (`ctx.frameworkVersion`). */
|
|
46
|
+
version?: string;
|
|
43
47
|
};
|
|
44
48
|
export declare function createHttpProvider(deps: HttpProviderDeps): Provider & {
|
|
45
49
|
stop(): Promise<void>;
|
package/dist/http-server.d.ts
CHANGED
|
@@ -15,14 +15,14 @@
|
|
|
15
15
|
* `/login/callback` is where a person's browser comes back after logging in to
|
|
16
16
|
* a service a plugin acts on as them; the single-use state is its credential.
|
|
17
17
|
*
|
|
18
|
-
* Every route but the OpenAPI document, the login callback and the CORS
|
|
18
|
+
* Every route but the OpenAPI document, `/version`, the login callback and the CORS
|
|
19
19
|
* preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
|
|
20
20
|
* before anything runs, and answers 401 when nobody is. A conversation belongs
|
|
21
21
|
* to whoever opened it: the session key is `<principal.id>:<conversationId>`.
|
|
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 ChannelI18n, type ChannelRuntimeContext, type LoginOutcome, type Principal, type Admission, type ChannelLinking } from "@mercury-fw/channel-types";
|
|
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
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
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`). */
|
|
@@ -124,9 +124,11 @@ export declare function handleLoginCallback(req: Request, deps: {
|
|
|
124
124
|
assistantName?: string;
|
|
125
125
|
i18n?: ChannelI18n;
|
|
126
126
|
}): Promise<Response>;
|
|
127
|
-
/** 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. */
|
|
128
128
|
type ReadRoute = {
|
|
129
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>;
|
|
130
132
|
OPTIONS: () => Response;
|
|
131
133
|
};
|
|
132
134
|
/**
|
|
@@ -138,9 +140,10 @@ type ReadRoute = {
|
|
|
138
140
|
* their data only; a conversation is named by its conversationId, as on
|
|
139
141
|
* `/turn`, and its session key is built from the caller.
|
|
140
142
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
141
|
-
* 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.
|
|
142
145
|
*/
|
|
143
|
-
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>;
|
|
144
147
|
export type HttpServerDeps = TurnRequestDeps & {
|
|
145
148
|
port: number;
|
|
146
149
|
reads?: ChannelHostReads;
|
|
@@ -150,8 +153,12 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
150
153
|
completeLogin?: CompleteLoginFn;
|
|
151
154
|
/** Linking another account to the caller; `/link` is mounted only with it. */
|
|
152
155
|
linking?: ChannelLinking;
|
|
156
|
+
/** The person's own settings; `PATCH /me` is mounted only with it. */
|
|
157
|
+
settings?: ChannelSettings;
|
|
153
158
|
/** What the pages people read call the assistant. */
|
|
154
159
|
assistantName?: string;
|
|
160
|
+
/** The framework's version; `/version` is mounted only with it. */
|
|
161
|
+
version?: string;
|
|
155
162
|
};
|
|
156
163
|
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
157
164
|
* when `reads` is supplied. */
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The HTTP channel plugin: an opt-in conversational HTTP surface (SSE `/turn`,
|
|
3
|
-
* `/confirm`, read-only introspection routes, the OpenAPI doc) that a custom UI
|
|
3
|
+
* `/confirm`, read-only introspection routes, the OpenAPI doc, `/version`) that a custom UI
|
|
4
4
|
* calls. Exported as a `ChannelPlugin` the core's channel loader consumes; it
|
|
5
5
|
* does not import the app. Enabled by listing it in `mercury.config.ts` — there
|
|
6
6
|
* is no runtime inert condition (unlike Google Chat's subscription): if it's
|
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, ChannelI18n, 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,12 +31,16 @@ 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;
|
|
38
40
|
/** The texts in each caller's language (`ctx.i18n`). */
|
|
39
41
|
i18n?: ChannelI18n;
|
|
42
|
+
/** The framework's version, served on `/version` (`ctx.frameworkVersion`). */
|
|
43
|
+
version?: string;
|
|
40
44
|
};
|
|
41
45
|
|
|
42
46
|
export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop(): Promise<void> } {
|
|
@@ -54,9 +58,11 @@ export function createHttpProvider(deps: HttpProviderDeps): Provider & { stop():
|
|
|
54
58
|
corsOrigin: deps.corsOrigin,
|
|
55
59
|
completeLogin: deps.logins?.complete,
|
|
56
60
|
...(deps.linking === undefined ? {} : { linking: deps.linking }),
|
|
61
|
+
...(deps.settings === undefined ? {} : { settings: deps.settings }),
|
|
57
62
|
...(deps.resumeMaxChars === undefined ? {} : { resumeMaxChars: deps.resumeMaxChars }),
|
|
58
63
|
...(deps.assistantName === undefined ? {} : { assistantName: deps.assistantName }),
|
|
59
64
|
...(deps.i18n === undefined ? {} : { i18n: deps.i18n }),
|
|
65
|
+
...(deps.version === undefined ? {} : { version: deps.version }),
|
|
60
66
|
});
|
|
61
67
|
deps.logins?.accept(deps.logins.callbackUrl);
|
|
62
68
|
},
|
package/http-server.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `/login/callback` is where a person's browser comes back after logging in to
|
|
16
16
|
* a service a plugin acts on as them; the single-use state is its credential.
|
|
17
17
|
*
|
|
18
|
-
* Every route but the OpenAPI document, the login callback and the CORS
|
|
18
|
+
* Every route but the OpenAPI document, `/version`, the login callback and the CORS
|
|
19
19
|
* preflights asks the injected `authenticate` (the auth provider the app declares) who is calling
|
|
20
20
|
* before anything runs, and answers 401 when nobody is. A conversation belongs
|
|
21
21
|
* to whoever opened it: the session key is `<principal.id>:<conversationId>`.
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
type Principal,
|
|
36
36
|
type Admission,
|
|
37
37
|
type ChannelLinking,
|
|
38
|
+
type ChannelSettings,
|
|
38
39
|
} from "@mercury-fw/channel-types";
|
|
39
40
|
import { englishHttp } from "./messages.ts";
|
|
40
41
|
|
|
@@ -65,6 +66,12 @@ function languageHint(req: Request): string | undefined {
|
|
|
65
66
|
return value ? value : undefined;
|
|
66
67
|
}
|
|
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
|
+
|
|
68
75
|
/** `Content-Language` for a response to `principal` asking for `hint`, when the core says which language its texts are in. */
|
|
69
76
|
async function contentLanguage(i18n: ChannelI18n | undefined, principal: Principal | null, hint: string | undefined): Promise<Record<string, string>> {
|
|
70
77
|
return i18n ? { "content-language": (await i18n.forRequest(principal, hint)).locale } : {};
|
|
@@ -104,8 +111,8 @@ const SSE_HEADERS = {
|
|
|
104
111
|
function corsHeaders(origin: string): Record<string, string> {
|
|
105
112
|
return {
|
|
106
113
|
"access-control-allow-origin": origin,
|
|
107
|
-
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
108
|
-
"access-control-allow-headers": "content-type, authorization, accept-language",
|
|
114
|
+
"access-control-allow-methods": "GET, POST, PATCH, OPTIONS",
|
|
115
|
+
"access-control-allow-headers": "content-type, authorization, accept-language, x-time-zone",
|
|
109
116
|
};
|
|
110
117
|
}
|
|
111
118
|
|
|
@@ -207,6 +214,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
207
214
|
}
|
|
208
215
|
const sessionKey = `${principal.id}:${conversationId}`;
|
|
209
216
|
const hint = languageHint(req);
|
|
217
|
+
const zone = timeZoneHint(req);
|
|
210
218
|
const language = await contentLanguage(deps.i18n, principal, hint);
|
|
211
219
|
|
|
212
220
|
// Aborted when the client disconnects (see the stream's `cancel` below) so
|
|
@@ -280,6 +288,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
|
|
|
280
288
|
...(opening ? { opening: true as const } : {}),
|
|
281
289
|
...(resuming ? { resumeAfterConfirm: true as const } : {}),
|
|
282
290
|
...(hint === undefined ? {} : { languageHint: hint }),
|
|
291
|
+
...(zone === undefined ? {} : { timeZoneHint: zone }),
|
|
283
292
|
},
|
|
284
293
|
sink,
|
|
285
294
|
);
|
|
@@ -449,9 +458,11 @@ function loginRoutes(
|
|
|
449
458
|
};
|
|
450
459
|
}
|
|
451
460
|
|
|
452
|
-
/** 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. */
|
|
453
462
|
type ReadRoute = {
|
|
454
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>;
|
|
455
466
|
OPTIONS: () => Response;
|
|
456
467
|
};
|
|
457
468
|
|
|
@@ -464,13 +475,15 @@ type ReadRoute = {
|
|
|
464
475
|
* their data only; a conversation is named by its conversationId, as on
|
|
465
476
|
* `/turn`, and its session key is built from the caller.
|
|
466
477
|
* `json` JSON-encodes with the CORS headers merged in; `badRequest` does the
|
|
467
|
-
* 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.
|
|
468
480
|
*/
|
|
469
481
|
export function readRoutes(
|
|
470
482
|
reads: ChannelHostReads,
|
|
471
483
|
authenticate: Authenticate,
|
|
472
484
|
corsOrigin = "*",
|
|
473
485
|
admit?: AdmitFn,
|
|
486
|
+
settings?: ChannelSettings,
|
|
474
487
|
): Record<string, ReadRoute> {
|
|
475
488
|
const cors = corsHeaders(corsOrigin);
|
|
476
489
|
const json = (payload: object, status = 200): Response =>
|
|
@@ -515,12 +528,31 @@ export function readRoutes(
|
|
|
515
528
|
...(reads.me === undefined
|
|
516
529
|
? {}
|
|
517
530
|
: {
|
|
518
|
-
"/me":
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
+
},
|
|
524
556
|
}),
|
|
525
557
|
"/health": route(async () => json({ ok: true, ...(await reads.health() as object) })),
|
|
526
558
|
"/wiki/list": route(async (_req, principal) => json({ ok: true, files: await reads.wikiList(principal) })),
|
|
@@ -547,6 +579,19 @@ export function readRoutes(
|
|
|
547
579
|
};
|
|
548
580
|
}
|
|
549
581
|
|
|
582
|
+
/**
|
|
583
|
+
* `GET /version`: which Mercury this is (the framework's version), for any
|
|
584
|
+
* front end to show. Nobody is asked who they are, like `/openapi.yaml`.
|
|
585
|
+
*/
|
|
586
|
+
function versionRoutes(version: string, corsOrigin = "*"): Record<string, ReadRoute> {
|
|
587
|
+
return {
|
|
588
|
+
"/version": {
|
|
589
|
+
GET: () => Response.json({ ok: true, version }, { headers: corsHeaders(corsOrigin) }),
|
|
590
|
+
OPTIONS: () => preflight(corsOrigin),
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
550
595
|
export type HttpServerDeps = TurnRequestDeps & {
|
|
551
596
|
port: number;
|
|
552
597
|
reads?: ChannelHostReads;
|
|
@@ -556,8 +601,12 @@ export type HttpServerDeps = TurnRequestDeps & {
|
|
|
556
601
|
completeLogin?: CompleteLoginFn;
|
|
557
602
|
/** Linking another account to the caller; `/link` is mounted only with it. */
|
|
558
603
|
linking?: ChannelLinking;
|
|
604
|
+
/** The person's own settings; `PATCH /me` is mounted only with it. */
|
|
605
|
+
settings?: ChannelSettings;
|
|
559
606
|
/** What the pages people read call the assistant. */
|
|
560
607
|
assistantName?: string;
|
|
608
|
+
/** The framework's version; `/version` is mounted only with it. */
|
|
609
|
+
version?: string;
|
|
561
610
|
};
|
|
562
611
|
|
|
563
612
|
/** Starts the HTTP surface: `POST /turn` (4a) plus the read-only routes (4b)
|
|
@@ -589,7 +638,8 @@ export function startHttpServer(deps: HttpServerDeps): ReturnType<typeof Bun.ser
|
|
|
589
638
|
OPTIONS: () => preflight(origin),
|
|
590
639
|
},
|
|
591
640
|
"/openapi.yaml": { GET: () => openApiResponse(origin), OPTIONS: () => preflight(origin) },
|
|
592
|
-
...(deps.
|
|
641
|
+
...(deps.version === undefined ? {} : versionRoutes(deps.version, origin)),
|
|
642
|
+
...(deps.reads ? readRoutes(deps.reads, deps.authenticate, origin, deps.admit, deps.settings) : {}),
|
|
593
643
|
...(deps.completeLogin ? loginRoutes(deps.completeLogin, deps.assistantName, deps.i18n) : {}),
|
|
594
644
|
...(deps.linking ? linkRoutes({ ...deps, start: deps.linking.start, corsOrigin: origin }) : {}),
|
|
595
645
|
},
|
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The HTTP channel plugin: an opt-in conversational HTTP surface (SSE `/turn`,
|
|
3
|
-
* `/confirm`, read-only introspection routes, the OpenAPI doc) that a custom UI
|
|
3
|
+
* `/confirm`, read-only introspection routes, the OpenAPI doc, `/version`) that a custom UI
|
|
4
4
|
* calls. Exported as a `ChannelPlugin` the core's channel loader consumes; it
|
|
5
5
|
* does not import the app. Enabled by listing it in `mercury.config.ts` — there
|
|
6
6
|
* is no runtime inert condition (unlike Google Chat's subscription): if it's
|
|
@@ -48,8 +48,10 @@ export const httpChannel: ChannelPlugin = {
|
|
|
48
48
|
admit: ctx.admit,
|
|
49
49
|
i18n: ctx.i18n,
|
|
50
50
|
...(ctx.linking === undefined ? {} : { linking: ctx.linking }),
|
|
51
|
+
...(ctx.settings === undefined ? {} : { settings: ctx.settings }),
|
|
51
52
|
...(resumeMaxChars === undefined ? {} : { resumeMaxChars }),
|
|
52
53
|
...(ctx.assistantName === undefined ? {} : { assistantName: ctx.assistantName }),
|
|
54
|
+
...(ctx.frameworkVersion === undefined ? {} : { version: ctx.frameworkVersion }),
|
|
53
55
|
...(logins ? { logins: { callbackUrl: `${publicUrl}/login/callback`, accept: logins.accept, complete: logins.complete } } : {}),
|
|
54
56
|
});
|
|
55
57
|
},
|
package/openapi.yaml
CHANGED
|
@@ -14,7 +14,7 @@ info:
|
|
|
14
14
|
|
|
15
15
|
## Authentication
|
|
16
16
|
|
|
17
|
-
Every route except this document and the `OPTIONS` preflights needs
|
|
17
|
+
Every route except this document, `/version` and the `OPTIONS` preflights needs
|
|
18
18
|
`Authorization: Bearer <token>`. The app's auth provider decides what a
|
|
19
19
|
valid token is (an OpenID Connect access token, a static test token); a
|
|
20
20
|
missing or refused one gets `401` with `WWW-Authenticate: Bearer`, before
|
|
@@ -104,9 +104,12 @@ paths:
|
|
|
104
104
|
an empty text. Every text the person reads that isn't the model's (a
|
|
105
105
|
refusal, a status line, a confirmation's reply) is in their language:
|
|
106
106
|
the one they saved, else the one `Accept-Language` asks for, else the
|
|
107
|
-
instance's default.
|
|
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.
|
|
108
110
|
parameters:
|
|
109
111
|
- $ref: "#/components/parameters/AcceptLanguage"
|
|
112
|
+
- $ref: "#/components/parameters/TimeZone"
|
|
110
113
|
requestBody:
|
|
111
114
|
required: true
|
|
112
115
|
content:
|
|
@@ -344,9 +347,9 @@ paths:
|
|
|
344
347
|
description: >
|
|
345
348
|
How the caller asked to be called, `null` when they never said: a UI
|
|
346
349
|
asks for an opening turn after a sign-in only then. Also the language
|
|
347
|
-
they asked the assistant to use
|
|
348
|
-
when
|
|
349
|
-
which can't say.
|
|
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.
|
|
350
353
|
responses:
|
|
351
354
|
"401":
|
|
352
355
|
$ref: "#/components/responses/Unauthorized"
|
|
@@ -355,7 +358,7 @@ paths:
|
|
|
355
358
|
"503":
|
|
356
359
|
$ref: "#/components/responses/NotAdmitted"
|
|
357
360
|
"200":
|
|
358
|
-
description: The caller's name and
|
|
361
|
+
description: The caller's name, language and time zone, or null.
|
|
359
362
|
content:
|
|
360
363
|
application/json:
|
|
361
364
|
schema:
|
|
@@ -369,6 +372,52 @@ paths:
|
|
|
369
372
|
language:
|
|
370
373
|
type: [string, "null"]
|
|
371
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
|
|
372
421
|
/tool-log:
|
|
373
422
|
get:
|
|
374
423
|
summary: The tool calls made in the caller's recent turns
|
|
@@ -540,6 +589,27 @@ paths:
|
|
|
540
589
|
content:
|
|
541
590
|
text/plain:
|
|
542
591
|
schema: { type: string }
|
|
592
|
+
/version:
|
|
593
|
+
get:
|
|
594
|
+
summary: Which Mercury this is
|
|
595
|
+
description: >
|
|
596
|
+
The framework's version, `major.minor.patch`, for a UI to show. Needs
|
|
597
|
+
no bearer token.
|
|
598
|
+
security: []
|
|
599
|
+
responses:
|
|
600
|
+
"200":
|
|
601
|
+
description: The framework's version.
|
|
602
|
+
content:
|
|
603
|
+
application/json:
|
|
604
|
+
schema:
|
|
605
|
+
type: object
|
|
606
|
+
required: [ok, version]
|
|
607
|
+
properties:
|
|
608
|
+
ok: { type: boolean, const: true }
|
|
609
|
+
version:
|
|
610
|
+
type: string
|
|
611
|
+
pattern: '^\d+\.\d+\.\d+$'
|
|
612
|
+
examples: ["0.59.0"]
|
|
543
613
|
/openapi.yaml:
|
|
544
614
|
get:
|
|
545
615
|
summary: This OpenAPI document
|
|
@@ -564,6 +634,17 @@ components:
|
|
|
564
634
|
ship is passed over.
|
|
565
635
|
schema:
|
|
566
636
|
type: string
|
|
637
|
+
TimeZone:
|
|
638
|
+
name: X-Time-Zone
|
|
639
|
+
in: header
|
|
640
|
+
required: false
|
|
641
|
+
description: >
|
|
642
|
+
The time zone the client is in, by its IANA name, as a browser gives
|
|
643
|
+
it (`Intl.DateTimeFormat().resolvedOptions().timeZone`). Used for the
|
|
644
|
+
turn's time when the person saved no time zone of their own; a name
|
|
645
|
+
that isn't a zone is passed over.
|
|
646
|
+
schema:
|
|
647
|
+
type: string
|
|
567
648
|
headers:
|
|
568
649
|
ContentLanguage:
|
|
569
650
|
description: >
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-http",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.59.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.59.0",
|
|
25
|
+
"@mercury-fw/plugin-types": "0.59.0",
|
|
26
26
|
"@mercury-fw/typescript-config": "*",
|
|
27
27
|
"@types/bun": "^1.4.2",
|
|
28
28
|
"typescript": "^6.0.3"
|