openclaw-vchat-plugin 0.0.8 → 0.0.9

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.
@@ -0,0 +1,204 @@
1
+ import Database from 'better-sqlite3';
2
+ import path from 'path';
3
+ import { randomUUID } from 'crypto';
4
+ import { MessageType } from './types';
5
+
6
+ export interface GroupEvent {
7
+ id: string;
8
+ groupId: string;
9
+ requestId?: string;
10
+ sessionId?: string;
11
+ role: 'user' | 'assistant' | 'system';
12
+ type: MessageType;
13
+ content: string;
14
+ mediaUrl?: string;
15
+ timestamp: number;
16
+ senderUserId?: string;
17
+ senderName?: string;
18
+ senderAvatarUrl?: string;
19
+ agentId?: string;
20
+ }
21
+
22
+ function normalizeText(value: unknown): string {
23
+ return String(value || '').trim();
24
+ }
25
+
26
+ function normalizeMessageType(value: unknown): MessageType {
27
+ const normalized = normalizeText(value).toLowerCase();
28
+ if (normalized === 'voice' || normalized === 'image' || normalized === 'system' || normalized === 'command') {
29
+ return normalized;
30
+ }
31
+ return 'text';
32
+ }
33
+
34
+ export class GroupEventStore {
35
+ private db: Database.Database;
36
+
37
+ constructor(dataDir: string) {
38
+ const dbPath = path.join(dataDir, 'openclaw-wechat.db');
39
+ this.db = new Database(dbPath);
40
+ this.db.pragma('journal_mode = WAL');
41
+ this.db.pragma('foreign_keys = ON');
42
+ this.initTables();
43
+ }
44
+
45
+ private initTables(): void {
46
+ this.db.exec(`
47
+ CREATE TABLE IF NOT EXISTS group_events (
48
+ id TEXT PRIMARY KEY,
49
+ group_id TEXT NOT NULL,
50
+ request_id TEXT,
51
+ session_id TEXT,
52
+ role TEXT NOT NULL,
53
+ type TEXT NOT NULL DEFAULT 'text',
54
+ content TEXT NOT NULL,
55
+ media_url TEXT,
56
+ sender_user_id TEXT,
57
+ sender_name TEXT,
58
+ sender_avatar_url TEXT,
59
+ agent_id TEXT,
60
+ created_at INTEGER NOT NULL
61
+ );
62
+ CREATE INDEX IF NOT EXISTS idx_group_events_group_id_created_at
63
+ ON group_events(group_id, created_at DESC);
64
+ CREATE INDEX IF NOT EXISTS idx_group_events_request_id
65
+ ON group_events(request_id);
66
+ `);
67
+ }
68
+
69
+ appendUserMessage(input: {
70
+ groupId: string;
71
+ requestId?: string;
72
+ sessionId?: string;
73
+ content: string;
74
+ type?: MessageType;
75
+ mediaUrl?: string;
76
+ senderUserId?: string;
77
+ senderName?: string;
78
+ senderAvatarUrl?: string;
79
+ timestamp?: number;
80
+ }): GroupEvent {
81
+ const groupId = normalizeText(input.groupId);
82
+ const content = String(input.content || '');
83
+ const requestId = normalizeText(input.requestId);
84
+
85
+ if (requestId) {
86
+ const existing = this.db.prepare(`
87
+ SELECT * FROM group_events
88
+ WHERE group_id = ? AND request_id = ? AND role = 'user'
89
+ LIMIT 1
90
+ `).get(groupId, requestId) as any;
91
+ if (existing) {
92
+ return this.mapRow(existing);
93
+ }
94
+ }
95
+
96
+ const event: GroupEvent = {
97
+ id: randomUUID(),
98
+ groupId,
99
+ requestId: requestId || undefined,
100
+ sessionId: normalizeText(input.sessionId) || undefined,
101
+ role: 'user',
102
+ type: normalizeMessageType(input.type),
103
+ content,
104
+ mediaUrl: normalizeText(input.mediaUrl) || undefined,
105
+ timestamp: Number(input.timestamp) || Date.now(),
106
+ senderUserId: normalizeText(input.senderUserId) || undefined,
107
+ senderName: normalizeText(input.senderName) || undefined,
108
+ senderAvatarUrl: normalizeText(input.senderAvatarUrl) || undefined,
109
+ };
110
+
111
+ this.insert(event);
112
+ return event;
113
+ }
114
+
115
+ appendAssistantMessage(input: {
116
+ groupId: string;
117
+ requestId?: string;
118
+ sessionId?: string;
119
+ agentId?: string;
120
+ content: string;
121
+ type?: MessageType;
122
+ mediaUrl?: string;
123
+ timestamp?: number;
124
+ }): GroupEvent {
125
+ const event: GroupEvent = {
126
+ id: randomUUID(),
127
+ groupId: normalizeText(input.groupId),
128
+ requestId: normalizeText(input.requestId) || undefined,
129
+ sessionId: normalizeText(input.sessionId) || undefined,
130
+ role: 'assistant',
131
+ type: normalizeMessageType(input.type),
132
+ content: String(input.content || ''),
133
+ mediaUrl: normalizeText(input.mediaUrl) || undefined,
134
+ timestamp: Number(input.timestamp) || Date.now(),
135
+ agentId: normalizeText(input.agentId) || undefined,
136
+ };
137
+
138
+ this.insert(event);
139
+ return event;
140
+ }
141
+
142
+ list(groupId: string, limit = 100, before?: number): GroupEvent[] {
143
+ const safeLimit = Math.max(1, Math.min(Number(limit) || 100, 200));
144
+ const safeBefore = Number(before) || 0;
145
+ const rows = safeBefore > 0
146
+ ? this.db.prepare(`
147
+ SELECT * FROM group_events
148
+ WHERE group_id = ? AND created_at < ?
149
+ ORDER BY created_at DESC
150
+ LIMIT ?
151
+ `).all(normalizeText(groupId), safeBefore, safeLimit) as any[]
152
+ : this.db.prepare(`
153
+ SELECT * FROM group_events
154
+ WHERE group_id = ?
155
+ ORDER BY created_at DESC
156
+ LIMIT ?
157
+ `).all(normalizeText(groupId), safeLimit) as any[];
158
+
159
+ return rows
160
+ .map((row) => this.mapRow(row))
161
+ .sort((a, b) => a.timestamp - b.timestamp);
162
+ }
163
+
164
+ private insert(event: GroupEvent): void {
165
+ this.db.prepare(`
166
+ INSERT INTO group_events (
167
+ id, group_id, request_id, session_id, role, type, content, media_url,
168
+ sender_user_id, sender_name, sender_avatar_url, agent_id, created_at
169
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
170
+ `).run(
171
+ event.id,
172
+ event.groupId,
173
+ event.requestId || null,
174
+ event.sessionId || null,
175
+ event.role,
176
+ event.type,
177
+ event.content,
178
+ event.mediaUrl || null,
179
+ event.senderUserId || null,
180
+ event.senderName || null,
181
+ event.senderAvatarUrl || null,
182
+ event.agentId || null,
183
+ event.timestamp,
184
+ );
185
+ }
186
+
187
+ private mapRow(row: any): GroupEvent {
188
+ return {
189
+ id: normalizeText(row.id),
190
+ groupId: normalizeText(row.group_id),
191
+ requestId: normalizeText(row.request_id) || undefined,
192
+ sessionId: normalizeText(row.session_id) || undefined,
193
+ role: normalizeText(row.role) === 'assistant' ? 'assistant' : normalizeText(row.role) === 'system' ? 'system' : 'user',
194
+ type: normalizeMessageType(row.type),
195
+ content: String(row.content || ''),
196
+ mediaUrl: normalizeText(row.media_url) || undefined,
197
+ timestamp: Number(row.created_at) || Date.now(),
198
+ senderUserId: normalizeText(row.sender_user_id) || undefined,
199
+ senderName: normalizeText(row.sender_name) || undefined,
200
+ senderAvatarUrl: normalizeText(row.sender_avatar_url) || undefined,
201
+ agentId: normalizeText(row.agent_id) || undefined,
202
+ };
203
+ }
204
+ }
package/src/index.ts CHANGED
@@ -234,6 +234,7 @@ function handleClientMessage(raw: string): void {
234
234
  sendTunnelFrame({ type: 'error', message: '插件尚未完成初始化', error: '插件尚未完成初始化' });
235
235
  return;
236
236
  }
