@astro-minimax/ai 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +223 -0
  2. package/dist/cache/global-cache.d.ts +31 -0
  3. package/dist/cache/global-cache.d.ts.map +1 -0
  4. package/dist/cache/global-cache.js +141 -0
  5. package/dist/cache/index.d.ts +8 -0
  6. package/dist/cache/index.d.ts.map +1 -0
  7. package/dist/cache/index.js +62 -0
  8. package/dist/cache/kv-adapter.d.ts +21 -0
  9. package/dist/cache/kv-adapter.d.ts.map +1 -0
  10. package/dist/cache/kv-adapter.js +102 -0
  11. package/dist/cache/memory-adapter.d.ts +24 -0
  12. package/dist/cache/memory-adapter.d.ts.map +1 -0
  13. package/dist/cache/memory-adapter.js +95 -0
  14. package/dist/cache/response-cache.d.ts +45 -0
  15. package/dist/cache/response-cache.d.ts.map +1 -0
  16. package/dist/cache/response-cache.js +85 -0
  17. package/dist/cache/types.d.ts +118 -0
  18. package/dist/cache/types.d.ts.map +1 -0
  19. package/dist/cache/types.js +16 -0
  20. package/dist/data/index.d.ts +3 -0
  21. package/dist/data/index.d.ts.map +1 -0
  22. package/dist/data/index.js +1 -0
  23. package/dist/data/metadata-loader.d.ts +37 -0
  24. package/dist/data/metadata-loader.d.ts.map +1 -0
  25. package/dist/data/metadata-loader.js +54 -0
  26. package/dist/data/types.d.ts +51 -0
  27. package/dist/data/types.d.ts.map +1 -0
  28. package/dist/data/types.js +1 -0
  29. package/dist/index.d.ts +19 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +28 -0
  32. package/dist/intelligence/citation-guard.d.ts +24 -0
  33. package/dist/intelligence/citation-guard.d.ts.map +1 -0
  34. package/dist/intelligence/citation-guard.js +82 -0
  35. package/dist/intelligence/evidence-analysis.d.ts +29 -0
  36. package/dist/intelligence/evidence-analysis.d.ts.map +1 -0
  37. package/dist/intelligence/evidence-analysis.js +88 -0
  38. package/dist/intelligence/index.d.ts +6 -0
  39. package/dist/intelligence/index.d.ts.map +1 -0
  40. package/dist/intelligence/index.js +4 -0
  41. package/dist/intelligence/intent-detect.d.ts +29 -0
  42. package/dist/intelligence/intent-detect.d.ts.map +1 -0
  43. package/dist/intelligence/intent-detect.js +64 -0
  44. package/dist/intelligence/keyword-extract.d.ts +31 -0
  45. package/dist/intelligence/keyword-extract.d.ts.map +1 -0
  46. package/dist/intelligence/keyword-extract.js +114 -0
  47. package/dist/intelligence/types.d.ts +27 -0
  48. package/dist/intelligence/types.d.ts.map +1 -0
  49. package/dist/intelligence/types.js +1 -0
  50. package/dist/middleware/index.d.ts +3 -0
  51. package/dist/middleware/index.d.ts.map +1 -0
  52. package/dist/middleware/index.js +1 -0
  53. package/dist/middleware/rate-limiter.d.ts +26 -0
  54. package/dist/middleware/rate-limiter.d.ts.map +1 -0
  55. package/dist/middleware/rate-limiter.js +129 -0
  56. package/dist/prompt/dynamic-layer.d.ts +7 -0
  57. package/dist/prompt/dynamic-layer.d.ts.map +1 -0
  58. package/dist/prompt/dynamic-layer.js +40 -0
  59. package/dist/prompt/index.d.ts +6 -0
  60. package/dist/prompt/index.d.ts.map +1 -0
  61. package/dist/prompt/index.js +4 -0
  62. package/dist/prompt/prompt-builder.d.ts +11 -0
  63. package/dist/prompt/prompt-builder.d.ts.map +1 -0
  64. package/dist/prompt/prompt-builder.js +19 -0
  65. package/dist/prompt/semi-static-layer.d.ts +7 -0
  66. package/dist/prompt/semi-static-layer.d.ts.map +1 -0
  67. package/dist/prompt/semi-static-layer.js +32 -0
  68. package/dist/prompt/static-layer.d.ts +3 -0
  69. package/dist/prompt/static-layer.d.ts.map +1 -0
  70. package/dist/prompt/static-layer.js +78 -0
  71. package/dist/prompt/types.d.ts +25 -0
  72. package/dist/prompt/types.d.ts.map +1 -0
  73. package/dist/prompt/types.js +1 -0
  74. package/dist/provider-manager/base.d.ts +26 -0
  75. package/dist/provider-manager/base.d.ts.map +1 -0
  76. package/dist/provider-manager/base.js +47 -0
  77. package/dist/provider-manager/config.d.ts +7 -0
  78. package/dist/provider-manager/config.d.ts.map +1 -0
  79. package/dist/provider-manager/config.js +134 -0
  80. package/dist/provider-manager/index.d.ts +8 -0
  81. package/dist/provider-manager/index.d.ts.map +1 -0
  82. package/dist/provider-manager/index.js +6 -0
  83. package/dist/provider-manager/manager.d.ts +18 -0
  84. package/dist/provider-manager/manager.d.ts.map +1 -0
  85. package/dist/provider-manager/manager.js +121 -0
  86. package/dist/provider-manager/mock.d.ts +18 -0
  87. package/dist/provider-manager/mock.d.ts.map +1 -0
  88. package/dist/provider-manager/mock.js +56 -0
  89. package/dist/provider-manager/openai.d.ts +20 -0
  90. package/dist/provider-manager/openai.d.ts.map +1 -0
  91. package/dist/provider-manager/openai.js +83 -0
  92. package/dist/provider-manager/types.d.ts +217 -0
  93. package/dist/provider-manager/types.d.ts.map +1 -0
  94. package/dist/provider-manager/types.js +6 -0
  95. package/dist/provider-manager/workers.d.ts +20 -0
  96. package/dist/provider-manager/workers.d.ts.map +1 -0
  97. package/dist/provider-manager/workers.js +74 -0
  98. package/dist/providers/index.d.ts +2 -0
  99. package/dist/providers/index.d.ts.map +1 -0
  100. package/dist/providers/index.js +1 -0
  101. package/dist/providers/mock.d.ts +14 -0
  102. package/dist/providers/mock.d.ts.map +1 -0
  103. package/dist/providers/mock.js +234 -0
  104. package/dist/search/index.d.ts +5 -0
  105. package/dist/search/index.d.ts.map +1 -0
  106. package/dist/search/index.js +3 -0
  107. package/dist/search/search-api.d.ts +28 -0
  108. package/dist/search/search-api.d.ts.map +1 -0
  109. package/dist/search/search-api.js +110 -0
  110. package/dist/search/search-index.d.ts +6 -0
  111. package/dist/search/search-index.d.ts.map +1 -0
  112. package/dist/search/search-index.js +22 -0
  113. package/dist/search/search-utils.d.ts +43 -0
  114. package/dist/search/search-utils.d.ts.map +1 -0
  115. package/dist/search/search-utils.js +114 -0
  116. package/dist/search/session-cache.d.ts +19 -0
  117. package/dist/search/session-cache.d.ts.map +1 -0
  118. package/dist/search/session-cache.js +92 -0
  119. package/dist/search/types.d.ts +41 -0
  120. package/dist/search/types.d.ts.map +1 -0
  121. package/dist/search/types.js +1 -0
  122. package/dist/server/chat-handler.d.ts +3 -0
  123. package/dist/server/chat-handler.d.ts.map +1 -0
  124. package/dist/server/chat-handler.js +750 -0
  125. package/dist/server/dev-server.d.ts +18 -0
  126. package/dist/server/dev-server.d.ts.map +1 -0
  127. package/dist/server/dev-server.js +294 -0
  128. package/dist/server/errors.d.ts +17 -0
  129. package/dist/server/errors.d.ts.map +1 -0
  130. package/dist/server/errors.js +41 -0
  131. package/dist/server/index.d.ts +8 -0
  132. package/dist/server/index.d.ts.map +1 -0
  133. package/dist/server/index.js +5 -0
  134. package/dist/server/metadata-init.d.ts +11 -0
  135. package/dist/server/metadata-init.d.ts.map +1 -0
  136. package/dist/server/metadata-init.js +45 -0
  137. package/dist/server/notify.d.ts +25 -0
  138. package/dist/server/notify.d.ts.map +1 -0
  139. package/dist/server/notify.js +62 -0
  140. package/dist/server/types.d.ts +56 -0
  141. package/dist/server/types.d.ts.map +1 -0
  142. package/dist/server/types.js +13 -0
  143. package/dist/stream/index.d.ts +3 -0
  144. package/dist/stream/index.d.ts.map +1 -0
  145. package/dist/stream/index.js +2 -0
  146. package/dist/stream/mock-stream.d.ts +12 -0
  147. package/dist/stream/mock-stream.d.ts.map +1 -0
  148. package/dist/stream/mock-stream.js +27 -0
  149. package/dist/stream/response.d.ts +10 -0
  150. package/dist/stream/response.d.ts.map +1 -0
  151. package/dist/stream/response.js +22 -0
  152. package/dist/utils/i18n.d.ts +18 -0
  153. package/dist/utils/i18n.d.ts.map +1 -0
  154. package/dist/utils/i18n.js +148 -0
  155. package/package.json +93 -0
  156. package/src/components/AIChatContainer.tsx +30 -0
  157. package/src/components/AIChatWidget.astro +30 -0
  158. package/src/components/ChatPanel.tsx +865 -0
  159. package/src/styles/source.css +2 -0
