@seasonkoh/webaz 0.1.27 → 0.1.28

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,2162 @@
1
+ // WebAZ — AI assistant domain (classic multi-script split, slice E / app-ai.js)
2
+ //
3
+ // Loaded as a CLASSIC script in this order (index.html):
4
+ // i18n → app-admin → app-contribution → app-ai → app-discover → app-profile → app-account → app-shop → app-listings → app-seller → app.js (source of truth: index.html)
5
+ // Top-level functions / window.* handlers are global; the AI pages run only on
6
+ // route/click (after app.js loads), so cross-file globals (GET/POST/api/state/
7
+ // escHtml/navigate/toast$/t/...) resolve at call time. No import/export.
8
+ //
9
+ // Pure relocation of the P-AI V2 multi-provider assistant: provider registry +
10
+ // fallback chain, IndexedDB conversation store, tool calling, LLM transport,
11
+ // task state machine, and the #ai-recommend / #ai-demo render surfaces + their
12
+ // ai* handlers. All script-scoped AI consts (AI_PROVIDERS/AI_TOOLS/AI_SYSTEM_PROMPT/
13
+ // TASK_*/aiTTS/...) are used only within this file; the two functions called from
14
+ // app.js (aiCallLLM, aiGetProvider) stay global and resolve cross-file.
15
+ //
16
+ // No money/order/payment/wallet/settlement/status path. No UI/behavior change.
17
+
18
+ // ═══════════════════════════════════════════════════════════════
19
+ // P-AI V2:多 provider 私有 agent — Claude/OpenAI/DeepSeek/Groq/OpenRouter/Ollama
20
+ // + 预留 WebAZ Native (即将上线)
21
+ // ═══════════════════════════════════════════════════════════════
22
+
23
+ // Provider registry:name / desc / models / key 协议 / 请求格式 / endpoint
24
+ const AI_PROVIDERS = [
25
+ {
26
+ id: 'webaz',
27
+ name: 'WebAZ Native',
28
+ desc: '平台原生模型 · 注册即用 · 无需 API key',
29
+ free: true,
30
+ enabled: false, // 暂未上线 — 待模型 ready 后启用
31
+ badge: '即将上线',
32
+ keyRequired: false,
33
+ models: [{ id: 'webaz-1', label: 'WebAZ-1 (Coming Soon)' }],
34
+ defaultModel: 'webaz-1',
35
+ format: 'webaz', // 内部协议 (TBD)
36
+ },
37
+ {
38
+ id: 'anthropic',
39
+ name: 'Anthropic Claude',
40
+ desc: '业内最强代理模型 · 工具调用一流 · 付费',
41
+ free: false,
42
+ enabled: true,
43
+ keyRequired: true,
44
+ keyPrefix: 'sk-ant-',
45
+ keyHint: 'console.anthropic.com → API Keys',
46
+ models: [
47
+ { id: 'claude-opus-4-7', label: 'Claude Opus 4.7 (最强)', vision: true },
48
+ { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 (均衡)', vision: true },
49
+ { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 (快/省,推荐)', vision: true },
50
+ ],
51
+ defaultModel: 'claude-haiku-4-5-20251001',
52
+ endpoint: 'https://api.anthropic.com/v1/messages',
53
+ headersFn: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true' }),
54
+ format: 'anthropic',
55
+ },
56
+ {
57
+ id: 'openai',
58
+ name: 'OpenAI',
59
+ desc: 'GPT-5 / GPT-4o 系列 · 付费',
60
+ free: false,
61
+ enabled: true,
62
+ keyRequired: true,
63
+ keyPrefix: 'sk-',
64
+ keyHint: 'platform.openai.com → API Keys',
65
+ models: [
66
+ { id: 'gpt-5', label: 'GPT-5 (最强)', vision: true },
67
+ { id: 'gpt-5-mini', label: 'GPT-5 mini (推荐)', vision: true },
68
+ { id: 'gpt-4o', label: 'GPT-4o', vision: true },
69
+ { id: 'gpt-4o-mini', label: 'GPT-4o mini (经济)', vision: true },
70
+ ],
71
+ defaultModel: 'gpt-5-mini',
72
+ endpoint: 'https://api.openai.com/v1/chat/completions',
73
+ headersFn: (k) => ({ 'Authorization': 'Bearer ' + k }),
74
+ format: 'openai',
75
+ },
76
+ {
77
+ id: 'deepseek',
78
+ name: 'DeepSeek',
79
+ desc: 'DeepSeek V3 / R1 · 开源协议 · 极高性价比',
80
+ free: false,
81
+ enabled: true,
82
+ keyRequired: true,
83
+ keyPrefix: 'sk-',
84
+ keyHint: 'platform.deepseek.com → API Keys',
85
+ models: [
86
+ { id: 'deepseek-chat', label: 'DeepSeek V3 (聊天,推荐)' },
87
+ { id: 'deepseek-reasoner', label: 'DeepSeek R1 (推理强)' },
88
+ ],
89
+ defaultModel: 'deepseek-chat',
90
+ endpoint: 'https://api.deepseek.com/v1/chat/completions',
91
+ headersFn: (k) => ({ 'Authorization': 'Bearer ' + k }),
92
+ format: 'openai',
93
+ },
94
+ {
95
+ id: 'qwen',
96
+ name: '通义千问 Qwen',
97
+ desc: '阿里 · 有开源版 (Qwen2.5/3 全家) · DashScope 兼容 OpenAI',
98
+ free: false,
99
+ enabled: true,
100
+ keyRequired: true,
101
+ keyPrefix: 'sk-',
102
+ keyHint: 'dashscope.console.aliyun.com → API-KEY 管理',
103
+ models: [
104
+ { id: 'qwen-max', label: 'Qwen-Max (旗舰)' },
105
+ { id: 'qwen-plus', label: 'Qwen-Plus (推荐)' },
106
+ { id: 'qwen-turbo', label: 'Qwen-Turbo (经济)' },
107
+ { id: 'qwen-vl-max', label: 'Qwen-VL-Max (视觉)', vision: true },
108
+ ],
109
+ defaultModel: 'qwen-plus',
110
+ endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
111
+ headersFn: (k) => ({ 'Authorization': 'Bearer ' + k }),
112
+ format: 'openai',
113
+ },
114
+ {
115
+ id: 'glm',
116
+ name: '智谱 GLM',
117
+ desc: '清华系 · GLM-4-Flash 完全免费 · 中文开发者首选',
118
+ free: true,
119
+ enabled: true,
120
+ keyRequired: true,
121
+ keyHint: 'open.bigmodel.cn → 用户中心 → API Keys',
122
+ models: [
123
+ { id: 'glm-4-flash', label: 'GLM-4-Flash (完全免费,推荐)' },
124
+ { id: 'glm-4-plus', label: 'GLM-4-Plus (旗舰,付费)' },
125
+ { id: 'glm-4-air', label: 'GLM-4-Air (经济)' },
126
+ { id: 'glm-z1-air', label: 'GLM-Z1-Air (推理)' },
127
+ ],
128
+ defaultModel: 'glm-4-flash',
129
+ endpoint: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
130
+ headersFn: (k) => ({ 'Authorization': 'Bearer ' + k }),
131
+ format: 'openai',
132
+ },
133
+ {
134
+ id: 'kimi',
135
+ name: 'Kimi 月之暗面',
136
+ desc: '长上下文之王 · 128k 稳定 · OpenAI 兼容',
137
+ free: false,
138
+ enabled: true,
139
+ keyRequired: true,
140
+ keyPrefix: 'sk-',
141
+ keyHint: 'platform.moonshot.cn → 用户中心 → API Key',
142
+ models: [
143
+ { id: 'moonshot-v1-8k', label: 'Moonshot v1 8k (经济)' },
144
+ { id: 'moonshot-v1-32k', label: 'Moonshot v1 32k (推荐)' },
145
+ { id: 'moonshot-v1-128k', label: 'Moonshot v1 128k (长文)' },
146
+ ],
147
+ defaultModel: 'moonshot-v1-32k',
148
+ endpoint: 'https://api.moonshot.cn/v1/chat/completions',
149
+ headersFn: (k) => ({ 'Authorization': 'Bearer ' + k }),
150
+ format: 'openai',
151
+ },
152
+ {
153
+ id: 'doubao',
154
+ name: '豆包 Doubao',
155
+ desc: '字节 · 火山方舟 · 大厂背书 · OpenAI 兼容',
156
+ free: false,
157
+ enabled: true,
158
+ keyRequired: true,
159
+ keyHint: '火山引擎 → 方舟控制台 → API Key(注意:模型 ID 是 endpoint id 形式 ep-xxx)',
160
+ models: [
161
+ { id: 'doubao-pro-32k', label: 'Doubao Pro 32k (旗舰,需替换为 ep-xxx)' },
162
+ { id: 'doubao-pro-128k', label: 'Doubao Pro 128k (长文)' },
163
+ { id: 'doubao-lite-32k', label: 'Doubao Lite 32k (经济)' },
164
+ ],
165
+ defaultModel: 'doubao-pro-32k',
166
+ endpoint: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
167
+ headersFn: (k) => ({ 'Authorization': 'Bearer ' + k }),
168
+ format: 'openai',
169
+ },
170
+ {
171
+ id: 'groq',
172
+ name: 'Groq',
173
+ desc: 'Llama / Mixtral on Groq · 免费层 + 极快推理 · 开源',
174
+ free: true,
175
+ enabled: true,
176
+ keyRequired: true,
177
+ keyPrefix: 'gsk_',
178
+ keyHint: 'console.groq.com → API Keys (有免费层)',
179
+ models: [
180
+ { id: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70B (推荐)' },
181
+ { id: 'llama-3.1-8b-instant', label: 'Llama 3.1 8B (极快)' },
182
+ { id: 'mixtral-8x7b-32768', label: 'Mixtral 8x7B' },
183
+ ],
184
+ defaultModel: 'llama-3.3-70b-versatile',
185
+ endpoint: 'https://api.groq.com/openai/v1/chat/completions',
186
+ headersFn: (k) => ({ 'Authorization': 'Bearer ' + k }),
187
+ format: 'openai',
188
+ },
189
+ {
190
+ id: 'openrouter',
191
+ name: 'OpenRouter',
192
+ desc: '一个 key 用所有模型 · 聚合付费',
193
+ free: false,
194
+ enabled: true,
195
+ keyRequired: true,
196
+ keyPrefix: 'sk-or-',
197
+ keyHint: 'openrouter.ai → Keys',
198
+ models: [
199
+ { id: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet', vision: true },
200
+ { id: 'openai/gpt-4o', label: 'GPT-4o', vision: true },
201
+ { id: 'meta-llama/llama-3.3-70b-instruct', label: 'Llama 3.3 70B (开源)' },
202
+ { id: 'google/gemini-2.0-flash-exp:free', label: 'Gemini 2.0 Flash (免费)', vision: true },
203
+ ],
204
+ defaultModel: 'anthropic/claude-3.5-sonnet',
205
+ endpoint: 'https://openrouter.ai/api/v1/chat/completions',
206
+ headersFn: (k) => ({ 'Authorization': 'Bearer ' + k, 'HTTP-Referer': location.origin, 'X-Title': 'WebAZ' }),
207
+ format: 'openai',
208
+ },
209
+ {
210
+ id: 'ollama',
211
+ name: 'Ollama 本地',
212
+ desc: '本机跑开源模型 · 完全离线 · 零费用 · 隐私最强',
213
+ free: true,
214
+ enabled: true,
215
+ keyRequired: false,
216
+ customEndpoint: true,
217
+ defaultEndpoint: 'http://localhost:11434/v1/chat/completions',
218
+ keyHint: '本地需先 `ollama serve`,默认端口 11434',
219
+ models: [
220
+ { id: 'llama3.2', label: 'Llama 3.2' },
221
+ { id: 'qwen2.5', label: 'Qwen 2.5' },
222
+ { id: 'mistral', label: 'Mistral' },
223
+ { id: 'phi3.5', label: 'Phi 3.5' },
224
+ ],
225
+ defaultModel: 'llama3.2',
226
+ format: 'openai',
227
+ headersFn: () => ({}),
228
+ },
229
+ {
230
+ id: 'custom',
231
+ name: '自定义 (你的 agent)',
232
+ desc: '接入你自己部署的 agent / 代理 (OpenAI 或 Anthropic 兼容协议)',
233
+ free: true, // 取决于用户,UI 归免费组(自有部署多免费)
234
+ enabled: true,
235
+ keyRequired: false,
236
+ keyOptional: true,
237
+ customEndpoint: true,
238
+ defaultEndpoint: 'https://your-agent.example.com/v1/chat/completions',
239
+ keyHint: '填完整 endpoint URL(含 path);如需鉴权再填 Bearer Token',
240
+ isCustom: true, // 配置 modal 显示额外输入(model + format + name)
241
+ models: [{ id: 'custom-model', label: '自定义模型' }],
242
+ defaultModel: 'custom-model',
243
+ format: 'openai',
244
+ headersFn: (k) => k ? { 'Authorization': 'Bearer ' + k } : {},
245
+ },
246
+ ]
247
+
248
+ function aiGetProvider(id) {
249
+ const p = AI_PROVIDERS.find(x => x.id === id)
250
+ if (!p) return null
251
+ // 自定义 provider: 用 localStorage 覆盖 name/model/format(用户在 modal 配置)
252
+ if (p.isCustom) {
253
+ const cName = localStorage.getItem('webaz_ai_custom_name') || p.name
254
+ const cModel = localStorage.getItem('webaz_ai_custom_model') || p.defaultModel
255
+ const cFormat= localStorage.getItem('webaz_ai_custom_format') || p.format
256
+ const cLabel = localStorage.getItem('webaz_ai_custom_label') || '自定义模型'
257
+ return {
258
+ ...p,
259
+ name: cName,
260
+ format: cFormat,
261
+ models: [{ id: cModel, label: cLabel }],
262
+ defaultModel: cModel,
263
+ }
264
+ }
265
+ return p
266
+ }
267
+
268
+ // 调用链:用户配置多个 provider 按顺序备选,第一个失败自动 fallback 到下一个
269
+ // 默认含 WebAZ(占位首位,模型 ready 后自动启用;未启用时 aiCallLLM 自动 skip)
270
+ function aiGetChain() {
271
+ try {
272
+ const raw = localStorage.getItem('webaz_ai_chain')
273
+ if (raw) {
274
+ const arr = JSON.parse(raw)
275
+ // 保证 webaz 始终在第一位(除非用户显式移除)
276
+ if (!arr.includes('webaz') && localStorage.getItem('webaz_ai_chain_webaz_removed') !== '1') arr.unshift('webaz')
277
+ return arr
278
+ }
279
+ } catch {}
280
+ const old = localStorage.getItem('webaz_ai_provider')
281
+ if (old) return ['webaz', old]
282
+ return ['webaz'] // 全新用户默认:webaz 占位 → 后续配置自动 append
283
+ }
284
+ function aiSetChain(arr) {
285
+ localStorage.setItem('webaz_ai_chain', JSON.stringify(arr))
286
+ if (arr.length) localStorage.setItem('webaz_ai_provider', arr[0]) // 兼容
287
+ }
288
+ function aiAddToChain(pid, asPrimary) {
289
+ let chain = aiGetChain().filter(x => x !== pid)
290
+ if (asPrimary) chain = [pid, ...chain]
291
+ else chain.push(pid)
292
+ aiSetChain(chain)
293
+ }
294
+ function aiRemoveFromChain(pid) {
295
+ if (pid === 'webaz') localStorage.setItem('webaz_ai_chain_webaz_removed', '1')
296
+ aiSetChain(aiGetChain().filter(x => x !== pid))
297
+ }
298
+ function aiMoveInChain(pid, direction) {
299
+ const chain = aiGetChain()
300
+ const i = chain.indexOf(pid)
301
+ if (i < 0) return
302
+ const ni = direction === 'up' ? i - 1 : i + 1
303
+ if (ni < 0 || ni >= chain.length) return
304
+ ;[chain[i], chain[ni]] = [chain[ni], chain[i]]
305
+ aiSetChain(chain)
306
+ }
307
+ window.aiMoveInChain = aiMoveInChain
308
+ window.aiRemoveFromChain = aiRemoveFromChain
309
+
310
+ // 当前"实际生效"的 provider / model:chain 里第一个 enabled + 有 key 的
311
+ // 如果都不可用,回退到 chain[0](用于配置页显示)
312
+ function aiGetActive() {
313
+ const chain = aiGetChain()
314
+ for (const pid of chain) {
315
+ const p = aiGetProvider(pid)
316
+ if (p && p.enabled && (!p.keyRequired || aiGetKey(pid))) {
317
+ const mid = localStorage.getItem('webaz_ai_model_' + p.id) || p.defaultModel
318
+ return { provider: p, modelId: mid }
319
+ }
320
+ }
321
+ // fallback:chain[0] 或 anthropic
322
+ const fallbackId = chain[0] || 'anthropic'
323
+ const fp = aiGetProvider(fallbackId) || aiGetProvider('anthropic')
324
+ return { provider: fp, modelId: localStorage.getItem('webaz_ai_model_' + fp.id) || fp.defaultModel }
325
+ }
326
+ function aiSetActive(pid, mid) {
327
+ aiAddToChain(pid, true) // 设为主用
328
+ if (mid) localStorage.setItem('webaz_ai_model_' + pid, mid)
329
+ }
330
+ function aiGetKey(pid) {
331
+ return localStorage.getItem('webaz_ai_key_' + pid)
332
+ || (pid === 'anthropic' ? localStorage.getItem('webaz_ai_key') : null) // 旧版兼容
333
+ }
334
+ function aiSetKey(pid, key) { localStorage.setItem('webaz_ai_key_' + pid, key) }
335
+ function aiGetEndpoint(pid) {
336
+ const p = aiGetProvider(pid)
337
+ if (!p) return null
338
+ if (p.customEndpoint) return localStorage.getItem('webaz_ai_endpoint_' + pid) || p.defaultEndpoint
339
+ return p.endpoint
340
+ }
341
+ function aiSetEndpoint(pid, url) { localStorage.setItem('webaz_ai_endpoint_' + pid, url) }
342
+
343
+ const AI_DB_NAME = 'webaz_ai_v1'
344
+ const AI_DB_VER = 1
345
+ let _aiDb = null
346
+
347
+ function openAIDB() {
348
+ if (_aiDb) return Promise.resolve(_aiDb)
349
+ return new Promise((resolve, reject) => {
350
+ const req = indexedDB.open(AI_DB_NAME, AI_DB_VER)
351
+ req.onupgradeneeded = (e) => {
352
+ const db = e.target.result
353
+ if (!db.objectStoreNames.contains('conversations')) {
354
+ const s = db.createObjectStore('conversations', { keyPath: 'id' })
355
+ s.createIndex('updated_at', 'updated_at')
356
+ }
357
+ }
358
+ req.onsuccess = () => { _aiDb = req.result; resolve(_aiDb) }
359
+ req.onerror = () => reject(req.error)
360
+ })
361
+ }
362
+
363
+ async function aiSaveConversation(conv) {
364
+ conv.updated_at = new Date().toISOString()
365
+ return openAIDB().then(db => new Promise((res, rej) => {
366
+ const tx = db.transaction('conversations', 'readwrite')
367
+ tx.objectStore('conversations').put(conv)
368
+ tx.oncomplete = () => res(conv)
369
+ tx.onerror = () => rej(tx.error)
370
+ }))
371
+ }
372
+
373
+ async function aiListConversations() {
374
+ return openAIDB().then(db => new Promise((res) => {
375
+ const list = []
376
+ db.transaction('conversations').objectStore('conversations').index('updated_at').openCursor(null, 'prev').onsuccess = (e) => {
377
+ const c = e.target.result
378
+ if (c) { list.push(c.value); c.continue() }
379
+ else res(list)
380
+ }
381
+ }))
382
+ }
383
+
384
+ async function aiGetConversation(id) {
385
+ return openAIDB().then(db => new Promise((res, rej) => {
386
+ const r = db.transaction('conversations').objectStore('conversations').get(id)
387
+ r.onsuccess = () => res(r.result)
388
+ r.onerror = () => rej(r.error)
389
+ }))
390
+ }
391
+
392
+ async function aiDeleteConversation(id) {
393
+ return openAIDB().then(db => new Promise((res, rej) => {
394
+ const tx = db.transaction('conversations', 'readwrite')
395
+ tx.objectStore('conversations').delete(id)
396
+ tx.oncomplete = () => res()
397
+ tx.onerror = () => rej(tx.error)
398
+ }))
399
+ }
400
+
401
+ const AI_TOOLS = [
402
+ {
403
+ name: 'search_products',
404
+ description: '在 WebAZ 平台搜索商品。自动按当前用户的默认配送地址过滤不可达商品。返回前 10 个匹配商品(含 id/title/price/seller/stock/sales_count/commission_rate)。',
405
+ input_schema: {
406
+ type: 'object',
407
+ properties: {
408
+ q: { type: 'string', description: '关键词(如商品名)' },
409
+ max_price: { type: 'number', description: '最高价格 WAZ' },
410
+ has_sales: { type: 'string', enum: ['true', 'false'], description: 'true=只看已成交的(真实验证好物); false=只看新品(未成交)' },
411
+ },
412
+ },
413
+ },
414
+ {
415
+ name: 'get_product_detail',
416
+ description: '获取单个商品的完整详情(描述/库存/卖家/退换货政策/质保)。',
417
+ input_schema: {
418
+ type: 'object',
419
+ properties: { product_id: { type: 'string' } },
420
+ required: ['product_id'],
421
+ },
422
+ },
423
+ {
424
+ name: 'search_nearby',
425
+ description: '获取附近(约 11km 范围)匿名聚合购买活跃度(k-anonymity ≥ 3 隐私保护)。返回 24h/7d 活跃数 + 热门商品 + 热门类目。',
426
+ input_schema: { type: 'object', properties: {} },
427
+ },
428
+ {
429
+ name: 'search_by_anchor',
430
+ description: '按创作者"流量口令"查找评测内容(如某创作者的口令)。返回外链评测(YouTube/TikTok/etc)+ 原生 P2P 评测。',
431
+ input_schema: {
432
+ type: 'object',
433
+ properties: { anchor: { type: 'string', description: '创作者的口令字符串' } },
434
+ required: ['anchor'],
435
+ },
436
+ },
437
+ {
438
+ name: 'get_my_profile',
439
+ description: '获取用户自己的资料(钱包余额、累计赚取、地区、默认配送地址、bio、口令)。用于个性化推荐。',
440
+ input_schema: { type: 'object', properties: {} },
441
+ },
442
+ ]
443
+
444
+ async function aiExecTool(name, input) {
445
+ try {
446
+ switch (name) {
447
+ case 'search_products': {
448
+ const params = new URLSearchParams()
449
+ if (input.q) params.set('q', input.q)
450
+ if (input.max_price != null) params.set('max_price', input.max_price)
451
+ if (input.has_sales) params.set('has_sales', input.has_sales)
452
+ const shipTo = state.profileMini?.default_address_region
453
+ if (shipTo) params.set('ship_to', shipTo)
454
+ const r = await GET('/products' + (params.toString() ? '?' + params : ''))
455
+ const list = Array.isArray(r) ? r : []
456
+ const trimmed = list.slice(0, 10).map(p => ({
457
+ id: p.id, title: p.title, price: p.price, seller: p.seller_name,
458
+ stock: p.stock, sales_count: p.sales_count, commission_rate: p.commission_rate,
459
+ category: p.category, rep_level: p.rep_level,
460
+ }))
461
+ return { count: trimmed.length, ship_to_filter: shipTo || null, products: trimmed }
462
+ }
463
+ case 'get_product_detail': {
464
+ const r = await GET('/products/' + input.product_id)
465
+ if (r.error) return r
466
+ return {
467
+ id: r.id, title: r.title, description: r.description, price: r.price,
468
+ stock: r.stock, seller: r.seller_name, commission_rate: r.commission_rate,
469
+ ship_regions: r.ship_regions, brand: r.brand, model: r.model,
470
+ return_days: r.return_days, warranty_days: r.warranty_days,
471
+ }
472
+ }
473
+ case 'search_nearby':
474
+ return await GET('/nearby')
475
+ case 'search_by_anchor': {
476
+ const enc = encodeURIComponent(input.anchor)
477
+ const [s, m] = await Promise.all([
478
+ GET('/shareables/by-anchor/' + enc).catch(() => ({ shareables: [] })),
479
+ GET('/manifests/by-anchor/' + enc).catch(() => ({ manifests: [] })),
480
+ ])
481
+ return {
482
+ anchor: input.anchor,
483
+ shareables: (s.shareables || []).map(x => ({ id: x.id, title: x.title, platform: x.external_platform, url: x.external_url, owner: x.owner_name })),
484
+ manifests: (m.manifests || []).map(x => ({ hash: x.hash, title: x.title, type: x.content_type, owner: x.owner_name })),
485
+ }
486
+ }
487
+ case 'get_my_profile': {
488
+ const r = await GET('/profile')
489
+ if (r.error) return r
490
+ return {
491
+ name: r.name, role: r.role, region: r.region,
492
+ wallet_balance: r.wallet?.balance, wallet_earned: r.wallet?.earned,
493
+ default_address: r.default_address_text, default_address_region: r.default_address_region,
494
+ bio: r.bio, search_anchor: r.search_anchor,
495
+ }
496
+ }
497
+ }
498
+ return { error: 'unknown tool: ' + name }
499
+ } catch (e) {
500
+ return { error: (e && e.message) || String(e) }
501
+ }
502
+ }
503
+
504
+ const AI_SYSTEM_PROMPT = `你是 WebAZ 用户的私人购物助手 agent。帮用户在 WebAZ 平台找合适的商品、了解附近购买趋势、查询创作者评测。
505
+
506
+ WebAZ 是去中心化商业协议(agent 电商 + 社交电商,不是平台电商):
507
+ - 用 WAZ 代币交易
508
+ - 平台不主动推送,所有发现都是用户拉取的
509
+ - 创作者用"流量口令"从 TikTok/小红书 引流回 WebAZ
510
+ - "雷达扫描"匿名聚合附近购买趋势(k≥3 守护)
511
+ - 商品有真实成交验证("被买过的好物")
512
+
513
+ 工作原则:
514
+ 1. 优先理解用户需求,必要时简短反问澄清(不要冗长)
515
+ 2. 主动调用工具收集信息,不要凭空臆测
516
+ 3. 推荐商品时给清晰对比(价格/卖家/已售/库存)
517
+ 4. 使用 **粗体** 突出重点;用 markdown 列表
518
+ 5. 推荐具体商品时**附带商品 ID 完整路径** \`#order-product/prd_xxx\`,让用户点击直接跳转下单
519
+ 6. V1 只读:你不能直接下单。找到合适商品后告诉用户点击链接去下单
520
+ 7. 回答简洁有重点,避免长篇大论`
521
+
522
+ // 格式适配:anthropic ↔ openai
523
+ // Anthropic 内部协议: messages = [{role, content: string | [{type:'text'|'tool_use'|'tool_result', ...}]}]
524
+ // OpenAI 协议: messages = [{role:'system'|'user'|'assistant'|'tool', content, tool_call_id?, tool_calls?}]
525
+ function aiToolsToOpenAI(anthropicTools) {
526
+ return anthropicTools.map(t => ({
527
+ type: 'function',
528
+ function: { name: t.name, description: t.description, parameters: t.input_schema || { type: 'object', properties: {} } },
529
+ }))
530
+ }
531
+ function aiMessagesToOpenAI(messages, system) {
532
+ const out = [{ role: 'system', content: system }]
533
+ for (const m of messages) {
534
+ if (m.role === 'user') {
535
+ if (typeof m.content === 'string') {
536
+ out.push({ role: 'user', content: m.content })
537
+ } else if (Array.isArray(m.content)) {
538
+ // 区分两种 array content:① tool_result 走 role:tool ② text+image 走多模态 content array
539
+ const toolResults = m.content.filter(c => c.type === 'tool_result')
540
+ const visionParts = m.content.filter(c => c.type === 'text' || c.type === 'image')
541
+ if (visionParts.length > 0) {
542
+ // OpenAI 多模态:content: [{type:'text',...}, {type:'image_url', image_url:{url: 'data:...'}}]
543
+ const oaParts = visionParts.map(c => {
544
+ if (c.type === 'text') return { type: 'text', text: c.text }
545
+ if (c.type === 'image' && c.source?.type === 'base64') {
546
+ return { type: 'image_url', image_url: { url: `data:${c.source.media_type};base64,${c.source.data}` } }
547
+ }
548
+ return null
549
+ }).filter(Boolean)
550
+ out.push({ role: 'user', content: oaParts })
551
+ }
552
+ for (const c of toolResults) {
553
+ out.push({
554
+ role: 'tool',
555
+ tool_call_id: c.tool_use_id,
556
+ content: typeof c.content === 'string' ? c.content : JSON.stringify(c.content),
557
+ })
558
+ }
559
+ }
560
+ } else if (m.role === 'assistant') {
561
+ if (Array.isArray(m.content)) {
562
+ const textPart = m.content.filter(c => c.type === 'text').map(c => c.text).join('\n').trim()
563
+ const toolCalls = m.content.filter(c => c.type === 'tool_use').map(c => ({
564
+ id: c.id,
565
+ type: 'function',
566
+ function: { name: c.name, arguments: JSON.stringify(c.input || {}) },
567
+ }))
568
+ const oa = { role: 'assistant', content: textPart || null }
569
+ if (toolCalls.length) oa.tool_calls = toolCalls
570
+ out.push(oa)
571
+ } else if (typeof m.content === 'string') {
572
+ out.push({ role: 'assistant', content: m.content })
573
+ }
574
+ }
575
+ }
576
+ return out
577
+ }
578
+ function aiAdaptOpenAIResponse(oaiRes) {
579
+ const msg = oaiRes.choices?.[0]?.message
580
+ if (!msg) return { content: [], stop_reason: 'end_turn' }
581
+ const content = []
582
+ if (msg.content) content.push({ type: 'text', text: msg.content })
583
+ if (msg.tool_calls) {
584
+ for (const tc of msg.tool_calls) {
585
+ let input = {}
586
+ try { input = JSON.parse(tc.function?.arguments || '{}') } catch {}
587
+ content.push({ type: 'tool_use', id: tc.id, name: tc.function?.name || '', input })
588
+ }
589
+ }
590
+ const finish = oaiRes.choices[0].finish_reason
591
+ return { content, stop_reason: finish === 'tool_calls' ? 'tool_use' : 'end_turn' }
592
+ }
593
+
594
+ // 单 provider 调用(无 fallback)
595
+ async function aiCallOneProvider(provider, opts) {
596
+ const { messages, system, tools, max_tokens = 4096 } = opts
597
+ if (!provider.enabled) throw new Error(`${provider.name} ${t('暂未上线')}`)
598
+ if (provider.id === 'webaz') throw new Error(t('WebAZ Native 模型即将上线,请先选择其他 provider'))
599
+
600
+ const apiKey = aiGetKey(provider.id)
601
+ if (provider.keyRequired && !apiKey) throw new Error(`${t('未配置')} ${provider.name} ${t('的 API key')}`)
602
+ const endpoint = aiGetEndpoint(provider.id)
603
+ if (!endpoint) throw new Error(t('未配置 endpoint'))
604
+
605
+ const modelId = localStorage.getItem('webaz_ai_model_' + provider.id) || provider.defaultModel
606
+
607
+ let body, headers
608
+ if (provider.format === 'anthropic') {
609
+ body = { model: modelId, max_tokens, system, tools, messages }
610
+ headers = { 'Content-Type': 'application/json', ...provider.headersFn(apiKey) }
611
+ } else {
612
+ const oaMsgs = aiMessagesToOpenAI(messages, system)
613
+ const oaTools = tools && tools.length ? aiToolsToOpenAI(tools) : undefined
614
+ body = { model: modelId, messages: oaMsgs, max_tokens }
615
+ if (oaTools) { body.tools = oaTools; body.tool_choice = 'auto' }
616
+ headers = { 'Content-Type': 'application/json', ...provider.headersFn(apiKey) }
617
+ }
618
+
619
+ // 启用 SSE 流式(onDelta 提供 + provider 不是 webaz → 流式;Anthropic / OpenAI 双格式各自解析)
620
+ const useStream = typeof opts.onDelta === 'function'
621
+ if (useStream) body.stream = true
622
+
623
+ const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) })
624
+ if (!r.ok) {
625
+ let txt = ''
626
+ try { txt = await r.text() } catch {}
627
+ throw new Error(`${provider.name} API ${r.status}: ${txt.slice(0, 240)}`)
628
+ }
629
+ if (!useStream) {
630
+ const raw = await r.json()
631
+ return provider.format === 'anthropic' ? raw : aiAdaptOpenAIResponse(raw)
632
+ }
633
+ return await aiParseStream(r, provider.format, opts.onDelta)
634
+ }
635
+
636
+ // 解析 SSE 流,按需 onDelta(textChunk);累积所有 content 块返回与非流式同构 { content, stop_reason }
637
+ async function aiParseStream(response, format, onDelta) {
638
+ const reader = response.body.getReader()
639
+ const decoder = new TextDecoder()
640
+ let buf = ''
641
+ const textBlocks = [] // [{ index, text }]
642
+ const toolBlocks = new Map() // index → { id, name, input(JSON string) }
643
+ let stopReason = 'end_turn'
644
+
645
+ const flush = () => {
646
+ while (true) {
647
+ // SSE 事件用空行分隔(\n\n)
648
+ const i = buf.indexOf('\n\n')
649
+ if (i < 0) return
650
+ const chunk = buf.slice(0, i)
651
+ buf = buf.slice(i + 2)
652
+ handleSSEEvent(chunk)
653
+ }
654
+ }
655
+ const handleSSEEvent = (chunk) => {
656
+ let eventName = null
657
+ const dataLines = []
658
+ for (const ln of chunk.split('\n')) {
659
+ if (ln.startsWith('event:')) eventName = ln.slice(6).trim()
660
+ else if (ln.startsWith('data:')) dataLines.push(ln.slice(5).trim())
661
+ }
662
+ const data = dataLines.join('\n')
663
+ if (!data) return
664
+ if (data === '[DONE]') { stopReason = stopReason || 'end_turn'; return }
665
+ let json; try { json = JSON.parse(data) } catch { return }
666
+
667
+ if (format === 'anthropic') {
668
+ const type = json.type || eventName
669
+ if (type === 'content_block_start') {
670
+ const idx = json.index, cb = json.content_block
671
+ if (cb?.type === 'text') textBlocks.push({ index: idx, text: '' })
672
+ if (cb?.type === 'tool_use') toolBlocks.set(idx, { id: cb.id, name: cb.name, input: '' })
673
+ } else if (type === 'content_block_delta') {
674
+ const idx = json.index, d = json.delta
675
+ if (d?.type === 'text_delta') {
676
+ const b = textBlocks.find(x => x.index === idx); if (b) { b.text += d.text; onDelta(d.text) }
677
+ } else if (d?.type === 'input_json_delta') {
678
+ const tb = toolBlocks.get(idx); if (tb) tb.input += (d.partial_json || '')
679
+ }
680
+ } else if (type === 'message_delta') {
681
+ if (json.delta?.stop_reason) stopReason = json.delta.stop_reason
682
+ }
683
+ } else {
684
+ // OpenAI: data 每条形如 {choices:[{delta:{content, tool_calls}, finish_reason}]}
685
+ const ch = json.choices?.[0]; if (!ch) return
686
+ const delta = ch.delta || {}
687
+ if (typeof delta.content === 'string' && delta.content) {
688
+ let b = textBlocks[0]
689
+ if (!b) { b = { index: 0, text: '' }; textBlocks.push(b) }
690
+ b.text += delta.content
691
+ onDelta(delta.content)
692
+ }
693
+ if (Array.isArray(delta.tool_calls)) {
694
+ for (const tc of delta.tool_calls) {
695
+ const idx = tc.index ?? 0
696
+ let tb = toolBlocks.get(idx)
697
+ if (!tb) { tb = { id: tc.id || '', name: '', input: '' }; toolBlocks.set(idx, tb) }
698
+ if (tc.id) tb.id = tc.id
699
+ if (tc.function?.name) tb.name = tc.function.name
700
+ if (tc.function?.arguments) tb.input += tc.function.arguments
701
+ }
702
+ }
703
+ if (ch.finish_reason) {
704
+ stopReason = ch.finish_reason === 'tool_calls' ? 'tool_use' : ch.finish_reason
705
+ }
706
+ }
707
+ }
708
+
709
+ // eslint-disable-next-line no-constant-condition
710
+ while (true) {
711
+ const { done, value } = await reader.read()
712
+ if (done) break
713
+ buf += decoder.decode(value, { stream: true })
714
+ flush()
715
+ }
716
+ // 末尾残留
717
+ buf += decoder.decode()
718
+ if (buf.length) { handleSSEEvent(buf); buf = '' }
719
+
720
+ // 组装成与非流式同构的 content 数组
721
+ const content = []
722
+ for (const b of textBlocks) if (b.text) content.push({ type: 'text', text: b.text })
723
+ for (const tb of toolBlocks.values()) {
724
+ let input = {}
725
+ try { input = tb.input ? JSON.parse(tb.input) : {} } catch {}
726
+ content.push({ type: 'tool_use', id: tb.id, name: tb.name, input })
727
+ }
728
+ return { content, stop_reason: stopReason }
729
+ }
730
+
731
+ // 调用链:按顺序尝试 chain;某 provider 失败 → toast 提示后尝试下一个
732
+ async function aiCallLLM(opts) {
733
+ const chain = aiGetChain()
734
+ if (chain.length === 0) throw new Error(t('未配置任何 AI provider,请先去设置页选择'))
735
+ let lastErr = null
736
+ for (let i = 0; i < chain.length; i++) {
737
+ const pid = chain[i]
738
+ const p = aiGetProvider(pid)
739
+ if (!p || !p.enabled) continue
740
+ if (p.keyRequired && !aiGetKey(pid)) continue
741
+ try {
742
+ const result = await aiCallOneProvider(p, opts)
743
+ // 成功后,若之前 fallback 过,提示用户
744
+ if (i > 0) toast$(t('主用失败,已切到备选 ') + p.name, 'info')
745
+ return result
746
+ } catch (e) {
747
+ lastErr = e
748
+ console.warn(`[AI] ${p.name} failed:`, e.message)
749
+ // 如果还有 fallback,继续;否则抛
750
+ if (i < chain.length - 1) {
751
+ const nextP = aiGetProvider(chain[i + 1])
752
+ if (nextP) toast$(`⚠ ${p.name} ${t('失败,尝试备选')} ${nextP.name}…`, 'info')
753
+ }
754
+ }
755
+ }
756
+ throw lastErr || new Error(t('调用链全部失败'))
757
+ }
758
+
759
+ // 任务状态机:用户给需求 → AI 出方案 → 用户审核 → AI 执行 → 用户评价
760
+ const TASK_STATES = {
761
+ intent: { label: '提需求', short: '提需求', color: '#6b7280' },
762
+ planning: { label: '出方案', short: '出方案', color: '#3b82f6' },
763
+ review: { label: '审核', short: '审核', color: '#f59e0b' },
764
+ executing: { label: '执行中', short: '执行', color: '#7c3aed' },
765
+ results: { label: '看结果', short: '结果', color: '#06b6d4' },
766
+ completed: { label: '已完成', short: '完成', color: '#16a34a' },
767
+ cancelled: { label: '已取消', short: '取消', color: '#dc2626' },
768
+ }
769
+ const TASK_FLOW = ['intent', 'planning', 'review', 'executing', 'results', 'completed']
770
+
771
+ function aiInitTask(conv) {
772
+ if (!conv.task) conv.task = { state: 'intent', plan: null, results: null, rating: null, feedback: null, decision_history: [] }
773
+ return conv.task
774
+ }
775
+
776
+ function aiExtractText(content) {
777
+ if (!content) return ''
778
+ if (typeof content === 'string') return content
779
+ if (Array.isArray(content)) return content.filter(c => c.type === 'text').map(c => c.text).join('\n\n').trim()
780
+ return ''
781
+ }
782
+
783
+ // 按任务状态给 system prompt 加增强指令
784
+ function aiSystemForState(state) {
785
+ const base = AI_SYSTEM_PROMPT
786
+ if (state === 'planning') {
787
+ return base + `\n\n[任务规划阶段]\n用户给了你一个任务。请先**制定执行方案**(不要立即调用工具、不要直接给推荐):\n- 用 markdown 编号列表列出 3-5 个执行步骤\n- 每步说明会用什么工具、收集什么信息\n- 最后用一行总结预期产出\n\n方案结束后停止输出,等用户审核批准。`
788
+ }
789
+ if (state === 'executing') {
790
+ return base + `\n\n[执行阶段]\n用户已批准方案,请按方案调用工具执行。完成后给出**清晰的最终结果**(推荐商品/汇总信息),结构化呈现。`
791
+ }
792
+ if (state === 'completed' || state === 'results') {
793
+ return base + `\n\n[追问阶段]\n任务主流程已完成。用户在追问或细化,简洁直接回答。`
794
+ }
795
+ return base
796
+ }
797
+
798
+ // TTS:朗读 AI 回话(speechSynthesis 浏览器原生,无 cost)
799
+ const aiTTS = {
800
+ isEnabled() { return localStorage.getItem('webaz_ai_tts_enabled') === '1' },
801
+ setEnabled(v) { localStorage.setItem('webaz_ai_tts_enabled', v ? '1' : '0') },
802
+ supported() { return typeof window !== 'undefined' && 'speechSynthesis' in window },
803
+ stop() { try { window.speechSynthesis?.cancel() } catch {} },
804
+ speak(text) {
805
+ if (!this.supported() || !this.isEnabled()) return
806
+ const t = String(text || '').trim()
807
+ if (!t) return
808
+ // 清理 markdown / 表情包 / code 块,留可读纯文本
809
+ const clean = t
810
+ .replace(/```[\s\S]*?```/g, '') // code blocks
811
+ .replace(/`[^`]+`/g, '') // inline code
812
+ .replace(/!\[.*?\]\(.*?\)/g, '') // images
813
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links → text only
814
+ .replace(/[*_#>~|]/g, '') // markdown markup
815
+ .slice(0, 800) // 防超长一直播
816
+ this.stop()
817
+ const u = new SpeechSynthesisUtterance(clean)
818
+ u.lang = window._lang === 'en' ? 'en-US' : 'zh-CN'
819
+ u.rate = 1.0
820
+ u.pitch = 1.0
821
+ try { window.speechSynthesis.speak(u) } catch {}
822
+ },
823
+ }
824
+
825
+ window.aiToggleTTS = () => {
826
+ const next = !aiTTS.isEnabled()
827
+ aiTTS.setEnabled(next)
828
+ if (!next) aiTTS.stop()
829
+ // 重渲状态栏 chip
830
+ const cur = state.aiCurrentConv
831
+ if (cur && location.hash.startsWith('#ai-recommend') && !location.hash.includes('config') && !location.hash.includes('tasks')) {
832
+ renderAIRecommend(document.getElementById('app'))
833
+ }
834
+ }
835
+
836
+ // 当前 chain 首个可用 provider 的 active model 是否支持视觉?
837
+ function aiCurrentModelSupportsVision() {
838
+ const chain = aiGetChain()
839
+ for (const pid of chain) {
840
+ const p = aiGetProvider(pid)
841
+ if (!p || !p.enabled) continue
842
+ if (p.keyRequired && !aiGetKey(pid)) continue
843
+ const modelId = localStorage.getItem('webaz_ai_model_' + pid) || p.defaultModel
844
+ const m = p.models.find(x => x.id === modelId)
845
+ return !!m?.vision
846
+ }
847
+ return false
848
+ }
849
+
850
+ // 把 dataURL 拆 mime + base64
851
+ function aiParseDataURL(dataURL) {
852
+ const m = /^data:([^;]+);base64,(.+)$/.exec(dataURL || '')
853
+ return m ? { mime: m[1], data: m[2] } : null
854
+ }
855
+
856
+ async function aiChatTurn(conversation, userText, attachments, onProgress) {
857
+ // 向后兼容旧签名:aiChatTurn(conv, text, onProgress)
858
+ if (typeof attachments === 'function' && !onProgress) {
859
+ onProgress = attachments
860
+ attachments = []
861
+ }
862
+ attachments = attachments || []
863
+ const task = aiInitTask(conversation)
864
+ // 状态推进:intent → planning(用户首次输入)
865
+ if (task.state === 'intent') task.state = 'planning'
866
+ const titleSeed = typeof userText === 'string' ? userText : ''
867
+ if (!conversation.title && titleSeed) conversation.title = titleSeed.slice(0, 24)
868
+
869
+ // 构造 user content:纯文本 / 含视觉附件 → anthropic 风格 content array
870
+ // (aiMessagesToOpenAI 会把它翻译成 OpenAI image_url 形式)
871
+ const visionAttachments = attachments.filter(a => a.kind === 'image')
872
+ if (visionAttachments.length > 0 && aiCurrentModelSupportsVision()) {
873
+ const content = []
874
+ if (userText) content.push({ type: 'text', text: userText })
875
+ for (const a of visionAttachments) {
876
+ const p = aiParseDataURL(a.dataURL)
877
+ if (p) content.push({ type: 'image', source: { type: 'base64', media_type: p.mime, data: p.data } })
878
+ }
879
+ conversation.messages.push({ role: 'user', content })
880
+ } else {
881
+ conversation.messages.push({ role: 'user', content: userText })
882
+ }
883
+
884
+ let iters = 0
885
+ const MAX_ITERS = 8
886
+ // planning 阶段:禁用工具调用,强制只出方案
887
+ const tools = task.state === 'planning' ? [] : AI_TOOLS
888
+ while (iters++ < MAX_ITERS) {
889
+ onProgress?.('thinking', iters)
890
+ // 流式:每个 text token 上报 'text' chunk
891
+ const onTextDelta = (chunk) => onProgress?.('text', chunk)
892
+ const res = await aiCallLLM({ messages: conversation.messages, system: aiSystemForState(task.state), tools, onDelta: onTextDelta })
893
+ if (res.error) throw new Error(res.error.message || JSON.stringify(res.error))
894
+ conversation.messages.push({ role: 'assistant', content: res.content })
895
+ const toolUses = (res.content || []).filter(c => c.type === 'tool_use')
896
+ if (toolUses.length === 0 || res.stop_reason !== 'tool_use') {
897
+ // 状态收尾:planning 完成 → review;executing 完成 → results
898
+ if (task.state === 'planning') { task.plan = aiExtractText(res.content); task.state = 'review' }
899
+ if (task.state === 'executing') { task.results = aiExtractText(res.content); task.state = 'results' }
900
+ await aiSaveConversation(conversation)
901
+ return res
902
+ }
903
+ onProgress?.('tool_use', toolUses.map(t => t.name))
904
+ // 实时 trace:让 UI 拿到完整 tool 对象(含 id/name/input),先渲染 ⏳ 卡
905
+ onProgress?.('tool_use_start', toolUses)
906
+ const toolResults = await Promise.all(toolUses.map(async tu => {
907
+ const content = JSON.stringify(await aiExecTool(tu.name, tu.input))
908
+ // 每个 tool 落地即 ping UI 切到 ✅
909
+ onProgress?.('tool_result_one', { id: tu.id, name: tu.name, input: tu.input, content })
910
+ return { type: 'tool_result', tool_use_id: tu.id, content }
911
+ }))
912
+ conversation.messages.push({ role: 'user', content: toolResults })
913
+ }
914
+ await aiSaveConversation(conversation)
915
+ return { content: [{ type: 'text', text: '⚠️ 达到工具调用上限,请重新提问或细化问题。' }] }
916
+ }
917
+
918
+ function aiCreateConversation() {
919
+ return {
920
+ id: 'conv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8),
921
+ title: null,
922
+ messages: [],
923
+ created_at: new Date().toISOString(),
924
+ updated_at: new Date().toISOString(),
925
+ }
926
+ }
927
+
928
+ function renderAIMarkdown(text) {
929
+ if (!text) return ''
930
+ let html = escHtml(text)
931
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
932
+ html = html.replace(/`([^`]+)`/g, '<code style="background:#f3f4f6;padding:1px 4px;border-radius:3px;font-size:11px">$1</code>')
933
+ html = html.replace(/\n/g, '<br>')
934
+ // 自动 #order-product/prd_xxx 链接
935
+ html = html.replace(/#order-product\/(prd_[A-Za-z0-9_]+)/g, '<a href="#order-product/$1" style="color:#4f46e5;font-weight:600">#order-product/$1</a>')
936
+ html = html.replace(/(?<![\/\w])(prd_[A-Za-z0-9_]+)/g, '<a href="#order-product/$1" style="color:#4f46e5">$1</a>')
937
+ return html
938
+ }
939
+
940
+ // 渲染单个 tool_use 卡(含可选的配对 tool_result 展开)
941
+ function renderToolCard(toolUse, resultPreview) {
942
+ const params = toolUse.input ? JSON.stringify(toolUse.input, null, 2) : '{}'
943
+ const paramsShort = params.length > 80 ? params.slice(0, 78) + '…' : params
944
+ const hasResult = resultPreview != null
945
+ let resultText = ''
946
+ if (hasResult) {
947
+ try {
948
+ const parsed = typeof resultPreview === 'string' ? JSON.parse(resultPreview) : resultPreview
949
+ resultText = JSON.stringify(parsed, null, 2)
950
+ } catch { resultText = String(resultPreview) }
951
+ }
952
+ const resultShort = resultText.length > 240 ? resultText.slice(0, 240) + '…' : resultText
953
+ const statusEmoji = hasResult ? '✅' : '⏳'
954
+ const statusColor = hasResult ? '#16a34a' : '#f59e0b'
955
+ return `<details style="margin:6px 0;background:#fff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden">
956
+ <summary style="cursor:pointer;padding:6px 10px;font-size:11px;display:flex;align-items:center;gap:6px;list-style:none;user-select:none">
957
+ <span style="color:${statusColor}">${statusEmoji}</span>
958
+ <span style="font-family:ui-monospace,Consolas,monospace;color:#3730a3;font-weight:600">${escHtml(toolUse.name || '')}</span>
959
+ <span style="color:#9ca3af;font-family:ui-monospace,Consolas,monospace;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px">${escHtml(paramsShort.replace(/\n\s*/g, ' '))}</span>
960
+ <span style="color:#9ca3af;font-size:10px">▾</span>
961
+ </summary>
962
+ <div style="padding:6px 10px;border-top:1px solid #f3f4f6;background:#f9fafb">
963
+ <div style="font-size:10px;color:#6b7280;margin-bottom:3px">${t('参数')}</div>
964
+ <pre style="margin:0 0 8px;font-size:11px;font-family:ui-monospace,Consolas,monospace;color:#374151;white-space:pre-wrap;word-wrap:break-word">${escHtml(params)}</pre>
965
+ ${hasResult ? `
966
+ <div style="font-size:10px;color:#6b7280;margin-bottom:3px">${t('返回')}</div>
967
+ <pre style="margin:0;font-size:11px;font-family:ui-monospace,Consolas,monospace;color:#374151;white-space:pre-wrap;word-wrap:break-word;max-height:240px;overflow:auto">${escHtml(resultShort)}</pre>
968
+ ` : `<div style="font-size:11px;color:#92400e;font-style:italic">${t('执行中…')}</div>`}
969
+ </div>
970
+ </details>`
971
+ }
972
+
973
+ function renderAIMessages(messages) {
974
+ if (!messages || messages.length === 0) {
975
+ return `<div style="text-align:center;padding:30px 14px;color:#9ca3af">
976
+ <div style="font-size:36px;margin-bottom:10px">🤖</div>
977
+ <div style="font-size:13px;margin-bottom:8px">${t('你的私有 agent 已就绪')}</div>
978
+ <div style="font-size:11px;line-height:1.8;margin-bottom:14px">
979
+ ${t('试试问:')}<br>
980
+ • ${t('"帮我找适合送 60 岁妈妈的礼物 ≤ 500 元"')}<br>
981
+ • ${t('"附近最近有人买什么?"')}<br>
982
+ • ${t('"某个口令的创作者发了啥?"')}
983
+ </div>
984
+ <a href="#ai-demo" style="display:inline-block;font-size:12px;color:#007aff;text-decoration:none;background:#eff6ff;border:0.5px solid #bfdbfe;padding:6px 14px;border-radius:99px">🎬 ${t('看看预设演示 →')}</a>
985
+ </div>`
986
+ }
987
+ // 预扫所有 tool_result 建索引,渲染 tool_use 时按 id 配对
988
+ const toolResults = {}
989
+ for (const m of messages) {
990
+ if (m.role === 'user' && Array.isArray(m.content)) {
991
+ for (const c of m.content) {
992
+ if (c.type === 'tool_result') toolResults[c.tool_use_id] = c.content
993
+ }
994
+ }
995
+ }
996
+ return messages.map(m => {
997
+ if (m.role === 'user' && typeof m.content === 'string') {
998
+ return `<div style="display:flex;justify-content:flex-end;margin-bottom:10px">
999
+ <div style="background:#4f46e5;color:#fff;border-radius:10px 10px 2px 10px;padding:8px 12px;max-width:80%;white-space:pre-wrap;word-wrap:break-word">${escHtml(m.content)}</div>
1000
+ </div>`
1001
+ }
1002
+ if (m.role === 'user' && Array.isArray(m.content)) {
1003
+ // 区分:① text/image 多模态用户消息 ② tool_result 隐藏
1004
+ const visionParts = m.content.filter(c => c.type === 'text' || c.type === 'image')
1005
+ if (visionParts.length === 0) return '' // 纯 tool_result,UI 隐藏
1006
+ const blocks = visionParts.map(c => {
1007
+ if (c.type === 'text') return `<div style="white-space:pre-wrap;word-wrap:break-word">${escHtml(c.text)}</div>`
1008
+ if (c.type === 'image' && c.source?.type === 'base64') {
1009
+ // L-2: media_type / data 都做严格白名单后再拼 attribute,避免越界字符引号注入
1010
+ const mime = String(c.source.media_type || '').match(/^[a-zA-Z0-9.+-]+\/[a-zA-Z0-9.+-]+$/) ? c.source.media_type : 'image/png'
1011
+ const data = String(c.source.data || '').replace(/[^A-Za-z0-9+/=]/g, '')
1012
+ const url = `data:${mime};base64,${data}`
1013
+ return `<img src="${url}" style="max-width:240px;max-height:240px;border-radius:6px;margin-top:6px;display:block">`
1014
+ }
1015
+ return ''
1016
+ }).join('')
1017
+ return `<div style="display:flex;justify-content:flex-end;margin-bottom:10px">
1018
+ <div style="background:#4f46e5;color:#fff;border-radius:10px 10px 2px 10px;padding:8px 12px;max-width:80%">${blocks}</div>
1019
+ </div>`
1020
+ }
1021
+ if (m.role === 'assistant' && Array.isArray(m.content)) {
1022
+ const blocks = m.content.map(c => {
1023
+ if (c.type === 'text') return `<div>${renderAIMarkdown(c.text)}</div>`
1024
+ if (c.type === 'tool_use') return renderToolCard(c, toolResults[c.id])
1025
+ return ''
1026
+ }).join('')
1027
+ if (!blocks.trim()) return ''
1028
+ return `<div style="display:flex;justify-content:flex-start;margin-bottom:10px">
1029
+ <div style="background:#f3f4f6;color:#111827;border-radius:10px 10px 10px 2px;padding:8px 12px;max-width:90%;word-wrap:break-word">${blocks}</div>
1030
+ </div>`
1031
+ }
1032
+ return ''
1033
+ }).join('')
1034
+ }
1035
+
1036
+ function aiProviderHasKey(p) {
1037
+ if (!p.keyRequired) return true
1038
+ return !!aiGetKey(p.id)
1039
+ }
1040
+
1041
+ function renderAIProviderCard(p, opts = {}) {
1042
+ if (!p) return ''
1043
+ const isActive = opts.active
1044
+ const hasKey = aiProviderHasKey(p)
1045
+ const disabled = !p.enabled
1046
+ const chain = aiGetChain()
1047
+ const inChain = chain.includes(p.id)
1048
+ const chainIdx = chain.indexOf(p.id)
1049
+ const isPrimary = chainIdx === 0
1050
+ const freeChip = p.free ? `<span style="background:#dcfce7;color:#15803d;font-size:9px;padding:1px 6px;border-radius:99px;font-weight:600">${t('免费')}</span>` : `<span style="background:#fef3c7;color:#92400e;font-size:9px;padding:1px 6px;border-radius:99px;font-weight:600">${t('付费')}</span>`
1051
+ const badge = p.badge ? `<span style="background:#fde68a;color:#92400e;font-size:9px;padding:1px 6px;border-radius:99px;font-weight:600">${t(p.badge)}</span>` : ''
1052
+ const statusChip = !p.enabled
1053
+ ? `<span style="color:#9ca3af;font-size:10px">${t('暂未上线')}</span>`
1054
+ : isPrimary
1055
+ ? `<span style="color:#4f46e5;font-size:10px;font-weight:600">🟣 ${t('主用')}</span>`
1056
+ : inChain
1057
+ ? `<span style="color:#0891b2;font-size:10px">⚪ ${t('备选')} ${chainIdx}</span>`
1058
+ : hasKey
1059
+ ? `<span style="color:#059669;font-size:10px">✓ ${t('已配置')}</span>`
1060
+ : `<span style="color:#d97706;font-size:10px">⚠ ${t('未配 key')}</span>`
1061
+ return `<div onclick="${disabled ? '' : `aiOpenProviderConfig('${p.id}')`}"
1062
+ style="
1063
+ background:${isPrimary ? 'linear-gradient(135deg,#eef2ff,#faf5ff)' : inChain ? '#f0fdfa' : '#fff'};
1064
+ border:1px solid ${isPrimary ? '#a5b4fc' : inChain ? '#a7f3d0' : '#e5e7eb'};
1065
+ border-radius:10px;padding:12px;cursor:${disabled ? 'not-allowed' : 'pointer'};
1066
+ opacity:${disabled ? '0.55' : '1'};
1067
+ ${opts.fullWidth ? 'width:100%' : ''}
1068
+ transition:transform 0.1s ease;
1069
+ ">
1070
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:6px">
1071
+ <div style="font-size:13px;font-weight:600;color:#111827">${escHtml(p.name)}</div>
1072
+ <div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;justify-content:flex-end">${badge}${freeChip}</div>
1073
+ </div>
1074
+ <div style="font-size:11px;color:#6b7280;line-height:1.4;margin-bottom:6px">${t(p.desc)}</div>
1075
+ <div style="display:flex;align-items:center;justify-content:space-between">
1076
+ <div style="font-size:10px;color:#9ca3af">${p.models.length} ${t('个模型')}</div>
1077
+ ${statusChip}
1078
+ </div>
1079
+ </div>`
1080
+ }
1081
+
1082
+ // 状态栏:显示当前模型 + 付费/免费 + 视觉能力 + 任务/配置入口
1083
+ function renderAIStatusBar() {
1084
+ const { provider, modelId } = aiGetActive()
1085
+ const curModel = provider.models.find(m => m.id === modelId) || provider.models[0]
1086
+ const isFree = provider.free
1087
+ const isWebaz = provider.id === 'webaz'
1088
+ const visionChip = curModel?.vision
1089
+ ? `<span title="${t('当前模型可识图')}" style="background:#eef2ff;color:#3730a3;font-size:9px;padding:2px 6px;border-radius:99px;font-weight:600;flex-shrink:0">👁 ${t('视觉')}</span>`
1090
+ : ''
1091
+ const dot = isWebaz ? '#fbbf24' : (aiProviderHasKey(provider) && provider.enabled ? '#10b981' : '#dc2626')
1092
+ return `<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;background:linear-gradient(135deg,#fafafa,#f3f4f6);border:1px solid #e5e7eb;border-radius:10px;margin-bottom:10px;flex-wrap:wrap">
1093
+ <div style="display:flex;align-items:center;gap:6px;flex:1;min-width:0">
1094
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${dot};flex-shrink:0"></span>
1095
+ <span style="background:${isFree ? '#dcfce7' : '#fef3c7'};color:${isFree ? '#15803d' : '#92400e'};font-size:9px;padding:2px 6px;border-radius:99px;font-weight:600;flex-shrink:0">${t(isFree ? '免费' : '付费')}</span>
1096
+ ${visionChip}
1097
+ <span style="font-weight:600;font-size:13px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(provider.name)}</span>
1098
+ <span style="color:#d1d5db;font-size:11px;flex-shrink:0">·</span>
1099
+ <span style="color:#6b7280;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(curModel?.label || modelId)}</span>
1100
+ </div>
1101
+ ${aiTTS.supported() ? `
1102
+ <button onclick="aiToggleTTS()" title="${aiTTS.isEnabled() ? t('点击关闭朗读') : t('点击开启朗读 AI 回话')}"
1103
+ style="background:${aiTTS.isEnabled() ? '#eef2ff' : 'none'};border:1px solid ${aiTTS.isEnabled() ? '#4f46e5' : '#e5e7eb'};border-radius:6px;padding:4px 8px;font-size:11px;cursor:pointer;color:${aiTTS.isEnabled() ? '#4f46e5' : '#6b7280'};flex-shrink:0">🔊</button>
1104
+ ` : ''}
1105
+ <button onclick="navigate('#ai-recommend/tasks')" title="${t('任务管理')}" style="background:none;border:1px solid #e5e7eb;border-radius:6px;padding:4px 8px;font-size:11px;cursor:pointer;color:#6b7280;flex-shrink:0">📋</button>
1106
+ <button onclick="navigate('#ai-recommend/config')" title="${t('AI 配置')}" style="background:none;border:1px solid #e5e7eb;border-radius:6px;padding:4px 8px;font-size:11px;cursor:pointer;color:#6b7280;flex-shrink:0">⚙️</button>
1107
+ </div>`
1108
+ }
1109
+
1110
+ // 任务流程 stepper:6 阶段 + 当前态高亮
1111
+ function renderTaskStepper(task) {
1112
+ if (task.state === 'cancelled') {
1113
+ return `<div style="background:#fee2e2;border:1px solid #fecaca;border-radius:8px;padding:8px 12px;font-size:12px;color:#991b1b;margin-bottom:10px">⊘ ${t('任务已取消')}</div>`
1114
+ }
1115
+ const curIdx = TASK_FLOW.indexOf(task.state)
1116
+ return `<div style="display:flex;align-items:center;gap:2px;margin-bottom:10px;font-size:10px;padding:6px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;overflow-x:auto;scrollbar-width:none">
1117
+ ${TASK_FLOW.map((s, i) => {
1118
+ const st = TASK_STATES[s]
1119
+ const done = i < curIdx
1120
+ const active = i === curIdx
1121
+ return `<div style="display:flex;align-items:center;gap:3px;flex-shrink:0">
1122
+ <span style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;font-size:9px;background:${done ? '#16a34a' : active ? st.color : '#e5e7eb'};color:${done || active ? '#fff' : '#9ca3af'};font-weight:${active ? '700' : '500'}">${done ? '✓' : i + 1}</span>
1123
+ <span style="color:${active ? st.color : done ? '#6b7280' : '#9ca3af'};font-weight:${active ? '600' : '400'};white-space:nowrap">${t(st.short)}</span>
1124
+ ${i < TASK_FLOW.length - 1 ? `<span style="color:#d1d5db;margin:0 1px">→</span>` : ''}
1125
+ </div>`
1126
+ }).join('')}
1127
+ </div>`
1128
+ }
1129
+
1130
+ // 当前任务状态下的可操作按钮(批准/改方案/取消 等)
1131
+ function renderTaskActions(task) {
1132
+ if (task.state === 'review' && task.plan) {
1133
+ return `<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:10px 12px;margin-top:8px">
1134
+ <div style="font-size:11px;color:#92400e;font-weight:600;margin-bottom:6px">📋 ${t('AI 已出方案 — 请审核:')}</div>
1135
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
1136
+ <button class="btn btn-primary btn-sm" style="width:auto;padding:6px 14px;font-size:12px;background:#16a34a;border-color:#16a34a" onclick="aiApprovePlan()">✓ ${t('批准执行')}</button>
1137
+ <button class="btn btn-outline btn-sm" style="width:auto;padding:6px 14px;font-size:12px" onclick="aiRequestModify()">✎ ${t('改方案')}</button>
1138
+ <button class="btn btn-outline btn-sm" style="width:auto;padding:6px 14px;font-size:12px;color:#dc2626;border-color:#fca5a5" onclick="aiCancelTask()">⊘ ${t('取消任务')}</button>
1139
+ </div>
1140
+ </div>`
1141
+ }
1142
+ if (task.state === 'results' && task.results) {
1143
+ return `<div style="background:#ecfdf5;border:1px solid #bbf7d0;border-radius:8px;padding:10px 12px;margin-top:8px">
1144
+ <div style="font-size:11px;color:#15803d;font-weight:600;margin-bottom:6px">✅ ${t('AI 已返回结果 — 确认并评价:')}</div>
1145
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
1146
+ <button class="btn btn-primary btn-sm" style="width:auto;padding:6px 14px;font-size:12px" onclick="aiOpenRateModal()">⭐ ${t('完成并评价')}</button>
1147
+ <button class="btn btn-outline btn-sm" style="width:auto;padding:6px 14px;font-size:12px" onclick="aiRequestRedo()">↻ ${t('要求重做')}</button>
1148
+ </div>
1149
+ </div>`
1150
+ }
1151
+ if (task.state === 'completed' && task.rating != null) {
1152
+ return `<div style="background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;padding:8px 12px;margin-top:8px;font-size:11px;color:#0c4a6e">
1153
+ 🎉 ${t('任务已完成')} · ${'⭐'.repeat(task.rating)}${task.feedback ? ' · ' + escHtml(task.feedback).slice(0, 50) : ''}
1154
+ <button onclick="aiNewConv()" style="background:none;border:none;color:#0284c7;font-size:11px;cursor:pointer;margin-left:6px;text-decoration:underline">${t('开新任务')}</button>
1155
+ </div>`
1156
+ }
1157
+ return ''
1158
+ }
1159
+
1160
+ function aiInputPlaceholder(task) {
1161
+ switch (task.state) {
1162
+ case 'intent': return t('告诉我你想做的事…例如:帮我找适合 60 岁妈妈的礼物 ≤ 500 WAZ')
1163
+ case 'planning': return t('AI 在思考方案,请稍候…')
1164
+ case 'review': return t('补充或调整方案要求(可选)')
1165
+ case 'executing': return t('AI 在执行,请稍候…')
1166
+ case 'results': return t('追问 / 细化(可选)')
1167
+ case 'completed': return t('继续追问或点上方"开新任务"')
1168
+ default: return t('问问你的 agent...')
1169
+ }
1170
+ }
1171
+
1172
+ // 任务快速模板(intent 阶段显示)— 让用户一键启动常见 agent 场景
1173
+ const AI_QUICK_TEMPLATES = [
1174
+ { icon: '🎁', label: '找礼物', prompt: '帮我找一个适合 [对象/年龄] 的礼物,预算 [金额] WAZ,要 [风格/品类] 类型' },
1175
+ { icon: '🔍', label: '比价', prompt: '帮我比较 [商品名/链接],找 WebAZ 上的最优价' },
1176
+ { icon: '⭐', label: '挑信誉', prompt: '推荐 3 个 [品类] 商品,优先 trusted+ 卖家 + 高完成率' },
1177
+ { icon: '🚚', label: '看物流', prompt: '我在 [地区],哪些 [品类] 商品能 24h 内发货?' },
1178
+ { icon: '📦', label: '查订单', prompt: '帮我看下最近 7 天的订单状态,有没有需要确认收货的?' },
1179
+ { icon: '🧩', label: '组方案', prompt: '我想为 [场景] 配齐一套商品,预算 [金额] WAZ — 帮我规划' },
1180
+ ]
1181
+
1182
+ // 状态文案 + 副标题(强化进度可视化)
1183
+ const TASK_STATE_HINTS = {
1184
+ intent: { title: '告诉 Agent 你想做什么', subtitle: '用一句话描述需求,越具体效果越好' },
1185
+ planning: { title: 'Agent 正在制定执行方案', subtitle: '正在调用思考能力规划步骤…' },
1186
+ review: { title: '审核方案', subtitle: '看一眼 Agent 的计划,批准即可开干' },
1187
+ executing: { title: 'Agent 正在执行', subtitle: '查询商品 / 锁价 / 比对 …' },
1188
+ results: { title: 'Agent 已完成执行', subtitle: '看结果是否满意,可让它重做或细化' },
1189
+ completed: { title: '任务完成 🎉', subtitle: '可继续追问 或 开新任务' },
1190
+ cancelled: { title: '任务已取消', subtitle: '点上方"新任务"开启下一次' },
1191
+ }
1192
+
1193
+ // 流式光标动画样式(一次性注入)
1194
+ function ensureAICursorStyle() {
1195
+ if (document.getElementById('ai-cursor-style')) return
1196
+ const el = document.createElement('style')
1197
+ el.id = 'ai-cursor-style'
1198
+ el.textContent = '@keyframes ai-cursor { 0%,50%{opacity:1} 51%,100%{opacity:0} }'
1199
+ document.head.appendChild(el)
1200
+ }
1201
+
1202
+ // 实时 trace 动画样式:滑入 + 完成 flash
1203
+ function ensureAITraceStyle() {
1204
+ if (document.getElementById('ai-trace-style')) return
1205
+ const el = document.createElement('style')
1206
+ el.id = 'ai-trace-style'
1207
+ el.textContent = `
1208
+ @keyframes ai-trace-slide-in {
1209
+ 0% { opacity: 0; transform: translateY(8px); }
1210
+ 100% { opacity: 1; transform: translateY(0); }
1211
+ }
1212
+ @keyframes ai-trace-flash {
1213
+ 0%, 100% { background: transparent; }
1214
+ 30% { background: rgba(22,163,74,0.12); }
1215
+ }
1216
+ .ai-trace-card {
1217
+ opacity: 0;
1218
+ animation: ai-trace-slide-in 280ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
1219
+ border-radius: 8px;
1220
+ }
1221
+ .ai-trace-card-done {
1222
+ animation: ai-trace-flash 600ms ease;
1223
+ }
1224
+ `
1225
+ document.head.appendChild(el)
1226
+ }
1227
+
1228
+ // 在消息容器末尾找/建一个 trace 容器(每次 send 重新建一个)
1229
+ function ensureTraceContainer(msgEl) {
1230
+ if (!msgEl) return null
1231
+ let trace = msgEl.querySelector('#ai-trace-active')
1232
+ if (!trace) {
1233
+ trace = document.createElement('div')
1234
+ trace.id = 'ai-trace-active'
1235
+ trace.style.marginBottom = '10px'
1236
+ msgEl.appendChild(trace)
1237
+ }
1238
+ return trace
1239
+ }
1240
+
1241
+ // AI Agent 演示画廊 — 新用户入口,4 个场景化 demo + 预期 agent 行为预览
1242
+ // 一键运行 → seed prompt + 跳 #ai-recommend
1243
+ async function renderAIDemo(app) {
1244
+ if (!state.user) return navigate('#login')
1245
+ const DEMOS = [
1246
+ {
1247
+ icon: '🎁', tag: '送礼',
1248
+ title: '帮我挑生日礼物',
1249
+ subtitle: '给妈妈 60 岁生日,预算 500 WAZ,实用 + 有心意',
1250
+ prompt: '我妈妈 60 岁生日,预算 500 WAZ 以内。希望礼物实用又有心意,最好是手工或有故事的。优先选 trusted 卖家。',
1251
+ expectedSteps: [
1252
+ { tool: 'webaz_search', desc: '搜索 500 WAZ 内的手工 / 健康类商品' },
1253
+ { tool: 'webaz_search', desc: '叠加 trusted 卖家筛选' },
1254
+ { tool: '推理', desc: '从信誉 + 评价 + 故事性挑出 3 件' },
1255
+ ],
1256
+ tone: '#fef3c7',
1257
+ },
1258
+ {
1259
+ icon: '🔍', tag: '比价',
1260
+ title: '帮我比价外部链接',
1261
+ subtitle: '粘贴淘宝/京东链接,agent 自动查 WebAZ 上的同款',
1262
+ prompt: '我看上一件商品 https://item.example.com/p/123456 帮我看看 WebAZ 上有没有同款或更便宜的替代品,比较价格和卖家信誉。',
1263
+ expectedSteps: [
1264
+ { tool: 'webaz_agent_buy', desc: '解析外链 → 提取标题 + 价格' },
1265
+ { tool: 'webaz_search', desc: '在 WebAZ 上找同款 / 相似品' },
1266
+ { tool: '推理', desc: '比对价格 + 信誉,给出"买 WebAZ"或"原平台"建议' },
1267
+ ],
1268
+ tone: '#dbeafe',
1269
+ },
1270
+ {
1271
+ icon: '⭐', tag: '挑信誉',
1272
+ title: '只看高信誉卖家',
1273
+ subtitle: '推荐 3 件咖啡豆,trusted+ 卖家 + 退款率 < 5%',
1274
+ prompt: '推荐 3 件咖啡豆,只要 trusted 或以上卖家,退款率低于 5%,最好准时发货率 > 90%。',
1275
+ expectedSteps: [
1276
+ { tool: 'webaz_search', desc: '类目筛 "咖啡豆"' },
1277
+ { tool: 'webaz_reputation', desc: '逐个查卖家 4 维信誉指标' },
1278
+ { tool: '推理', desc: '保留达标的,挑出最优 3 件' },
1279
+ ],
1280
+ tone: '#dcfce7',
1281
+ },
1282
+ {
1283
+ icon: '🚛', tag: '看物流',
1284
+ title: '本地速达需求',
1285
+ subtitle: '上海能 24h 收到的耳机,预算 300 WAZ',
1286
+ prompt: '我在上海,找耳机预算 300 WAZ 以内,希望能 24h 内收到,备货时间短的优先。',
1287
+ expectedSteps: [
1288
+ { tool: 'webaz_search', desc: '价格 ≤ 300 + 类目 "耳机"' },
1289
+ { tool: 'webaz_profile', desc: '读我的默认配送地址(上海)' },
1290
+ { tool: '过滤', desc: '保留备货 ≤ 12h + ship_to 包含上海' },
1291
+ ],
1292
+ tone: '#fae8ff',
1293
+ },
1294
+ ]
1295
+
1296
+ // 检测是否有可用 provider
1297
+ const chain = aiGetChain()
1298
+ const usable = chain.find(pid => { const p = aiGetProvider(pid); return p && p.enabled && (!p.keyRequired || aiGetKey(pid)) })
1299
+ const noProviderBanner = !usable ? `
1300
+ <div style="background:#fff7ed;border:0.5px solid #fed7aa;border-radius:12px;padding:14px 16px;margin-bottom:14px;display:flex;align-items:center;gap:10px">
1301
+ <div style="font-size:22px">🔐</div>
1302
+ <div style="flex:1">
1303
+ <div style="font-size:13px;font-weight:600;color:#9a3412">${t('还没配置 AI provider')}</div>
1304
+ <div style="font-size:11px;color:#9a3412;margin-top:2px">${t('推荐先用 智谱 GLM-4-Flash(完全免费)')}</div>
1305
+ </div>
1306
+ <button class="btn btn-primary btn-sm" style="width:auto;padding:6px 14px;font-size:12px" onclick="navigate('#ai-recommend/config')">${t('去配置')}</button>
1307
+ </div>` : ''
1308
+
1309
+ const cards = DEMOS.map((d, i) => `
1310
+ <div style="background:#fff;border:0.5px solid #e5e7eb;border-radius:12px;padding:16px;margin-bottom:10px">
1311
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">
1312
+ <div style="font-size:26px;line-height:1">${d.icon}</div>
1313
+ <div style="flex:1">
1314
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">
1315
+ <span style="font-size:10px;background:${d.tone};color:#1f2937;padding:1px 7px;border-radius:99px;font-weight:600">${t(d.tag)}</span>
1316
+ </div>
1317
+ <div style="font-size:15px;font-weight:600;color:#1f2937">${t(d.title)}</div>
1318
+ </div>
1319
+ </div>
1320
+ <div style="font-size:12px;color:#6b7280;margin-bottom:10px;line-height:1.5">${t(d.subtitle)}</div>
1321
+ <details style="background:#f9fafb;border-radius:8px;margin-bottom:10px">
1322
+ <summary style="cursor:pointer;padding:8px 12px;font-size:11px;color:#374151;list-style:none">
1323
+ ▸ ${t('Agent 大致会这样做')}(${d.expectedSteps.length} ${t('步')})
1324
+ </summary>
1325
+ <div style="padding:0 12px 10px">
1326
+ ${d.expectedSteps.map((s, j) => `
1327
+ <div style="display:flex;gap:8px;padding:4px 0;font-size:11px;color:#374151">
1328
+ <span style="color:#9ca3af;flex-shrink:0">${j+1}.</span>
1329
+ <code style="background:#eef2ff;color:#3730a3;padding:1px 6px;border-radius:4px;font-size:10px;font-family:ui-monospace,Consolas,monospace;flex-shrink:0">${escHtml(s.tool)}</code>
1330
+ <span style="color:#6b7280">${t(s.desc)}</span>
1331
+ </div>`).join('')}
1332
+ </div>
1333
+ </details>
1334
+ <div style="display:flex;gap:8px">
1335
+ <button data-prompt="${escAttr(d.prompt)}" onclick="runAIDemo(this.dataset.prompt)" style="flex:1;padding:11px;background:#007aff;color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer">${t('一键运行')} →</button>
1336
+ </div>
1337
+ <div style="margin-top:8px;font-size:10px;color:#9ca3af;padding:6px 8px;background:#f9fafb;border-radius:6px;line-height:1.5">
1338
+ 💬 ${t('提示原文')}:${escHtml(d.prompt.slice(0, 80))}${d.prompt.length > 80 ? '…' : ''}
1339
+ </div>
1340
+ </div>
1341
+ `).join('')
1342
+
1343
+ // 对外 MCP 接入卡片 — Claude Desktop / Code 用户也能跑同样的 demo
1344
+ const mcpConfigJson = `{
1345
+ "mcpServers": {
1346
+ "webaz": {
1347
+ "command": "webaz",
1348
+ "env": {
1349
+ "WEBAZ_API_URL": "https://webaz.xyz"
1350
+ }
1351
+ }
1352
+ }
1353
+ }`
1354
+ const mcpBlock = `
1355
+ <div style="margin-top:24px;background:#fff;border:0.5px solid #e5e7eb;border-radius:12px;padding:16px">
1356
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
1357
+ <div style="font-size:22px">🔌</div>
1358
+ <div style="font-size:15px;font-weight:600;color:#1f2937">${t('用 Claude Desktop / Code 跑同样的 demo')}</div>
1359
+ </div>
1360
+ <div style="font-size:12px;color:#8e8e93;line-height:1.5;margin-bottom:12px">
1361
+ ${t('WebAZ 把所有工具同时暴露成 MCP 协议。外部 LLM client(Claude Desktop / Claude Code / 任何兼容 MCP 的 host)可以直接调用 — 上面 4 个 demo 提示词原样粘进 Claude 即可。')}
1362
+ </div>
1363
+ <div style="font-size:11px;font-weight:600;color:#374151;margin:10px 0 4px">${t('1. 安装 CLI')}</div>
1364
+ ${copyableCodeBlock('npm install -g @seasonkoh/webaz')}
1365
+ <div style="font-size:11px;font-weight:600;color:#374151;margin:10px 0 4px">${t('2. 在 claude_desktop_config.json 加入')}</div>
1366
+ ${copyableCodeBlock(mcpConfigJson)}
1367
+ <div style="font-size:11px;color:#8e8e93;margin-top:8px;line-height:1.5">${t('Claude Desktop config 路径:')}<br>· macOS: <code style="font-size:10px;background:#f3f4f6;padding:1px 5px;border-radius:4px">~/Library/Application Support/Claude/claude_desktop_config.json</code><br>· Windows: <code style="font-size:10px;background:#f3f4f6;padding:1px 5px;border-radius:4px">%APPDATA%\\Claude\\claude_desktop_config.json</code></div>
1368
+ <div style="font-size:11px;font-weight:600;color:#374151;margin:14px 0 4px">${t('3. 重启 Claude,把上面任一 demo 提示粘进去')}</div>
1369
+ <div style="font-size:11px;color:#8e8e93;line-height:1.5">${t('Claude 会自动调用 webaz_search / webaz_verify_price / webaz_place_order 等 30+ 个 tool。需要 api_key 时先用 webaz_register 注册或登录现有账号。')}</div>
1370
+ <div style="margin-top:14px;padding:10px 12px;background:#f0fdf4;border:0.5px solid #bbf7d0;border-radius:8px;font-size:11px;color:#15803d;line-height:1.5">
1371
+ 💡 ${t('内部 vs 外部')}:${t('内部走浏览器接 LLM key 适合体验;外部 MCP 适合长跑 / 复杂任务 / 命令行自动化。两条路径后端、信誉、escrow 完全共享。')}
1372
+ </div>
1373
+ </div>`
1374
+
1375
+ app.innerHTML = shell(`
1376
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
1377
+ <button class="btn btn-gray btn-sm" style="width:auto" onclick="history.back()">${t('← 返回')}</button>
1378
+ <h1 class="page-title" style="margin:0">🤖 ${t('Agent 演示')}</h1>
1379
+ </div>
1380
+ <div style="font-size:12px;color:#8e8e93;margin-bottom:14px;line-height:1.5">
1381
+ ${t('WebAZ 的 agent 不只会聊天,它会调用真实工具完成任务。选一个场景体验:')}
1382
+ </div>
1383
+ ${noProviderBanner}
1384
+ ${cards}
1385
+ ${mcpBlock}
1386
+ <div style="text-align:center;font-size:11px;color:#9ca3af;margin-top:14px;line-height:1.6">
1387
+ ${t('用完后可在')} <a href="#ai-recommend" style="color:#007aff">${t('AI 推荐')}</a> ${t('继续追问或开新任务')}
1388
+ </div>
1389
+ `, 'ai-recommend')
1390
+ }
1391
+
1392
+ // 可复制代码块(一键复制按钮)
1393
+ function copyableCodeBlock(code) {
1394
+ const id = 'codeblk-' + Math.random().toString(36).slice(2, 9)
1395
+ return `<div style="position:relative;background:#1f2937;border-radius:8px;padding:10px 12px;margin-bottom:4px">
1396
+ <button onclick="copyCodeBlock('${id}', this)" style="position:absolute;top:6px;right:6px;background:rgba(255,255,255,0.1);border:0.5px solid rgba(255,255,255,0.2);color:#e5e7eb;font-size:10px;padding:3px 8px;border-radius:6px;cursor:pointer">${t('复制')}</button>
1397
+ <pre id="${id}" style="margin:0;font-family:ui-monospace,Consolas,monospace;font-size:11px;color:#e5e7eb;white-space:pre-wrap;word-break:break-all;padding-right:50px;line-height:1.6">${escHtml(code)}</pre>
1398
+ </div>`
1399
+ }
1400
+ window.copyCodeBlock = (id, btn) => {
1401
+ const el = document.getElementById(id)
1402
+ if (!el) return
1403
+ try {
1404
+ navigator.clipboard.writeText(el.textContent || '')
1405
+ if (btn) {
1406
+ const orig = btn.textContent
1407
+ btn.textContent = t('已复制')
1408
+ btn.style.background = 'rgba(34,197,94,0.3)'
1409
+ setTimeout(() => { btn.textContent = orig; btn.style.background = 'rgba(255,255,255,0.1)' }, 1500)
1410
+ }
1411
+ } catch { alert(el.textContent) }
1412
+ }
1413
+
1414
+ window.runAIDemo = (prompt) => {
1415
+ // seed 到全局 state,让 renderAIRecommend 在 intent 阶段自动 prefill
1416
+ state._aiDemoSeed = String(prompt || '')
1417
+ navigate('#ai-recommend')
1418
+ }
1419
+
1420
+ async function renderAIRecommend(app) {
1421
+ if (!state.user) return navigate('#login')
1422
+ await ensureProfileMini()
1423
+ ensureAICursorStyle()
1424
+
1425
+ // 检查是否有任何可用 provider;没有 → 引导去配置
1426
+ const chain = aiGetChain()
1427
+ const usable = chain.find(pid => {
1428
+ const p = aiGetProvider(pid)
1429
+ return p && p.enabled && (!p.keyRequired || aiGetKey(pid))
1430
+ })
1431
+ if (!usable) {
1432
+ app.innerHTML = shell(`
1433
+ <h1 class="page-title">🤖 ${t('AI 推荐')}</h1>
1434
+ <div class="card" style="background:linear-gradient(135deg,#f5f3ff,#fdf4ff);border-color:#ddd6fe;text-align:center;padding:30px 16px">
1435
+ <div style="font-size:48px">🔐</div>
1436
+ <div style="font-size:16px;font-weight:600;margin:14px 0 6px">${t('还没有可用的 AI provider')}</div>
1437
+ <p style="font-size:12px;color:#374151;line-height:1.6;margin-bottom:16px">${t('需要先配置一个 LLM API。推荐先用 智谱 GLM-4-Flash(完全免费)')}</p>
1438
+ <button class="btn btn-primary" style="width:auto;padding:10px 24px" onclick="navigate('#ai-recommend/config')">${t('去配置 →')}</button>
1439
+ </div>
1440
+ <div style="text-align:center;font-size:11px;color:#9ca3af;margin-top:14px">${t('WebAZ Native 模型即将上线,届时无需配置 key 即可使用')}</div>
1441
+ `, 'ai-recommend')
1442
+ return
1443
+ }
1444
+
1445
+ const conversations = await aiListConversations().catch(() => [])
1446
+ if (!state.aiCurrentConv) state.aiCurrentConv = conversations[0] || aiCreateConversation()
1447
+ const conv = state.aiCurrentConv
1448
+ const task = aiInitTask(conv)
1449
+ state.aiAttachments = state.aiAttachments || [] // [{name, type, dataURL, size}]
1450
+ const hint = TASK_STATE_HINTS[task.state] || TASK_STATE_HINTS.intent
1451
+
1452
+ // 浏览器能力探测
1453
+ const speechSupported = !!(window.SpeechRecognition || window.webkitSpeechRecognition)
1454
+
1455
+ app.innerHTML = shell(`
1456
+ ${renderAIStatusBar()}
1457
+
1458
+ <!-- 任务进度卡:状态文案 + stepper -->
1459
+ <div style="background:linear-gradient(135deg,#f5f3ff,#fff);border:1px solid #ddd6fe;border-radius:12px;padding:12px 14px;margin-bottom:10px">
1460
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
1461
+ <div style="font-size:14px;font-weight:700;color:#3730a3">${t(hint.title)}</div>
1462
+ <span style="font-size:10px;color:#6366f1;background:#eef2ff;padding:2px 8px;border-radius:99px;font-weight:600">${t(TASK_STATES[task.state]?.label || task.state)}</span>
1463
+ </div>
1464
+ <div style="font-size:11px;color:#6b7280;margin-bottom:8px;line-height:1.5">${t(hint.subtitle)}</div>
1465
+ ${renderTaskStepper(task)}
1466
+ </div>
1467
+
1468
+ <div style="display:flex;flex-direction:column;height:calc(100vh - 360px);min-height:380px">
1469
+ <!-- 任务/对话切换条 -->
1470
+ <div style="display:flex;gap:6px;margin-bottom:8px;overflow-x:auto;padding-bottom:2px;scrollbar-width:none">
1471
+ <button onclick="aiNewConv()" style="background:#4f46e5;color:#fff;border:none;border-radius:99px;padding:5px 12px;font-size:11px;cursor:pointer;white-space:nowrap;flex-shrink:0">+ ${t('新任务')}</button>
1472
+ ${conversations.slice(0, 8).map(c => {
1473
+ const st = (c.task?.state || 'intent')
1474
+ const stColor = TASK_STATES[st]?.color || '#6b7280'
1475
+ const isCur = c.id === conv.id
1476
+ return `<button style="background:${isCur ? '#eef2ff' : '#fff'};color:${isCur ? '#4f46e5' : '#374151'};border:1px solid ${isCur ? '#4f46e5' : '#e5e7eb'};border-radius:99px;padding:4px 10px;font-size:11px;cursor:pointer;white-space:nowrap;flex-shrink:0;display:inline-flex;align-items:center;gap:4px" onclick="aiLoadConv('${c.id}')"><span style="color:${stColor}">●</span>${escHtml((c.title || t('新任务')).slice(0, 14))}</button>`
1477
+ }).join('')}
1478
+ </div>
1479
+
1480
+ <!-- 消息流 -->
1481
+ <div id="ai-messages" style="flex:1;overflow-y:auto;background:#fff;border:1px solid #e5e7eb;border-radius:10px;padding:14px;font-size:13px;line-height:1.6">
1482
+ ${renderAIMessages(conv.messages || [])}
1483
+ </div>
1484
+
1485
+ ${renderTaskActions(task)}
1486
+
1487
+ ${task.state === 'intent' && (!conv.messages || conv.messages.length === 0) ? `
1488
+ <!-- 快速模板(intent 空对话才显示)-->
1489
+ <div style="margin-top:8px;display:flex;gap:6px;overflow-x:auto;padding-bottom:2px;scrollbar-width:none">
1490
+ ${AI_QUICK_TEMPLATES.map(t2 => `
1491
+ <button onclick="aiFillTemplate('${t2.prompt.replace(/'/g, "\\'")}')"
1492
+ style="background:#fff;border:1px dashed #c7d2fe;border-radius:8px;padding:6px 10px;font-size:11px;color:#4338ca;cursor:pointer;white-space:nowrap;flex-shrink:0;font-weight:500">
1493
+ ${t2.icon} ${t(t2.label)}
1494
+ </button>`).join('')}
1495
+ </div>` : ''}
1496
+
1497
+ <!-- 附件预览区(仅有附件时显示)-->
1498
+ <div id="ai-attach-preview" style="margin-top:8px"></div>
1499
+
1500
+ <!-- 输入卡(textarea + 工具栏)-->
1501
+ <div style="margin-top:8px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:8px;box-shadow:0 1px 2px rgba(0,0,0,0.03)">
1502
+ <textarea id="ai-input" placeholder="${aiInputPlaceholder(task)}"
1503
+ style="width:100%;min-height:54px;border:none;outline:none;resize:none;font-size:13px;font-family:inherit;background:transparent;padding:6px 4px"
1504
+ oninput="aiAutoResizeInput(this)"
1505
+ onkeydown="if(event.key==='Enter'&&(event.metaKey||event.ctrlKey))aiSendMessage()"></textarea>
1506
+
1507
+ <div style="display:flex;align-items:center;gap:4px;margin-top:4px;padding-top:6px;border-top:1px solid #f3f4f6">
1508
+ <!-- 图片附件 -->
1509
+ <input type="file" id="ai-file-img" accept="image/*" style="display:none" onchange="aiAttachFile(event,'image')">
1510
+ <button title="${t('附加图片')}" onclick="document.getElementById('ai-file-img').click()"
1511
+ style="background:none;border:none;cursor:pointer;padding:6px 8px;color:#6b7280;border-radius:6px;display:inline-flex;align-items:center"
1512
+ onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='none'">${SVG_CAMERA}</button>
1513
+
1514
+ <!-- 视频/文件附件 -->
1515
+ <input type="file" id="ai-file-video" accept="video/*,application/pdf" style="display:none" onchange="aiAttachFile(event,'video')">
1516
+ <button title="${t('附加视频 / 文件')}" onclick="document.getElementById('ai-file-video').click()"
1517
+ style="background:none;border:none;cursor:pointer;padding:6px 8px;font-size:18px;color:#6b7280;border-radius:6px"
1518
+ onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='none'">📎</button>
1519
+
1520
+ <!-- 语音输入 -->
1521
+ ${speechSupported ? `
1522
+ <button id="ai-voice-btn" title="${t('按住说话')}" onclick="aiToggleVoice()"
1523
+ style="background:none;border:none;cursor:pointer;padding:6px 8px;font-size:18px;color:#6b7280;border-radius:6px"
1524
+ onmouseover="this.style.background='#f3f4f6'" onmouseout="this.style.background='none'">🎤</button>
1525
+ ` : `
1526
+ <button title="${t('当前浏览器不支持语音')}" disabled
1527
+ style="background:none;border:none;padding:6px 8px;font-size:18px;color:#d1d5db;border-radius:6px;cursor:not-allowed">🎤</button>
1528
+ `}
1529
+
1530
+ <div style="flex:1"></div>
1531
+ <span style="font-size:10px;color:#9ca3af;margin-right:4px">${t('Cmd/Ctrl + ↵')}</span>
1532
+ <button class="btn btn-primary" id="ai-send-btn" onclick="aiSendMessage()"
1533
+ style="width:auto;padding:7px 18px;font-size:13px;font-weight:600;border-radius:8px">${t('发送')} →</button>
1534
+ </div>
1535
+ </div>
1536
+ </div>
1537
+ `, 'ai-recommend')
1538
+
1539
+ // 渲染已有附件
1540
+ aiRenderAttachPreview()
1541
+ setTimeout(() => {
1542
+ const m = document.getElementById('ai-messages')
1543
+ if (m) m.scrollTop = m.scrollHeight
1544
+ // Demo gallery → 跳进来时 prefill 提示词
1545
+ if (state._aiDemoSeed && task.state === 'intent') {
1546
+ const inp = document.getElementById('ai-input')
1547
+ if (inp) { inp.value = state._aiDemoSeed; aiAutoResizeInput(inp); inp.focus() }
1548
+ state._aiDemoSeed = null
1549
+ }
1550
+ }, 100)
1551
+ }
1552
+
1553
+ // 附件管理
1554
+ window.aiAttachFile = (e, kind) => {
1555
+ const file = e.target.files?.[0]
1556
+ e.target.value = '' // reset for re-select
1557
+ if (!file) return
1558
+ const MAX = 8 * 1024 * 1024 // 8MB cap
1559
+ if (file.size > MAX) { alert(t('文件过大(≤ 8MB)')); return }
1560
+ if (state.aiAttachments.length >= 4) { alert(t('每次最多附 4 个文件')); return }
1561
+ const reader = new FileReader()
1562
+ reader.onload = () => {
1563
+ state.aiAttachments.push({ name: file.name, type: file.type, kind, dataURL: reader.result, size: file.size })
1564
+ aiRenderAttachPreview()
1565
+ }
1566
+ reader.readAsDataURL(file)
1567
+ }
1568
+
1569
+ window.aiRemoveAttach = (idx) => {
1570
+ state.aiAttachments.splice(idx, 1)
1571
+ aiRenderAttachPreview()
1572
+ }
1573
+
1574
+ function aiRenderAttachPreview() {
1575
+ const el = document.getElementById('ai-attach-preview')
1576
+ if (!el) return
1577
+ if (!state.aiAttachments?.length) { el.innerHTML = ''; return }
1578
+ el.innerHTML = `<div style="display:flex;gap:6px;flex-wrap:wrap;padding:6px;background:#f9fafb;border:1px dashed #e5e7eb;border-radius:8px">
1579
+ ${state.aiAttachments.map((a, i) => {
1580
+ const preview = a.kind === 'image'
1581
+ ? `<img src="${a.dataURL}" style="width:42px;height:42px;object-fit:cover;border-radius:4px">`
1582
+ : `<div style="width:42px;height:42px;background:#e0e7ff;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:18px">📎</div>`
1583
+ return `<div style="display:flex;align-items:center;gap:6px;background:#fff;border:1px solid #e5e7eb;border-radius:6px;padding:4px 6px 4px 4px">
1584
+ ${preview}
1585
+ <div style="font-size:10px;color:#374151;max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(a.name)}</div>
1586
+ <button onclick="aiRemoveAttach(${i})" style="background:none;border:none;color:#9ca3af;cursor:pointer;font-size:14px;padding:0 2px">×</button>
1587
+ </div>`
1588
+ }).join('')}
1589
+ </div>`
1590
+ }
1591
+
1592
+ // 语音输入(Web Speech API;浏览器原生,无需后端)
1593
+ let _aiSpeech = null
1594
+ window.aiToggleVoice = () => {
1595
+ const btn = document.getElementById('ai-voice-btn')
1596
+ const inp = document.getElementById('ai-input')
1597
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition
1598
+ if (!SR) { alert(t('当前浏览器不支持语音输入')); return }
1599
+ if (_aiSpeech) {
1600
+ _aiSpeech.stop()
1601
+ return
1602
+ }
1603
+ _aiSpeech = new SR()
1604
+ _aiSpeech.lang = window._lang === 'en' ? 'en-US' : 'zh-CN'
1605
+ _aiSpeech.interimResults = true
1606
+ _aiSpeech.continuous = true
1607
+ _aiSpeech.onstart = () => {
1608
+ if (btn) { btn.style.background = '#fee2e2'; btn.style.color = '#dc2626'; btn.title = t('点击停止') }
1609
+ }
1610
+ _aiSpeech.onresult = (e) => {
1611
+ let finalText = ''
1612
+ let interim = ''
1613
+ for (let i = e.resultIndex; i < e.results.length; i++) {
1614
+ const r = e.results[i]
1615
+ if (r.isFinal) finalText += r[0].transcript
1616
+ else interim += r[0].transcript
1617
+ }
1618
+ if (inp) {
1619
+ // 把已有 final 累加进 input,interim 暂存在 dataset 提示用
1620
+ if (finalText) inp.value = (inp.value || '') + finalText
1621
+ inp.dataset.interim = interim
1622
+ aiAutoResizeInput(inp)
1623
+ }
1624
+ }
1625
+ _aiSpeech.onerror = (e) => {
1626
+ console.warn('speech err', e.error)
1627
+ }
1628
+ _aiSpeech.onend = () => {
1629
+ _aiSpeech = null
1630
+ if (btn) { btn.style.background = ''; btn.style.color = '#6b7280'; btn.title = t('按住说话') }
1631
+ if (inp) delete inp.dataset.interim
1632
+ }
1633
+ _aiSpeech.start()
1634
+ }
1635
+
1636
+ // 自动撑高 textarea
1637
+ window.aiAutoResizeInput = (el) => {
1638
+ el.style.height = 'auto'
1639
+ el.style.height = Math.min(180, el.scrollHeight) + 'px'
1640
+ }
1641
+
1642
+ // 快捷模板填入
1643
+ window.aiFillTemplate = (prompt) => {
1644
+ const inp = document.getElementById('ai-input')
1645
+ if (!inp) return
1646
+ inp.value = prompt
1647
+ inp.focus()
1648
+ aiAutoResizeInput(inp)
1649
+ // 把光标定到第一个 [...] 占位符
1650
+ const m = prompt.match(/\[[^\]]+\]/)
1651
+ if (m) {
1652
+ const start = prompt.indexOf(m[0])
1653
+ inp.setSelectionRange(start, start + m[0].length)
1654
+ }
1655
+ }
1656
+
1657
+ // 独立配置页(路由 #ai-recommend/config)
1658
+ async function renderAIConfig(app) {
1659
+ if (!state.user) return navigate('#login')
1660
+ const chain = aiGetChain()
1661
+ const webazProvider = aiGetProvider('webaz')
1662
+ const freeProviders = AI_PROVIDERS.filter(p => p.id !== 'webaz' && p.free)
1663
+ const paidProviders = AI_PROVIDERS.filter(p => p.id !== 'webaz' && !p.free)
1664
+
1665
+ const renderChainCard = () => {
1666
+ if (chain.length === 0) {
1667
+ return `<div class="card" style="background:#fef3c7;border-color:#fde68a;padding:12px;margin-bottom:14px">
1668
+ <div style="font-size:12px;color:#92400e">${t('还没配置任何 provider — 从下方选一个开始 👇')}</div>
1669
+ </div>`
1670
+ }
1671
+ return `<div class="card" style="margin-bottom:14px;padding:12px">
1672
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
1673
+ <div style="font-size:12px;font-weight:600;color:#374151">📊 ${t('当前调用链')}</div>
1674
+ <span style="font-size:10px;color:#9ca3af">${t('主用失败自动切换到下一个')}</span>
1675
+ </div>
1676
+ ${chain.map((pid, i) => {
1677
+ const p = aiGetProvider(pid)
1678
+ if (!p) return ''
1679
+ const ok = !p.keyRequired || aiGetKey(pid)
1680
+ const labelTag = i === 0 ? `<span style="background:#4f46e5;color:#fff;font-size:9px;padding:1px 6px;border-radius:99px;font-weight:600">${t('主用')}</span>` : `<span style="background:#e5e7eb;color:#6b7280;font-size:9px;padding:1px 6px;border-radius:99px">${t('备选')} ${i}</span>`
1681
+ return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:${i < chain.length - 1 ? '1px solid #f3f4f6' : 'none'}">
1682
+ <span style="font-size:11px;color:#9ca3af;width:14px">${i + 1}</span>
1683
+ ${labelTag}
1684
+ <span style="flex:1;font-size:12px;color:#111827;font-weight:500">${escHtml(p.name)}</span>
1685
+ ${p.enabled ? (ok ? '<span style="color:#059669;font-size:10px">✓</span>' : '<span style="color:#dc2626;font-size:10px">✗</span>') : '<span style="color:#fbbf24;font-size:10px">⏳</span>'}
1686
+ <button onclick="aiMoveInChain('${pid}','up');renderAIConfig(document.getElementById('app'))" ${i === 0 ? 'disabled' : ''} style="background:none;border:none;cursor:${i === 0 ? 'not-allowed' : 'pointer'};color:${i === 0 ? '#d1d5db' : '#6b7280'};padding:0 4px;font-size:14px">▲</button>
1687
+ <button onclick="aiMoveInChain('${pid}','down');renderAIConfig(document.getElementById('app'))" ${i === chain.length - 1 ? 'disabled' : ''} style="background:none;border:none;cursor:${i === chain.length - 1 ? 'not-allowed' : 'pointer'};color:${i === chain.length - 1 ? '#d1d5db' : '#6b7280'};padding:0 4px;font-size:14px">▼</button>
1688
+ <button onclick="aiRemoveFromChain('${pid}');renderAIConfig(document.getElementById('app'))" style="background:none;border:none;cursor:pointer;color:#dc2626;font-size:11px;padding:0 4px">×</button>
1689
+ </div>`
1690
+ }).join('')}
1691
+ </div>`
1692
+ }
1693
+
1694
+ app.innerHTML = shell(`
1695
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
1696
+ <button class="btn btn-outline btn-sm" style="width:auto;padding:4px 12px;font-size:11px" onclick="navigate('#ai-recommend')">← ${t('返回对话')}</button>
1697
+ <h1 style="font-size:18px;margin:0">⚙️ ${t('AI 配置')}</h1>
1698
+ </div>
1699
+
1700
+ <div class="card" style="background:linear-gradient(135deg,#f5f3ff,#fdf4ff);border-color:#ddd6fe;margin-bottom:14px;padding:14px">
1701
+ <div style="font-size:13px;font-weight:600;margin-bottom:4px">🔐 ${t('选择 AI Provider')}</div>
1702
+ <div style="font-size:11px;color:#6b7280;line-height:1.5">${t('Key 存在你浏览器本地,永不发给 WebAZ 服务器。Agent 直接调 LLM + WebAZ 工具集,所有上下文你独享。')}</div>
1703
+ </div>
1704
+
1705
+ ${renderChainCard()}
1706
+
1707
+ <!-- WebAZ Native: 独立第一行 -->
1708
+ <div style="margin-bottom:14px">${renderAIProviderCard(webazProvider, { active: false, fullWidth: true })}</div>
1709
+
1710
+ <!-- 🆓 免费分组 -->
1711
+ <details style="margin-bottom:10px;background:#fff;border:1px solid #e5e7eb;border-radius:10px" ${chain.filter(p => p !== 'webaz').length === 0 ? 'open' : ''}>
1712
+ <summary style="padding:12px;font-size:13px;font-weight:600;cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;color:#374151">
1713
+ <span>🆓 ${t('免费 / 免费层')} <span style="font-weight:400;color:#9ca3af;font-size:11px">(${freeProviders.length} ${t('个')})</span></span>
1714
+ <span style="font-size:11px;color:#9ca3af">▾</span>
1715
+ </summary>
1716
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:0 12px 12px">
1717
+ ${freeProviders.map(p => renderAIProviderCard(p, { active: chain.includes(p.id) })).join('')}
1718
+ </div>
1719
+ </details>
1720
+
1721
+ <!-- 💰 付费分组 -->
1722
+ <details style="margin-bottom:14px;background:#fff;border:1px solid #e5e7eb;border-radius:10px">
1723
+ <summary style="padding:12px;font-size:13px;font-weight:600;cursor:pointer;list-style:none;display:flex;align-items:center;justify-content:space-between;color:#374151">
1724
+ <span>💰 ${t('付费')} <span style="font-weight:400;color:#9ca3af;font-size:11px">(${paidProviders.length} ${t('个')})</span></span>
1725
+ <span style="font-size:11px;color:#9ca3af">▾</span>
1726
+ </summary>
1727
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:0 12px 12px">
1728
+ ${paidProviders.map(p => renderAIProviderCard(p, { active: chain.includes(p.id) })).join('')}
1729
+ </div>
1730
+ </details>
1731
+
1732
+ <div style="font-size:11px;color:#9ca3af;text-align:center">
1733
+ ${t('💡 推荐组合:智谱 GLM-4-Flash (主用,完全免费) + Groq (备选,海外极速) + DeepSeek (备选,长稳)')}
1734
+ </div>
1735
+ `, 'ai-recommend')
1736
+ }
1737
+
1738
+ // 任务列表页(路由 #ai-recommend/tasks)
1739
+ async function renderAITaskList(app) {
1740
+ if (!state.user) return navigate('#login')
1741
+ const all = await aiListConversations().catch(() => [])
1742
+ const groups = { active: [], completed: [], cancelled: [] }
1743
+ for (const c of all) {
1744
+ const st = c.task?.state || 'intent'
1745
+ if (st === 'completed') groups.completed.push(c)
1746
+ else if (st === 'cancelled') groups.cancelled.push(c)
1747
+ else groups.active.push(c)
1748
+ }
1749
+ const renderRow = (c) => {
1750
+ const st = c.task?.state || 'intent'
1751
+ const stInfo = TASK_STATES[st]
1752
+ return `<div onclick="aiLoadConv('${c.id}');navigate('#ai-recommend')" style="display:flex;align-items:center;gap:10px;padding:10px 12px;background:#fff;border:1px solid #e5e7eb;border-radius:8px;margin-bottom:6px;cursor:pointer">
1753
+ <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${stInfo.color};flex-shrink:0"></span>
1754
+ <div style="flex:1;min-width:0">
1755
+ <div style="font-size:13px;font-weight:500;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(c.title || t('未命名任务'))}</div>
1756
+ <div style="font-size:10px;color:#9ca3af;margin-top:2px">${t(stInfo.label)} · ${fmtTime(c.updated_at || c.created_at)}${c.task?.rating ? ' · ' + '⭐'.repeat(c.task.rating) : ''}</div>
1757
+ </div>
1758
+ <button onclick="event.stopPropagation();aiDeleteTask('${c.id}')" style="background:none;border:none;color:#dc2626;font-size:14px;cursor:pointer;padding:4px 8px" title="${t('删除')}">×</button>
1759
+ </div>`
1760
+ }
1761
+ app.innerHTML = shell(`
1762
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
1763
+ <button class="btn btn-outline btn-sm" style="width:auto;padding:4px 12px;font-size:11px" onclick="navigate('#ai-recommend')">← ${t('返回对话')}</button>
1764
+ <h1 style="font-size:18px;margin:0">📋 ${t('任务管理')}</h1>
1765
+ </div>
1766
+ ${all.length === 0 ? `
1767
+ <div class="card" style="text-align:center;padding:30px 16px">
1768
+ <div style="font-size:48px;margin-bottom:10px">📭</div>
1769
+ <div style="font-size:13px;color:#9ca3af">${t('还没有任务')}</div>
1770
+ <button class="btn btn-primary" style="width:auto;padding:8px 24px;margin-top:14px" onclick="aiNewConv();navigate('#ai-recommend')">${t('开始第一个任务')}</button>
1771
+ </div>
1772
+ ` : `
1773
+ ${groups.active.length > 0 ? `
1774
+ <div style="margin-bottom:14px">
1775
+ <div style="font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">🔄 ${t('进行中')} (${groups.active.length})</div>
1776
+ ${groups.active.map(renderRow).join('')}
1777
+ </div>
1778
+ ` : ''}
1779
+ ${groups.completed.length > 0 ? `
1780
+ <div style="margin-bottom:14px">
1781
+ <div style="font-size:11px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">✅ ${t('已完成')} (${groups.completed.length})</div>
1782
+ ${groups.completed.map(renderRow).join('')}
1783
+ </div>
1784
+ ` : ''}
1785
+ ${groups.cancelled.length > 0 ? `
1786
+ <details style="margin-bottom:14px">
1787
+ <summary style="font-size:11px;color:#9ca3af;cursor:pointer;margin-bottom:6px">⊘ ${t('已取消')} (${groups.cancelled.length})</summary>
1788
+ ${groups.cancelled.map(renderRow).join('')}
1789
+ </details>
1790
+ ` : ''}
1791
+ `}
1792
+ `, 'ai-recommend')
1793
+ }
1794
+
1795
+ window.aiDeleteTask = async (id) => {
1796
+ if (!confirm(t('删除该任务?此操作不可恢复'))) return
1797
+ await aiDeleteConversation(id)
1798
+ if (state.aiCurrentConv?.id === id) state.aiCurrentConv = null
1799
+ toast$(t('已删除'))
1800
+ renderAITaskList(document.getElementById('app'))
1801
+ }
1802
+
1803
+ // 任务工作流操作
1804
+ window.aiApprovePlan = async () => {
1805
+ const conv = state.aiCurrentConv
1806
+ if (!conv?.task) return
1807
+ conv.task.state = 'executing'
1808
+ conv.task.decision_history = conv.task.decision_history || []
1809
+ conv.task.decision_history.push({ at: new Date().toISOString(), action: 'approve' })
1810
+ await aiSaveConversation(conv)
1811
+ // 自动给 AI 发执行指令
1812
+ renderAIRecommend(document.getElementById('app'))
1813
+ setTimeout(() => _aiSendRaw('(用户批准方案,请按上述方案执行)'), 100)
1814
+ }
1815
+
1816
+ window.aiRequestModify = () => {
1817
+ const conv = state.aiCurrentConv
1818
+ if (!conv?.task) return
1819
+ // 保留 review 状态,让用户在 input 输入修改意见后点发送,state 会回 planning(下一轮 chatTurn 处理)
1820
+ conv.task.state = 'planning' // 重新进入规划
1821
+ conv.task.decision_history = conv.task.decision_history || []
1822
+ conv.task.decision_history.push({ at: new Date().toISOString(), action: 'modify' })
1823
+ toast$(t('请在输入栏说明你想怎么改'), 'info')
1824
+ renderAIRecommend(document.getElementById('app'))
1825
+ setTimeout(() => document.getElementById('ai-input')?.focus(), 100)
1826
+ }
1827
+
1828
+ window.aiCancelTask = async () => {
1829
+ const conv = state.aiCurrentConv
1830
+ if (!conv?.task) return
1831
+ if (!confirm(t('确认取消任务?'))) return
1832
+ conv.task.state = 'cancelled'
1833
+ conv.task.decision_history = conv.task.decision_history || []
1834
+ conv.task.decision_history.push({ at: new Date().toISOString(), action: 'cancel' })
1835
+ await aiSaveConversation(conv)
1836
+ toast$(t('任务已取消'))
1837
+ renderAIRecommend(document.getElementById('app'))
1838
+ }
1839
+
1840
+ window.aiRequestRedo = async () => {
1841
+ const conv = state.aiCurrentConv
1842
+ if (!conv?.task) return
1843
+ conv.task.state = 'executing'
1844
+ conv.task.results = null
1845
+ conv.task.decision_history = conv.task.decision_history || []
1846
+ conv.task.decision_history.push({ at: new Date().toISOString(), action: 'redo' })
1847
+ await aiSaveConversation(conv)
1848
+ renderAIRecommend(document.getElementById('app'))
1849
+ setTimeout(() => _aiSendRaw('(用户要求重做,请再次执行)'), 100)
1850
+ }
1851
+
1852
+ window.aiOpenRateModal = () => {
1853
+ _openModal(`
1854
+ <h2 style="font-size:16px;font-weight:600;margin-bottom:10px">⭐ ${t('完成并评价')}</h2>
1855
+ <div style="font-size:12px;color:#6b7280;margin-bottom:12px">${t('给本次任务的满意度评分(1-5 星)')}</div>
1856
+ <div style="display:flex;gap:8px;justify-content:center;margin-bottom:14px" id="rate-stars">
1857
+ ${[1,2,3,4,5].map(n => `<button data-n="${n}" onclick="aiSetRateStars(${n})" style="background:none;border:none;font-size:32px;cursor:pointer;color:#d1d5db;padding:0;transition:transform 0.1s">☆</button>`).join('')}
1858
+ </div>
1859
+ <div class="form-group">
1860
+ <label class="form-label" style="font-size:12px">${t('反馈(可选)')}</label>
1861
+ <textarea id="rate-feedback" class="form-control" placeholder="${t('哪里好 / 哪里可以改进…')}" style="font-size:13px;min-height:50px;resize:vertical;font-family:inherit"></textarea>
1862
+ </div>
1863
+ <div id="rate-msg" style="margin-bottom:8px"></div>
1864
+ <div style="display:flex;gap:8px">
1865
+ <button class="btn btn-outline" style="flex:1" onclick="closeModal()">${t('取消')}</button>
1866
+ <button class="btn btn-primary" style="flex:1" onclick="aiSubmitRate()">${t('提交评价')}</button>
1867
+ </div>
1868
+ `)
1869
+ }
1870
+ window._aiRating = 0
1871
+ window.aiSetRateStars = (n) => {
1872
+ window._aiRating = n
1873
+ document.querySelectorAll('#rate-stars button').forEach(btn => {
1874
+ const k = Number(btn.dataset.n)
1875
+ btn.textContent = k <= n ? '★' : '☆'
1876
+ btn.style.color = k <= n ? '#f59e0b' : '#d1d5db'
1877
+ })
1878
+ }
1879
+ window.aiSubmitRate = async () => {
1880
+ const rating = window._aiRating
1881
+ if (!rating) { const m = document.getElementById('rate-msg'); if (m) m.innerHTML = alert$('error', t('请选择 1-5 星')); return }
1882
+ const feedback = document.getElementById('rate-feedback')?.value?.trim() || ''
1883
+ const conv = state.aiCurrentConv
1884
+ if (!conv?.task) return
1885
+ conv.task.state = 'completed'
1886
+ conv.task.rating = rating
1887
+ conv.task.feedback = feedback
1888
+ await aiSaveConversation(conv)
1889
+ window._aiRating = 0
1890
+ closeModal()
1891
+ toast$(t('已记录评价') + ' ' + '⭐'.repeat(rating))
1892
+ renderAIRecommend(document.getElementById('app'))
1893
+ }
1894
+
1895
+ // 内部:用既有 aiSendMessage 流程但直接传入文本(用于工作流自动消息)
1896
+ async function _aiSendRaw(text) {
1897
+ const inp = document.getElementById('ai-input')
1898
+ if (inp) inp.value = text
1899
+ return aiSendMessage()
1900
+ }
1901
+
1902
+ // 旧的 aiForceConfig 兼容(路由跳转)
1903
+ window.aiForceConfigPage = () => navigate('#ai-recommend/config')
1904
+ window.aiExitConfig = () => navigate('#ai-recommend')
1905
+ window.aiSetModel = (mid) => {
1906
+ const { provider } = aiGetActive()
1907
+ aiSetActive(provider.id, mid)
1908
+ toast$(t('已切换模型'))
1909
+ }
1910
+
1911
+ window.aiOpenProviderConfig = (pid) => {
1912
+ const p = aiGetProvider(pid)
1913
+ if (!p || !p.enabled) return toast$(t('该 provider 暂未上线'), 'error')
1914
+ const existingKey = aiGetKey(pid) || ''
1915
+ const existingEndpoint = aiGetEndpoint(pid) || ''
1916
+ const isCustom = !!p.isCustom
1917
+ const cName = localStorage.getItem('webaz_ai_custom_name') || ''
1918
+ const cModel = localStorage.getItem('webaz_ai_custom_model') || ''
1919
+ const cLabel = localStorage.getItem('webaz_ai_custom_label') || ''
1920
+ const cFormat = localStorage.getItem('webaz_ai_custom_format') || 'openai'
1921
+
1922
+ _openModal(`
1923
+ <h2 style="font-size:16px;font-weight:600;margin-bottom:6px">${escHtml(p.name)}</h2>
1924
+ <div style="font-size:12px;color:#6b7280;margin-bottom:10px">${t(p.desc)}</div>
1925
+
1926
+ ${isCustom ? `
1927
+ <div class="form-group">
1928
+ <label class="form-label">${t('显示名称')}</label>
1929
+ <input id="ai-pcfg-cname" class="form-control" placeholder="${t('例:我的 LangChain Agent')}" value="${escHtml(cName)}" style="font-size:13px">
1930
+ </div>
1931
+ ` : ''}
1932
+
1933
+ ${p.keyRequired || (isCustom) ? `
1934
+ <div class="form-group">
1935
+ <label class="form-label">${t(isCustom ? 'Bearer Token (可选)' : 'API Key')}</label>
1936
+ <input id="ai-pcfg-key" type="password" class="form-control" placeholder="${p.keyPrefix || (isCustom ? '可空' : '')}..." value="${escHtml(existingKey)}" style="font-family:monospace;font-size:13px">
1937
+ <div style="font-size:11px;color:#9ca3af;margin-top:4px">${t(p.keyHint)}</div>
1938
+ </div>
1939
+ ` : `
1940
+ <div style="font-size:12px;color:#6b7280;margin-bottom:8px">${t('此 provider 无需 API key')}</div>
1941
+ `}
1942
+
1943
+ ${p.customEndpoint ? `
1944
+ <div class="form-group">
1945
+ <label class="form-label">${t('Endpoint URL')}</label>
1946
+ <input id="ai-pcfg-ep" type="text" class="form-control" placeholder="${escHtml(p.defaultEndpoint)}" value="${escHtml(existingEndpoint || p.defaultEndpoint)}" style="font-family:monospace;font-size:12px">
1947
+ </div>
1948
+ ` : ''}
1949
+
1950
+ ${isCustom ? `
1951
+ <div class="form-group">
1952
+ <label class="form-label">${t('模型 ID')}</label>
1953
+ <input id="ai-pcfg-cmodel" class="form-control" placeholder="${t('例:gpt-4 / claude-3 / 你的 agent 接受的 model 字段')}" value="${escHtml(cModel)}" style="font-family:monospace;font-size:13px">
1954
+ </div>
1955
+ <div class="form-group">
1956
+ <label class="form-label">${t('模型显示标签 (可选)')}</label>
1957
+ <input id="ai-pcfg-clabel" class="form-control" placeholder="${t('例:我的购物 agent v1')}" value="${escHtml(cLabel)}" style="font-size:13px">
1958
+ </div>
1959
+ <div class="form-group">
1960
+ <label class="form-label">${t('协议格式')}</label>
1961
+ <select id="ai-pcfg-cformat" class="form-control" style="font-size:13px">
1962
+ <option value="openai" ${cFormat==='openai'?'selected':''}>OpenAI 兼容 (chat/completions)</option>
1963
+ <option value="anthropic" ${cFormat==='anthropic'?'selected':''}>Anthropic 兼容 (messages)</option>
1964
+ </select>
1965
+ <div style="font-size:11px;color:#9ca3af;margin-top:4px">${t('多数自建 agent / 代理用 OpenAI 协议')}</div>
1966
+ </div>
1967
+ ` : `
1968
+ <div class="form-group">
1969
+ <label class="form-label">${t('默认模型')}</label>
1970
+ <select id="ai-pcfg-model" class="form-control" style="font-size:13px">
1971
+ ${p.models.map(m => `<option value="${m.id}" ${m.id === (localStorage.getItem('webaz_ai_model_' + p.id) || p.defaultModel) ? 'selected' : ''}>${escHtml(m.label)}</option>`).join('')}
1972
+ </select>
1973
+ </div>
1974
+ `}
1975
+
1976
+ <div id="ai-pcfg-msg" style="margin-bottom:8px"></div>
1977
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
1978
+ <button class="btn btn-outline" style="flex:1;min-width:80px" onclick="closeModal()">${t('取消')}</button>
1979
+ ${existingKey || !p.keyRequired ? `<button class="btn btn-outline" style="width:auto;color:#dc2626;border-color:#fca5a5;padding:0 12px" onclick="aiClearProviderKey('${p.id}')">${t('清除')}</button>` : ''}
1980
+ <button class="btn btn-outline" style="flex:1;min-width:90px;border-color:#0891b2;color:#0891b2" onclick="aiSaveProviderConfig('${p.id}','fallback')">${t('加入备选')}</button>
1981
+ <button class="btn btn-primary" style="flex:1;min-width:90px" onclick="aiSaveProviderConfig('${p.id}','primary')">${t('设为主用')}</button>
1982
+ </div>
1983
+ `)
1984
+ setTimeout(() => document.getElementById(isCustom ? 'ai-pcfg-cname' : 'ai-pcfg-key')?.focus(), 50)
1985
+ }
1986
+
1987
+ window.aiSaveProviderConfig = (pid, mode) => {
1988
+ const p = aiGetProvider(pid)
1989
+ const msg = document.getElementById('ai-pcfg-msg')
1990
+ const isCustom = !!p.isCustom
1991
+
1992
+ if (isCustom) {
1993
+ // custom: name + model + format 都来自用户填的字段
1994
+ const cname = document.getElementById('ai-pcfg-cname')?.value?.trim() || '我的 Agent'
1995
+ const cmodel = document.getElementById('ai-pcfg-cmodel')?.value?.trim()
1996
+ const clabel = document.getElementById('ai-pcfg-clabel')?.value?.trim()
1997
+ const cformat = document.getElementById('ai-pcfg-cformat')?.value || 'openai'
1998
+ if (!cmodel) { if (msg) msg.innerHTML = alert$('error', t('请填写模型 ID')); return }
1999
+ localStorage.setItem('webaz_ai_custom_name', cname)
2000
+ localStorage.setItem('webaz_ai_custom_model', cmodel)
2001
+ localStorage.setItem('webaz_ai_custom_label', clabel || cmodel)
2002
+ localStorage.setItem('webaz_ai_custom_format', cformat)
2003
+ // key 可选
2004
+ const key = document.getElementById('ai-pcfg-key')?.value?.trim() || ''
2005
+ if (key) aiSetKey(pid, key)
2006
+ else { localStorage.removeItem('webaz_ai_key_' + pid) }
2007
+ // endpoint
2008
+ const ep = document.getElementById('ai-pcfg-ep')?.value?.trim() || p.defaultEndpoint
2009
+ if (!/^https?:\/\//.test(ep)) { if (msg) msg.innerHTML = alert$('error', t('Endpoint 必须以 http:// 或 https:// 开头')); return }
2010
+ aiSetEndpoint(pid, ep)
2011
+ } else {
2012
+ if (p.keyRequired) {
2013
+ const key = document.getElementById('ai-pcfg-key')?.value?.trim() || ''
2014
+ if (!key) { if (msg) msg.innerHTML = alert$('error', t('请填写 API key')); return }
2015
+ if (p.keyPrefix && !key.startsWith(p.keyPrefix)) { if (msg) msg.innerHTML = alert$('error', t('API key 应以 ') + p.keyPrefix + t(' 开头')); return }
2016
+ aiSetKey(pid, key)
2017
+ }
2018
+ if (p.customEndpoint) {
2019
+ const ep = document.getElementById('ai-pcfg-ep')?.value?.trim() || p.defaultEndpoint
2020
+ aiSetEndpoint(pid, ep)
2021
+ }
2022
+ const mid = document.getElementById('ai-pcfg-model')?.value
2023
+ if (mid) localStorage.setItem('webaz_ai_model_' + pid, mid)
2024
+ }
2025
+ aiAddToChain(pid, mode === 'primary')
2026
+ closeModal()
2027
+ toast$(t(mode === 'primary' ? '已设为主用 ' : '已加入备选 ') + p.name)
2028
+ // 配置完成后回到主对话页
2029
+ navigate('#ai-recommend')
2030
+ }
2031
+
2032
+ window.aiClearProviderKey = (pid) => {
2033
+ const p = aiGetProvider(pid)
2034
+ if (!confirm(t('清除 ') + p.name + t(' 的 API key?'))) return
2035
+ localStorage.removeItem('webaz_ai_key_' + pid)
2036
+ if (pid === 'anthropic') localStorage.removeItem('webaz_ai_key')
2037
+ aiRemoveFromChain(pid)
2038
+ closeModal()
2039
+ toast$(t('已清除'))
2040
+ // 留在 config 页(用户可能还要继续配其他 provider)
2041
+ if (location.hash === '#ai-recommend/config') renderAIConfig(document.getElementById('app'))
2042
+ else renderAIRecommend(document.getElementById('app'))
2043
+ }
2044
+
2045
+ window.aiNewConv = async () => {
2046
+ const conv = aiCreateConversation()
2047
+ state.aiCurrentConv = conv
2048
+ await aiSaveConversation(conv)
2049
+ renderAIRecommend(document.getElementById('app'))
2050
+ }
2051
+
2052
+ window.aiLoadConv = async (id) => {
2053
+ const conv = await aiGetConversation(id)
2054
+ if (conv) state.aiCurrentConv = conv
2055
+ renderAIRecommend(document.getElementById('app'))
2056
+ }
2057
+
2058
+ window.aiSendMessage = async () => {
2059
+ const inp = document.getElementById('ai-input')
2060
+ const text = inp?.value?.trim()
2061
+ const attachments = state.aiAttachments || []
2062
+ if (!text && attachments.length === 0) return
2063
+ const btn = document.getElementById('ai-send-btn')
2064
+ const msgEl = document.getElementById('ai-messages')
2065
+ const conv = state.aiCurrentConv = state.aiCurrentConv || aiCreateConversation()
2066
+
2067
+ // 视觉路由:当前模型支持视觉 + 有图 → attachments 直接走 image content;
2068
+ // 否则(非视觉模型 / 视频文件等)→ 文件名以 text marker 注入
2069
+ const visionSupported = aiCurrentModelSupportsVision()
2070
+ const visionAttachments = attachments.filter(a => a.kind === 'image')
2071
+ const nonVisionAttachments = attachments.filter(a => a.kind !== 'image' || !visionSupported)
2072
+ let finalText = text || ''
2073
+ if (nonVisionAttachments.length > 0) {
2074
+ const marks = nonVisionAttachments.map(a => `[${a.kind === 'image' ? '🖼' : '📎'} ${a.name}]`).join(' ')
2075
+ finalText = (finalText ? finalText + '\n\n' : '') + marks
2076
+ }
2077
+ // 真正喂给 chatTurn 的 attachments — 仅含视觉模型能用的图片
2078
+ const passAttachments = visionSupported ? visionAttachments : []
2079
+
2080
+ // 乐观渲染用户消息(含视觉时构造 content array)
2081
+ if (passAttachments.length > 0) {
2082
+ const content = []
2083
+ if (finalText) content.push({ type: 'text', text: finalText })
2084
+ for (const a of passAttachments) {
2085
+ const p = aiParseDataURL(a.dataURL)
2086
+ if (p) content.push({ type: 'image', source: { type: 'base64', media_type: p.mime, data: p.data } })
2087
+ }
2088
+ conv.messages.push({ role: 'user', content })
2089
+ } else {
2090
+ conv.messages.push({ role: 'user', content: finalText })
2091
+ }
2092
+ if (msgEl) { msgEl.innerHTML = renderAIMessages(conv.messages); msgEl.scrollTop = msgEl.scrollHeight }
2093
+ inp.value = ''
2094
+ state.aiAttachments = []
2095
+ aiRenderAttachPreview()
2096
+ conv.messages.pop() // chatTurn 会重新 add
2097
+
2098
+ // 思考指示器(流式时会被替换成增长文本气泡)
2099
+ let streamingText = ''
2100
+ if (msgEl) {
2101
+ msgEl.insertAdjacentHTML('beforeend', `<div id="ai-thinking" style="display:flex;justify-content:flex-start;margin-bottom:10px"><div id="ai-thinking-inner" style="background:#f3f4f6;border-radius:10px 10px 10px 2px;padding:8px 12px;color:#374151;font-size:13px;max-width:90%;line-height:1.6"><span style="font-style:italic;color:#6b7280">🤖 ${t('思考中…')}</span></div></div>`)
2102
+ msgEl.scrollTop = msgEl.scrollHeight
2103
+ }
2104
+
2105
+ if (btn) btn.disabled = true
2106
+ try {
2107
+ ensureAITraceStyle()
2108
+ await aiChatTurn(conv, finalText, passAttachments, (status, payload) => {
2109
+ const tIn = document.getElementById('ai-thinking-inner')
2110
+ if (status === 'text' && tIn) {
2111
+ streamingText += payload
2112
+ tIn.innerHTML = renderAIMarkdown(streamingText) + '<span style="display:inline-block;width:6px;height:14px;background:#9ca3af;margin-left:2px;animation:ai-cursor 1s infinite;vertical-align:middle"></span>'
2113
+ if (msgEl) msgEl.scrollTop = msgEl.scrollHeight
2114
+ } else if (status === 'tool_use_start') {
2115
+ // 实时 trace:开一个 trace 容器,把每个 tool 渲染成 ⏳ 卡(slide-in 错开 80ms)
2116
+ streamingText = ''
2117
+ if (tIn) tIn.innerHTML = `<span style="font-style:italic;color:#6b7280">🔧 ${t('调用工具')}…</span>`
2118
+ const trace = ensureTraceContainer(msgEl)
2119
+ for (let i = 0; i < payload.length; i++) {
2120
+ const tu = payload[i]
2121
+ const card = document.createElement('div')
2122
+ card.setAttribute('data-tool-id', tu.id)
2123
+ card.className = 'ai-trace-card'
2124
+ card.style.animationDelay = (i * 80) + 'ms'
2125
+ card.innerHTML = renderToolCard(tu, null)
2126
+ trace.appendChild(card)
2127
+ }
2128
+ if (msgEl) msgEl.scrollTop = msgEl.scrollHeight
2129
+ } else if (status === 'tool_result_one') {
2130
+ // 把对应卡片 swap 到 ✅,加 flash 高亮
2131
+ const card = document.querySelector(`.ai-trace-card[data-tool-id="${payload.id}"]`)
2132
+ if (card) {
2133
+ card.classList.add('ai-trace-card-done')
2134
+ card.innerHTML = renderToolCard({ id: payload.id, name: payload.name, input: payload.input }, payload.content)
2135
+ setTimeout(() => card.classList.remove('ai-trace-card-done'), 600)
2136
+ }
2137
+ if (msgEl) msgEl.scrollTop = msgEl.scrollHeight
2138
+ } else if (status === 'thinking' && !streamingText && tIn) {
2139
+ tIn.innerHTML = `<span style="font-style:italic;color:#6b7280">🤖 ${t('思考中… 第')} ${payload} ${t('轮')}</span>`
2140
+ }
2141
+ })
2142
+ if (!conv.title) {
2143
+ const first = conv.messages.find(m => m.role === 'user' && typeof m.content === 'string')
2144
+ if (first) { conv.title = first.content.slice(0, 30); await aiSaveConversation(conv) }
2145
+ }
2146
+ } catch (e) {
2147
+ conv.messages.push({ role: 'assistant', content: [{ type: 'text', text: `❌ ${e.message || e}` }] })
2148
+ await aiSaveConversation(conv)
2149
+ }
2150
+
2151
+ if (msgEl) { msgEl.innerHTML = renderAIMessages(conv.messages); msgEl.scrollTop = msgEl.scrollHeight }
2152
+ if (btn) btn.disabled = false
2153
+
2154
+ // TTS:把最新 assistant text 块朗读
2155
+ if (aiTTS.isEnabled()) {
2156
+ const last = [...conv.messages].reverse().find(m => m.role === 'assistant' && Array.isArray(m.content))
2157
+ if (last) {
2158
+ const text = last.content.filter(c => c.type === 'text').map(c => c.text).join(' ').trim()
2159
+ if (text) aiTTS.speak(text)
2160
+ }
2161
+ }
2162
+ }