@4fu/arbor-plugin-http 0.0.0-stage → 2026.1106.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/LICENSE +21 -0
- package/README.md +85 -2
- package/channel.d.ts +135 -0
- package/channel.js +395 -0
- package/index.d.ts +10 -0
- package/index.js +10 -0
- package/package.json +41 -4
- package/plugin.d.ts +42 -0
- package/plugin.js +95 -0
- package/server.d.ts +32 -0
- package/server.js +121 -0
- package/web/app.js +197 -0
- package/web/index.html +213 -0
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4fu/arbor-plugin-http",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "2026.1106.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The HTTP test channel plugin: a webhook in, sends out, and a page that drives the whole host.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./index.d.ts",
|
|
9
|
+
"default": "./index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"main": "./index.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"channel.d.ts",
|
|
16
|
+
"channel.js",
|
|
17
|
+
"index.d.ts",
|
|
18
|
+
"index.js",
|
|
19
|
+
"plugin.d.ts",
|
|
20
|
+
"plugin.js",
|
|
21
|
+
"server.d.ts",
|
|
22
|
+
"server.js",
|
|
23
|
+
"web/app.js",
|
|
24
|
+
"web/index.html"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=24"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/4fuu/arbor.git",
|
|
33
|
+
"directory": "packages/plugin-http"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@4fu/arbor-errors": "2026.1106.0",
|
|
40
|
+
"@4fu/arbor-interfaces": "2026.1106.0",
|
|
41
|
+
"@4fu/arbor-prompt": "2026.1106.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/plugin.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP test channel plugin: the whole domain, through the public plugin
|
|
3
|
+
* surface (docs/architecture.md#plugins).
|
|
4
|
+
*
|
|
5
|
+
* A plugin adds; it never patches the core. This one is a chat channel, and
|
|
6
|
+
* every part of it is optional in the framework's own terms:
|
|
7
|
+
*
|
|
8
|
+
* - **adapters**: the `Channel` (docs/journal.md#external-ids) and the
|
|
9
|
+
* `EffectExecutor` that carries a window's `send` out through it
|
|
10
|
+
* (docs/coordination.md#effects). The host takes the channel's push source and
|
|
11
|
+
* ingests what arrives (docs/dispatch.md#tasks).
|
|
12
|
+
* - **a prompt section**: the blocks its own inputs use, so the model reads what
|
|
13
|
+
* the channel is and how to answer on it. The blocks carry data; the words come
|
|
14
|
+
* from the text table the section merges into the host's one renderer.
|
|
15
|
+
* - **judge criteria**: one extra criterion for its own inputs, which the host
|
|
16
|
+
* collects for the judge (`Arbor.judgeCriteria`).
|
|
17
|
+
*
|
|
18
|
+
* What it deliberately does not have: a direct tool or a code mode module of its
|
|
19
|
+
* own. Capabilities are MCP servers and skills in `/env`, which the model reads,
|
|
20
|
+
* changes and reinstalls with `reload` like any item.
|
|
21
|
+
*/
|
|
22
|
+
import type { Effect, EffectExecutor, EffectResult, Plugin } from "@4fu/arbor-interfaces";
|
|
23
|
+
import type { HttpChannel } from "./channel.ts";
|
|
24
|
+
/** What the plugin is built from. */
|
|
25
|
+
export interface HttpPluginOptions {
|
|
26
|
+
channel: HttpChannel;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The executor that carries a window's send out through the channel. One effect
|
|
30
|
+
* queue per host executes each effect exactly once and in the order it was made
|
|
31
|
+
* (docs/coordination.md#effects); this is the executor that owns the sends whose
|
|
32
|
+
* target is one of this channel's conversations.
|
|
33
|
+
*/
|
|
34
|
+
export declare class HttpSendExecutor implements EffectExecutor {
|
|
35
|
+
readonly id = "http-send";
|
|
36
|
+
private readonly channel;
|
|
37
|
+
constructor(channel: HttpChannel);
|
|
38
|
+
can(effect: Effect): boolean;
|
|
39
|
+
run(effect: Effect): Promise<EffectResult>;
|
|
40
|
+
}
|
|
41
|
+
/** The plugin a deployment registers with the host. */
|
|
42
|
+
export declare function httpPlugin(options: HttpPluginOptions): Plugin;
|
package/plugin.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP test channel plugin: the whole domain, through the public plugin
|
|
3
|
+
* surface (docs/architecture.md#plugins).
|
|
4
|
+
*
|
|
5
|
+
* A plugin adds; it never patches the core. This one is a chat channel, and
|
|
6
|
+
* every part of it is optional in the framework's own terms:
|
|
7
|
+
*
|
|
8
|
+
* - **adapters**: the `Channel` (docs/journal.md#external-ids) and the
|
|
9
|
+
* `EffectExecutor` that carries a window's `send` out through it
|
|
10
|
+
* (docs/coordination.md#effects). The host takes the channel's push source and
|
|
11
|
+
* ingests what arrives (docs/dispatch.md#tasks).
|
|
12
|
+
* - **a prompt section**: the blocks its own inputs use, so the model reads what
|
|
13
|
+
* the channel is and how to answer on it. The blocks carry data; the words come
|
|
14
|
+
* from the text table the section merges into the host's one renderer.
|
|
15
|
+
* - **judge criteria**: one extra criterion for its own inputs, which the host
|
|
16
|
+
* collects for the judge (`Arbor.judgeCriteria`).
|
|
17
|
+
*
|
|
18
|
+
* What it deliberately does not have: a direct tool or a code mode module of its
|
|
19
|
+
* own. Capabilities are MCP servers and skills in `/env`, which the model reads,
|
|
20
|
+
* changes and reinstalls with `reload` like any item.
|
|
21
|
+
*/
|
|
22
|
+
/** The block kind this plugin's prompt section renders. */
|
|
23
|
+
const HTTP_BLOCKS = [
|
|
24
|
+
{
|
|
25
|
+
kind: "http_channel",
|
|
26
|
+
fields: [
|
|
27
|
+
{ name: "channel", type: "string", required: true },
|
|
28
|
+
{ name: "conversations", type: "strings" },
|
|
29
|
+
],
|
|
30
|
+
lines: ["http.channel", "http.conversations"],
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
/** The fixed strings its own block writes, merged into the host's table. */
|
|
34
|
+
const HTTP_TEXT = {
|
|
35
|
+
"http.channel": "HTTP 测试频道 {channel}:回复用 send,目标写 {channel}:<来源名>。",
|
|
36
|
+
"http.conversations": "当前来源:{conversations}",
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The executor that carries a window's send out through the channel. One effect
|
|
40
|
+
* queue per host executes each effect exactly once and in the order it was made
|
|
41
|
+
* (docs/coordination.md#effects); this is the executor that owns the sends whose
|
|
42
|
+
* target is one of this channel's conversations.
|
|
43
|
+
*/
|
|
44
|
+
export class HttpSendExecutor {
|
|
45
|
+
id = "http-send";
|
|
46
|
+
channel;
|
|
47
|
+
constructor(channel) {
|
|
48
|
+
this.channel = channel;
|
|
49
|
+
}
|
|
50
|
+
can(effect) {
|
|
51
|
+
return effect.kind === "send" && effect.target.startsWith(`${this.channel.id}:`);
|
|
52
|
+
}
|
|
53
|
+
async run(effect) {
|
|
54
|
+
const sent = await this.channel.send({
|
|
55
|
+
channel: this.channel.id,
|
|
56
|
+
target: effect.target,
|
|
57
|
+
content: effect.content,
|
|
58
|
+
...(effect.replyTo === undefined ? {} : { replyTo: effect.replyTo }),
|
|
59
|
+
});
|
|
60
|
+
// The native id the outside world gave it, which the host records on the
|
|
61
|
+
// effect's result, so a ref resolves to what was sent.
|
|
62
|
+
return { ok: true, external: sent.external };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The plugin a deployment registers with the host. */
|
|
66
|
+
export function httpPlugin(options) {
|
|
67
|
+
const channel = options.channel;
|
|
68
|
+
return {
|
|
69
|
+
name: "http",
|
|
70
|
+
adapters: {
|
|
71
|
+
channels: [channel],
|
|
72
|
+
effectExecutors: [new HttpSendExecutor(channel)],
|
|
73
|
+
},
|
|
74
|
+
promptSection: {
|
|
75
|
+
tag: "http",
|
|
76
|
+
blocks: HTTP_BLOCKS,
|
|
77
|
+
text: HTTP_TEXT,
|
|
78
|
+
content: () => [
|
|
79
|
+
{
|
|
80
|
+
kind: "http_channel",
|
|
81
|
+
fields: {
|
|
82
|
+
channel: channel.id,
|
|
83
|
+
conversations: channel.conversations().map((target) => target.startsWith(`${channel.id}:`) ? target.slice(channel.id.length + 1) : target),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
judgeCriteria: [
|
|
89
|
+
{
|
|
90
|
+
decision: "route",
|
|
91
|
+
criteria: ["一个来源里的消息进入已经在该来源中的窗口,而不是新开一个窗口"],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
}
|
package/server.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runnable entry point: compose the default stack around the HTTP test
|
|
3
|
+
* channel and start the server.
|
|
4
|
+
*
|
|
5
|
+
* ```sh
|
|
6
|
+
* DEEPSEEK_API_KEY=… JEV_API_KEY=… pnpm --filter @4fu/arbor-plugin-http start
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* Every module is the default one - the SQLite journal and notes, Jev, the
|
|
10
|
+
* directory environment, the workspace `openWorkspace` picks, pi as the backend
|
|
11
|
+
* (docs/architecture.md#stack) - and the one plugin is the HTTP channel. Nothing
|
|
12
|
+
* here is specific to this plugin: a deployment that plugs a different channel in
|
|
13
|
+
* composes the same stack the same way.
|
|
14
|
+
*
|
|
15
|
+
* Configuration names a secret and never holds its value
|
|
16
|
+
* (docs/architecture.md#data-placement). The keys pi and Jev read are
|
|
17
|
+
* environment variables those two modules read themselves; the settings name
|
|
18
|
+
* them, so the host's scrub covers every string that leaves it.
|
|
19
|
+
*
|
|
20
|
+
* | Name | What it names | Default |
|
|
21
|
+
* | --- | --- | --- |
|
|
22
|
+
* | `ARBOR_DATA_DIR` | where the journal, notes, `/env` and the workspaces live | `./.arbor/http` |
|
|
23
|
+
* | `ARBOR_HOST` | the host the page binds | `127.0.0.1` |
|
|
24
|
+
* | `ARBOR_PORT` | the port the page binds | `8787` |
|
|
25
|
+
* | `ARBOR_MODEL` | the model the `chat` tier maps to | `deepseek-flash` |
|
|
26
|
+
* | `DEEPSEEK_API_KEY` | pi's key for DeepSeek | - |
|
|
27
|
+
* | `JEV_API_KEY` | Jev's key | - |
|
|
28
|
+
*
|
|
29
|
+
* The page is a local test tool: it binds localhost by default, and visibility,
|
|
30
|
+
* permissions and auth are deferred.
|
|
31
|
+
*/
|
|
32
|
+
export {};
|
package/server.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runnable entry point: compose the default stack around the HTTP test
|
|
3
|
+
* channel and start the server.
|
|
4
|
+
*
|
|
5
|
+
* ```sh
|
|
6
|
+
* DEEPSEEK_API_KEY=… JEV_API_KEY=… pnpm --filter @4fu/arbor-plugin-http start
|
|
7
|
+
* ```
|
|
8
|
+
*
|
|
9
|
+
* Every module is the default one - the SQLite journal and notes, Jev, the
|
|
10
|
+
* directory environment, the workspace `openWorkspace` picks, pi as the backend
|
|
11
|
+
* (docs/architecture.md#stack) - and the one plugin is the HTTP channel. Nothing
|
|
12
|
+
* here is specific to this plugin: a deployment that plugs a different channel in
|
|
13
|
+
* composes the same stack the same way.
|
|
14
|
+
*
|
|
15
|
+
* Configuration names a secret and never holds its value
|
|
16
|
+
* (docs/architecture.md#data-placement). The keys pi and Jev read are
|
|
17
|
+
* environment variables those two modules read themselves; the settings name
|
|
18
|
+
* them, so the host's scrub covers every string that leaves it.
|
|
19
|
+
*
|
|
20
|
+
* | Name | What it names | Default |
|
|
21
|
+
* | --- | --- | --- |
|
|
22
|
+
* | `ARBOR_DATA_DIR` | where the journal, notes, `/env` and the workspaces live | `./.arbor/http` |
|
|
23
|
+
* | `ARBOR_HOST` | the host the page binds | `127.0.0.1` |
|
|
24
|
+
* | `ARBOR_PORT` | the port the page binds | `8787` |
|
|
25
|
+
* | `ARBOR_MODEL` | the model the `chat` tier maps to | `deepseek-flash` |
|
|
26
|
+
* | `DEEPSEEK_API_KEY` | pi's key for DeepSeek | - |
|
|
27
|
+
* | `JEV_API_KEY` | Jev's key | - |
|
|
28
|
+
*
|
|
29
|
+
* The page is a local test tool: it binds localhost by default, and visibility,
|
|
30
|
+
* permissions and auth are deferred.
|
|
31
|
+
*/
|
|
32
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { Arbor, openWorkspace } from "@4fu/arbor-core";
|
|
36
|
+
import { DirectoryEnvironment } from "@4fu/arbor-environment-dir";
|
|
37
|
+
import { SqliteJournal } from "@4fu/arbor-journal-sqlite";
|
|
38
|
+
import { SqliteNoteStore } from "@4fu/arbor-notes-sqlite";
|
|
39
|
+
import { ProcessSupervisor } from "@4fu/arbor-supervisor-process";
|
|
40
|
+
import { PiAgentBackend } from "@4fu/arbor-agent-pi";
|
|
41
|
+
import { HttpChannel } from "./channel.js";
|
|
42
|
+
import { httpPlugin } from "./plugin.js";
|
|
43
|
+
/** The environment variables this entry point reads. None of them is a secret. */
|
|
44
|
+
const DATA_DIR = process.env.ARBOR_DATA_DIR ?? join(tmpdir(), "arbor-http");
|
|
45
|
+
const HOST = process.env.ARBOR_HOST ?? "127.0.0.1";
|
|
46
|
+
const PORT = Number(process.env.ARBOR_PORT ?? 8787);
|
|
47
|
+
const MODEL = process.env.ARBOR_MODEL ?? "deepseek-flash";
|
|
48
|
+
/** The tier a new window runs at, and what that tier is for. */
|
|
49
|
+
const TIER = "chat";
|
|
50
|
+
/** The secrets this configuration names. Their values stay in the environment. */
|
|
51
|
+
const SECRETS = ["DEEPSEEK_API_KEY", "JEV_API_KEY"];
|
|
52
|
+
const SETTINGS = {
|
|
53
|
+
identity: "arbor 的 HTTP 测试频道接线员",
|
|
54
|
+
generalRules: ["用中文回答", "回答要短"],
|
|
55
|
+
modelTiers: { [TIER]: MODEL },
|
|
56
|
+
defaultTier: TIER,
|
|
57
|
+
judge: {
|
|
58
|
+
tierCriteria: {
|
|
59
|
+
[TIER]: "A short conversational message on the HTTP test channel, where a fast model is enough.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
window: { idleSeconds: 120 },
|
|
63
|
+
secrets: [...SECRETS],
|
|
64
|
+
};
|
|
65
|
+
async function main() {
|
|
66
|
+
await mkdir(DATA_DIR, { recursive: true });
|
|
67
|
+
const journal = new SqliteJournal({ path: join(DATA_DIR, "journal.sqlite") });
|
|
68
|
+
const notes = new SqliteNoteStore({ path: join(DATA_DIR, "notes.sqlite"), journal });
|
|
69
|
+
const supervisor = new ProcessSupervisor({ root: join(DATA_DIR, "services") });
|
|
70
|
+
const environment = new DirectoryEnvironment({
|
|
71
|
+
root: join(DATA_DIR, "env"),
|
|
72
|
+
supervisor,
|
|
73
|
+
// A service's secret is read from the environment by name, never held here.
|
|
74
|
+
secretOf: (name) => process.env[name],
|
|
75
|
+
});
|
|
76
|
+
// The settings are installed, so they live in `/env` where the model changes
|
|
77
|
+
// them with `reload`, and the host reads them back at every change.
|
|
78
|
+
const seed = join(DATA_DIR, "seed");
|
|
79
|
+
await mkdir(seed, { recursive: true });
|
|
80
|
+
const settingsPath = join(seed, "settings.json");
|
|
81
|
+
await writeFile(settingsPath, JSON.stringify(SETTINGS, null, 2), "utf8");
|
|
82
|
+
await environment.install([{ kind: "settings", name: "settings", path: settingsPath }]);
|
|
83
|
+
// The workspace: one directory per window, under the data directory. It needs
|
|
84
|
+
// nothing a host does not already have, so it runs on any platform.
|
|
85
|
+
const workspace = await openWorkspace({ root: join(DATA_DIR, "workspaces") });
|
|
86
|
+
// The channel is created before the host and reads the board from it, so the
|
|
87
|
+
// plugin holds no reference to the host and the deployment supplies one.
|
|
88
|
+
let arbor;
|
|
89
|
+
const channel = new HttpChannel({
|
|
90
|
+
host: HOST,
|
|
91
|
+
port: PORT,
|
|
92
|
+
board: () => arbor?.boardLines() ?? [],
|
|
93
|
+
});
|
|
94
|
+
arbor = new Arbor({
|
|
95
|
+
journal,
|
|
96
|
+
notes,
|
|
97
|
+
backend: new PiAgentBackend(),
|
|
98
|
+
workspace,
|
|
99
|
+
environment,
|
|
100
|
+
supervisor,
|
|
101
|
+
settings: SETTINGS,
|
|
102
|
+
// The judge is the default: Jev, which reads `JEV_API_KEY` per request. A
|
|
103
|
+
// host without one falls back on every decision's own rule.
|
|
104
|
+
plugins: [httpPlugin({ channel })],
|
|
105
|
+
});
|
|
106
|
+
await arbor.start();
|
|
107
|
+
const address = await channel.listen();
|
|
108
|
+
const models = await arbor.models();
|
|
109
|
+
console.log(`arbor: HTTP test channel on ${address}`);
|
|
110
|
+
console.log(`arbor: journal ${join(DATA_DIR, "journal.sqlite")}, model ${SETTINGS.modelTiers?.[TIER]}`);
|
|
111
|
+
console.log(`arbor: workspace ${join(DATA_DIR, "workspaces")}`);
|
|
112
|
+
console.log(`arbor: ${models.length} models in the catalog, ${arbor.channels().length} channel plugged in`);
|
|
113
|
+
console.log(`arbor: open ${address} in a browser, or POST a message to ${address}/webhook`);
|
|
114
|
+
const stop = async () => {
|
|
115
|
+
await channel.close();
|
|
116
|
+
await arbor?.close();
|
|
117
|
+
};
|
|
118
|
+
process.on("SIGINT", () => void stop().then(() => process.exit(0)));
|
|
119
|
+
process.on("SIGTERM", () => void stop().then(() => process.exit(0)));
|
|
120
|
+
}
|
|
121
|
+
await main();
|
package/web/app.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page the HTTP test channel serves: pick or create a conversation, send a
|
|
3
|
+
* message, watch replies arrive, and read the board.
|
|
4
|
+
*
|
|
5
|
+
* Plain JavaScript, no build step. Every string that came from outside is set
|
|
6
|
+
* with `textContent`, so message text can never become markup. The page polls,
|
|
7
|
+
* because a test tool does not need a socket.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
"use strict";
|
|
11
|
+
|
|
12
|
+
const POLL_MS = 1200;
|
|
13
|
+
|
|
14
|
+
/** What the page holds between polls. */
|
|
15
|
+
const state = {
|
|
16
|
+
channel: "http",
|
|
17
|
+
online: false,
|
|
18
|
+
address: "",
|
|
19
|
+
conversations: [],
|
|
20
|
+
board: [],
|
|
21
|
+
messages: [],
|
|
22
|
+
selected: null,
|
|
23
|
+
/** Conversations the page made that the channel has no message for yet. */
|
|
24
|
+
created: [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const elements = {
|
|
28
|
+
status: document.getElementById("status"),
|
|
29
|
+
conversationList: document.getElementById("conversation-list"),
|
|
30
|
+
newConversation: document.getElementById("new-conversation"),
|
|
31
|
+
newConversationName: document.getElementById("new-conversation-name"),
|
|
32
|
+
conversationTitle: document.getElementById("conversation-title"),
|
|
33
|
+
messages: document.getElementById("messages"),
|
|
34
|
+
chatEmpty: document.getElementById("chat-empty"),
|
|
35
|
+
composer: document.getElementById("composer"),
|
|
36
|
+
text: document.getElementById("text"),
|
|
37
|
+
send: document.getElementById("send"),
|
|
38
|
+
boardBody: document.getElementById("board-body"),
|
|
39
|
+
boardEmpty: document.getElementById("board-empty"),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** One GET against the channel's own reads. */
|
|
43
|
+
async function get(path) {
|
|
44
|
+
const response = await fetch(path, { headers: { accept: "application/json" } });
|
|
45
|
+
return response.json();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The conversation name without the channel prefix, for display. */
|
|
49
|
+
function nameOf(target) {
|
|
50
|
+
const prefix = `${state.channel}:`;
|
|
51
|
+
return target.startsWith(prefix) ? target.slice(prefix.length) : target;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderStatus() {
|
|
55
|
+
elements.status.textContent = state.online
|
|
56
|
+
? `${state.channel} 在线 · ${state.address}`
|
|
57
|
+
: `${state.channel} 离线`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderConversations() {
|
|
61
|
+
const known = new Set(state.conversations.map((one) => one.target));
|
|
62
|
+
const every = [...state.conversations, ...state.created.filter((target) => !known.has(target)).map((target) => ({ target, messages: 0 }))];
|
|
63
|
+
elements.conversationList.replaceChildren(
|
|
64
|
+
...every.map((one) => {
|
|
65
|
+
const item = document.createElement("li");
|
|
66
|
+
item.setAttribute("role", "button");
|
|
67
|
+
item.setAttribute("aria-current", String(one.target === state.selected));
|
|
68
|
+
const label = document.createElement("span");
|
|
69
|
+
label.textContent = nameOf(one.target);
|
|
70
|
+
const count = document.createElement("span");
|
|
71
|
+
count.className = "count";
|
|
72
|
+
count.textContent = String(one.messages);
|
|
73
|
+
item.append(label, count);
|
|
74
|
+
item.addEventListener("click", () => {
|
|
75
|
+
state.selected = one.target;
|
|
76
|
+
void refreshMessages();
|
|
77
|
+
});
|
|
78
|
+
return item;
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderMessages() {
|
|
84
|
+
const title = state.selected === null ? "选择一个来源" : `来源 ${nameOf(state.selected)}`;
|
|
85
|
+
elements.conversationTitle.textContent = title;
|
|
86
|
+
const shown = state.selected === null ? [] : state.messages.filter((one) => one.conversation === state.selected);
|
|
87
|
+
elements.chatEmpty.hidden = shown.length > 0;
|
|
88
|
+
elements.messages.replaceChildren(
|
|
89
|
+
...shown.map((message) => {
|
|
90
|
+
const item = document.createElement("li");
|
|
91
|
+
item.className = `message ${message.direction}`;
|
|
92
|
+
const head = document.createElement("header");
|
|
93
|
+
const who = document.createElement("span");
|
|
94
|
+
who.textContent = message.direction === "out" ? "arbor" : message.from;
|
|
95
|
+
const when = document.createElement("time");
|
|
96
|
+
when.textContent = new Date(message.time).toLocaleTimeString();
|
|
97
|
+
head.append(who, when);
|
|
98
|
+
const body = document.createElement("div");
|
|
99
|
+
body.textContent = message.text;
|
|
100
|
+
item.append(head, body);
|
|
101
|
+
return item;
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderBoard() {
|
|
107
|
+
elements.boardEmpty.hidden = state.board.length > 0;
|
|
108
|
+
elements.boardBody.replaceChildren(
|
|
109
|
+
...state.board.map((line) => {
|
|
110
|
+
const row = document.createElement("tr");
|
|
111
|
+
const cells = [
|
|
112
|
+
line.window,
|
|
113
|
+
line.state,
|
|
114
|
+
line.doing,
|
|
115
|
+
`${line.usage.used}/${line.usage.limit}`,
|
|
116
|
+
];
|
|
117
|
+
for (const [index, value] of cells.entries()) {
|
|
118
|
+
const cell = document.createElement("td");
|
|
119
|
+
if (index === 2) cell.className = "doing";
|
|
120
|
+
cell.textContent = value;
|
|
121
|
+
row.append(cell);
|
|
122
|
+
}
|
|
123
|
+
return row;
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One poll: the state, then the selected conversation's messages. */
|
|
129
|
+
async function refresh() {
|
|
130
|
+
let next;
|
|
131
|
+
try {
|
|
132
|
+
next = await get("/api/state");
|
|
133
|
+
} catch {
|
|
134
|
+
state.online = false;
|
|
135
|
+
renderStatus();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
state.channel = next.channel;
|
|
139
|
+
state.online = next.online;
|
|
140
|
+
state.address = next.address;
|
|
141
|
+
state.conversations = next.conversations;
|
|
142
|
+
state.board = next.board;
|
|
143
|
+
// A conversation the page made stays selected until the channel has a message
|
|
144
|
+
// for it, so a new conversation does not disappear on the next poll.
|
|
145
|
+
if (state.selected === null && state.conversations.length > 0) {
|
|
146
|
+
state.selected = state.conversations[0].target;
|
|
147
|
+
}
|
|
148
|
+
renderStatus();
|
|
149
|
+
renderConversations();
|
|
150
|
+
renderBoard();
|
|
151
|
+
await refreshMessages();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function refreshMessages() {
|
|
155
|
+
if (state.selected === null) {
|
|
156
|
+
state.messages = [];
|
|
157
|
+
renderMessages();
|
|
158
|
+
renderConversations();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const page = await get(`/api/messages?conversation=${encodeURIComponent(state.selected)}`);
|
|
162
|
+
state.messages = page.items;
|
|
163
|
+
renderMessages();
|
|
164
|
+
renderConversations();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
elements.newConversation.addEventListener("submit", (event) => {
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
const name = elements.newConversationName.value.trim();
|
|
170
|
+
if (name === "") return;
|
|
171
|
+
const target = `${state.channel}:${name}`;
|
|
172
|
+
if (!state.created.includes(target)) state.created.push(target);
|
|
173
|
+
state.selected = target;
|
|
174
|
+
elements.newConversationName.value = "";
|
|
175
|
+
void refreshMessages();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
elements.composer.addEventListener("submit", async (event) => {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
const text = elements.text.value.trim();
|
|
181
|
+
if (text === "" || state.selected === null) return;
|
|
182
|
+
elements.send.disabled = true;
|
|
183
|
+
try {
|
|
184
|
+
await fetch("/webhook", {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: { "content-type": "application/json" },
|
|
187
|
+
body: JSON.stringify({ conversation: nameOf(state.selected), text }),
|
|
188
|
+
});
|
|
189
|
+
elements.text.value = "";
|
|
190
|
+
await refresh();
|
|
191
|
+
} finally {
|
|
192
|
+
elements.send.disabled = false;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
void refresh();
|
|
197
|
+
setInterval(() => void refresh(), POLL_MS);
|