@@ -0,0 +1,750 @@
1
+ import { createUIMessageStream, createUIMessageStreamResponse, streamText, convertToModelMessages, } from 'ai';
2
+ import { t, getLang } from '../utils/i18n.js';
3
+ import { getClientIP, checkRateLimit, rateLimitResponse, searchArticles, searchProjects, getSessionCacheKey, getCachedContext, setCachedContext, shouldReuseSearchContext, buildLocalSearchQuery, shouldRunKeywordExtraction, extractSearchKeywords, KEYWORD_EXTRACTION_TIMEOUT_MS, shouldSkipAnalysis, analyzeRetrievedEvidence, buildEvidenceSection, EVIDENCE_ANALYSIS_TIMEOUT_MS, getCitationGuardPreflight, buildSystemPrompt, getAuthorContext, getVoiceProfile, mergeResults, ProviderManager, createCacheAdapter, detectPublicQuestion, getGlobalSearchCache, setGlobalSearchCache, getGlobalCacheTTL, getResponseCache, setResponseCache, getResponseCacheConfig, createResponsePlaybackGenerator, } from '../index.js';
4
+ import { createChatStatusData } from './types.js';
5
+ import { errors, corsPreflightResponse } from './errors.js';
6
+ import { notifyAiChat } from './notify.js';
7
+ const MAX_HISTORY_MESSAGES = 20;
8
+ const MAX_INPUT_LENGTH = 500;
9
+ const REQUEST_TIMEOUT_MS = 45_000;
10
+ // ── Message Helpers ───────────────────────────────────────────
11
+ function getMessageText(message) {
12
+ if (Array.isArray(message.parts)) {
13
+ return message.parts
14
+ .filter((p) => p.type === 'text')
15
+ .map(p => p.text)
16
+ .join('');
17
+ }
18
+ return '';
19
+ }
20
+ function hasContent(message) {
21
+ const text = getMessageText(message);
22
+ if (text.trim())
23
+ return true;
24
+ if (Array.isArray(message.parts)) {
25
+ return message.parts.some(p => p.type !== 'text');
26
+ }
27
+ return false;
28
+ }
29
+ function filterValidMessages(messages) {
30
+ const filtered = [];
31
+ let lastRole = null;
32
+ for (const msg of messages) {
33
+ if (!hasContent(msg))
34
+ continue;
35
+ if (msg.role === lastRole)
36
+ continue;
37
+ filtered.push(msg);
38
+ lastRole = msg.role;
39
+ }
40
+ if (filtered.length > 0 && filtered[filtered.length - 1].role !== 'user') {
41
+ filtered.pop();
42
+ }
43
+ return filtered;
44
+ }
45
+ // ── Article Context Prompt Enhancement ────────────────────────
46
+ function buildArticleContextPrompt(context) {
47
+ if (context.scope !== 'article' || !context.article)
48
+ return '';
49
+ const a = context.article;
50
+ const parts = [
51
+ '\n[当前阅读文章]',
52
+ `用户正在阅读:《${a.title}》`,
53
+ ];
54
+ if (a.summary)
55
+ parts.push(`摘要:${a.summary}`);
56
+ if (a.abstract)
57
+ parts.push(`详细概要:${a.abstract}`);
58
+ if (a.keyPoints?.length)
59
+ parts.push(`核心要点:${a.keyPoints.join(';')}`);
60
+ if (a.categories?.length)
61
+ parts.push(`分类:${a.categories.join('、')}`);
62
+ parts.push('', '你正在陪用户阅读这篇文章。优先围绕这篇文章的内容回答问题。', '当用户的问题与当前文章相关时,引用文章中的具体内容。', '当用户想要延伸时,推荐相关的博客文章。');
63
+ return parts.join('\n');
64
+ }
65
+ // ── Main Handler ──────────────────────────────────────────────
66
+ export async function handleChatRequest(options) {
67
+ const { env, request: req } = options;
68
+ if (req.method === 'OPTIONS')
69
+ return corsPreflightResponse();
70
+ if (req.method !== 'POST')
71
+ return errors.methodNotAllowed('zh');
72
+ const ip = getClientIP(req);
73
+ const rateCheck = checkRateLimit(ip, env);
74
+ if (!rateCheck.allowed)
75
+ return rateLimitResponse(rateCheck, 'zh');
76
+ let body;
77
+ try {
78
+ body = await req.json();
79
+ }
80
+ catch {
81
+ return errors.invalidRequest(t('ai.error.format', 'zh'));
82
+ }
83
+ const lang = getLang(body.lang ?? env.SITE_LANG);
84
+ const context = body.context ?? { scope: 'global' };
85
+ const rawMessages = (body.messages ?? []).slice(-MAX_HISTORY_MESSAGES);
86
+ if (!rawMessages.length)
87
+ return errors.emptyMessage(lang);
88
+ const messages = filterValidMessages(rawMessages);
89
+ if (!messages.length)
90
+ return errors.emptyMessage(lang);
91
+ const latestMessage = messages[messages.length - 1];
92
+ const latestText = getMessageText(latestMessage);
93
+ if (!latestText)
94
+ return errors.emptyContent(lang);
95
+ if (latestText.length > MAX_INPUT_LENGTH)
96
+ return errors.inputTooLong(MAX_INPUT_LENGTH, lang);
97
+ const requestAbort = new AbortController();
98
+ const requestTimer = setTimeout(() => requestAbort.abort(), REQUEST_TIMEOUT_MS);
99
+ try {
100
+ return await runPipeline({ env, messages, latestText, context, req, requestAbort, lang });
101
+ }
102
+ catch (err) {
103
+ if (requestAbort.signal.aborted)
104
+ return errors.timeout(lang);
105
+ console.error('[chat-handler] Unexpected error:', err);
106
+ return errors.internal(undefined, lang);
107
+ }
108
+ finally {
109
+ clearTimeout(requestTimer);
110
+ }
111
+ }
112
+ async function runPipeline(args) {
113
+ const { env, messages, latestText, context, req, lang } = args;
114
+ const timing = { start: Date.now() };
115
+ const cache = createCacheAdapter(env);
116
+ const responseCacheConfig = getResponseCacheConfig(env);
117
+ const manager = new ProviderManager(env, {
118
+ enableMockFallback: true,
119
+ unhealthyThreshold: 3,
120
+ healthRecoveryTTL: 60_000,
121
+ });
122
+ const hasRealProvider = manager.hasProviders();
123
+ const adapter = hasRealProvider ? await manager.getAvailableAdapter() : null;
124
+ // ── Global Cache Check for Public Questions ─────────────────────────
125
+ const articleSlug = context.scope === 'article' && context.article?.slug
126
+ ? context.article.slug
127
+ : undefined;
128
+ const publicQuestion = detectPublicQuestion(latestText);
129
+ let globalCacheHit = false;
130
+ let globalCacheType;
131
+ if (publicQuestion && (!publicQuestion.needsContext || articleSlug)) {
132
+ const globalCacheContext = { articleSlug, lang };
133
+ // Check response cache first if enabled
134
+ if (responseCacheConfig.enabled) {
135
+ const cachedResponse = await getResponseCache(cache, publicQuestion.type, globalCacheContext);
136
+ if (cachedResponse) {
137
+ globalCacheHit = true;
138
+ globalCacheType = publicQuestion.type;
139
+ const stream = createUIMessageStream({
140
+ execute: async ({ writer }) => {
141
+ writer.write({
142
+ type: 'message-metadata',
143
+ messageMetadata: createChatStatusData({
144
+ stage: 'search',
145
+ message: t('ai.status.found', lang, { count: cachedResponse.articles.length + cachedResponse.projects.length }),
146
+ progress: 40,
147
+ }),
148
+ });
149
+ writer.write({
150
+ type: 'message-metadata',
151
+ messageMetadata: createChatStatusData({
152
+ stage: 'answer',
153
+ message: t('ai.status.generating', lang),
154
+ progress: 60,
155
+ }),
156
+ });
157
+ for (const article of cachedResponse.articles.slice(0, 3)) {
158
+ try {
159
+ writer.write({
160
+ type: 'source-url',
161
+ sourceId: `source-${article.title}`,
162
+ url: article.url ?? '#',
163
+ title: article.title,
164
+ });
165
+ }
166
+ catch { /* best-effort */ }
167
+ }
168
+ writer.write({
169
+ type: 'message-metadata',
170
+ messageMetadata: createChatStatusData({
171
+ stage: 'answer',
172
+ message: t('ai.status.generating', lang),
173
+ progress: 70,
174
+ }),
175
+ });
176
+ const playbackGenerator = createResponsePlaybackGenerator(cachedResponse, responseCacheConfig);
177
+ let hasThinking = !!cachedResponse.thinking;
178
+ let thinkingId;
179
+ const textId = `text-${Date.now()}`;
180
+ let textStarted = false;
181
+ for await (const chunk of playbackGenerator) {
182
+ if (chunk.type === 'thinking') {
183
+ if (!thinkingId) {
184
+ thinkingId = `thinking-${Date.now()}`;
185
+ writer.write({ type: 'reasoning-start', id: thinkingId });
186
+ }
187
+ writer.write({ type: 'reasoning-delta', id: thinkingId, delta: chunk.text });
188
+ }
189
+ else {
190
+ if (thinkingId) {
191
+ writer.write({ type: 'reasoning-end', id: thinkingId });
192
+ thinkingId = undefined;
193
+ }
194
+ if (!textStarted) {
195
+ writer.write({ type: 'text-start', id: textId });
196
+ textStarted = true;
197
+ }
198
+ writer.write({ type: 'text-delta', id: textId, delta: chunk.text });
199
+ }
200
+ }
201
+ if (thinkingId) {
202
+ writer.write({ type: 'reasoning-end', id: thinkingId });
203
+ }
204
+ if (textStarted) {
205
+ writer.write({ type: 'text-end', id: textId });
206
+ }
207
+ writer.write({
208
+ type: 'message-metadata',
209
+ messageMetadata: createChatStatusData({
210
+ stage: 'answer',
211
+ message: t('ai.status.generating', lang),
212
+ progress: 100,
213
+ done: true,
214
+ }),
215
+ });
216
+ writer.write({ type: 'finish', finishReason: 'stop' });
217
+ },
218
+ });
219
+ return createUIMessageStreamResponse({
220
+ stream,
221
+ headers: {
222
+ 'Access-Control-Allow-Origin': '*',
223
+ 'Cache-Control': 'no-cache',
224
+ },
225
+ });
226
+ }
227
+ }
228
+ // Check search context cache
229
+ const cachedSearch = await getGlobalSearchCache(cache, publicQuestion.type, globalCacheContext);
230
+ if (cachedSearch) {
231
+ globalCacheHit = true;
232
+ globalCacheType = publicQuestion.type;
233
+ const stream = createUIMessageStream({
234
+ execute: async ({ writer }) => {
235
+ writer.write({
236
+ type: 'message-metadata',
237
+ messageMetadata: createChatStatusData({
238
+ stage: 'search',
239
+ message: t('ai.status.found', lang, { count: cachedSearch.articles.length + cachedSearch.projects.length }),
240
+ progress: 40,
241
+ }),
242
+ });
243
+ const articleCount = cachedSearch.articles.length + cachedSearch.projects.length;
244
+ if (articleCount > 0) {
245
+ writer.write({
246
+ type: 'message-metadata',
247
+ messageMetadata: createChatStatusData({
248
+ stage: 'answer',
249
+ message: t('ai.status.generating', lang),
250
+ progress: 60,
251
+ }),
252
+ });
253
+ }
254
+ for (const article of cachedSearch.articles.slice(0, 3)) {
255
+ try {
256
+ writer.write({
257
+ type: 'source-url',
258
+ sourceId: `source-${article.title}`,
259
+ url: article.url ?? '#',
260
+ title: article.title,
261
+ });
262
+ }
263
+ catch { /* best-effort */ }
264
+ }
265
+ let responseText = '';
266
+ if (adapter) {
267
+ try {
268
+ const provider = adapter.getProvider();
269
+ const articlePrompt = buildArticleContextPrompt(context);
270
+ const systemPrompt = buildSystemPrompt({
271
+ static: {
272
+ authorName: env.SITE_AUTHOR || '博主',
273
+ siteUrl: env.SITE_URL || '',
274
+ lang,
275
+ },
276
+ semiStatic: {
277
+ authorContext: getAuthorContext(),
278
+ voiceProfile: getVoiceProfile(),
279
+ },
280
+ dynamic: {
281
+ userQuery: cachedSearch.query,
282
+ articles: cachedSearch.articles,
283
+ projects: cachedSearch.projects,
284
+ evidenceSection: articlePrompt,
285
+ },
286
+ });
287
+ const result = streamText({
288
+ model: provider.chatModel(adapter.model),
289
+ system: systemPrompt,
290
+ messages: await convertToModelMessages(messages),
291
+ temperature: 0.3,
292
+ maxOutputTokens: 2500,
293
+ });
294
+ const streamErrors = [];
295
+ writer.merge(result.toUIMessageStream({ sendFinish: false }));
296
+ await result.consumeStream({
297
+ onError: (error) => {
298
+ streamErrors.push(error instanceof Error ? error : new Error(String(error)));
299
+ },
300
+ });
301
+ const text = await result.text;
302
+ const reasoningPromise = result.reasoning;
303
+ let reasoningText;
304
+ if (reasoningPromise) {
305
+ try {
306
+ const reasoningOutput = await Promise.resolve(reasoningPromise);
307
+ reasoningText = typeof reasoningOutput === 'string' ? reasoningOutput :
308
+ (Array.isArray(reasoningOutput) ? reasoningOutput.map((r) => {
309
+ if (typeof r === 'object' && r !== null && 'text' in r)
310
+ return r.text;
311
+ return String(r);
312
+ }).join('') : undefined);
313
+ }
314
+ catch { /* reasoning is optional */ }
315
+ }
316
+ responseText = text;
317
+ if (streamErrors.length > 0) {
318
+ adapter.recordFailure(streamErrors[0]);
319
+ const errorId = `error-${Date.now()}`;
320
+ writer.write({ type: 'text-start', id: errorId });
321
+ writer.write({
322
+ type: 'text-delta',
323
+ id: errorId,
324
+ delta: t('ai.error.generic', lang)
325
+ });
326
+ writer.write({ type: 'text-end', id: errorId });
327
+ writer.write({ type: 'finish', finishReason: 'error' });
328
+ }
329
+ else if (text.length > 0) {
330
+ adapter.recordSuccess();
331
+ writer.write({ type: 'finish', finishReason: 'stop' });
332
+ }
333
+ else {
334
+ const noOutputId = `no-output-${Date.now()}`;
335
+ writer.write({ type: 'text-start', id: noOutputId });
336
+ writer.write({ type: 'text-delta', id: noOutputId, delta: t('ai.error.noOutput', lang) });
337
+ writer.write({ type: 'text-end', id: noOutputId });
338
+ writer.write({ type: 'finish', finishReason: 'stop' });
339
+ }
340
+ // Save to response cache if enabled
341
+ if (responseCacheConfig.enabled && text.length > 0 && streamErrors.length === 0) {
342
+ const globalTTL = getGlobalCacheTTL(publicQuestion.type);
343
+ const responseCacheData = {
344
+ query: cachedSearch.query,
345
+ thinking: reasoningText,
346
+ response: text,
347
+ articles: cachedSearch.articles,
348
+ projects: cachedSearch.projects,
349
+ lang,
350
+ model: adapter.model,
351
+ updatedAt: Date.now(),
352
+ };
353
+ await setResponseCache(cache, publicQuestion.type, responseCacheData, globalTTL, globalCacheContext);
354
+ }
355
+ }
356
+ catch (err) {
357
+ console.error('[chat-handler] Global cache LLM error:', err);
358
+ const errorId = `error-${Date.now()}`;
359
+ writer.write({ type: 'text-start', id: errorId });
360
+ writer.write({
361
+ type: 'text-delta',
362
+ id: errorId,
363
+ delta: t('ai.error.generic', lang)
364
+ });
365
+ writer.write({ type: 'text-end', id: errorId });
366
+ writer.write({ type: 'finish', finishReason: 'error' });
367
+ }
368
+ }
369
+ else {
370
+ const { getMockResponse } = await import('../providers/mock.js');
371
+ const mockText = getMockResponse(latestText, lang);
372
+ responseText = mockText;
373
+ const mockId = `mock-${Date.now()}`;
374
+ writer.write({ type: 'text-start', id: mockId });
375
+ writer.write({ type: 'text-delta', id: mockId, delta: mockText });
376
+ writer.write({ type: 'text-end', id: mockId });
377
+ writer.write({ type: 'finish', finishReason: 'stop' });
378
+ }
379
+ },
380
+ });
381
+ return createUIMessageStreamResponse({
382
+ stream,
383
+ headers: {
384
+ 'Access-Control-Allow-Origin': '*',
385
+ 'Cache-Control': 'no-cache',
386
+ },
387
+ });
388
+ }
389
+ }
390
+ // ── Search / Retrieval ──────────────────────────────────────
391
+ const cacheKey = getSessionCacheKey(req);
392
+ const now = Date.now();
393
+ const cachedContext = cacheKey ? await getCachedContext(cacheKey, cache) : undefined;
394
+ const userTurnCount = messages.filter((m) => m.role === 'user').length;
395
+ const reuseContext = shouldReuseSearchContext({ latestText, cachedContext, userTurnCount, now });
396
+ let searchQuery = buildLocalSearchQuery(latestText) || latestText;
397
+ let relatedArticles = reuseContext && cachedContext ? cachedContext.articles : [];
398
+ let relatedProjects = reuseContext && cachedContext ? cachedContext.projects : [];
399
+ if (reuseContext && cachedContext && cacheKey) {
400
+ searchQuery = cachedContext.query;
401
+ await setCachedContext(cacheKey, { ...cachedContext, updatedAt: now }, cache);
402
+ }
403
+ else {
404
+ if (hasRealProvider && adapter) {
405
+ const runKW = shouldRunKeywordExtraction({
406
+ messageCount: messages.length,
407
+ localQuery: searchQuery,
408
+ latestText,
409
+ });
410
+ if (runKW) {
411
+ const kwStart = Date.now();
412
+ const abortCtrl = new AbortController();
413
+ const timeoutId = setTimeout(() => abortCtrl.abort(), KEYWORD_EXTRACTION_TIMEOUT_MS);
414
+ try {
415
+ const provider = adapter.getProvider();
416
+ const kwResult = await extractSearchKeywords({
417
+ messages: messages,
418
+ provider,
419
+ model: adapter.keywordModel,
420
+ abortSignal: abortCtrl.signal,
421
+ });
422
+ timing.keywordExtraction = Date.now() - kwStart;
423
+ if (kwResult.query && !kwResult.usedFallback) {
424
+ searchQuery = kwResult.query;
425
+ if (kwResult.primaryQuery && kwResult.primaryQuery !== searchQuery) {
426
+ const searchStart = Date.now();
427
+ const primary = searchArticles(kwResult.primaryQuery, { enableDeepContent: false });
428
+ relatedArticles = mergeResults(searchArticles(searchQuery, { enableDeepContent: true }), primary);
429
+ relatedProjects = searchProjects(searchQuery);
430
+ timing.search = Date.now() - searchStart;
431
+ }
432
+ }
433
+ }
434
+ catch {
435
+ timing.keywordExtraction = Date.now() - kwStart;
436
+ }
437
+ finally {
438
+ clearTimeout(timeoutId);
439
+ }
440
+ }
441
+ }
442
+ if (!relatedArticles.length) {
443
+ const searchStart = Date.now();
444
+ relatedArticles = searchArticles(searchQuery, { enableDeepContent: true });
445
+ relatedProjects = searchProjects(searchQuery);
446
+ timing.search = Date.now() - searchStart;
447
+ }
448
+ if (cacheKey) {
449
+ await setCachedContext(cacheKey, {
450
+ query: searchQuery,
451
+ articles: relatedArticles,
452
+ projects: relatedProjects,
453
+ updatedAt: now,
454
+ }, cache);
455
+ }
456
+ if (publicQuestion && (!publicQuestion.needsContext || articleSlug)) {
457
+ const globalTTL = getGlobalCacheTTL(publicQuestion.type);
458
+ await setGlobalSearchCache(cache, publicQuestion.type, {
459
+ query: searchQuery,
460
+ articles: relatedArticles,
461
+ projects: relatedProjects,
462
+ updatedAt: now,
463
+ }, globalTTL, { articleSlug, lang });
464
+ }
465
+ }
466
+ // ── Evidence Analysis (optional) ────────────────────────────
467
+ let evidenceSection = '';
468
+ if (hasRealProvider && adapter) {
469
+ const skipEvidence = shouldSkipAnalysis(latestText, relatedArticles.length, 'moderate');
470
+ if (!skipEvidence) {
471
+ const evidenceStart = Date.now();
472
+ const abortCtrl = new AbortController();
473
+ const timeoutId = setTimeout(() => abortCtrl.abort(), EVIDENCE_ANALYSIS_TIMEOUT_MS);
474
+ try {
475
+ const provider = adapter.getProvider();
476
+ const evidenceResult = await analyzeRetrievedEvidence({
477
+ userQuery: latestText,
478
+ articles: relatedArticles,
479
+ projects: relatedProjects,
480
+ provider,
481
+ model: adapter.evidenceModel,
482
+ abortSignal: abortCtrl.signal,
483
+ });
484
+ if (evidenceResult.analysis) {
485
+ evidenceSection = buildEvidenceSection(evidenceResult.analysis);
486
+ }
487
+ timing.evidenceAnalysis = Date.now() - evidenceStart;
488
+ }
489
+ catch {
490
+ timing.evidenceAnalysis = Date.now() - evidenceStart;
491
+ }
492
+ finally {
493
+ clearTimeout(timeoutId);
494
+ }
495
+ }
496
+ }
497
+ // ── Citation Guard ──────────────────────────────────────────
498
+ const preflight = getCitationGuardPreflight({
499
+ userQuery: latestText,
500
+ articles: relatedArticles,
501
+ projects: relatedProjects,
502
+ });
503
+ // ── Build System Prompt ─────────────────────────────────────
504
+ const articlePrompt = buildArticleContextPrompt(context);
505
+ const systemPrompt = buildSystemPrompt({
506
+ static: {
507
+ authorName: env.SITE_AUTHOR || '博主',
508
+ siteUrl: env.SITE_URL || '',
509
+ lang,
510
+ },
511
+ semiStatic: {
512
+ authorContext: getAuthorContext(),
513
+ voiceProfile: getVoiceProfile(),
514
+ },
515
+ dynamic: {
516
+ userQuery: searchQuery,
517
+ articles: relatedArticles,
518
+ projects: relatedProjects,
519
+ evidenceSection: articlePrompt
520
+ ? `${evidenceSection}\n${articlePrompt}`
521
+ : evidenceSection,
522
+ },
523
+ });
524
+ // ── Stream Response via createUIMessageStream ───────────────
525
+ const stream = createUIMessageStream({
526
+ execute: async ({ writer }) => {
527
+ const articleCount = relatedArticles.length + relatedProjects.length;
528
+ // Push status: search results
529
+ if (articleCount > 0) {
530
+ writer.write({
531
+ type: 'message-metadata',
532
+ messageMetadata: createChatStatusData({
533
+ stage: 'search',
534
+ message: t('ai.status.found', lang, { count: articleCount }),
535
+ progress: 40,
536
+ }),
537
+ });
538
+ }
539
+ // Push source parts for top related articles
540
+ for (const article of relatedArticles.slice(0, 3)) {
541
+ try {
542
+ writer.write({
543
+ type: 'source-url',
544
+ sourceId: `source-${article.title}`,
545
+ url: article.url ?? '#',
546
+ title: article.title,
547
+ });
548
+ }
549
+ catch {
550
+ // source writing is best-effort
551
+ }
552
+ }
553
+ // Citation guard preflight: return canned response without calling LLM
554
+ if (preflight) {
555
+ writer.write({
556
+ type: 'message-metadata',
557
+ messageMetadata: createChatStatusData({
558
+ stage: 'answer',
559
+ message: t('ai.status.citation', lang),
560
+ progress: 100,
561
+ done: true,
562
+ }),
563
+ });
564
+ const partId = `preflight-${Date.now()}`;
565
+ writer.write({ type: 'text-start', id: partId });
566
+ writer.write({ type: 'text-delta', id: partId, delta: preflight.text });
567
+ writer.write({ type: 'text-end', id: partId });
568
+ writer.write({ type: 'finish', finishReason: 'stop' });
569
+ return;
570
+ }
571
+ // Push status: generating
572
+ writer.write({
573
+ type: 'message-metadata',
574
+ messageMetadata: createChatStatusData({
575
+ stage: 'answer',
576
+ message: t('ai.status.generating', lang),
577
+ progress: 60,
578
+ }),
579
+ });
580
+ // Try real provider with stream-level error detection
581
+ let streamSuccess = false;
582
+ let responseText = '';
583
+ let reasoningText;
584
+ let tokenUsage;
585
+ const generationStart = Date.now();
586
+ if (adapter) {
587
+ try {
588
+ const provider = adapter.getProvider();
589
+ const result = streamText({
590
+ model: provider.chatModel(adapter.model),
591
+ system: systemPrompt,
592
+ messages: await convertToModelMessages(messages),
593
+ temperature: 0.3,
594
+ maxOutputTokens: 2500,
595
+ onError: ({ error }) => {
596
+ console.error('[chat-handler] streamText error:', error);
597
+ },
598
+ });
599
+ let hasTextOutput = false;
600
+ const errors = [];
601
+ writer.merge(result.toUIMessageStream({ sendFinish: false }));
602
+ await result.consumeStream({
603
+ onError: (error) => {
604
+ errors.push(error instanceof Error ? error : new Error(String(error)));
605
+ },
606
+ });
607
+ const text = await result.text;
608
+ const reasoningPromise = result.reasoning;
609
+ const usagePromise = result.usage;
610
+ if (reasoningPromise) {
611
+ try {
612
+ const reasoningOutput = await Promise.resolve(reasoningPromise);
613
+ reasoningText = typeof reasoningOutput === 'string' ? reasoningOutput :
614
+ (Array.isArray(reasoningOutput) ? reasoningOutput.map((r) => {
615
+ if (typeof r === 'object' && r !== null && 'text' in r)
616
+ return r.text;
617
+ return String(r);
618
+ }).join('') : undefined);
619
+ }
620
+ catch { }
621
+ }
622
+ if (usagePromise) {
623
+ try {
624
+ const usage = await Promise.resolve(usagePromise);
625
+ const inputTokens = usage.inputTokens ?? 0;
626
+ const outputTokens = usage.outputTokens ?? 0;
627
+ tokenUsage = {
628
+ total: usage.totalTokens ?? inputTokens + outputTokens,
629
+ input: inputTokens,
630
+ output: outputTokens,
631
+ };
632
+ }
633
+ catch { }
634
+ }
635
+ timing.generation = Date.now() - generationStart;
636
+ responseText = text;
637
+ hasTextOutput = text.length > 0;
638
+ if (hasTextOutput && errors.length === 0) {
639
+ adapter.recordSuccess();
640
+ writer.write({ type: 'finish', finishReason: 'stop' });
641
+ streamSuccess = true;
642
+ // Save to response cache if enabled and public question
643
+ if (responseCacheConfig.enabled && publicQuestion && (!publicQuestion.needsContext || articleSlug)) {
644
+ const globalTTL = getGlobalCacheTTL(publicQuestion.type);
645
+ const responseCacheData = {
646
+ query: searchQuery,
647
+ thinking: reasoningText,
648
+ response: text,
649
+ articles: relatedArticles,
650
+ projects: relatedProjects,
651
+ lang,
652
+ model: adapter.model,
653
+ updatedAt: Date.now(),
654
+ };
655
+ await setResponseCache(cache, publicQuestion.type, responseCacheData, globalTTL, { articleSlug, lang });
656
+ }
657
+ }
658
+ else if (errors.length > 0) {
659
+ adapter.recordFailure(errors[0]);
660
+ console.error('[chat-handler] Stream error:', errors[0].message);
661
+ const errorId = `error-${Date.now()}`;
662
+ writer.write({ type: 'text-start', id: errorId });
663
+ writer.write({
664
+ type: 'text-delta',
665
+ id: errorId,
666
+ delta: t('ai.error.generic', lang)
667
+ });
668
+ writer.write({ type: 'text-end', id: errorId });
669
+ writer.write({ type: 'finish', finishReason: 'error' });
670
+ streamSuccess = true;
671
+ }
672
+ else if (!hasTextOutput) {
673
+ const noOutputId = `no-output-${Date.now()}`;
674
+ writer.write({ type: 'text-start', id: noOutputId });
675
+ writer.write({ type: 'text-delta', id: noOutputId, delta: t('ai.error.noOutput', lang) });
676
+ writer.write({ type: 'text-end', id: noOutputId });
677
+ writer.write({ type: 'finish', finishReason: 'stop' });
678
+ streamSuccess = true;
679
+ }
680
+ else {
681
+ writer.write({ type: 'finish', finishReason: 'stop' });
682
+ streamSuccess = true;
683
+ }
684
+ }
685
+ catch (err) {
686
+ timing.generation = Date.now() - generationStart;
687
+ adapter.recordFailure(err instanceof Error ? err : new Error(String(err)));
688
+ console.error('[chat-handler] Provider threw:', err.message);
689
+ }
690
+ }
691
+ // Fallback to mock if real provider didn't produce output
692
+ if (!streamSuccess) {
693
+ const { getMockResponse } = await import('../providers/mock.js');
694
+ const mockText = getMockResponse(latestText, lang);
695
+ timing.generation = Date.now() - generationStart;
696
+ responseText = mockText;
697
+ writer.write({
698
+ type: 'message-metadata',
699
+ messageMetadata: createChatStatusData({
700
+ stage: 'answer',
701
+ message: t('ai.status.fallback', lang),
702
+ progress: 80,
703
+ }),
704
+ });
705
+ const fallbackId = `fallback-${Date.now()}`;
706
+ writer.write({ type: 'text-start', id: fallbackId });
707
+ writer.write({ type: 'text-delta', id: fallbackId, delta: mockText });
708
+ writer.write({ type: 'text-end', id: fallbackId });
709
+ writer.write({ type: 'finish', finishReason: 'stop' });
710
+ }
711
+ // Send notification (fire and forget)
712
+ if (responseText) {
713
+ const notifyTiming = {
714
+ total: Date.now() - timing.start,
715
+ keywordExtraction: timing.keywordExtraction,
716
+ search: timing.search,
717
+ evidenceAnalysis: timing.evidenceAnalysis,
718
+ generation: timing.generation,
719
+ };
720
+ const notifyModel = adapter ? {
721
+ name: adapter.model,
722
+ provider: env.AI_PROVIDER || undefined,
723
+ apiHost: env.AI_BASE_URL || undefined,
724
+ } : undefined;
725
+ const notifyArticles = relatedArticles.slice(0, 5).map(a => ({
726
+ title: a.title,
727
+ url: a.url,
728
+ }));
729
+ const sessionId = cacheKey || `dev-${Date.now().toString(36)}`;
730
+ void notifyAiChat({
731
+ env,
732
+ sessionId,
733
+ messages,
734
+ aiResponse: responseText,
735
+ referencedArticles: notifyArticles,
736
+ model: notifyModel,
737
+ usage: tokenUsage,
738
+ timing: notifyTiming,
739
+ });
740
+ }
741
+ },
742
+ });
743
+ return createUIMessageStreamResponse({
744
+ stream,
745
+ headers: {
746
+ 'Access-Control-Allow-Origin': '*',
747
+ 'Cache-Control': 'no-cache',
748
+ },
749
+ });
750
+ }