openclaw-vchat-plugin 0.0.7 → 0.0.8

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.
@@ -4,6 +4,7 @@ import multer from 'multer';
4
4
  import path from 'path';
5
5
  import fs from 'fs';
6
6
  import http from 'http';
7
+ import { execFileSync } from 'child_process';
7
8
  import { WebSocketServer, WebSocket } from 'ws';
8
9
  import { randomUUID } from 'crypto';
9
10
  import { SessionManager } from './session-manager';
@@ -26,6 +27,7 @@ interface GatewayWechatSession {
26
27
  lastMessage: string;
27
28
  lastMessageTime: number;
28
29
  createdAt: number;
30
+ model?: string;
29
31
  }
30
32
 
31
33
  type RequestMemoEntry<T> = {
@@ -69,6 +71,7 @@ function toBridgeSessionPayload(item: {
69
71
  lastMessageTime?: number;
70
72
  createdAt?: number;
71
73
  agentId?: string;
74
+ model?: string;
72
75
  }) {
73
76
  const createdAt = Number(item.createdAt) || Date.now();
74
77
  return {
@@ -78,6 +81,7 @@ function toBridgeSessionPayload(item: {
78
81
  lastMessageTime: Number(item.lastMessageTime) || createdAt,
79
82
  createdAt,
80
83
  agentId: String(item.agentId || '').trim() || 'main',
84
+ model: String(item.model || '').trim(),
81
85
  };
82
86
  }
83
87
 
@@ -148,14 +152,75 @@ function isRenderableHistoryRole(roleRaw: string): boolean {
148
152
  return roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system';
149
153
  }
150
154
 
151
- function normalizePreviewMessageText(raw: any): string {
155
+ function getHistoryProvenance(row: any): any {
156
+ if (!row || typeof row !== 'object') return null;
157
+ return row?.provenance || row?.message?.provenance || null;
158
+ }
159
+
160
+ function shouldHideHistoryRow(row: any, currentSessionKey?: string): boolean {
161
+ const provenance = getHistoryProvenance(row);
162
+ if (!provenance || typeof provenance !== 'object') return false;
163
+
164
+ const kind = String(provenance?.kind || '').trim().toLowerCase();
165
+ if (kind !== 'inter_session') return false;
166
+
167
+ const sourceTool = String(provenance?.sourceTool || '').trim().toLowerCase();
168
+ const sourceSessionKey = String(provenance?.sourceSessionKey || '').trim().toLowerCase();
169
+ const targetSessionKey = String(currentSessionKey || '').trim().toLowerCase();
170
+
171
+ // Hide self-routed relay messages. They are persisted by OpenClaw with user role,
172
+ // but should not be rendered as human-authored bubbles in the WeChat UI.
173
+ return sourceTool === 'sessions_send' && Boolean(targetSessionKey) && sourceSessionKey === targetSessionKey;
174
+ }
175
+
176
+ function normalizePreviewMessageText(raw: any, currentSessionKey?: string): string {
152
177
  if (!raw || typeof raw !== 'object') return normalizeMessageText(raw);
153
178
  const roleRaw = getHistoryRoleRaw(raw);
154
179
  if (!isRenderableHistoryRole(roleRaw)) return '';
180
+ if (shouldHideHistoryRow(raw, currentSessionKey)) return '';
155
181
  return normalizeMessageText(raw);
156
182
  }
157
183
 
158
- function normalizeHistoryMessages(rawPayload: any, sessionId: string, limit: number, before?: number): Message[] {
184
+ function isInternalSessionTitle(raw: any): boolean {
185
+ const title = String(raw || '').trim();
186
+ if (!title) return true;
187
+
188
+ const lower = title.toLowerCase();
189
+ if (
190
+ lower === 'openclaw-wechat-plugin'
191
+ || lower === 'openclaw-vchat-plugin'
192
+ || lower === 'openclaw-vchat'
193
+ || lower === 'openclaw-tui'
194
+ ) {
195
+ return true;
196
+ }
197
+
198
+ return (
199
+ lower.startsWith('webchat:')
200
+ || lower.startsWith('agent:')
201
+ || lower.includes('wechat:direct:')
202
+ || lower.includes('g-agent-')
203
+ || /^[a-f0-9-]{24,}$/i.test(title)
204
+ );
205
+ }
206
+
207
+ function pickVisibleSessionTitle(row: any, lastMessage: string): string {
208
+ const candidates = [row?.title, row?.displayName, row?.name];
209
+ for (const candidate of candidates) {
210
+ const title = String(candidate || '').trim();
211
+ if (!title || isInternalSessionTitle(title)) continue;
212
+ return title;
213
+ }
214
+ return String(lastMessage || '').trim() || '新对话';
215
+ }
216
+
217
+ function normalizeHistoryMessages(
218
+ rawPayload: any,
219
+ sessionId: string,
220
+ limit: number,
221
+ before?: number,
222
+ currentSessionKey?: string
223
+ ): Message[] {
159
224
  const payload = rawPayload || {};
160
225
  const rows = Array.isArray(payload.messages) ? payload.messages
161
226
  : Array.isArray(payload.items) ? payload.items
@@ -164,6 +229,7 @@ function normalizeHistoryMessages(rawPayload: any, sessionId: string, limit: num
164
229
  : [];
165
230
 
166
231
  const mapped = rows.map((row: any) => {
232
+ if (shouldHideHistoryRow(row, currentSessionKey)) return null;
167
233
  const roleRaw = getHistoryRoleRaw(row) || 'assistant';
168
234
  if (!isRenderableHistoryRole(roleRaw)) return null;
169
235
  const role = roleRaw === 'user' ? 'user' : roleRaw === 'system' ? 'system' : 'assistant';
@@ -192,12 +258,100 @@ function normalizeHistoryMessages(rawPayload: any, sessionId: string, limit: num
192
258
  return filtered.slice(filtered.length - limit);
193
259
  }
194
260
 
261
+ function readConfiguredPrimaryModel(gateway: GatewayClient): string {
262
+ try {
263
+ const raw = gateway.readConfig();
264
+ const agents = Array.isArray(raw?.agents?.list) ? raw.agents.list : [];
265
+ const mainAgent = agents.find((item: any) => String(item?.id || '').trim() === 'main');
266
+ const candidates = [
267
+ mainAgent?.model,
268
+ raw?.agents?.defaults?.model?.primary,
269
+ raw?.agents?.defaults?.primary,
270
+ raw?.defaults?.primary,
271
+ raw?.model,
272
+ ];
273
+ for (const candidate of candidates) {
274
+ const value = String(candidate || '').trim();
275
+ if (value) return value;
276
+ }
277
+ } catch {
278
+ // ignore
279
+ }
280
+ return '';
281
+ }
282
+
283
+ function readOpenClawVersion(): string {
284
+ try {
285
+ const value = execFileSync('openclaw', ['--version'], {
286
+ encoding: 'utf8',
287
+ timeout: 3000,
288
+ stdio: ['ignore', 'pipe', 'ignore'],
289
+ });
290
+ return String(value || '').trim();
291
+ } catch {
292
+ return '';
293
+ }
294
+ }
295
+
296
+ async function getOpenClawSummary(gateway: GatewayClient): Promise<{
297
+ online: boolean;
298
+ version: string;
299
+ model: string;
300
+ error?: string;
301
+ }> {
302
+ const version = readOpenClawVersion();
303
+ try {
304
+ const status = await gateway.call('status', {});
305
+ const recent = status?.sessions?.recent?.[0];
306
+ const model = String(
307
+ status?.sessions?.defaults?.model
308
+ || recent?.model
309
+ || readConfiguredPrimaryModel(gateway)
310
+ || ''
311
+ ).trim();
312
+ return {
313
+ online: true,
314
+ version,
315
+ model,
316
+ };
317
+ } catch (err: any) {
318
+ return {
319
+ online: false,
320
+ version,
321
+ model: readConfiguredPrimaryModel(gateway),
322
+ error: err?.message || 'gateway-unavailable',
323
+ };
324
+ }
325
+ }
326
+
327
+ function buildGatewayRecentModelHints(statusPayload: any) {
328
+ const hints = new Map<string, string>();
329
+ const recent = Array.isArray(statusPayload?.sessions?.recent) ? statusPayload.sessions.recent : [];
330
+
331
+ for (const row of recent) {
332
+ const model = String(row?.model || '').trim();
333
+ if (!model) continue;
334
+
335
+ const key = String(row?.key || row?.sessionKey || '').trim().toLowerCase();
336
+ if (key) {
337
+ hints.set(key, model);
338
+ const parsed = parseWeChatDirectThreadSessionKey(key);
339
+ if (parsed) {
340
+ hints.set(`${parsed.userId}:${parsed.agentId}:${parsed.sessionId}`, model);
341
+ }
342
+ }
343
+ }
344
+
345
+ return hints;
346
+ }
347
+
195
348
  async function listGatewayWechatSessions(gateway: GatewayClient, userId: string, agentId?: string): Promise<GatewayWechatSession[]> {
196
349
  const targetUserId = sanitizeWeChatId(userId, 'unknown');
197
350
  const targetAgentId = sanitizeWeChatId(agentId, '');
198
351
  const response = await gateway.call('sessions.list', {});
199
352
  const rawSessions = Array.isArray(response?.sessions) ? response.sessions : [];
200
353
  const now = Date.now();
354
+ let recentModelHints: Map<string, string> | null = null;
201
355
 
202
356
  const rows: GatewayWechatSession[] = [];
203
357
  for (const row of rawSessions) {
@@ -208,19 +362,36 @@ async function listGatewayWechatSessions(gateway: GatewayClient, userId: string,
208
362
  if (parsed.userId !== targetUserId) continue;
209
363
  if (targetAgentId && parsed.agentId !== targetAgentId) continue;
210
364
 
211
- const lastMessage = normalizePreviewMessageText(row?.lastMessage || row?.preview || row?.summary || '');
212
- const title = String(row?.title || row?.displayName || row?.name || '').trim();
365
+ const lastMessage = normalizePreviewMessageText(row?.lastMessage || row?.preview || row?.summary || '', key);
366
+ const title = pickVisibleSessionTitle(row, lastMessage);
213
367
  const createdAt = parseTimestamp(row?.createdAt) || pickTimestamp(row, now);
214
368
  const lastMessageTime = pickTimestamp(row, createdAt || now);
369
+ const modelProvider = String(row?.modelProvider || '').trim();
370
+ const modelId = String(row?.model || '').trim();
371
+ let model = modelProvider && modelId ? `${modelProvider}/${modelId}` : modelId;
372
+ if (!model) {
373
+ if (!recentModelHints) {
374
+ try {
375
+ const status = await gateway.call('status', {});
376
+ recentModelHints = buildGatewayRecentModelHints(status);
377
+ } catch {
378
+ recentModelHints = new Map();
379
+ }
380
+ }
381
+ model = recentModelHints.get(key)
382
+ || recentModelHints.get(`${parsed.userId}:${parsed.agentId}:${parsed.sessionId}`)
383
+ || '';
384
+ }
215
385
  rows.push({
216
386
  id: parsed.sessionId,
217
387
  userId: targetUserId,
218
388
  agentId: parsed.agentId,
219
389
  gatewaySessionKey: key,
220
- title: title || lastMessage || '新对话',
390
+ title,
221
391
  lastMessage,
222
392
  lastMessageTime,
223
393
  createdAt: createdAt || lastMessageTime || now,
394
+ model,
224
395
  });
225
396
  }
226
397
 
@@ -270,7 +441,7 @@ async function callGatewayHistory(
270
441
  for (const params of attempts) {
271
442
  try {
272
443
  const result = await gateway.call('chat.history', params);
273
- const messages = normalizeHistoryMessages(result, sessionId, limit, before);
444
+ const messages = normalizeHistoryMessages(result, sessionId, limit, before, key);
274
445
  const hasMore = Boolean(result?.hasMore) || messages.length === limit;
275
446
  return { messages, hasMore };
276
447
  } catch (err: any) {
@@ -387,12 +558,14 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
387
558
  const internal = express.Router();
388
559
  internal.use(internalAuth);
389
560
 
390
- internal.get('/bridge/capabilities', (_req: Request, res: Response) => {
561
+ internal.get('/bridge/capabilities', async (_req: Request, res: Response) => {
562
+ const openclaw = await getOpenClawSummary(gateway);
391
563
  res.json({
392
564
  protocolVersion: BRIDGE_PROTOCOL_VERSION,
393
565
  pluginVersion: BRIDGE_PLUGIN_VERSION,
394
566
  capabilities: Array.from(BRIDGE_CAPABILITIES),
395
567
  bridgeRole: 'gateway-bridge',
568
+ openclaw,
396
569
  runtime: {
397
570
  persistMessagesEnabled: sessionManager.isPersistMessagesEnabled(),
398
571
  localFallbackEnabled: Boolean(config.allowLocalFallback),
@@ -429,6 +602,7 @@ export function createRelayServer(config: PluginConfig, gateway: GatewayClient)
429
602
  lastMessageTime: item.lastMessageTime || item.createdAt || Date.now(),
430
603
  createdAt: local?.createdAt || item.createdAt,
431
604
  agentId: item.agentId,
605
+ model: item.model,
432
606
  });
433
607
  }).sort((a, b) => b.lastMessageTime - a.lastMessageTime);
434
608
 
@@ -27,6 +27,16 @@ router.get('/providers', (req: Request, res: Response) => {
27
27
  }
28
28
  });
29
29
 
30
+ router.get('/models', (req: Request, res: Response) => {
31
+ try {
32
+ const models = configService.getConfiguredModels();
33
+ res.json({ models });
34
+ } catch (err: any) {
35
+ console.error('[ConfigRoutes] getConfiguredModels 错误:', err);
36
+ res.status(500).json({ error: err.message || '获取模型列表失败' });
37
+ }
38
+ });
39
+
30
40
  /**
31
41
  * POST /api/config/providers
32
42
  * Body: { preset?, providerId?, baseUrl?, apiKey, name?, api?, authHeader?, headers?, models? }
@@ -22,7 +22,9 @@ const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', '..', 'default-config.jso
22
22
  function readConfig(): any {
23
23
  try {
24
24
  const content = fs.readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8');
25
- return JSON.parse(content);
25
+ const parsed = JSON.parse(content);
26
+ normalizeAgentList(parsed);
27
+ return parsed;
26
28
  } catch {
27
29
  return { models: { providers: {} } };
28
30
  }
@@ -150,12 +152,139 @@ function ensureAgentDefaults(config: any): Record<string, any> {
150
152
  return config.agents.defaults;
151
153
  }
152
154
 
155
+ function normalizeAgentList(config: any): any[] {
156
+ if (!config.agents || typeof config.agents !== 'object') config.agents = {};
157
+ const raw = config.agents.list;
158
+
159
+ if (Array.isArray(raw)) {
160
+ config.agents.list = raw
161
+ .filter((item) => item && typeof item === 'object')
162
+ .map((item: any, index: number) => {
163
+ const entry = { ...item };
164
+ const id = String(entry.id || '').trim() || `agent_${index + 1}`;
165
+ entry.id = id;
166
+ return entry;
167
+ });
168
+ return config.agents.list;
169
+ }
170
+
171
+ const normalized: any[] = [];
172
+ if (raw && typeof raw === 'object') {
173
+ Object.entries(raw).forEach(([key, value], index) => {
174
+ if (!value || typeof value !== 'object') return;
175
+ const entry: any = { ...(value as Record<string, any>) };
176
+ entry.id = String(entry.id || key || '').trim() || `agent_${index + 1}`;
177
+ normalized.push(entry);
178
+ });
179
+ }
180
+
181
+ config.agents.list = normalized;
182
+ return config.agents.list;
183
+ }
184
+
185
+ function getAgentModel(config: any, agentId = 'main'): string {
186
+ const list = normalizeAgentList(config);
187
+ const hit = list.find((item: any) => String(item?.id || '').trim() === agentId);
188
+ return String(hit?.model || '').trim();
189
+ }
190
+
153
191
  function getConfiguredPrimaryModel(config: any): string {
154
- return String(config?.agents?.list?.main?.model || config?.agents?.defaults?.model?.primary || config?.defaults?.primary || '').trim();
192
+ return String(
193
+ getAgentModel(config, 'main')
194
+ || config?.agents?.defaults?.model?.primary
195
+ || config?.defaults?.primary
196
+ || ''
197
+ ).trim();
198
+ }
199
+
200
+ function buildModelRef(providerId: string, modelId: string): string {
201
+ const provider = String(providerId || '').trim();
202
+ const rawModelId = String(modelId || '').trim();
203
+ if (!provider || !rawModelId) return '';
204
+ if (rawModelId.toLowerCase().startsWith(`${provider.toLowerCase()}/`)) {
205
+ return rawModelId;
206
+ }
207
+ return `${provider}/${rawModelId}`;
208
+ }
209
+
210
+ function collectProviderModelRefs(providerId: string, providerEntry: any): string[] {
211
+ const models = Array.isArray(providerEntry?.models) ? providerEntry.models : [];
212
+ const refs = models
213
+ .map((model: any) => buildModelRef(providerId, String(model?.id || model?.name || model || '').trim()))
214
+ .filter(Boolean);
215
+ return Array.from(new Set(refs));
216
+ }
217
+
218
+ function collectAllowedModelRefs(config: any): string[] {
219
+ const defaults = ensureAgentDefaults(config);
220
+ const allowed = defaults?.models;
221
+ const refs = (allowed && typeof allowed === 'object' && !Array.isArray(allowed))
222
+ ? Object.keys(allowed).map((key) => String(key || '').trim()).filter(Boolean)
223
+ : [];
224
+
225
+ if (refs.length > 0) return refs;
226
+
227
+ const primary = getConfiguredPrimaryModel(config);
228
+ return primary ? [primary] : [];
229
+ }
230
+
231
+ function reconcileProviderAllowedModels(config: any, providerId: string, providerEntry?: any | null): void {
232
+ const defaults = ensureAgentDefaults(config);
233
+ const agentList = normalizeAgentList(config);
234
+ const currentAllowed = defaults?.models && typeof defaults.models === 'object' && !Array.isArray(defaults.models)
235
+ ? defaults.models
236
+ : {};
237
+
238
+ const providerPrefix = `${String(providerId || '').trim().toLowerCase()}/`;
239
+ const nextAllowed: Record<string, any> = {};
240
+
241
+ Object.entries(currentAllowed).forEach(([ref, meta]) => {
242
+ const safeRef = String(ref || '').trim();
243
+ if (!safeRef) return;
244
+ if (providerPrefix && safeRef.toLowerCase().startsWith(providerPrefix)) return;
245
+ nextAllowed[safeRef] = meta && typeof meta === 'object' && !Array.isArray(meta) ? meta : {};
246
+ });
247
+
248
+ collectProviderModelRefs(providerId, providerEntry).forEach((ref) => {
249
+ nextAllowed[ref] = currentAllowed[ref] && typeof currentAllowed[ref] === 'object' && !Array.isArray(currentAllowed[ref])
250
+ ? currentAllowed[ref]
251
+ : {};
252
+ });
253
+
254
+ defaults.models = nextAllowed;
255
+
256
+ if (!defaults.model || typeof defaults.model !== 'object') defaults.model = {};
257
+
258
+ const allowedRefs = Object.keys(nextAllowed);
259
+ const currentPrimary = String(defaults.model.primary || '').trim();
260
+ if (!currentPrimary || !nextAllowed[currentPrimary]) {
261
+ const mainAgent = agentList.find((item: any) => String(item?.id || '').trim() === 'main');
262
+ const currentMainModel = String(mainAgent?.model || '').trim();
263
+ if (currentMainModel && nextAllowed[currentMainModel]) {
264
+ defaults.model.primary = currentMainModel;
265
+ } else if (allowedRefs.length > 0) {
266
+ defaults.model.primary = allowedRefs[0];
267
+ } else {
268
+ delete defaults.model.primary;
269
+ }
270
+ }
271
+
272
+ let mainAgent = agentList.find((item: any) => String(item?.id || '').trim() === 'main');
273
+ const primary = String(defaults.model.primary || '').trim();
274
+ if (!mainAgent && primary) {
275
+ mainAgent = { id: 'main', model: primary };
276
+ agentList.unshift(mainAgent);
277
+ } else if (mainAgent) {
278
+ const currentMainModel = String(mainAgent.model || '').trim();
279
+ if ((!currentMainModel || !nextAllowed[currentMainModel]) && primary) {
280
+ mainAgent.model = primary;
281
+ }
282
+ }
155
283
  }
156
284
 
157
285
  function applyDefaultModelFallback(config: any, modelRef: string): void {
158
286
  const defaults = ensureAgentDefaults(config);
287
+ normalizeAgentList(config);
159
288
  if (!defaults.model || typeof defaults.model !== 'object') defaults.model = {};
160
289
  defaults.model.primary = modelRef;
161
290
  if (!defaults.models || typeof defaults.models !== 'object' || Array.isArray(defaults.models)) {
@@ -166,7 +295,7 @@ function applyDefaultModelFallback(config: any, modelRef: string): void {
166
295
  }
167
296
  }
168
297
 
169
- function setDefaultModelOfficial(config: any, modelRef: string): void {
298
+ export function setDefaultModelOfficial(config: any, modelRef: string): void {
170
299
  // 等价于官方 `openclaw models set <provider/model>` 的最小配置结果。
171
300
  // 真相源仍然是 OpenClaw 官方字段,不引入私有结构。
172
301
  applyDefaultModelFallback(config, modelRef);
@@ -174,6 +303,58 @@ function setDefaultModelOfficial(config: any, modelRef: string): void {
174
303
  console.log(`[ConfigService] 已自动设置默认模型: ${modelRef}`);
175
304
  }
176
305
 
306
+ function resolveConfiguredModelRef(config: any, input: string): string {
307
+ const raw = String(input || '').trim();
308
+ if (!raw) throw new Error('请提供模型名称');
309
+
310
+ const providers = ensureProvidersObject(config);
311
+ const loweredInput = raw.toLowerCase();
312
+ const matches: string[] = [];
313
+
314
+ Object.entries(providers).forEach(([providerId, providerEntry]) => {
315
+ const models = Array.isArray((providerEntry as any)?.models) ? (providerEntry as any).models : [];
316
+ models.forEach((model: any) => {
317
+ const modelId = String(model?.id || model?.name || '').trim();
318
+ const modelName = String(model?.name || model?.id || '').trim();
319
+ if (!modelId) return;
320
+ const modelRef = `${providerId}/${modelId}`;
321
+ if (
322
+ modelRef.toLowerCase() === loweredInput
323
+ || modelId.toLowerCase() === loweredInput
324
+ || modelName.toLowerCase() === loweredInput
325
+ ) {
326
+ matches.push(modelRef);
327
+ }
328
+ });
329
+ });
330
+
331
+ if (matches.length === 1) return matches[0];
332
+ if (matches.length > 1) {
333
+ throw new Error(`模型名重复,请使用 provider/model:${matches.join('、')}`);
334
+ }
335
+
336
+ const available = Object.entries(providers).flatMap(([providerId, providerEntry]) => {
337
+ const models = Array.isArray((providerEntry as any)?.models) ? (providerEntry as any).models : [];
338
+ return models
339
+ .map((model: any) => String(model?.id || model?.name || '').trim())
340
+ .filter(Boolean)
341
+ .map((modelId: string) => `${providerId}/${modelId}`);
342
+ });
343
+
344
+ if (available.length === 0) {
345
+ throw new Error('当前没有可用模型,请先配置模型提供商');
346
+ }
347
+
348
+ throw new Error(`模型未配置:${raw}。当前可用:${available.join('、')}`);
349
+ }
350
+
351
+ export function setConfiguredModel(input: string): string {
352
+ const config = readConfig();
353
+ const modelRef = resolveConfiguredModelRef(config, input);
354
+ setDefaultModelOfficial(config, modelRef);
355
+ return modelRef;
356
+ }
357
+
177
358
  function maybeBootstrapDefaultModel(config: any, providerId: string, providerEntry: any): void {
178
359
  const existingPrimary = getConfiguredPrimaryModel(config);
179
360
  if (existingPrimary) return;
@@ -203,6 +384,7 @@ function pickProviderDisplayName(providerId: string, entry: any): string {
203
384
  */
204
385
  function writeConfig(config: any): void {
205
386
  fs.mkdirSync(path.dirname(OPENCLAW_CONFIG_PATH), { recursive: true });
387
+ normalizeAgentList(config);
206
388
  fs.writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
207
389
  }
208
390
 
@@ -216,6 +398,41 @@ export function getProvidersMap(): Record<string, any> {
216
398
  return JSON.parse(JSON.stringify(providersObj));
217
399
  }
218
400
 
401
+ export function getConfiguredModels(): Array<{
402
+ value: string;
403
+ label: string;
404
+ providerId: string;
405
+ modelId: string;
406
+ }> {
407
+ const config = readConfig();
408
+ const providers = ensureProvidersObject(config);
409
+ const allowedRefs = collectAllowedModelRefs(config);
410
+
411
+ return allowedRefs
412
+ .map((ref) => {
413
+ const rawRef = String(ref || '').trim();
414
+ if (!rawRef) return null;
415
+
416
+ const slashIndex = rawRef.indexOf('/');
417
+ const providerId = slashIndex >= 0 ? rawRef.slice(0, slashIndex) : '';
418
+ const modelId = slashIndex >= 0 ? rawRef.slice(slashIndex + 1) : rawRef;
419
+ const providerModels = Array.isArray(providers?.[providerId]?.models) ? providers[providerId].models : [];
420
+ const hit = providerModels.find((model: any) => {
421
+ const candidate = String(model?.id || model?.name || model || '').trim();
422
+ return candidate === modelId || buildModelRef(providerId, candidate) === rawRef;
423
+ });
424
+ const label = String(hit?.name || hit?.id || modelId || rawRef).trim() || rawRef;
425
+
426
+ return {
427
+ value: rawRef,
428
+ label,
429
+ providerId,
430
+ modelId,
431
+ };
432
+ })
433
+ .filter((item): item is { value: string; label: string; providerId: string; modelId: string } => Boolean(item));
434
+ }
435
+
219
436
  /**
220
437
  * 获取提供商列表(apiKey 脱敏,供 UI 展示)
221
438
  */
@@ -295,6 +512,7 @@ export function addProvider(options: {
295
512
  }
296
513
 
297
514
  providers[providerId] = providerEntry;
515
+ reconcileProviderAllowedModels(config, providerId, providerEntry);
298
516
  writeConfig(config);
299
517
  maybeBootstrapDefaultModel(config, providerId, providerEntry);
300
518
 
@@ -324,6 +542,7 @@ export function deleteProvider(providerId: string): void {
324
542
  }
325
543
 
326
544
  delete providers[providerId];
545
+ reconcileProviderAllowedModels(config, providerId, null);
327
546
  writeConfig(config);
328
547
 
329
548
  console.log(`[ConfigService] 删除提供商: ${providerId}`);
@@ -370,6 +589,7 @@ export function updateProvider(providerId: string, data: Partial<{
370
589
  if (data.models !== undefined) next.models = formatModels(data.models);
371
590
 
372
591
  providers[providerId] = next;
592
+ reconcileProviderAllowedModels(config, providerId, next);
373
593
  writeConfig(config);
374
594
  maybeBootstrapDefaultModel(config, providerId, next);
375
595