@steerable/agent-shell-web 0.6.15

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 (121) hide show
  1. package/LICENSE +91 -0
  2. package/README.md +20 -0
  3. package/package.json +69 -0
  4. package/public/favicon.ico +0 -0
  5. package/public/favicon.png +0 -0
  6. package/src/AppShell.tsx +24 -0
  7. package/src/assets/logo-generic.png +0 -0
  8. package/src/brand.test.ts +22 -0
  9. package/src/brand.ts +48 -0
  10. package/src/components/AgentSidebar.test.tsx +127 -0
  11. package/src/components/AgentSidebar.tsx +1010 -0
  12. package/src/components/ChatHeader.test.tsx +230 -0
  13. package/src/components/ChatHeader.tsx +436 -0
  14. package/src/components/DockPanelHeader.tsx +69 -0
  15. package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
  16. package/src/components/LocalLlmSettingsModal.tsx +72 -0
  17. package/src/components/TerminalPanel.tsx +68 -0
  18. package/src/components/TerminalView.tsx +228 -0
  19. package/src/components/chat/ApprovalModal.test.tsx +83 -0
  20. package/src/components/chat/ApprovalModal.tsx +229 -0
  21. package/src/components/chat/AskUserModalHost.test.tsx +176 -0
  22. package/src/components/chat/AskUserModalHost.tsx +61 -0
  23. package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
  24. package/src/components/chat/AssistantMessage.test.tsx +68 -0
  25. package/src/components/chat/AssistantMessage.tsx +469 -0
  26. package/src/components/chat/ChatInput.test.tsx +609 -0
  27. package/src/components/chat/ChatInput.tsx +2068 -0
  28. package/src/components/chat/ChatProjectBadge.tsx +362 -0
  29. package/src/components/chat/EmptyChat.tsx +18 -0
  30. package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
  31. package/src/components/chat/ExecPolicyPicker.tsx +120 -0
  32. package/src/components/chat/ExecutedActionsCard.tsx +216 -0
  33. package/src/components/chat/InterruptedTurnCard.tsx +53 -0
  34. package/src/components/chat/LocalChatPanel.tsx +408 -0
  35. package/src/components/chat/Markdown.test.tsx +170 -0
  36. package/src/components/chat/Markdown.tsx +432 -0
  37. package/src/components/chat/MessageList.tsx +332 -0
  38. package/src/components/chat/ModelPicker.test.tsx +149 -0
  39. package/src/components/chat/ModelPicker.tsx +289 -0
  40. package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
  41. package/src/components/chat/PlanTodoList.tsx +121 -0
  42. package/src/components/chat/ProjectTrustBanner.tsx +91 -0
  43. package/src/components/chat/SessionTreeModal.test.tsx +161 -0
  44. package/src/components/chat/SessionTreeModal.tsx +221 -0
  45. package/src/components/chat/StreamingStatus.tsx +95 -0
  46. package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
  47. package/src/components/chat/TaskOutcomeCards.tsx +128 -0
  48. package/src/components/chat/TaskPanelModal.test.tsx +198 -0
  49. package/src/components/chat/TaskPanelModal.tsx +310 -0
  50. package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
  51. package/src/components/chat/TaskProcessPanel.tsx +208 -0
  52. package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
  53. package/src/components/chat/TurnProcessGroup.tsx +193 -0
  54. package/src/components/chat/UnifiedDiff.tsx +31 -0
  55. package/src/components/chat/UserMessage.tsx +155 -0
  56. package/src/components/chat/elapsed.test.ts +42 -0
  57. package/src/components/chat/elapsed.ts +45 -0
  58. package/src/components/chat/executed-actions-model.test.ts +134 -0
  59. package/src/components/chat/executed-actions-model.ts +143 -0
  60. package/src/components/chat/orchestration-children-model.test.ts +115 -0
  61. package/src/components/chat/orchestration-children-model.ts +104 -0
  62. package/src/components/chat/timestamp.ts +24 -0
  63. package/src/components/chat/turn-timeline.test.ts +97 -0
  64. package/src/components/chat/turn-timeline.ts +132 -0
  65. package/src/components/chat/useChatTasks.test.ts +136 -0
  66. package/src/components/chat/useChatTasks.ts +134 -0
  67. package/src/components/chat/useCopy.ts +62 -0
  68. package/src/components/settings/AgentCapabilityFields.tsx +343 -0
  69. package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
  70. package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
  71. package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
  72. package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
  73. package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
  74. package/src/components/settings/LlmSettingsPanel.tsx +875 -0
  75. package/src/components/settings/McpSettingsPanel.tsx +466 -0
  76. package/src/components/settings/ModelCapabilityChips.tsx +46 -0
  77. package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
  78. package/src/components/settings/ModelIdCombobox.tsx +185 -0
  79. package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
  80. package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
  81. package/src/components/settings/SettingsSaveButton.tsx +35 -0
  82. package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
  83. package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
  84. package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
  85. package/src/components/settings/UsagePanel.tsx +196 -0
  86. package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
  87. package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
  88. package/src/components/settings/compat-flags-model.test.ts +71 -0
  89. package/src/components/settings/compat-flags-model.ts +57 -0
  90. package/src/components/settings/llm-vendors.test.ts +171 -0
  91. package/src/components/settings/llm-vendors.ts +275 -0
  92. package/src/components/settings/model-capabilities.test.ts +88 -0
  93. package/src/components/settings/model-capabilities.ts +123 -0
  94. package/src/components/settings/preset-choice-model.test.ts +96 -0
  95. package/src/components/settings/preset-choice-model.ts +75 -0
  96. package/src/fixtures/browser-dev-data.json +315 -0
  97. package/src/hooks/useChatsAndAgents.ts +182 -0
  98. package/src/layouts/AgentLayout.tsx +574 -0
  99. package/src/lib/attachments.ts +78 -0
  100. package/src/lib/browser-dev-electron-mock.ts +640 -0
  101. package/src/lib/chat-title.ts +33 -0
  102. package/src/lib/chat-transport.ts +346 -0
  103. package/src/lib/date-groups.ts +34 -0
  104. package/src/lib/demo-flag.ts +15 -0
  105. package/src/lib/electron-bridge.ts +270 -0
  106. package/src/lib/exec-policy.ts +25 -0
  107. package/src/lib/http-bridge.ts +293 -0
  108. package/src/lib/insights.ts +17 -0
  109. package/src/lib/local-api.ts +714 -0
  110. package/src/lib/pending-first-message.test.ts +17 -0
  111. package/src/lib/pending-first-message.ts +36 -0
  112. package/src/lib/slash-sources.ts +191 -0
  113. package/src/main.tsx +103 -0
  114. package/src/packs/registry.ts +64 -0
  115. package/src/pages/AgentPage.tsx +1226 -0
  116. package/src/pages/ChatPanelPreviewPage.tsx +264 -0
  117. package/src/pages/SettingsPage.test.tsx +102 -0
  118. package/src/pages/SettingsPage.tsx +197 -0
  119. package/src/styles/index.css +156 -0
  120. package/src/vite-env.d.ts +1 -0
  121. package/vite.base.ts +94 -0