237
+ const currentRelayServer = relayServer;
237
238
 
238
239
  const content = String(msg.content || '').trim();
239
240
  if (!content) {
@@ -258,6 +259,20 @@ function handleClientMessage(raw: string): void {
258
259
  requestId: typeof msg.requestId === 'string' ? msg.requestId : undefined,
259
260
  };
260
261
 
262
+ if (groupId) {
263
+ currentRelayServer.groupEventStore.appendUserMessage({
264
+ groupId,
265
+ requestId: requestId || undefined,
266
+ sessionId: sid,
267
+ content,
268
+ type: messageType,
269
+ mediaUrl,
270
+ senderUserId: userId,
271
+ senderName: typeof msg.senderName === 'string' ? msg.senderName : undefined,
272
+ senderAvatarUrl: typeof msg.senderAvatarUrl === 'string' ? msg.senderAvatarUrl : undefined,
273
+ });
274
+ }
275
+
261
276
  const previousAbort = tunnelAbortByUser.get(userId);
262
277
  if (previousAbort) {
263
278
  previousAbort();
@@ -290,6 +305,17 @@ function handleClientMessage(raw: string): void {
290
305
  onDone: (userMessage, assistantMessage) => {
291
306
  streamFinished = true;
292
307
  if (currentAbort && tunnelAbortByUser.get(userId) === currentAbort) tunnelAbortByUser.delete(userId);
308
+ if (groupId && assistantMessage?.content) {
309
+ currentRelayServer.groupEventStore.appendAssistantMessage({
310
+ groupId,
311
+ requestId: requestId || undefined,
312
+ sessionId: sid,
313
+ agentId,
314
+ content: assistantMessage.content,
315
+ type: assistantMessage.type,
316
+ mediaUrl: assistantMessage.mediaUrl,
317
+ });
318
+ }
293
319
  sendTunnelFrame({
294
320
  type: 'done',
295
321
  sessionId: sid,
@@ -0,0 +1,217 @@
1
+ import fs from 'fs';
2
+ import http from 'http';
3
+ import { execFileSync } from 'child_process';
4
+
5
+ type UpdatePhase =
6
+ | 'queued'
7
+ | 'checking'
8
+ | 'installing'
9
+ | 'restarting'
10
+ | 'verifying'
11
+ | 'succeeded'
12
+ | 'failed'
13
+ | 'rolled_back';
14
+
15
+ type UpdateStatus = 'running' | 'succeeded' | 'failed' | 'rolled_back';
16
+
17
+ type UpdateState = {
18
+ status: UpdateStatus;
19
+ phase: UpdatePhase;
20
+ currentVersion: string;
21
+ latestVersion: string;
22
+ targetVersion: string;
23
+ updateAvailable: boolean;
24
+ message: string;
25
+ packageName: string;
26
+ registry: string;
27
+ pm2ProcessName: string;
28
+ pm2Managed: boolean;
29
+ startedAt: number;
30
+ finishedAt: number;
31
+ error: string;
32
+ warning: string;
33
+ };
34
+
35
+ function cleanString(value: unknown): string {
36
+ return String(value || '').trim();
37
+ }
38
+
39
+ function npmCommand(): string {
40
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
41
+ }
42
+
43
+ function pm2Command(): string {
44
+ return process.platform === 'win32' ? 'pm2.cmd' : 'pm2';
45
+ }
46
+
47
+ function sleep(ms: number): Promise<void> {
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ const statePath = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_STATE);
52
+ const packageName = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_PACKAGE) || 'openclaw-vchat-plugin';
53
+ const currentVersion = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_CURRENT);
54
+ const targetVersion = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_TARGET);
55
+ const registry = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_REGISTRY);
56
+ const pm2Name = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_PM2_NAME) || 'openclaw-vchat';
57
+ const healthUrl = cleanString(process.env.OPENCLAW_VCHAT_UPDATE_HEALTH_URL) || 'http://127.0.0.1:39217/internal/health';
58
+
59
+ function readState(): UpdateState {
60
+ const raw = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
61
+ return raw as UpdateState;
62
+ }
63
+
64
+ function writeState(patch: Partial<UpdateState>): UpdateState {
65
+ const current = readState();
66
+ const next = {
67
+ ...current,
68
+ ...patch,
69
+ };
70
+ fs.writeFileSync(statePath, JSON.stringify(next, null, 2) + '\n', 'utf-8');
71
+ return next;
72
+ }
73
+
74
+ function runCommand(command: string, args: string[], timeout: number): string {
75
+ const stdout = execFileSync(command, args, {
76
+ encoding: 'utf8',
77
+ timeout,
78
+ stdio: ['ignore', 'pipe', 'pipe'],
79
+ env: process.env,
80
+ });
81
+ return cleanString(stdout);
82
+ }
83
+
84
+ function buildNpmArgs(version: string): string[] {
85
+ const args = ['install', '-g', `${packageName}@${version}`];
86
+ if (registry) args.push('--registry', registry);
87
+ return args;
88
+ }
89
+
90
+ function restartPm2(): void {
91
+ runCommand(pm2Command(), ['restart', pm2Name, '--update-env'], 30000);
92
+ try {
93
+ runCommand(pm2Command(), ['save'], 10000);
94
+ } catch {
95
+ // ignore save failure
96
+ }
97
+ }
98
+
99
+ async function waitForHealth(url: string, timeoutMs: number): Promise<void> {
100
+ const deadline = Date.now() + timeoutMs;
101
+ while (Date.now() < deadline) {
102
+ const ok = await new Promise<boolean>((resolve) => {
103
+ const req = http.get(url, (res) => {
104
+ const statusCode = Number(res.statusCode) || 0;
105
+ res.resume();
106
+ resolve(statusCode >= 200 && statusCode < 300);
107
+ });
108
+ req.on('error', () => resolve(false));
109
+ req.setTimeout(3000, () => {
110
+ req.destroy();
111
+ resolve(false);
112
+ });
113
+ });
114
+ if (ok) return;
115
+ await sleep(1500);
116
+ }
117
+ throw new Error('插件重启后健康检查超时');
118
+ }
119
+
120
+ async function rollback(reason: string): Promise<void> {
121
+ if (!currentVersion) {
122
+ writeState({
123
+ status: 'failed',
124
+ phase: 'failed',
125
+ finishedAt: Date.now(),
126
+ error: reason,
127
+ message: '更新失败,且缺少回滚版本',
128
+ });
129
+ return;
130
+ }
131
+
132
+ try {
133
+ writeState({
134
+ phase: 'installing',
135
+ message: `更新失败,正在回滚到 v${currentVersion}`,
136
+ error: reason,
137
+ });
138
+ runCommand(npmCommand(), buildNpmArgs(currentVersion), 600000);
139
+ writeState({
140
+ phase: 'restarting',
141
+ message: '回滚安装完成,正在重启插件',
142
+ error: reason,
143
+ });
144
+ restartPm2();
145
+ writeState({
146
+ phase: 'verifying',
147
+ message: '正在验证回滚后的插件状态',
148
+ error: reason,
149
+ });
150
+ await waitForHealth(healthUrl, 60000);
151
+ writeState({
152
+ status: 'rolled_back',
153
+ phase: 'rolled_back',
154
+ finishedAt: Date.now(),
155
+ message: `更新失败,已回滚到 v${currentVersion}`,
156
+ error: reason,
157
+ warning: '请稍后重试,或先检查 Node / npm / 网络环境',
158
+ });
159
+ } catch (rollbackErr: any) {
160
+ writeState({
161
+ status: 'failed',
162
+ phase: 'failed',
163
+ finishedAt: Date.now(),
164
+ message: '更新失败,且自动回滚失败',
165
+ error: `${reason}; rollback=${cleanString(rollbackErr?.message) || 'unknown'}`,
166
+ });
167
+ }
168
+ }
169
+
170
+ async function main() {
171
+ try {
172
+ writeState({
173
+ status: 'running',
174
+ phase: 'checking',
175
+ message: `准备更新到 v${targetVersion}`,
176
+ error: '',
177
+ warning: '',
178
+ });
179
+
180
+ writeState({
181
+ phase: 'installing',
182
+ message: `正在下载并安装 v${targetVersion}`,
183
+ });
184
+ runCommand(npmCommand(), buildNpmArgs(targetVersion), 600000);
185
+
186
+ writeState({
187
+ phase: 'restarting',
188
+ message: '安装完成,正在重启插件进程',
189
+ });
190
+ restartPm2();
191
+
192
+ writeState({
193
+ phase: 'verifying',
194
+ message: '正在验证更新后的插件状态',
195
+ });
196
+ await waitForHealth(healthUrl, 60000);
197
+
198
+ writeState({
199
+ status: 'succeeded',
200
+ phase: 'succeeded',
201
+ currentVersion: targetVersion,
202
+ latestVersion: targetVersion,
203
+ targetVersion,
204
+ updateAvailable: false,
205
+ finishedAt: Date.now(),
206
+ message: `插件已更新到 v${targetVersion}`,
207
+ error: '',
208
+ warning: '',
209
+ });
210
+ } catch (err: any) {
211
+ await rollback(cleanString(err?.message) || 'plugin-update-failed');
212
+ }
213
+ }
214
+
215
+ main()
216
+ .then(() => process.exit(0))
217
+ .catch(() => process.exit(1));
@@ -12,11 +12,14 @@ import { MessageHandler } from './message-handler';
12
12
  import { MediaHandler } from './media-handler';
13
13
  import { GatewayClient } from './gateway-client';
14
14
  import { GroupManager } from './group-manager';
15
+ import { GroupEventStore } from './group-event-store';
15
16
  import { BOT_COMMANDS } from './commands';
16
17
  import { Message, PluginConfig } from './types';
17
18
  import { BRIDGE_CAPABILITIES, BRIDGE_PLUGIN_VERSION, BRIDGE_PROTOCOL_VERSION } from './constants';
18
19
  import { buildWeChatGatewaySessionKey, parseWeChatDirectThreadSessionKey, sanitizeWeChatId } from './session-key';
19
20
  import configRoutes from './routes/config.routes';
21
+ import { getSkillsStatus, installSkill, setSkillEnabledLocally, updateSkill } from './services/skills.service';
22
+ import { checkPluginUpdateInfo, readPluginUpdateState, startPluginUpdate } from './services/plugin-update.service';
20
23
 
21
24
  interface GatewayWechatSession {
22
25
  id: string;
@@ -496,11 +499,13 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
496
499
  const app = express();
497
500
  const dataDir = path.join(config.uploadDir, '..');
498
501
  const sessionManager = new SessionManager(dataDir);
502
+ const groupEventStore = new GroupEventStore(dataDir);
499
503
  const messageHandler = new MessageHandler(config, sessionManager, gateway);
500
504
  const mediaHandler = new MediaHandler(config);
501
505
  const groupManager = new GroupManager(dataDir);
502
506
  const memoSessions = createRequestMemo<any>(1000);
503
507
  const memoGroups = createRequestMemo<any>(1000);
508
+ const memoSkills = createRequestMemo<any>(1500);
504
509
  const memoHistory = createRequestMemo<any>(800);
505
510
 
506
511
  // ===== 中间件 =====
@@ -817,6 +822,19 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
817
822
  .catch((err: any) => res.status(500).json({ error: err?.message || '群列表获取失败' }));
818
823
  });
