branching-chat-connector 0.1.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.
Files changed (2) hide show
  1. package/claude-connector.mjs +476 -0
  2. package/package.json +17 -0
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env node
2
+ // 本机「陪读」连接器:把本机已登录的 Claude(Agent SDK)暴露成一个
3
+ // OpenAI 兼容的流式 /v1/chat/completions,供 Branching Chat 前端在选用
4
+ // `claude-cli` 模型时直接连接。只监听 127.0.0.1,不收集任何密钥。
5
+ import http from 'node:http'
6
+ import crypto from 'node:crypto'
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import { query } from '@anthropic-ai/claude-agent-sdk'
11
+
12
+ const HOST = '127.0.0.1'
13
+ const PORT = Number.parseInt(process.env.CLAUDE_CONNECTOR_PORT || '8765', 10)
14
+ const MODEL = process.env.CLAUDE_CONNECTOR_MODEL?.trim() || undefined
15
+ const MAX_BODY_BYTES = 5 * 1024 * 1024
16
+ const TOKEN_FILE = path.join(os.homedir(), '.branching-chat-connector.json')
17
+
18
+ const DEFAULT_ALLOWED_ORIGINS = [
19
+ 'https://chat.branchingchat.xyz',
20
+ 'https://staging.branchingchat.xyz',
21
+ 'http://localhost:5173',
22
+ 'http://127.0.0.1:5173',
23
+ ]
24
+ const ALLOWED_ORIGINS = new Set(
25
+ ((process.env.CLAUDE_CONNECTOR_ALLOWED_ORIGINS || '')
26
+ .split(',')
27
+ .map((value) => value.trim().replace(/\/+$/, ''))
28
+ .filter(Boolean)
29
+ .length
30
+ ? process.env.CLAUDE_CONNECTOR_ALLOWED_ORIGINS.split(',')
31
+ : DEFAULT_ALLOWED_ORIGINS
32
+ )
33
+ .map((value) => value.trim().replace(/\/+$/, ''))
34
+ .filter(Boolean)
35
+ )
36
+
37
+ function loadOrCreateToken() {
38
+ const envToken = process.env.CLAUDE_CONNECTOR_TOKEN?.trim()
39
+ if (envToken) return { token: envToken, source: 'env' }
40
+ try {
41
+ const parsed = JSON.parse(fs.readFileSync(TOKEN_FILE, 'utf8'))
42
+ if (parsed && typeof parsed.token === 'string' && parsed.token.length >= 16) {
43
+ return { token: parsed.token, source: 'file' }
44
+ }
45
+ } catch {
46
+ // no existing token file; fall through to generate one
47
+ }
48
+ const token = crypto.randomBytes(24).toString('base64url')
49
+ try {
50
+ fs.writeFileSync(TOKEN_FILE, `${JSON.stringify({ token }, null, 2)}\n`, { mode: 0o600 })
51
+ fs.chmodSync(TOKEN_FILE, 0o600)
52
+ } catch {
53
+ // best effort; token still works for this run even if it can't be persisted
54
+ }
55
+ return { token, source: 'generated' }
56
+ }
57
+
58
+ const { token: PAIRING_TOKEN, source: TOKEN_SOURCE } = loadOrCreateToken()
59
+
60
+ // 系统提示以网页端「编辑系统 Prompt」为准:请求里携带的 system 消息会直接作为
61
+ // SDK systemPrompt。只有当网页没有下发任何 system 时,才用这个极简兜底(或下面的环境变量覆盖)。
62
+ const FALLBACK_SYSTEM_PROMPT =
63
+ process.env.CLAUDE_CONNECTOR_SYSTEM_PROMPT?.trim() ||
64
+ '请用简体中文,清晰、有条理地回答。你没有文件读写或命令执行能力,直接用文字回答即可。'
65
+
66
+ // 订阅登录链路上 Claude 的思考文本会被加密屏蔽(只回签名、不回文字)。思考确实发生时
67
+ // 用这句占位,让前端的「思考」折叠至少能显示出来,而不是空白。
68
+ const THINKING_REDACTED_NOTE = '(本机 Claude 思考过程不外显:订阅链路只返回加密签名,没有可显示的思考文本。)'
69
+
70
+ function normalizeOrigin(origin) {
71
+ return typeof origin === 'string' ? origin.trim().replace(/\/+$/, '') : ''
72
+ }
73
+
74
+ function isAllowedOrigin(origin) {
75
+ // No Origin header = non-browser/local caller (curl, same process) — trusted on loopback.
76
+ if (!origin) return true
77
+ return ALLOWED_ORIGINS.has(normalizeOrigin(origin))
78
+ }
79
+
80
+ function applyCors(req, res) {
81
+ const origin = normalizeOrigin(req.headers.origin)
82
+ // Only reflect explicitly allow-listed origins; a disallowed site gets no ACAO and is blocked.
83
+ if (origin && ALLOWED_ORIGINS.has(origin)) {
84
+ res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
85
+ // HTTPS sites (e.g. chat.branchingchat.xyz) calling http://127.0.0.1 trigger Chrome's
86
+ // Private Network Access preflight; acknowledge it so the loopback request isn't blocked.
87
+ if (req.headers['access-control-request-private-network'] === 'true') {
88
+ res.setHeader('Access-Control-Allow-Private-Network', 'true')
89
+ }
90
+ }
91
+ res.setHeader('Vary', 'Origin')
92
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
93
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Pairing-Token')
94
+ res.setHeader('Access-Control-Max-Age', '600')
95
+ }
96
+
97
+ function getRequestToken(req) {
98
+ const header = req.headers['x-pairing-token']
99
+ if (typeof header === 'string' && header.trim()) return header.trim()
100
+ const auth = req.headers['authorization']
101
+ if (typeof auth === 'string') {
102
+ const match = /^Bearer\s+(.+)$/i.exec(auth.trim())
103
+ if (match) return match[1].trim()
104
+ }
105
+ return ''
106
+ }
107
+
108
+ function tokenValid(req) {
109
+ const provided = getRequestToken(req)
110
+ if (!provided || !PAIRING_TOKEN) return false
111
+ const a = Buffer.from(provided)
112
+ const b = Buffer.from(PAIRING_TOKEN)
113
+ if (a.length !== b.length) return false
114
+ return crypto.timingSafeEqual(a, b)
115
+ }
116
+
117
+ function sendJson(res, status, body) {
118
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
119
+ res.end(JSON.stringify(body))
120
+ }
121
+
122
+ function extractText(content) {
123
+ if (typeof content === 'string') return content
124
+ if (Array.isArray(content)) {
125
+ return content
126
+ .map((part) => {
127
+ if (part && typeof part === 'object') {
128
+ if (part.type === 'text' && typeof part.text === 'string') return part.text
129
+ if (part.type === 'image_url') return '[图片:本机 Claude 暂不支持图片,已忽略]'
130
+ }
131
+ return ''
132
+ })
133
+ .filter(Boolean)
134
+ .join('\n')
135
+ }
136
+ return ''
137
+ }
138
+
139
+ function extractSystemPrompt(messages) {
140
+ const parts = []
141
+ for (const message of Array.isArray(messages) ? messages : []) {
142
+ if (message?.role !== 'system') continue
143
+ const text = extractText(message.content).trim()
144
+ if (text) parts.push(text)
145
+ }
146
+ return parts.join('\n\n')
147
+ }
148
+
149
+ // system 消息已经单独抽给 SDK 的 systemPrompt,这里只拍平用户/助手对话。
150
+ function buildPrompt(messages) {
151
+ const lines = []
152
+ for (const message of Array.isArray(messages) ? messages : []) {
153
+ if (message?.role === 'system') continue
154
+ const text = extractText(message?.content).trim()
155
+ if (!text) continue
156
+ const role = message.role === 'assistant' ? '助手' : '用户'
157
+ lines.push(`${role}:${text}`)
158
+ }
159
+ return lines.join('\n\n')
160
+ }
161
+
162
+ function buildQueryOptions({ stream, systemPrompt, thinking }) {
163
+ const options = {
164
+ systemPrompt: systemPrompt || FALLBACK_SYSTEM_PROMPT,
165
+ allowedTools: [],
166
+ settingSources: [],
167
+ maxTurns: 1,
168
+ includePartialMessages: stream,
169
+ }
170
+ if (MODEL) options.model = MODEL
171
+ if (thinking) options.thinking = { type: 'adaptive' }
172
+ return options
173
+ }
174
+
175
+ function streamEventText(message) {
176
+ if (message?.type !== 'stream_event') return ''
177
+ const event = message.event
178
+ if (event?.type !== 'content_block_delta') return ''
179
+ const delta = event.delta
180
+ if (delta?.type === 'text_delta' && typeof delta.text === 'string') return delta.text
181
+ return ''
182
+ }
183
+
184
+ function streamEventThinking(message) {
185
+ if (message?.type !== 'stream_event') return ''
186
+ const event = message.event
187
+ if (event?.type !== 'content_block_delta') return ''
188
+ const delta = event.delta
189
+ if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') return delta.thinking
190
+ return ''
191
+ }
192
+
193
+ function isThinkingBlockStart(message) {
194
+ if (message?.type !== 'stream_event') return false
195
+ const event = message.event
196
+ if (event?.type !== 'content_block_start') return false
197
+ const blockType = event.content_block?.type
198
+ return blockType === 'thinking' || blockType === 'redacted_thinking'
199
+ }
200
+
201
+ function assistantMessageText(message) {
202
+ if (message?.type !== 'assistant') return ''
203
+ const content = message.message?.content
204
+ if (!Array.isArray(content)) return ''
205
+ return content
206
+ .map((block) =>
207
+ block?.type === 'text' && typeof block.text === 'string' ? block.text : ''
208
+ )
209
+ .filter(Boolean)
210
+ .join('')
211
+ }
212
+
213
+ function assistantMessageThinking(message) {
214
+ if (message?.type !== 'assistant') return ''
215
+ const content = message.message?.content
216
+ if (!Array.isArray(content)) return ''
217
+ return content
218
+ .map((block) =>
219
+ block?.type === 'thinking' && typeof block.thinking === 'string' ? block.thinking : ''
220
+ )
221
+ .filter(Boolean)
222
+ .join('')
223
+ }
224
+
225
+ function assistantHasThinkingBlock(message) {
226
+ if (message?.type !== 'assistant') return false
227
+ const content = message.message?.content
228
+ return (
229
+ Array.isArray(content) &&
230
+ content.some((block) => block?.type === 'thinking' || block?.type === 'redacted_thinking')
231
+ )
232
+ }
233
+
234
+ function sseData(payload) {
235
+ return `data: ${JSON.stringify(payload)}\n\n`
236
+ }
237
+
238
+ // 识别 Claude 订阅用量/速率达到上限的错误(订阅链路常见的几种措辞)。
239
+ function isCliLimitText(text) {
240
+ return /usage limit|rate.?limit|rate_limit|limit reached|limit_reached|too many requests|overloaded|429|quota|用量.{0,4}上限|额度|限额/i.test(
241
+ String(text || '')
242
+ )
243
+ }
244
+
245
+ async function handleChatCompletion(req, res, body) {
246
+ const stream = Boolean(body?.stream)
247
+ const prompt = buildPrompt(body?.messages)
248
+ const systemPrompt = extractSystemPrompt(body?.messages)
249
+ const thinking = body?.thinkingMode === 'enabled'
250
+ if (!prompt) {
251
+ sendJson(res, 400, {
252
+ error: { message: 'messages 为空,无法生成回答。', code: 'empty_messages' },
253
+ })
254
+ return
255
+ }
256
+
257
+ const routeHeaders = {
258
+ 'x-chat-provider': 'claude-cli',
259
+ 'x-chat-model': MODEL || 'claude-code',
260
+ 'x-chat-route': 'local:claude-cli',
261
+ }
262
+
263
+ if (!stream) {
264
+ try {
265
+ let full = ''
266
+ let reasoning = ''
267
+ let sawThinking = false
268
+ for await (const message of query({
269
+ prompt,
270
+ options: buildQueryOptions({ stream: false, systemPrompt, thinking }),
271
+ })) {
272
+ full += assistantMessageText(message)
273
+ reasoning += assistantMessageThinking(message)
274
+ if (assistantHasThinkingBlock(message)) sawThinking = true
275
+ }
276
+ const reasoningOut = thinking
277
+ ? reasoning.trim() || (sawThinking ? THINKING_REDACTED_NOTE : '')
278
+ : ''
279
+ res.writeHead(200, { ...routeHeaders, 'Content-Type': 'application/json; charset=utf-8' })
280
+ res.end(
281
+ JSON.stringify({
282
+ choices: [
283
+ {
284
+ message: {
285
+ role: 'assistant',
286
+ content: full,
287
+ ...(reasoningOut ? { reasoning_content: reasoningOut } : {}),
288
+ },
289
+ finish_reason: 'stop',
290
+ },
291
+ ],
292
+ })
293
+ )
294
+ } catch (error) {
295
+ const detail = error instanceof Error ? error.message : String(error)
296
+ if (isCliLimitText(detail)) {
297
+ sendJson(res, 429, {
298
+ error: { message: `本机 Claude 用量已达上限:${detail}`, code: 'cli_rate_limited' },
299
+ })
300
+ } else {
301
+ sendJson(res, 502, {
302
+ error: { message: `本机 Claude 连接器出错:${detail}`, code: 'connector_error' },
303
+ })
304
+ }
305
+ }
306
+ return
307
+ }
308
+
309
+ res.writeHead(200, {
310
+ ...routeHeaders,
311
+ 'Content-Type': 'text/event-stream; charset=utf-8',
312
+ 'Cache-Control': 'no-cache, no-transform',
313
+ Connection: 'keep-alive',
314
+ })
315
+ let sentAny = false
316
+ let reasoningSent = false
317
+ let thinkingNoteSent = false
318
+ const emitThinkingNote = () => {
319
+ if (thinkingNoteSent || reasoningSent) return
320
+ thinkingNoteSent = true
321
+ res.write(sseData({ choices: [{ delta: { reasoning_content: THINKING_REDACTED_NOTE } }] }))
322
+ }
323
+ try {
324
+ for await (const message of query({
325
+ prompt,
326
+ options: buildQueryOptions({ stream: true, systemPrompt, thinking }),
327
+ })) {
328
+ // 思考块出现就先亮出折叠(订阅链路里随后的 thinking_delta 文本是空的)。
329
+ // 仅当用户开启思考模式时才显示;关闭时即便底层默认触发思考块也不外显。
330
+ if (isThinkingBlockStart(message)) {
331
+ if (thinking) emitThinkingNote()
332
+ continue
333
+ }
334
+ const reasoning = thinking ? streamEventThinking(message) : ''
335
+ if (reasoning) {
336
+ reasoningSent = true
337
+ res.write(sseData({ choices: [{ delta: { reasoning_content: reasoning } }] }))
338
+ continue
339
+ }
340
+ const text = streamEventText(message)
341
+ if (text) {
342
+ sentAny = true
343
+ res.write(sseData({ choices: [{ delta: { content: text } }] }))
344
+ continue
345
+ }
346
+ if (
347
+ message?.type === 'result' &&
348
+ message.subtype &&
349
+ message.subtype !== 'success'
350
+ ) {
351
+ if (isCliLimitText(message.subtype)) {
352
+ res.write(sseData({ x_limit: 'cli_rate_limited' }))
353
+ }
354
+ if (!sentAny) {
355
+ res.write(
356
+ sseData({
357
+ choices: [{ delta: { content: `本机 Claude 返回错误:${message.subtype}` } }],
358
+ })
359
+ )
360
+ }
361
+ }
362
+ }
363
+ } catch (error) {
364
+ const detail = error instanceof Error ? error.message : String(error)
365
+ if (isCliLimitText(detail)) {
366
+ res.write(sseData({ x_limit: 'cli_rate_limited' }))
367
+ }
368
+ if (!sentAny) {
369
+ res.write(sseData({ choices: [{ delta: { content: `本机 Claude 连接器出错:${detail}` } }] }))
370
+ }
371
+ }
372
+ res.write(sseData({ choices: [{ delta: {}, finish_reason: 'stop' }] }))
373
+ res.write('data: [DONE]\n\n')
374
+ res.end()
375
+ }
376
+
377
+ const server = http.createServer((req, res) => {
378
+ applyCors(req, res)
379
+
380
+ if (req.method === 'OPTIONS') {
381
+ res.writeHead(204)
382
+ res.end()
383
+ return
384
+ }
385
+
386
+ const url = new URL(req.url || '/', `http://${HOST}:${PORT}`)
387
+ const originOk = isAllowedOrigin(req.headers.origin)
388
+
389
+ if (req.method === 'GET' && url.pathname === '/health') {
390
+ sendJson(res, 200, {
391
+ ok: true,
392
+ provider: 'claude-cli',
393
+ model: MODEL || 'claude-code',
394
+ requiresPairing: true,
395
+ paired: tokenValid(req),
396
+ })
397
+ return
398
+ }
399
+
400
+ if (req.method === 'POST' && url.pathname === '/pair') {
401
+ // Hand the pairing token only to allow-listed browser origins (or local non-browser callers).
402
+ if (!originOk) {
403
+ sendJson(res, 403, {
404
+ error: { message: '该来源未被授权配对。', code: 'origin_not_allowed' },
405
+ })
406
+ return
407
+ }
408
+ sendJson(res, 200, { ok: true, token: PAIRING_TOKEN, model: MODEL || 'claude-code' })
409
+ return
410
+ }
411
+
412
+ if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
413
+ if (!originOk) {
414
+ sendJson(res, 403, {
415
+ error: { message: '该来源未被授权访问本机 Claude。', code: 'origin_not_allowed' },
416
+ })
417
+ return
418
+ }
419
+ if (!tokenValid(req)) {
420
+ sendJson(res, 401, {
421
+ error: { message: '未配对或配对令牌失效,请在设置里重新配对。', code: 'unauthorized' },
422
+ })
423
+ return
424
+ }
425
+ let received = 0
426
+ const chunks = []
427
+ req.on('data', (chunk) => {
428
+ received += chunk.length
429
+ if (received > MAX_BODY_BYTES) {
430
+ sendJson(res, 413, { error: { message: '请求体过大。', code: 'payload_too_large' } })
431
+ req.destroy()
432
+ return
433
+ }
434
+ chunks.push(chunk)
435
+ })
436
+ req.on('end', () => {
437
+ if (res.writableEnded) return
438
+ let body
439
+ try {
440
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')
441
+ } catch {
442
+ sendJson(res, 400, {
443
+ error: { message: '请求体不是合法 JSON。', code: 'invalid_json' },
444
+ })
445
+ return
446
+ }
447
+ handleChatCompletion(req, res, body).catch((error) => {
448
+ if (res.writableEnded) return
449
+ const detail = error instanceof Error ? error.message : String(error)
450
+ sendJson(res, 500, { error: { message: detail, code: 'connector_error' } })
451
+ })
452
+ })
453
+ return
454
+ }
455
+
456
+ sendJson(res, 404, { error: { message: 'Not found', code: 'not_found' } })
457
+ })
458
+
459
+ server.listen(PORT, HOST, () => {
460
+ const tokenSourceLabel =
461
+ TOKEN_SOURCE === 'env'
462
+ ? '来自环境变量 CLAUDE_CONNECTOR_TOKEN'
463
+ : TOKEN_SOURCE === 'file'
464
+ ? `来自 ${TOKEN_FILE}`
465
+ : `已生成并保存到 ${TOKEN_FILE}`
466
+ process.stdout.write(`Claude 陪读连接器已启动:http://${HOST}:${PORT}\n`)
467
+ process.stdout.write(
468
+ `模型:${MODEL || '(SDK 默认)'};健康检查 GET /health;配对 POST /pair;聊天 POST /v1/chat/completions\n`
469
+ )
470
+ process.stdout.write(`允许的网页来源:${[...ALLOWED_ORIGINS].join(',') || '(无)'}\n`)
471
+ process.stdout.write(`配对令牌(${tokenSourceLabel}):${PAIRING_TOKEN}\n`)
472
+ process.stdout.write(
473
+ '在网页设置里点「一键配对」即可自动获取上面的令牌;也可手动复制。只有列表里的来源才能配对。\n'
474
+ )
475
+ process.stdout.write('凭证使用本机已登录的 Claude(或 ANTHROPIC_API_KEY),连接器不收集任何密钥。\n')
476
+ })
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "branching-chat-connector",
3
+ "version": "0.1.0",
4
+ "description": "本机「陪读」连接器:把你已登录的 Claude(Agent SDK)暴露成 OpenAI 兼容接口,供 Branching Chat 的「claude-cli(本机)」模型调用。只监听 127.0.0.1,不收集任何密钥。",
5
+ "type": "module",
6
+ "bin": "claude-connector.mjs",
7
+ "files": [
8
+ "claude-connector.mjs"
9
+ ],
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "dependencies": {
14
+ "@anthropic-ai/claude-agent-sdk": "^0.3.158"
15
+ },
16
+ "license": "MIT"
17
+ }