@@ -0,0 +1,171 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ defaultModelForVendor,
4
+ FALLBACK_VENDORS,
5
+ inferVendorId,
6
+ llmProviderFromWireKind,
7
+ mergeVendorOptions,
8
+ modelPickerOptions,
9
+ modelPickerRows,
10
+ vendorLabel,
11
+ type VendorOption,
12
+ } from './llm-vendors';
13
+ import type { LlmSettings } from '@/lib/local-api';
14
+
15
+ const vendors = mergeVendorOptions([
16
+ {
17
+ id: 'deepseek',
18
+ apiBaseUrl: 'https://api.deepseek.com',
19
+ envVars: [],
20
+ wireKind: 'openai_compat',
21
+ models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
22
+ },
23
+ {
24
+ id: 'anthropic',
25
+ apiBaseUrl: 'https://api.anthropic.com',
26
+ envVars: [],
27
+ wireKind: 'anthropic',
28
+ models: ['claude-sonnet-4-6'],
29
+ },
30
+ {
31
+ id: 'azure',
32
+ apiBaseUrl: null,
33
+ envVars: ['AZURE_API_KEY'],
34
+ wireKind: 'openai_compat',
35
+ models: ['gpt-4o'],
36
+ },
37
+ ]);
38
+
39
+ describe('mergeVendorOptions', () => {
40
+ it('pins featured vendors and injects ollama / custom', () => {
41
+ const ids = vendors.map((v) => v.id);
42
+ expect(ids.slice(0, 2)).toEqual(['deepseek', 'anthropic']);
43
+ expect(ids).toContain('ollama');
44
+ expect(ids).toContain('custom');
45
+ expect(ids).toContain('azure');
46
+ expect(vendorLabel('alibaba-cn')).toBe('阿里云百炼');
47
+ });
48
+
49
+ it('orders the fallback featured list by common usage', () => {
50
+ expect(
51
+ mergeVendorOptions(FALLBACK_VENDORS)
52
+ .filter((v) => v.featured)
53
+ .map((v) => v.id),
54
+ ).toEqual([
55
+ 'deepseek',
56
+ 'alibaba-cn',
57
+ 'moonshotai-cn',
58
+ 'zhipuai',
59
+ 'openai',
60
+ 'anthropic',
61
+ 'google',
62
+ 'openrouter',
63
+ 'xai',
64
+ 'groq',
65
+ 'lmstudio',
66
+ 'ollama',
67
+ 'custom',
68
+ ]);
69
+ });
70
+ });
71
+
72
+ describe('inferVendorId', () => {
73
+ it('prefers an explicit vendorId when that vendor is listed', () => {
74
+ expect(
75
+ inferVendorId({ provider: 'openai-compat', vendorId: 'anthropic', model: 'x' }, vendors),
76
+ ).toBe('anthropic');
77
+ });
78
+
79
+ it('matches a saved DeepSeek URL back to the catalog vendor', () => {
80
+ const settings: LlmSettings = {
81
+ provider: 'openai-compat',
82
+ model: 'deepseek-v4-flash',
83
+ baseUrl: 'https://api.deepseek.com',
84
+ };
85
+ expect(inferVendorId(settings, vendors)).toBe('deepseek');
86
+ });
87
+
88
+ it('uses ollama for the local provider', () => {
89
+ expect(
90
+ inferVendorId(
91
+ { provider: 'ollama', model: 'llama3.1:8b', baseUrl: 'http://127.0.0.1:11434' },
92
+ vendors,
93
+ ),
94
+ ).toBe('ollama');
95
+ });
96
+ });
97
+
98
+ describe('llmProviderFromWireKind', () => {
99
+ it('maps sidecar kinds onto persisted provider ids', () => {
100
+ expect(llmProviderFromWireKind('openai_compat')).toBe('openai-compat');
101
+ expect(llmProviderFromWireKind('anthropic')).toBe('anthropic');
102
+ expect(llmProviderFromWireKind('google')).toBe('google');
103
+ expect(llmProviderFromWireKind('openai-responses')).toBe('openai-responses');
104
+ expect(llmProviderFromWireKind('ollama')).toBe('ollama');
105
+ });
106
+ });
107
+
108
+ describe('defaultModelForVendor', () => {
109
+ it('keeps the current model when it is on the vendor list', () => {
110
+ const vendor = vendors.find((v) => v.id === 'deepseek') as VendorOption;
111
+ expect(defaultModelForVendor(vendor, 'deepseek-v4-pro')).toBe('deepseek-v4-pro');
112
+ });
113
+
114
+ it('falls back to the first catalog model', () => {
115
+ const vendor = vendors.find((v) => v.id === 'deepseek') as VendorOption;
116
+ expect(defaultModelForVendor(vendor, 'other')).toBe('deepseek-v4-flash');
117
+ });
118
+ });
119
+
120
+ describe('modelPickerOptions', () => {
121
+ const catalog = ['deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'];
122
+
123
+ it('uses the live gateway list and does not mix in catalog ids', () => {
124
+ expect(
125
+ modelPickerOptions(['deepseek-chat', 'deepseek-reasoner'], catalog, 'deepseek-chat', 'live'),
126
+ ).toEqual(['deepseek-chat', 'deepseek-reasoner']);
127
+ });
128
+
129
+ it('keeps the currently selected id even if the gateway omitted it', () => {
130
+ expect(
131
+ modelPickerOptions(['deepseek-chat'], catalog, 'deepseek-v4-flash', 'live'),
132
+ ).toEqual(['deepseek-chat', 'deepseek-v4-flash']);
133
+ });
134
+
135
+ it('falls back to the vendor catalog when the gateway listing is unavailable', () => {
136
+ expect(modelPickerOptions([], catalog, 'deepseek-chat', 'offline')).toEqual(catalog);
137
+ });
138
+ });
139
+
140
+ describe('modelPickerRows', () => {
141
+ const catalog = ['deepseek-chat', 'deepseek-v4-flash'];
142
+
143
+ it('keeps live capability rows and marks a missing current id as unknown', () => {
144
+ const live = [
145
+ {
146
+ id: 'deepseek-v4-pro',
147
+ name: 'DeepSeek V4 Pro',
148
+ window: 1_000_000,
149
+ modalities: ['text'],
150
+ reasoningLevels: ['high', 'max'],
151
+ pricing: null,
152
+ joinedFrom: 'deepseek/deepseek-v4-pro',
153
+ capabilities: 'known' as const,
154
+ },
155
+ ];
156
+ expect(modelPickerRows(live, catalog, 'deepseek-v4-flash', 'live')).toEqual([
157
+ { id: 'deepseek-v4-pro', entry: live[0] },
158
+ {
159
+ id: 'deepseek-v4-flash',
160
+ entry: expect.objectContaining({ id: 'deepseek-v4-flash', capabilities: 'unknown' }),
161
+ },
162
+ ]);
163
+ });
164
+
165
+ it('does not invent capabilities for the vendor catalog fallback', () => {
166
+ expect(modelPickerRows([], catalog, 'deepseek-chat', 'offline')).toEqual([
167
+ { id: 'deepseek-chat', entry: null },
168
+ { id: 'deepseek-v4-flash', entry: null },
169
+ ]);
170
+ });
171
+ });
@@ -0,0 +1,275 @@
1
+ /**
2
+ * 设置页服务商选择:框架 sidecar `catalog.describe` 是目录真源,这里只做
3
+ * 常用置顶、中文标签、以及 sidecar 未就绪时的本地回退。
4
+ */
5
+
6
+ import type {
7
+ CatalogProviderDescriptor,
8
+ GatewayModelEntry,
9
+ LlmProvider,
10
+ LlmSettings,
11
+ } from '@/lib/local-api';
12
+ import { unknownGatewayEntry } from '@/components/settings/model-capabilities';
13
+
14
+ export interface VendorOption {
15
+ id: string;
16
+ label: string;
17
+ apiBaseUrl: string | null;
18
+ wireKind: string;
19
+ models: string[];
20
+ featured: boolean;
21
+ /** 本地注入(Ollama / 自定义),不在框架目录里。 */
22
+ local?: boolean;
23
+ }
24
+
25
+ export const FEATURED_VENDOR_IDS = [
26
+ 'deepseek',
27
+ 'alibaba-cn',
28
+ 'moonshotai-cn',
29
+ 'zhipuai',
30
+ 'openai',
31
+ 'anthropic',
32
+ 'google',
33
+ 'openrouter',
34
+ 'xai',
35
+ 'groq',
36
+ 'lmstudio',
37
+ ] as const;
38
+
39
+ export const VENDOR_LABELS: Record<string, string> = {
40
+ deepseek: 'DeepSeek',
41
+ openai: 'OpenAI',
42
+ anthropic: 'Anthropic',
43
+ google: 'Google Gemini',
44
+ openrouter: 'OpenRouter',
45
+ 'alibaba-cn': '阿里云百炼',
46
+ 'moonshotai-cn': '月之暗面 Kimi',
47
+ zhipuai: '智谱 GLM',
48
+ groq: 'Groq',
49
+ xai: 'xAI',
50
+ lmstudio: 'LM Studio',
51
+ ollama: 'Ollama (本地)',
52
+ custom: '自定义 OpenAI 兼容',
53
+ };
54
+
55
+ /** sidecar 未就绪时仍能选常用服务商。 */
56
+ export const FALLBACK_VENDORS: CatalogProviderDescriptor[] = [
57
+ {
58
+ id: 'deepseek',
59
+ apiBaseUrl: 'https://api.deepseek.com',
60
+ envVars: ['DEEPSEEK_API_KEY'],
61
+ wireKind: 'openai_compat',
62
+ models: ['deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'],
63
+ },
64
+ {
65
+ id: 'openai',
66
+ apiBaseUrl: 'https://api.openai.com/v1',
67
+ envVars: ['OPENAI_API_KEY'],
68
+ wireKind: 'openai_compat',
69
+ models: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-4.1', 'gpt-4o'],
70
+ },
71
+ {
72
+ id: 'anthropic',
73
+ apiBaseUrl: 'https://api.anthropic.com',
74
+ envVars: ['ANTHROPIC_API_KEY'],
75
+ wireKind: 'anthropic',
76
+ models: ['claude-sonnet-4-6', 'claude-opus-4-7', 'claude-haiku-4-5'],
77
+ },
78
+ {
79
+ id: 'google',
80
+ apiBaseUrl: 'https://generativelanguage.googleapis.com',
81
+ envVars: ['GEMINI_API_KEY'],
82
+ wireKind: 'google',
83
+ models: ['gemini-3.5-flash', 'gemini-3.1-pro-preview', 'gemini-2.5-flash'],
84
+ },
85
+ {
86
+ id: 'openrouter',
87
+ apiBaseUrl: 'https://openrouter.ai/api/v1',
88
+ envVars: ['OPENROUTER_API_KEY'],
89
+ wireKind: 'openai_compat',
90
+ models: [],
91
+ },
92
+ {
93
+ id: 'alibaba-cn',
94
+ apiBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
95
+ envVars: ['DASHSCOPE_API_KEY'],
96
+ wireKind: 'openai_compat',
97
+ models: ['qwen3.5-plus', 'qwen3-max', 'qwen-plus'],
98
+ },
99
+ {
100
+ id: 'moonshotai-cn',
101
+ apiBaseUrl: 'https://api.moonshot.cn/v1',
102
+ envVars: ['MOONSHOT_API_KEY'],
103
+ wireKind: 'openai_compat',
104
+ models: ['kimi-k2.5', 'kimi-k2.6'],
105
+ },
106
+ {
107
+ id: 'zhipuai',
108
+ apiBaseUrl: 'https://open.bigmodel.cn/api/paas/v4',
109
+ envVars: ['ZHIPU_API_KEY'],
110
+ wireKind: 'openai_compat',
111
+ models: ['glm-5', 'glm-5.3-flash'],
112
+ },
113
+ {
114
+ id: 'groq',
115
+ apiBaseUrl: 'https://api.groq.com/openai/v1',
116
+ envVars: ['GROQ_API_KEY'],
117
+ wireKind: 'openai_compat',
118
+ models: [],
119
+ },
120
+ {
121
+ id: 'xai',
122
+ apiBaseUrl: 'https://api.x.ai/v1',
123
+ envVars: ['XAI_API_KEY'],
124
+ wireKind: 'openai-responses',
125
+ models: ['grok-4', 'grok-3'],
126
+ },
127
+ {
128
+ id: 'lmstudio',
129
+ apiBaseUrl: 'http://127.0.0.1:1234/v1',
130
+ envVars: ['LMSTUDIO_API_KEY'],
131
+ wireKind: 'openai_compat',
132
+ models: [],
133
+ },
134
+ ];
135
+
136
+ const LOCAL_VENDORS: VendorOption[] = [
137
+ {
138
+ id: 'ollama',
139
+ label: VENDOR_LABELS.ollama,
140
+ apiBaseUrl: 'http://127.0.0.1:11434',
141
+ wireKind: 'ollama',
142
+ models: ['llama3.1:8b', 'qwen2.5:14b', 'qwen2.5-coder:32b'],
143
+ featured: true,
144
+ local: true,
145
+ },
146
+ {
147
+ id: 'custom',
148
+ label: VENDOR_LABELS.custom,
149
+ apiBaseUrl: null,
150
+ wireKind: 'openai_compat',
151
+ models: [],
152
+ featured: true,
153
+ local: true,
154
+ },
155
+ ];
156
+
157
+ export function vendorLabel(id: string): string {
158
+ return VENDOR_LABELS[id] ?? id;
159
+ }
160
+
161
+ export function llmProviderFromWireKind(wireKind: string): LlmProvider {
162
+ if (wireKind === 'ollama') return 'ollama';
163
+ if (wireKind === 'anthropic') return 'anthropic';
164
+ if (wireKind === 'google') return 'google';
165
+ if (wireKind === 'openai-responses') return 'openai-responses';
166
+ return 'openai-compat';
167
+ }
168
+
169
+ export function usesOpenAiCompatExtras(provider: LlmProvider): boolean {
170
+ return provider === 'openai-compat' || provider === 'openai-responses';
171
+ }
172
+
173
+ function hostOf(url: string | null | undefined): string | null {
174
+ if (!url) return null;
175
+ try {
176
+ return new URL(url.includes('://') ? url : `https://${url}`).hostname.toLowerCase();
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ export function mergeVendorOptions(catalog: CatalogProviderDescriptor[]): VendorOption[] {
183
+ const featured = new Set<string>(FEATURED_VENDOR_IDS);
184
+ const fromCatalog: VendorOption[] = catalog.map((row) => ({
185
+ id: row.id,
186
+ label: vendorLabel(row.id),
187
+ apiBaseUrl: row.apiBaseUrl,
188
+ wireKind: row.wireKind,
189
+ models: row.models,
190
+ featured: featured.has(row.id),
191
+ }));
192
+ const seen = new Set(fromCatalog.map((v) => v.id));
193
+ const extras = LOCAL_VENDORS.filter((v) => !seen.has(v.id));
194
+ const featuredOrder = [...FEATURED_VENDOR_IDS, 'ollama', 'custom'];
195
+ const rank = (id: string) => {
196
+ const idx = featuredOrder.indexOf(id);
197
+ return idx === -1 ? featuredOrder.length : idx;
198
+ };
199
+ return [...fromCatalog, ...extras].sort((a, b) => {
200
+ const byFeatured = Number(b.featured) - Number(a.featured);
201
+ if (byFeatured !== 0) return byFeatured;
202
+ const byRank = rank(a.id) - rank(b.id);
203
+ if (byRank !== 0) return byRank;
204
+ return a.label.localeCompare(b.label, 'zh');
205
+ });
206
+ }
207
+
208
+ export function inferVendorId(settings: LlmSettings, vendors: VendorOption[]): string {
209
+ if (settings.vendorId && vendors.some((v) => v.id === settings.vendorId)) {
210
+ return settings.vendorId;
211
+ }
212
+ if (settings.provider === 'ollama') return 'ollama';
213
+ const host = hostOf(settings.baseUrl);
214
+ if (host) {
215
+ const hit = vendors.find((v) => hostOf(v.apiBaseUrl) === host);
216
+ if (hit) return hit.id;
217
+ }
218
+ if (settings.provider === 'anthropic') return 'anthropic';
219
+ if (settings.provider === 'google') return 'google';
220
+ if (settings.provider === 'openai-responses') return 'xai';
221
+ return 'custom';
222
+ }
223
+
224
+ export function defaultModelForVendor(vendor: VendorOption, current?: string): string {
225
+ if (current && vendor.models.includes(current)) return current;
226
+ return vendor.models[0] ?? current ?? '';
227
+ }
228
+
229
+ export type LiveCatalogStatus = 'idle' | 'live' | 'stale' | 'offline';
230
+
231
+ /**
232
+ * 下拉优先用网关 `GET /models` 的实时目录;拉不到才回退服务商内置列表。
233
+ * 当前已选 id 始终保留,避免保存过的模型从列表里消失。
234
+ */
235
+ export function modelPickerOptions(
236
+ liveModels: string[],
237
+ catalogModels: string[],
238
+ current: string | undefined,
239
+ status: LiveCatalogStatus,
240
+ ): string[] {
241
+ const useLive = (status === 'live' || status === 'stale') && liveModels.length > 0;
242
+ const primary = useLive ? liveModels : catalogModels;
243
+ return Array.from(new Set([...primary, current ?? ''].filter((id) => id.trim())));
244
+ }
245
+
246
+ export interface ModelPickerRow {
247
+ id: string;
248
+ entry: GatewayModelEntry | null;
249
+ }
250
+
251
+ /**
252
+ * 下拉行:实时目录带上 join 后的能力;当前 id 不在网关列表里时标成未识别。
253
+ * 回退服务商内置列表时没有核实过的能力,筹码留空。
254
+ */
255
+ export function modelPickerRows(
256
+ liveEntries: GatewayModelEntry[],
257
+ catalogModels: string[],
258
+ current: string | undefined,
259
+ status: LiveCatalogStatus,
260
+ ): ModelPickerRow[] {
261
+ const useLive = (status === 'live' || status === 'stale') && liveEntries.length > 0;
262
+ const ids = modelPickerOptions(
263
+ liveEntries.map((entry) => entry.id),
264
+ catalogModels,
265
+ current,
266
+ status,
267
+ );
268
+ const byId = new Map(liveEntries.map((entry) => [entry.id, entry]));
269
+ return ids.map((id) => {
270
+ const entry = byId.get(id) ?? null;
271
+ if (entry) return { id, entry };
272
+ if (useLive) return { id, entry: unknownGatewayEntry(id) };
273
+ return { id, entry: null };
274
+ });
275
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { GatewayModelEntry } from '@/lib/local-api';
3
+ import {
4
+ formatContextWindow,
5
+ modelCapabilityChips,
6
+ } from './model-capabilities';
7
+
8
+ function entry(overrides: Partial<GatewayModelEntry> & Pick<GatewayModelEntry, 'id'>): GatewayModelEntry {
9
+ return {
10
+ name: null,
11
+ window: null,
12
+ modalities: [],
13
+ reasoningLevels: [],
14
+ pricing: null,
15
+ joinedFrom: null,
16
+ capabilities: 'unknown',
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe('formatContextWindow', () => {
22
+ it('renders million- and thousand-token windows', () => {
23
+ expect(formatContextWindow(1_000_000)).toBe('1M');
24
+ expect(formatContextWindow(1_048_576)).toBe('1M');
25
+ expect(formatContextWindow(131_072)).toBe('131K');
26
+ expect(formatContextWindow(512)).toBe('512');
27
+ expect(formatContextWindow(null)).toBeNull();
28
+ });
29
+ });
30
+
31
+ describe('modelCapabilityChips', () => {
32
+ it('shows verified thinking, multimodal, and window chips', () => {
33
+ const chips = modelCapabilityChips(
34
+ entry({
35
+ id: 'gemini-3.5-flash',
36
+ window: 1_048_576,
37
+ modalities: ['audio', 'image', 'pdf', 'text', 'video'],
38
+ reasoningLevels: ['minimal', 'low', 'medium', 'high'],
39
+ joinedFrom: 'google/gemini-3.5-flash',
40
+ capabilities: 'known',
41
+ }),
42
+ );
43
+ expect(chips.map((chip) => chip.label)).toEqual([
44
+ '思考',
45
+ '图像',
46
+ 'PDF',
47
+ '音频',
48
+ '视频',
49
+ '1M',
50
+ ]);
51
+ });
52
+
53
+ it('puts reasoning levels on the thinking chip in the selected summary', () => {
54
+ const chips = modelCapabilityChips(
55
+ entry({
56
+ id: 'deepseek-v4-pro',
57
+ window: 1_000_000,
58
+ modalities: ['text'],
59
+ reasoningLevels: ['high', 'max'],
60
+ joinedFrom: 'deepseek/deepseek-v4-pro',
61
+ capabilities: 'known',
62
+ }),
63
+ { detail: true },
64
+ );
65
+ expect(chips.map((chip) => chip.label)).toEqual(['思考(high / max)', '1M']);
66
+ });
67
+
68
+ it('does not invent thinking or image chips from heuristic unknown rows', () => {
69
+ expect(
70
+ modelCapabilityChips(
71
+ entry({
72
+ id: 'deepseek-flash',
73
+ window: 131_072,
74
+ modalities: ['text'],
75
+ reasoningLevels: [],
76
+ capabilities: 'unknown',
77
+ }),
78
+ ),
79
+ ).toEqual([
80
+ expect.objectContaining({ key: 'unknown', label: '未识别' }),
81
+ ]);
82
+ });
83
+
84
+ it('omits chips when capabilities was not reported', () => {
85
+ expect(modelCapabilityChips({ id: 'custom-model' } as GatewayModelEntry)).toEqual([]);
86
+ expect(modelCapabilityChips(null)).toEqual([]);
87
+ });
88
+ });
@@ -0,0 +1,123 @@
1
+ import type { GatewayModelEntry } from '@/lib/local-api';
2
+
3
+ /**
4
+ * 设置页 / 聊天模型选择器共用的能力展示。
5
+ *
6
+ * 只把 `capabilities === 'known'`(models.dev 叶节点 join 上)当成核实过的
7
+ * 事实。`unknown` 时 wire 仍可能带上前缀启发式的 window / text 模态,那些
8
+ * 不能画成「思考」「图像」。文本是默认输入,不单独占一枚筹码。
9
+ */
10
+
11
+ export interface ModelCapabilityChip {
12
+ key: string;
13
+ label: string;
14
+ title: string;
15
+ kind: 'reasoning' | 'modality' | 'window' | 'unknown';
16
+ }
17
+
18
+ const MODALITY_ORDER = ['image', 'pdf', 'audio', 'video'] as const;
19
+
20
+ const MODALITY_LABELS: Record<(typeof MODALITY_ORDER)[number], string> = {
21
+ image: '图像',
22
+ pdf: 'PDF',
23
+ audio: '音频',
24
+ video: '视频',
25
+ };
26
+
27
+ const MODALITY_TITLES: Record<(typeof MODALITY_ORDER)[number], string> = {
28
+ image: '支持图像输入',
29
+ pdf: '支持 PDF 输入',
30
+ audio: '支持音频输入',
31
+ video: '支持视频输入',
32
+ };
33
+
34
+ /** 上下文窗口:1_000_000 → 1M,131_072 → 131K。 */
35
+ export function formatContextWindow(window: number | null | undefined): string | null {
36
+ if (window == null || window <= 0) return null;
37
+ if (window >= 1_000_000) {
38
+ const millions = Number((window / 1_000_000).toFixed(1));
39
+ return `${Number.isInteger(millions) ? String(millions) : millions.toFixed(1)}M`;
40
+ }
41
+ if (window >= 1_000) return `${Math.round(window / 1_000)}K`;
42
+ return String(window);
43
+ }
44
+
45
+ function unknownChip(): ModelCapabilityChip {
46
+ return {
47
+ key: 'unknown',
48
+ label: '未识别',
49
+ title: '网关目录未匹配到该 id,思考档位与多模态未核实。',
50
+ kind: 'unknown',
51
+ };
52
+ }
53
+
54
+ /**
55
+ * @param detail 选中摘要用:思考筹码带上档位,如 `思考(high / max)`。
56
+ */
57
+ export function modelCapabilityChips(
58
+ entry: GatewayModelEntry | null | undefined,
59
+ options?: { detail?: boolean },
60
+ ): ModelCapabilityChip[] {
61
+ if (!entry) return [];
62
+ if (entry.capabilities === 'unknown') return [unknownChip()];
63
+ if (entry.capabilities !== 'known') return [];
64
+
65
+ const chips: ModelCapabilityChip[] = [];
66
+ const levels = entry.reasoningLevels ?? [];
67
+ if (levels.length > 0) {
68
+ const joined = levels.join(' / ');
69
+ chips.push({
70
+ key: 'reasoning',
71
+ label: options?.detail ? `思考(${joined})` : '思考',
72
+ title: `支持思考档位:${joined}`,
73
+ kind: 'reasoning',
74
+ });
75
+ }
76
+
77
+ const seen = new Set<string>();
78
+ for (const modality of MODALITY_ORDER) {
79
+ if (!(entry.modalities ?? []).includes(modality)) continue;
80
+ seen.add(modality);
81
+ chips.push({
82
+ key: modality,
83
+ label: MODALITY_LABELS[modality],
84
+ title: MODALITY_TITLES[modality],
85
+ kind: 'modality',
86
+ });
87
+ }
88
+ for (const modality of entry.modalities ?? []) {
89
+ const key = modality.toLowerCase();
90
+ if (key === 'text' || seen.has(key) || key in MODALITY_LABELS) continue;
91
+ seen.add(key);
92
+ chips.push({
93
+ key: key,
94
+ label: modality,
95
+ title: `输入模态:${modality}`,
96
+ kind: 'modality',
97
+ });
98
+ }
99
+
100
+ const windowLabel = formatContextWindow(entry.window);
101
+ if (windowLabel) {
102
+ chips.push({
103
+ key: 'window',
104
+ label: windowLabel,
105
+ title: `上下文窗口 ${entry.window} tokens`,
106
+ kind: 'window',
107
+ });
108
+ }
109
+ return chips;
110
+ }
111
+
112
+ export function unknownGatewayEntry(id: string): GatewayModelEntry {
113
+ return {
114
+ id,
115
+ name: null,
116
+ window: null,
117
+ modalities: [],
118
+ reasoningLevels: [],
119
+ pricing: null,
120
+ joinedFrom: null,
121
+ capabilities: 'unknown',
122
+ };
123
+ }