819
824
 
825
+ internal.get('/groups/:id/history', (req: Request, res: Response) => {
826
+ try {
827
+ const limit = parseInt(String(req.query.limit || ''), 10) || 100;
828
+ const before = req.query.before ? parseInt(String(req.query.before), 10) : undefined;
829
+ res.json({
830
+ messages: groupEventStore.list(req.params.id, limit, before),
831
+ source: 'local-group-events',
832
+ });
833
+ } catch (err: any) {
834
+ res.status(500).json({ error: err?.message || '群历史获取失败' });
835
+ }
836
+ });
837
+
820
838
  internal.post('/groups', (req: Request, res: Response) => {
821
839
  const { name, emoji, agentIds, mode } = req.body;
822
840
  if (!name) { res.status(400).json({ error: '缺少群名称' }); return; }
@@ -847,6 +865,98 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
847
865
  group ? res.json(group) : res.status(404).json({ error: '群不存在' });
848
866
  });
849
867
 
868
+ // ===== Skills API =====
869
+
870
+ internal.get('/skills', async (req: Request, res: Response) => {
871
+ try {
872
+ const agentId = typeof req.query.agentId === 'string' ? req.query.agentId : '';
873
+ const memoKey = `skills:${String(agentId || '*').trim() || '*'}`;
874
+ const payload = await memoSkills(memoKey, async () => {
875
+ const status = await getSkillsStatus(gateway, agentId);
876
+ return {
877
+ ...status,
878
+ source: 'gateway',
879
+ };
880
+ });
881
+ res.json(payload);
882
+ } catch (err: any) {
883
+ res.status(500).json({ error: err?.message || '获取 Skills 列表失败' });
884
+ }
885
+ });
886
+
887
+ internal.post('/skills/install', async (req: Request, res: Response) => {
888
+ try {
889
+ const result = await installSkill(gateway, {
890
+ name: typeof req.body?.name === 'string' ? req.body.name : '',
891
+ installId: typeof req.body?.installId === 'string' ? req.body.installId : '',
892
+ timeoutMs: req.body?.timeoutMs,
893
+ });
894
+ res.json(result);
895
+ } catch (err: any) {
896
+ res.status(500).json({ error: err?.message || '安装 Skill 失败' });
897
+ }
898
+ });
899
+
900
+ internal.post('/skills/:skillKey/update', async (req: Request, res: Response) => {
901
+ try {
902
+ const skillKey = String(req.params.skillKey || '').trim();
903
+ if (!skillKey) {
904
+ res.status(400).json({ error: '缺少 skillKey' });
905
+ return;
906
+ }
907
+
908
+ const payload = {
909
+ enabled: typeof req.body?.enabled === 'boolean' ? req.body.enabled : undefined,
910
+ apiKey: typeof req.body?.apiKey === 'string' ? req.body.apiKey : undefined,
911
+ env: req.body?.env && typeof req.body.env === 'object' ? req.body.env : undefined,
912
+ };
913
+
914
+ try {
915
+ const result = await updateSkill(gateway, skillKey, payload);
916
+ res.json(result);
917
+ } catch (err: any) {
918
+ if (typeof payload.enabled !== 'boolean') throw err;
919
+ const fallback = setSkillEnabledLocally(gateway, skillKey, payload.enabled);
920
+ res.json({
921
+ ...fallback,
922
+ source: 'local-config',
923
+ warning: err?.message || 'Gateway skills.update 失败,已改为本地配置写入',
924
+ });
925
+ }
926
+ } catch (err: any) {
927
+ res.status(500).json({ error: err?.message || '更新 Skill 状态失败' });
928
+ }
929
+ });
930
+
931
+ // ===== Plugin update =====
932
+
933
+ internal.get('/plugin/update/info', async (_req: Request, res: Response) => {
934
+ try {
935
+ const info = await checkPluginUpdateInfo();
936
+ res.json(info);
937
+ } catch (err: any) {
938
+ res.status(500).json({ error: err?.message || '检查插件更新失败' });
939
+ }
940
+ });
941
+
942
+ internal.get('/plugin/update/status', (_req: Request, res: Response) => {
943
+ try {
944
+ res.json(readPluginUpdateState());
945
+ } catch (err: any) {
946
+ res.status(500).json({ error: err?.message || '获取插件更新状态失败' });
947
+ }
948
+ });
949
+
950
+ internal.post('/plugin/update/start', async (req: Request, res: Response) => {
951
+ try {
952
+ const targetVersion = typeof req.body?.version === 'string' ? req.body.version : '';
953
+ const result = await startPluginUpdate(targetVersion);
954
+ res.json(result);
955
+ } catch (err: any) {
956
+ res.status(400).json({ error: err?.message || '启动插件更新失败' });
957
+ }
958
+ });
959
+
850
960
  // ===== 聊天 =====
