@chatpanel/bridge 0.11.4 → 0.11.5
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/package.json +1 -1
- package/scripts/sync-channels.mjs +1 -1
- package/src/channels/adapters/telegram.js +14 -7
- package/src/channels/gateway.js +106 -0
- package/src/channels/service.js +34 -4
- package/src/channels/stream.js +20 -0
- package/src/server.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -24,7 +24,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
24
24
|
// The service and everything under it. `config.js` is deliberately absent: it resolves paths
|
|
25
25
|
// and reads the bridge token from disk, and the bridge already knows both.
|
|
26
26
|
const FILES = [
|
|
27
|
-
'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js',
|
|
27
|
+
'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js', 'gateway.js',
|
|
28
28
|
'eventlog.js', 'service.js', 'adapters/telegram.js',
|
|
29
29
|
];
|
|
30
30
|
|
|
@@ -46,6 +46,12 @@ export function startTelegram({
|
|
|
46
46
|
savePairing = async () => {},
|
|
47
47
|
appender, // createEventLog(...) or nullEventLog()
|
|
48
48
|
agent = 'claude',
|
|
49
|
+
// Which transport answers, and with what. `bridge` runs a CLI agent on this machine;
|
|
50
|
+
// `gateway` reaches any destination the user configured there — an API provider or, via the
|
|
51
|
+
// gateway's own bridge backend, the same CLI agents. The adapter must not be able to tell
|
|
52
|
+
// which it has: both expose chat()/cancel() and fold into one reply state.
|
|
53
|
+
transport = bridge,
|
|
54
|
+
model = '',
|
|
49
55
|
system = '',
|
|
50
56
|
redact = { tier: 'basic' },
|
|
51
57
|
privacy = 'standard',
|
|
@@ -113,7 +119,7 @@ export function startTelegram({
|
|
|
113
119
|
}
|
|
114
120
|
if (name === 'stop') {
|
|
115
121
|
const st = chatState(norm.chatId);
|
|
116
|
-
const ok = await
|
|
122
|
+
const ok = await transport.cancel(st.runId, { baseUrl, token });
|
|
117
123
|
st.runId = null;
|
|
118
124
|
return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
|
|
119
125
|
}
|
|
@@ -151,15 +157,16 @@ export function startTelegram({
|
|
|
151
157
|
const messages = [...st.history, { role: 'user', content: redacted }];
|
|
152
158
|
|
|
153
159
|
try {
|
|
154
|
-
const finalState = await
|
|
155
|
-
{ agent, system, messages, images, options: { reach } },
|
|
160
|
+
const finalState = await transport.chat(
|
|
161
|
+
{ agent, model, system, messages, images, options: { reach } },
|
|
156
162
|
{
|
|
157
163
|
baseUrl, token, signal,
|
|
158
164
|
onEvent: (ev, state) => {
|
|
159
|
-
if (
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
165
|
+
if (state.runId) st.runId = state.runId;
|
|
166
|
+
// Driven by the folded STATE, not by an event's `type`: the bridge emits
|
|
167
|
+
// {type:'delta'} and the gateway emits OpenAI chunks, and this has to work on both.
|
|
168
|
+
// The `first !== shown` guard below makes a no-text event a no-op anyway.
|
|
169
|
+
if (replyId && (state.done || gate.ready())) {
|
|
163
170
|
const text = outboundText(state.text, st.vault, { privacy });
|
|
164
171
|
const first = splitForTelegram(text || '…')[0];
|
|
165
172
|
if (first && first !== shown) { shown = first; edit(norm.chatId, replyId, first).catch(() => {}); }
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// GENERATED — do not edit.
|
|
2
|
+
// Source of truth: chatpanel-channels/src/gateway.js (npm @chatpanel/channels).
|
|
3
|
+
// Edit there, then run: npm run sync:channels
|
|
4
|
+
//
|
|
5
|
+
// Vendored rather than depended on: the bridge ships zero runtime dependencies so a
|
|
6
|
+
// curl one-liner install cannot fail on someone's registry, and so the compiled
|
|
7
|
+
// single-file binary has nothing to resolve. Package imports are rewritten to the
|
|
8
|
+
// vendored engines (src/pii, src/events) by the sync script.
|
|
9
|
+
|
|
10
|
+
// Gateway backend: reach ANY configured destination — an API provider or a CLI agent —
|
|
11
|
+
// through the ChatPanel gateway's OpenAI-compatible endpoint.
|
|
12
|
+
//
|
|
13
|
+
// channel → POST http://127.0.0.1:4320/v1/chat/completions { model, messages, stream }
|
|
14
|
+
//
|
|
15
|
+
// WHY THIS EXISTS ALONGSIDE bridge.js. The bridge runs CLI agents, and that is all it can
|
|
16
|
+
// answer with — so a phone could only ever talk to Claude Code or Codex, never to the OpenAI,
|
|
17
|
+
// Anthropic or local-model endpoints the same user already configured. Those live in the
|
|
18
|
+
// gateway, which persists its `destinations` (0600, keys included) and routes a model id to
|
|
19
|
+
// an API provider OR back to the bridge for an agent. So the gateway is the superset, and
|
|
20
|
+
// pointing a channel at it is what makes "answer from my phone" work with every target the
|
|
21
|
+
// user has rather than a subset.
|
|
22
|
+
//
|
|
23
|
+
// NO NEW SECRET. The alternative was teaching the bridge to hold provider API keys, which
|
|
24
|
+
// would have put them on disk a second time, in a second format, with a second thing to
|
|
25
|
+
// rotate. The gateway already holds them and already guards them; this borrows the routing
|
|
26
|
+
// instead of copying the credentials.
|
|
27
|
+
//
|
|
28
|
+
// The /v1 data plane is deliberately unauthenticated for local clients — that is the
|
|
29
|
+
// gateway's product — so there is no token to present here, and none to leak.
|
|
30
|
+
|
|
31
|
+
import { parseSse, foldOpenAiEvent, initialState } from './stream.js';
|
|
32
|
+
|
|
33
|
+
// Per-turn aborts, so /stop can cancel a gateway turn the way it cancels a bridge run. The
|
|
34
|
+
// bridge hands out a run id for this; OpenAI's API has no such handle, so we mint one and
|
|
35
|
+
// keep the controller behind it rather than leaving /stop silently broken on this transport.
|
|
36
|
+
const inflight = new Map();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Drive one turn against a gateway destination. Same shape as bridge.chat so the adapter does
|
|
40
|
+
* not know which transport it has: streams events to onEvent(ev, state) and returns the folded
|
|
41
|
+
* final state.
|
|
42
|
+
*/
|
|
43
|
+
export async function chat({ model = '', system = '', messages = [], options = {} }, {
|
|
44
|
+
baseUrl, signal, onEvent = () => {},
|
|
45
|
+
} = {}) {
|
|
46
|
+
const runId = `gw_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
47
|
+
const ac = new AbortController();
|
|
48
|
+
inflight.set(runId, ac);
|
|
49
|
+
// The caller's signal (the poll loop shutting down) must still abort the turn.
|
|
50
|
+
const relay = () => ac.abort();
|
|
51
|
+
signal?.addEventListener?.('abort', relay, { once: true });
|
|
52
|
+
|
|
53
|
+
let state = initialState();
|
|
54
|
+
const emit = (ev) => { state = foldOpenAiEvent(state, ev); onEvent(ev, state); };
|
|
55
|
+
emit({ type: 'run', id: runId });
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`${String(baseUrl).replace(/\/$/, '')}/v1/chat/completions`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'content-type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
model,
|
|
63
|
+
stream: true,
|
|
64
|
+
messages: [
|
|
65
|
+
...(system ? [{ role: 'system', content: system }] : []),
|
|
66
|
+
...messages.map((m) => ({ role: m.role, content: String(m.content ?? '') })),
|
|
67
|
+
],
|
|
68
|
+
// Carried through so a capped phone's reach still reaches the router. The gateway
|
|
69
|
+
// ignores what it does not know, which is what keeps an older gateway working.
|
|
70
|
+
...(options?.reach ? { chatpanel: { reach: options.reach } } : {}),
|
|
71
|
+
}),
|
|
72
|
+
signal: ac.signal,
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok || !res.body) {
|
|
75
|
+
const detail = await res.text().catch(() => '');
|
|
76
|
+
throw new Error(`gateway ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
|
|
77
|
+
}
|
|
78
|
+
let buffer = '';
|
|
79
|
+
const decoder = new TextDecoder();
|
|
80
|
+
for await (const chunk of res.body) {
|
|
81
|
+
buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
82
|
+
const { events, rest } = parseSse(buffer);
|
|
83
|
+
buffer = rest;
|
|
84
|
+
for (const ev of events) emit(ev);
|
|
85
|
+
}
|
|
86
|
+
// OpenAI streams end with `data: [DONE]`, which is not JSON and is dropped by parseSse —
|
|
87
|
+
// so a stream that ended cleanly still has to be marked done here.
|
|
88
|
+
if (!state.done) state = { ...state, done: true };
|
|
89
|
+
return state;
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (ac.signal.aborted) return { ...state, done: true };
|
|
92
|
+
return { ...state, done: true, error: e?.message || String(e) };
|
|
93
|
+
} finally {
|
|
94
|
+
inflight.delete(runId);
|
|
95
|
+
signal?.removeEventListener?.('abort', relay);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Stop a run by the id emitted as the first {type:'run'} event. Best-effort, like the bridge's. */
|
|
100
|
+
export async function cancel(runId) {
|
|
101
|
+
const ac = runId && inflight.get(runId);
|
|
102
|
+
if (!ac) return false;
|
|
103
|
+
ac.abort();
|
|
104
|
+
inflight.delete(runId);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
package/src/channels/service.js
CHANGED
|
@@ -27,11 +27,22 @@
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { readFile, writeFile, mkdir, rm, stat } from 'node:fs/promises';
|
|
29
29
|
import { createPairingStore } from './pairing.js';
|
|
30
|
+
import * as bridgeTransport from './bridge.js';
|
|
31
|
+
import * as gateway from './gateway.js';
|
|
32
|
+
|
|
33
|
+
// The gateway's fixed local port (see chatpanel-gateway: 4319 bridge / 4320 gateway).
|
|
34
|
+
const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:4320';
|
|
30
35
|
import { createEventLog } from './eventlog.js';
|
|
31
36
|
import { startTelegram } from './adapters/telegram.js';
|
|
32
37
|
|
|
33
38
|
const TELEGRAM_API = 'https://api.telegram.org';
|
|
34
|
-
|
|
39
|
+
// `agent` routes through the bridge (a CLI on this machine). `model` routes through the
|
|
40
|
+
// gateway, which reaches every destination the user configured there — API providers AND, via
|
|
41
|
+
// its own bridge backend, the same agents. They are mutually exclusive: update() clears one
|
|
42
|
+
// when the other is set, because "which thing answers" is one choice, not two.
|
|
43
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
44
|
+
agent: 'claude', model: '', gatewayUrl: DEFAULT_GATEWAY_URL, privacy: 'standard', tier: 'basic',
|
|
45
|
+
});
|
|
35
46
|
|
|
36
47
|
// Restart backoff. A long-poll that dies (network drop, laptop asleep, Telegram hiccup) must
|
|
37
48
|
// come back on its own — a channel nobody is watching is exactly the one that must self-heal —
|
|
@@ -120,9 +131,13 @@ export function createChannelService({
|
|
|
120
131
|
function spawnLoop(botToken) {
|
|
121
132
|
controller = new AbortController();
|
|
122
133
|
running = true;
|
|
134
|
+
// One choice, resolved here so the adapter never has to ask "which kind am I?".
|
|
135
|
+
const viaGateway = !!settings.model;
|
|
123
136
|
const done = startAdapter({
|
|
124
137
|
botToken,
|
|
125
|
-
|
|
138
|
+
transport: viaGateway ? gateway : bridgeTransport,
|
|
139
|
+
model: viaGateway ? settings.model : '',
|
|
140
|
+
baseUrl: viaGateway ? (settings.gatewayUrl || DEFAULT_GATEWAY_URL) : bridge.baseUrl,
|
|
126
141
|
token: bridge.token,
|
|
127
142
|
pairing,
|
|
128
143
|
savePairing,
|
|
@@ -232,7 +247,13 @@ export function createChannelService({
|
|
|
232
247
|
async update(patch = {}) {
|
|
233
248
|
await load();
|
|
234
249
|
const next = { ...settings };
|
|
235
|
-
|
|
250
|
+
for (const k of ['agent', 'model', 'gatewayUrl', 'privacy', 'tier', 'system']) {
|
|
251
|
+
if (patch[k] != null) next[k] = patch[k];
|
|
252
|
+
}
|
|
253
|
+
// Picking one target unpicks the other. Without this a stale `model` would silently win
|
|
254
|
+
// over the agent the user just chose, and the screen would disagree with the machine.
|
|
255
|
+
if (patch.agent != null && patch.model == null) next.model = '';
|
|
256
|
+
if (patch.model) next.agent = '';
|
|
236
257
|
settings = next;
|
|
237
258
|
await saveSettings();
|
|
238
259
|
// Settings are read when the loop starts, so a change only lands on a restart.
|
|
@@ -273,7 +294,16 @@ export function createChannelService({
|
|
|
273
294
|
bot: bot ? { ...bot } : null,
|
|
274
295
|
error: lastError,
|
|
275
296
|
paired: pairing.list(),
|
|
276
|
-
settings: {
|
|
297
|
+
settings: {
|
|
298
|
+
agent: settings.agent,
|
|
299
|
+
model: settings.model || '',
|
|
300
|
+
gatewayUrl: settings.gatewayUrl || DEFAULT_GATEWAY_URL,
|
|
301
|
+
privacy: settings.privacy,
|
|
302
|
+
tier: settings.tier,
|
|
303
|
+
},
|
|
304
|
+
// Which transport a message will actually take, so a screen can say so rather than
|
|
305
|
+
// inferring it from two fields and getting it wrong.
|
|
306
|
+
via: settings.model ? 'gateway' : 'bridge',
|
|
277
307
|
};
|
|
278
308
|
},
|
|
279
309
|
};
|
package/src/channels/stream.js
CHANGED
|
@@ -35,6 +35,26 @@ export function foldEvent(state, ev) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Fold one OpenAI-style chunk (what the gateway streams) into the same reply state, so a
|
|
40
|
+
* caller cannot tell which transport it has. Shares parseSse: `data: [DONE]` is not JSON and
|
|
41
|
+
* is dropped there, which is why the transport marks `done` itself when the stream ends.
|
|
42
|
+
*/
|
|
43
|
+
export function foldOpenAiEvent(state, ev) {
|
|
44
|
+
if (ev?.type === 'run') return { ...state, runId: ev.id || state.runId };
|
|
45
|
+
// An error can arrive as a streamed frame rather than an HTTP status.
|
|
46
|
+
if (ev?.error) return { ...state, done: true, error: ev.error.message || String(ev.error) };
|
|
47
|
+
const choice = ev?.choices?.[0];
|
|
48
|
+
let next = state;
|
|
49
|
+
const delta = choice?.delta?.content;
|
|
50
|
+
if (typeof delta === 'string' && delta) next = { ...next, text: next.text + delta };
|
|
51
|
+
// Non-streaming replies (a gateway destination that cannot stream) carry the whole message.
|
|
52
|
+
const whole = choice?.message?.content;
|
|
53
|
+
if (!next.text && typeof whole === 'string' && whole) next = { ...next, text: whole };
|
|
54
|
+
if (choice?.finish_reason) next = { ...next, done: true };
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
/**
|
|
39
59
|
* Pull complete SSE events out of a growing buffer. Returns the parsed events and the
|
|
40
60
|
* UNCONSUMED tail (a partial frame still arriving), which the caller prepends next read.
|
package/src/server.js
CHANGED
|
@@ -67,7 +67,7 @@ import {
|
|
|
67
67
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
68
68
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
69
69
|
// this drifts from package.json, so the two can't silently diverge.
|
|
70
|
-
const VERSION = '0.11.
|
|
70
|
+
const VERSION = '0.11.5';
|
|
71
71
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
72
72
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
73
73
|
|