851
961
 
852
962
  internal.get('/chat/history', async (req: Request, res: Response) => {
@@ -1111,7 +1221,7 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
1111
1221
  });
1112
1222
  });
1113
1223
 
1114
- return { app, server, sessionManager, wss };
1224
+ return { app, server, sessionManager, groupEventStore, wss };
1115
1225
  }
1116
1226
 
1117
1227
  // ===== WebSocket 处理函数 =====
@@ -37,6 +37,52 @@ router.get('/models', (req: Request, res: Response) => {
37
37
  }
38
38
  });
39
39
 
40
+ /**
41
+ * POST /api/config/providers/test
42
+ * 测试节点连通性并自动获取全部模型
43
+ * (通过后端代理请求,绕过小程序的合法域名限制)
44
+ */
45
+ router.post('/providers/test', async (req: Request, res: Response) => {
46
+ try {
47
+ const { baseUrl, apiKey } = req.body;
48
+ if (!baseUrl || !apiKey) {
49
+ res.status(400).json({ error: '请提供baseUrl和apiKey' });
50
+ return;
51
+ }
52
+
53
+ let pingUrl = baseUrl.trim();
54
+ if (!pingUrl.endsWith('/models') && !pingUrl.endsWith('/models/')) {
55
+ pingUrl = pingUrl.endsWith('/') ? pingUrl + 'models' : pingUrl + '/models';
56
+ }
57
+
58
+ // 使用 Node 18+ 内置的 fetch,并注入超时控制
59
+ const response = await fetch(pingUrl, {
60
+ method: 'GET',
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ 'Authorization': `Bearer ${apiKey.trim()}`
64
+ },
65
+ signal: AbortSignal.timeout(10000) // 10秒超时
66
+ });
67
+
68
+ if (!response.ok) {
69
+ res.status(response.status).json({ error: `提供商连接异常: ${response.statusText}` });
70
+ return;
71
+ }
72
+
73
+ const data: any = await response.json();
74
+ if (data && data.data && Array.isArray(data.data)) {
75
+ const models = data.data.map((m: any) => m.id);
76
+ res.json({ models });
77
+ } else {
78
+ res.status(400).json({ error: '返回格式非标准 OpenAI 规范' });
79
+ }
80
+ } catch (err: any) {
81
+ console.error('[ConfigRoutes] 测试提供商连通性异常:', err);
82
+ res.status(500).json({ error: err.name === 'TimeoutError' ? '连接超时' : err.message || '网络连接失败' });
83
+ }
84
+ });
85
+
40
86
  /**
41
87
  * POST /api/config/providers
42
88
  * Body: { preset?, providerId?, baseUrl?, apiKey, name?, api?, authHeader?, headers?, models? }