@astro-minimax/ai 0.8.2 → 0.9.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 (51) hide show
  1. package/dist/cache/global-cache.js +145 -0
  2. package/dist/cache/index.js +96 -0
  3. package/dist/cache/kv-adapter.js +99 -0
  4. package/dist/cache/memory-adapter.js +97 -0
  5. package/dist/cache/response-cache.js +87 -0
  6. package/dist/cache/types.js +8 -0
  7. package/dist/data/metadata-loader.js +48 -0
  8. package/dist/data/types.js +0 -0
  9. package/dist/fact-registry/fact-matcher.js +128 -0
  10. package/dist/fact-registry/prompt-injector.js +54 -0
  11. package/dist/fact-registry/registry.js +41 -0
  12. package/dist/fact-registry/types.js +0 -0
  13. package/dist/intelligence/citation-appender.js +63 -0
  14. package/dist/intelligence/citation-guard.js +108 -0
  15. package/dist/intelligence/evidence-analysis.js +79 -0
  16. package/dist/intelligence/intent-detect.js +93 -0
  17. package/dist/intelligence/keyword-extract.js +89 -0
  18. package/dist/intelligence/response-templates.js +117 -0
  19. package/dist/intelligence/types.js +0 -0
  20. package/dist/middleware/rate-limiter.js +110 -0
  21. package/dist/prompt/dynamic-layer.js +64 -0
  22. package/dist/prompt/prompt-builder.js +15 -0
  23. package/dist/prompt/semi-static-layer.js +28 -0
  24. package/dist/prompt/static-layer.js +153 -0
  25. package/dist/prompt/types.js +0 -0
  26. package/dist/provider-manager/base.js +53 -0
  27. package/dist/provider-manager/config.js +135 -0
  28. package/dist/provider-manager/index.js +19 -0
  29. package/dist/provider-manager/manager.js +122 -0
  30. package/dist/provider-manager/mock.js +77 -0
  31. package/dist/provider-manager/openai.js +106 -0
  32. package/dist/provider-manager/types.js +0 -0
  33. package/dist/provider-manager/workers.js +76 -0
  34. package/dist/providers/mock.js +227 -0
  35. package/dist/search/idf.js +24 -0
  36. package/dist/search/search-api.js +94 -0
  37. package/dist/search/search-index.js +32 -0
  38. package/dist/search/search-utils.js +81 -0
  39. package/dist/search/session-cache.js +96 -0
  40. package/dist/search/types.js +0 -0
  41. package/dist/search/vector-reranker.js +103 -0
  42. package/dist/server/chat-handler.js +603 -0
  43. package/dist/server/errors.js +46 -0
  44. package/dist/server/metadata-init.js +49 -0
  45. package/dist/server/notify.js +70 -0
  46. package/dist/server/stream-helpers.js +202 -0
  47. package/dist/server/types.js +16 -0
  48. package/dist/stream/mock-stream.js +26 -0
  49. package/dist/stream/response.js +21 -0
  50. package/dist/utils/i18n.js +154 -0
  51. package/package.json +3 -3
@@ -0,0 +1,603 @@
1
+ import {
2
+ createUIMessageStream,
3
+ createUIMessageStreamResponse,
4
+ streamText,
5
+ convertToModelMessages
6
+ } from "ai";
7
+ import { t, getLang } from "../utils/i18n.js";
8
+ import {
9
+ getClientIP,
10
+ checkRateLimit,
11
+ rateLimitResponse,
12
+ searchArticles,
13
+ searchProjects,
14
+ getSessionCacheKey,
15
+ getCachedContext,
16
+ setCachedContext,
17
+ shouldReuseSearchContext,
18
+ buildLocalSearchQuery,
19
+ shouldRunKeywordExtraction,
20
+ extractSearchKeywords,
21
+ KEYWORD_EXTRACTION_TIMEOUT_MS,
22
+ shouldSkipAnalysis,
23
+ analyzeRetrievedEvidence,
24
+ buildEvidenceSection,
25
+ EVIDENCE_ANALYSIS_TIMEOUT_MS,
26
+ getCitationGuardPreflight,
27
+ buildSystemPrompt,
28
+ getAuthorContext,
29
+ getVoiceProfile,
30
+ mergeResults,
31
+ getProviderManager,
32
+ createCacheAdapter,
33
+ detectPublicQuestion,
34
+ getGlobalSearchCache,
35
+ shouldAppendCitations,
36
+ formatCitationBlock,
37
+ selectCitations,
38
+ setGlobalSearchCache,
39
+ getGlobalCacheTTL,
40
+ getResponseCache,
41
+ setResponseCache,
42
+ getResponseCacheConfig,
43
+ rankArticlesByIntent,
44
+ matchFactsToQuery,
45
+ buildFactSection
46
+ } from "../index.js";
47
+ import { createChatStatusData } from "./types.js";
48
+ import { errors, corsPreflightResponse } from "./errors.js";
49
+ import { notifyAiChat } from "./notify.js";
50
+ import {
51
+ writeSearchStatus,
52
+ writeGeneratingStatus,
53
+ writeSourceArticles,
54
+ streamLLMResponse,
55
+ streamMockFallback,
56
+ streamCachedResponse
57
+ } from "./stream-helpers.js";
58
+ const MAX_HISTORY_MESSAGES = 20;
59
+ const MAX_INPUT_LENGTH = 500;
60
+ const REQUEST_TIMEOUT_MS = 45e3;
61
+ function sendNotification(args) {
62
+ const { env, messages, responseText, relatedArticles, model, usage, timing, cacheKey, waitUntil } = args;
63
+ const sessionId = cacheKey || `dev-${Date.now().toString(36)}`;
64
+ const notifyArticles = relatedArticles.slice(0, 5).map((a) => ({
65
+ title: a.title,
66
+ url: a.url
67
+ }));
68
+ const notifyPromise = notifyAiChat({
69
+ env,
70
+ sessionId,
71
+ messages,
72
+ aiResponse: responseText,
73
+ referencedArticles: notifyArticles,
74
+ model,
75
+ usage,
76
+ timing
77
+ });
78
+ if (waitUntil) {
79
+ waitUntil(notifyPromise);
80
+ } else {
81
+ void notifyPromise;
82
+ }
83
+ }
84
+ function getMessageText(message) {
85
+ if (Array.isArray(message.parts)) {
86
+ return message.parts.filter((p) => p.type === "text").map((p) => p.text).join("");
87
+ }
88
+ return "";
89
+ }
90
+ function hasContent(message) {
91
+ const text = getMessageText(message);
92
+ if (text.trim()) return true;
93
+ if (Array.isArray(message.parts)) {
94
+ return message.parts.some((p) => p.type !== "text");
95
+ }
96
+ return false;
97
+ }
98
+ function filterValidMessages(messages) {
99
+ const filtered = [];
100
+ let lastRole = null;
101
+ for (const msg of messages) {
102
+ if (!hasContent(msg)) continue;
103
+ if (msg.role === lastRole) continue;
104
+ filtered.push(msg);
105
+ lastRole = msg.role;
106
+ }
107
+ if (filtered.length > 0 && filtered[filtered.length - 1].role !== "user") {
108
+ filtered.pop();
109
+ }
110
+ return filtered;
111
+ }
112
+ function buildArticleContextPrompt(context) {
113
+ if (context.scope !== "article" || !context.article) return "";
114
+ const a = context.article;
115
+ const parts = [
116
+ "\n[\u5F53\u524D\u9605\u8BFB\u6587\u7AE0]",
117
+ `\u7528\u6237\u6B63\u5728\u9605\u8BFB\uFF1A\u300A${a.title}\u300B`
118
+ ];
119
+ if (a.summary) parts.push(`\u6458\u8981\uFF1A${a.summary}`);
120
+ if (a.abstract) parts.push(`\u8BE6\u7EC6\u6982\u8981\uFF1A${a.abstract}`);
121
+ if (a.keyPoints?.length) parts.push(`\u6838\u5FC3\u8981\u70B9\uFF1A${a.keyPoints.join("\uFF1B")}`);
122
+ if (a.categories?.length) parts.push(`\u5206\u7C7B\uFF1A${a.categories.join("\u3001")}`);
123
+ parts.push(
124
+ "",
125
+ "\u4F60\u6B63\u5728\u966A\u7528\u6237\u9605\u8BFB\u8FD9\u7BC7\u6587\u7AE0\u3002\u4F18\u5148\u56F4\u7ED5\u8FD9\u7BC7\u6587\u7AE0\u7684\u5185\u5BB9\u56DE\u7B54\u95EE\u9898\u3002",
126
+ "\u5F53\u7528\u6237\u7684\u95EE\u9898\u4E0E\u5F53\u524D\u6587\u7AE0\u76F8\u5173\u65F6\uFF0C\u5F15\u7528\u6587\u7AE0\u4E2D\u7684\u5177\u4F53\u5185\u5BB9\u3002",
127
+ "\u5F53\u7528\u6237\u60F3\u8981\u5EF6\u4F38\u65F6\uFF0C\u63A8\u8350\u76F8\u5173\u7684\u535A\u5BA2\u6587\u7AE0\u3002"
128
+ );
129
+ return parts.join("\n");
130
+ }
131
+ async function handleChatRequest(options) {
132
+ const { env, request: req, waitUntil } = options;
133
+ if (req.method === "OPTIONS") return corsPreflightResponse();
134
+ if (req.method !== "POST") return errors.methodNotAllowed("zh");
135
+ const ip = getClientIP(req);
136
+ const rateCheck = checkRateLimit(ip, env);
137
+ if (!rateCheck.allowed) return rateLimitResponse(rateCheck, "zh");
138
+ let body;
139
+ try {
140
+ body = await req.json();
141
+ } catch {
142
+ return errors.invalidRequest(t("ai.error.format", "zh"));
143
+ }
144
+ const lang = getLang(body.lang ?? env.SITE_LANG);
145
+ const context = body.context ?? { scope: "global" };
146
+ const rawMessages = (body.messages ?? []).slice(-MAX_HISTORY_MESSAGES);
147
+ if (!rawMessages.length) return errors.emptyMessage(lang);
148
+ const messages = filterValidMessages(rawMessages);
149
+ if (!messages.length) return errors.emptyMessage(lang);
150
+ const latestMessage = messages[messages.length - 1];
151
+ const latestText = getMessageText(latestMessage);
152
+ if (!latestText) return errors.emptyContent(lang);
153
+ if (latestText.length > MAX_INPUT_LENGTH) return errors.inputTooLong(MAX_INPUT_LENGTH, lang);
154
+ const requestAbort = new AbortController();
155
+ const requestTimer = setTimeout(() => requestAbort.abort(), REQUEST_TIMEOUT_MS);
156
+ try {
157
+ return await runPipeline({ env, messages, latestText, context, req, requestAbort, lang, waitUntil });
158
+ } catch (err) {
159
+ if (requestAbort.signal.aborted) return errors.timeout(lang);
160
+ console.error("[chat-handler] Unexpected error:", err);
161
+ return errors.internal(void 0, lang);
162
+ } finally {
163
+ clearTimeout(requestTimer);
164
+ }
165
+ }
166
+ async function runPipeline(args) {
167
+ const { env, messages, latestText, context, req, lang, waitUntil } = args;
168
+ const timing = { start: Date.now() };
169
+ const cache = createCacheAdapter(env);
170
+ const responseCacheConfig = getResponseCacheConfig(env);
171
+ const manager = getProviderManager(env, {
172
+ enableMockFallback: true,
173
+ unhealthyThreshold: 3,
174
+ healthRecoveryTTL: 6e4
175
+ });
176
+ const hasRealProvider = manager.hasProviders();
177
+ const adapter = hasRealProvider ? await manager.getAvailableAdapter() : null;
178
+ const articleSlug = context.scope === "article" && context.article?.slug ? context.article.slug : void 0;
179
+ const publicQuestion = detectPublicQuestion(latestText);
180
+ let globalCacheHit = false;
181
+ let globalCacheType;
182
+ if (publicQuestion && (!publicQuestion.needsContext || articleSlug)) {
183
+ const globalCacheContext = { articleSlug, lang };
184
+ if (responseCacheConfig.enabled) {
185
+ const cachedResponse = await getResponseCache(cache, publicQuestion.type, globalCacheContext);
186
+ if (cachedResponse) {
187
+ globalCacheHit = true;
188
+ globalCacheType = publicQuestion.type;
189
+ const notifyTiming = { total: Date.now() - timing.start };
190
+ sendNotification({ env, messages, responseText: cachedResponse.response, relatedArticles: cachedResponse.articles, timing: notifyTiming, waitUntil });
191
+ const stream2 = createUIMessageStream({
192
+ execute: async ({ writer }) => {
193
+ await streamCachedResponse(writer, cachedResponse, responseCacheConfig, lang);
194
+ }
195
+ });
196
+ return createUIMessageStreamResponse({ stream: stream2, headers: { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache" } });
197
+ }
198
+ }
199
+ const cachedSearch = await getGlobalSearchCache(cache, publicQuestion.type, globalCacheContext);
200
+ if (cachedSearch) {
201
+ globalCacheHit = true;
202
+ globalCacheType = publicQuestion.type;
203
+ const stream2 = createUIMessageStream({
204
+ execute: async ({ writer }) => {
205
+ const w = writer;
206
+ writeSearchStatus(w, cachedSearch.articles.length + cachedSearch.projects.length, lang);
207
+ if (cachedSearch.articles.length + cachedSearch.projects.length > 0) {
208
+ writeGeneratingStatus(w, lang);
209
+ }
210
+ writeSourceArticles(w, cachedSearch.articles);
211
+ let responseText = "";
212
+ if (adapter) {
213
+ const articlePrompt2 = buildArticleContextPrompt(context);
214
+ const matchedFacts2 = matchFactsToQuery(cachedSearch.query, lang);
215
+ const factPromptSection2 = buildFactSection(matchedFacts2, lang);
216
+ const systemPrompt2 = buildSystemPrompt({
217
+ static: { authorName: env.SITE_AUTHOR || "\u535A\u4E3B", siteUrl: env.SITE_URL || "", lang },
218
+ semiStatic: { authorContext: getAuthorContext(), voiceProfile: getVoiceProfile() },
219
+ dynamic: { userQuery: cachedSearch.query, articles: cachedSearch.articles, projects: cachedSearch.projects, evidenceSection: articlePrompt2, factSection: factPromptSection2 }
220
+ });
221
+ const llmResult = await streamLLMResponse({ writer: w, adapter, systemPrompt: systemPrompt2, messages, lang });
222
+ responseText = llmResult.responseText;
223
+ if (responseCacheConfig.enabled && llmResult.success && llmResult.responseText.length > 0) {
224
+ const globalTTL = getGlobalCacheTTL(publicQuestion.type);
225
+ const responseCacheData = {
226
+ query: cachedSearch.query,
227
+ thinking: llmResult.reasoningText,
228
+ response: llmResult.responseText,
229
+ articles: cachedSearch.articles,
230
+ projects: cachedSearch.projects,
231
+ lang,
232
+ model: adapter.model,
233
+ updatedAt: Date.now()
234
+ };
235
+ await setResponseCache(cache, publicQuestion.type, responseCacheData, globalTTL, globalCacheContext);
236
+ }
237
+ } else {
238
+ responseText = await streamMockFallback(w, latestText, lang);
239
+ }
240
+ }
241
+ });
242
+ return createUIMessageStreamResponse({ stream: stream2, headers: { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-cache" } });
243
+ }
244
+ }
245
+ const cacheKey = getSessionCacheKey(req);
246
+ const now = Date.now();
247
+ const cachedContext = cacheKey ? await getCachedContext(cacheKey, cache) : void 0;
248
+ const userTurnCount = messages.filter((m) => m.role === "user").length;
249
+ const reuseContext = shouldReuseSearchContext({ latestText, cachedContext, userTurnCount, now });
250
+ let searchQuery = buildLocalSearchQuery(latestText) || latestText;
251
+ let relatedArticles = reuseContext && cachedContext ? cachedContext.articles : [];
252
+ let relatedProjects = reuseContext && cachedContext ? cachedContext.projects : [];
253
+ if (reuseContext && cachedContext && cacheKey) {
254
+ searchQuery = cachedContext.query;
255
+ await setCachedContext(cacheKey, { ...cachedContext, updatedAt: now }, cache);
256
+ } else {
257
+ if (hasRealProvider && adapter) {
258
+ const runKW = shouldRunKeywordExtraction({
259
+ messageCount: messages.length,
260
+ localQuery: searchQuery,
261
+ latestText
262
+ });
263
+ if (runKW) {
264
+ const kwStart = Date.now();
265
+ const abortCtrl = new AbortController();
266
+ const timeoutId = setTimeout(() => abortCtrl.abort(), KEYWORD_EXTRACTION_TIMEOUT_MS);
267
+ try {
268
+ const provider = adapter.getProvider();
269
+ const kwResult = await extractSearchKeywords({
270
+ messages,
271
+ provider,
272
+ model: adapter.keywordModel,
273
+ abortSignal: abortCtrl.signal
274
+ });
275
+ timing.keywordExtraction = Date.now() - kwStart;
276
+ if (kwResult.query && !kwResult.usedFallback) {
277
+ searchQuery = kwResult.query;
278
+ if (kwResult.primaryQuery && kwResult.primaryQuery !== searchQuery) {
279
+ const searchStart = Date.now();
280
+ const primary = searchArticles(kwResult.primaryQuery, { enableDeepContent: false });
281
+ relatedArticles = mergeResults(
282
+ searchArticles(searchQuery, { enableDeepContent: true }),
283
+ primary
284
+ );
285
+ relatedProjects = searchProjects(searchQuery);
286
+ timing.search = Date.now() - searchStart;
287
+ }
288
+ }
289
+ } catch {
290
+ timing.keywordExtraction = Date.now() - kwStart;
291
+ } finally {
292
+ clearTimeout(timeoutId);
293
+ }
294
+ }
295
+ }
296
+ if (!relatedArticles.length) {
297
+ const searchStart = Date.now();
298
+ relatedArticles = searchArticles(searchQuery, { enableDeepContent: true });
299
+ relatedProjects = searchProjects(searchQuery);
300
+ timing.search = Date.now() - searchStart;
301
+ }
302
+ relatedArticles = rankArticlesByIntent(latestText, relatedArticles);
303
+ if (cacheKey) {
304
+ await setCachedContext(cacheKey, {
305
+ query: searchQuery,
306
+ articles: relatedArticles,
307
+ projects: relatedProjects,
308
+ updatedAt: now
309
+ }, cache);
310
+ }
311
+ if (publicQuestion && (!publicQuestion.needsContext || articleSlug)) {
312
+ const globalTTL = getGlobalCacheTTL(publicQuestion.type);
313
+ await setGlobalSearchCache(
314
+ cache,
315
+ publicQuestion.type,
316
+ {
317
+ query: searchQuery,
318
+ articles: relatedArticles,
319
+ projects: relatedProjects,
320
+ updatedAt: now
321
+ },
322
+ globalTTL,
323
+ { articleSlug, lang }
324
+ );
325
+ }
326
+ }
327
+ let evidenceSection = "";
328
+ if (hasRealProvider && adapter) {
329
+ const skipEvidence = shouldSkipAnalysis(latestText, relatedArticles.length, "moderate");
330
+ if (!skipEvidence) {
331
+ const evidenceStart = Date.now();
332
+ const abortCtrl = new AbortController();
333
+ const timeoutId = setTimeout(() => abortCtrl.abort(), EVIDENCE_ANALYSIS_TIMEOUT_MS);
334
+ try {
335
+ const provider = adapter.getProvider();
336
+ const evidenceResult = await analyzeRetrievedEvidence({
337
+ userQuery: latestText,
338
+ articles: relatedArticles,
339
+ projects: relatedProjects,
340
+ provider,
341
+ model: adapter.evidenceModel,
342
+ abortSignal: abortCtrl.signal
343
+ });
344
+ if (evidenceResult.analysis) {
345
+ evidenceSection = buildEvidenceSection(evidenceResult.analysis);
346
+ }
347
+ timing.evidenceAnalysis = Date.now() - evidenceStart;
348
+ } catch {
349
+ timing.evidenceAnalysis = Date.now() - evidenceStart;
350
+ } finally {
351
+ clearTimeout(timeoutId);
352
+ }
353
+ }
354
+ }
355
+ const preflight = getCitationGuardPreflight({
356
+ userQuery: latestText,
357
+ articles: relatedArticles,
358
+ projects: relatedProjects,
359
+ lang
360
+ });
361
+ const matchedFacts = matchFactsToQuery(latestText, lang);
362
+ const factPromptSection = buildFactSection(matchedFacts, lang);
363
+ const articlePrompt = buildArticleContextPrompt(context);
364
+ const systemPrompt = buildSystemPrompt({
365
+ static: {
366
+ authorName: env.SITE_AUTHOR || "\u535A\u4E3B",
367
+ siteUrl: env.SITE_URL || "",
368
+ lang
369
+ },
370
+ semiStatic: {
371
+ authorContext: getAuthorContext(),
372
+ voiceProfile: getVoiceProfile()
373
+ },
374
+ dynamic: {
375
+ userQuery: searchQuery,
376
+ articles: relatedArticles,
377
+ projects: relatedProjects,
378
+ evidenceSection: articlePrompt ? `${evidenceSection}
379
+ ${articlePrompt}` : evidenceSection,
380
+ factSection: factPromptSection,
381
+ lang
382
+ }
383
+ });
384
+ const stream = createUIMessageStream({
385
+ execute: async ({ writer }) => {
386
+ const articleCount = relatedArticles.length + relatedProjects.length;
387
+ if (articleCount > 0) {
388
+ writer.write({
389
+ type: "message-metadata",
390
+ messageMetadata: createChatStatusData({
391
+ stage: "search",
392
+ message: t("ai.status.found", lang, { count: articleCount }),
393
+ progress: 40
394
+ })
395
+ });
396
+ }
397
+ for (const article of relatedArticles.slice(0, 3)) {
398
+ try {
399
+ writer.write({
400
+ type: "source-url",
401
+ sourceId: `source-${article.title}`,
402
+ url: article.url ?? "#",
403
+ title: article.title
404
+ });
405
+ } catch {
406
+ }
407
+ }
408
+ if (preflight) {
409
+ writer.write({
410
+ type: "message-metadata",
411
+ messageMetadata: createChatStatusData({
412
+ stage: "answer",
413
+ message: t("ai.status.citation", lang),
414
+ progress: 100,
415
+ done: true
416
+ })
417
+ });
418
+ const partId = `preflight-${Date.now()}`;
419
+ writer.write({ type: "text-start", id: partId });
420
+ writer.write({ type: "text-delta", id: partId, delta: preflight.text });
421
+ writer.write({ type: "text-end", id: partId });
422
+ writer.write({ type: "finish", finishReason: "stop" });
423
+ return;
424
+ }
425
+ writer.write({
426
+ type: "message-metadata",
427
+ messageMetadata: createChatStatusData({
428
+ stage: "answer",
429
+ message: t("ai.status.generating", lang),
430
+ progress: 60
431
+ })
432
+ });
433
+ let streamSuccess = false;
434
+ let responseText = "";
435
+ let reasoningText;
436
+ let tokenUsage;
437
+ const generationStart = Date.now();
438
+ if (adapter) {
439
+ try {
440
+ const provider = adapter.getProvider();
441
+ const result = streamText({
442
+ model: provider.chatModel(adapter.model),
443
+ system: systemPrompt,
444
+ messages: await convertToModelMessages(messages),
445
+ temperature: 0.3,
446
+ maxOutputTokens: 2500,
447
+ onError: ({ error }) => {
448
+ console.error("[chat-handler] streamText error:", error);
449
+ }
450
+ });
451
+ let hasTextOutput = false;
452
+ const errors2 = [];
453
+ writer.merge(result.toUIMessageStream({ sendFinish: false }));
454
+ await result.consumeStream({
455
+ onError: (error) => {
456
+ errors2.push(error instanceof Error ? error : new Error(String(error)));
457
+ }
458
+ });
459
+ const text = await result.text;
460
+ const reasoningPromise = result.reasoning;
461
+ const usagePromise = result.usage;
462
+ if (reasoningPromise) {
463
+ try {
464
+ const reasoningOutput = await Promise.resolve(reasoningPromise);
465
+ reasoningText = typeof reasoningOutput === "string" ? reasoningOutput : Array.isArray(reasoningOutput) ? reasoningOutput.map((r) => {
466
+ if (typeof r === "object" && r !== null && "text" in r) return r.text;
467
+ return String(r);
468
+ }).join("") : void 0;
469
+ } catch {
470
+ }
471
+ }
472
+ if (usagePromise) {
473
+ try {
474
+ const usage = await Promise.resolve(usagePromise);
475
+ const inputTokens = usage.inputTokens ?? 0;
476
+ const outputTokens = usage.outputTokens ?? 0;
477
+ tokenUsage = {
478
+ total: usage.totalTokens ?? inputTokens + outputTokens,
479
+ input: inputTokens,
480
+ output: outputTokens
481
+ };
482
+ } catch {
483
+ }
484
+ }
485
+ timing.generation = Date.now() - generationStart;
486
+ responseText = text;
487
+ hasTextOutput = text.length > 0;
488
+ if (hasTextOutput && errors2.length === 0) {
489
+ adapter.recordSuccess();
490
+ if (shouldAppendCitations(responseText, relatedArticles, relatedProjects)) {
491
+ const citations = selectCitations(relatedArticles, relatedProjects, 3, 5);
492
+ if (citations.length > 0) {
493
+ const citationBlock = formatCitationBlock(citations, lang);
494
+ const citationId = `citation-${Date.now()}`;
495
+ writer.write({ type: "text-start", id: citationId });
496
+ writer.write({ type: "text-delta", id: citationId, delta: citationBlock });
497
+ writer.write({ type: "text-end", id: citationId });
498
+ responseText += citationBlock;
499
+ }
500
+ }
501
+ writer.write({ type: "finish", finishReason: "stop" });
502
+ streamSuccess = true;
503
+ if (responseCacheConfig.enabled && publicQuestion && (!publicQuestion.needsContext || articleSlug)) {
504
+ const globalTTL = getGlobalCacheTTL(publicQuestion.type);
505
+ const responseCacheData = {
506
+ query: searchQuery,
507
+ thinking: reasoningText,
508
+ response: text,
509
+ articles: relatedArticles,
510
+ projects: relatedProjects,
511
+ lang,
512
+ model: adapter.model,
513
+ updatedAt: Date.now()
514
+ };
515
+ await setResponseCache(cache, publicQuestion.type, responseCacheData, globalTTL, { articleSlug, lang });
516
+ }
517
+ } else if (errors2.length > 0) {
518
+ adapter.recordFailure(errors2[0]);
519
+ console.error("[chat-handler] Stream error:", errors2[0].message);
520
+ const errorId = `error-${Date.now()}`;
521
+ writer.write({ type: "text-start", id: errorId });
522
+ writer.write({
523
+ type: "text-delta",
524
+ id: errorId,
525
+ delta: t("ai.error.generic", lang)
526
+ });
527
+ writer.write({ type: "text-end", id: errorId });
528
+ writer.write({ type: "finish", finishReason: "error" });
529
+ streamSuccess = true;
530
+ } else if (!hasTextOutput) {
531
+ const noOutputId = `no-output-${Date.now()}`;
532
+ writer.write({ type: "text-start", id: noOutputId });
533
+ writer.write({ type: "text-delta", id: noOutputId, delta: t("ai.error.noOutput", lang) });
534
+ writer.write({ type: "text-end", id: noOutputId });
535
+ writer.write({ type: "finish", finishReason: "stop" });
536
+ streamSuccess = true;
537
+ } else {
538
+ writer.write({ type: "finish", finishReason: "stop" });
539
+ streamSuccess = true;
540
+ }
541
+ } catch (err) {
542
+ timing.generation = Date.now() - generationStart;
543
+ adapter.recordFailure(err instanceof Error ? err : new Error(String(err)));
544
+ console.error("[chat-handler] Provider threw:", err.message);
545
+ }
546
+ }
547
+ if (!streamSuccess) {
548
+ const { getMockResponse } = await import("../providers/mock.js");
549
+ const mockText = getMockResponse(latestText, lang);
550
+ timing.generation = Date.now() - generationStart;
551
+ responseText = mockText;
552
+ writer.write({
553
+ type: "message-metadata",
554
+ messageMetadata: createChatStatusData({
555
+ stage: "answer",
556
+ message: t("ai.status.fallback", lang),
557
+ progress: 80
558
+ })
559
+ });
560
+ const fallbackId = `fallback-${Date.now()}`;
561
+ writer.write({ type: "text-start", id: fallbackId });
562
+ writer.write({ type: "text-delta", id: fallbackId, delta: mockText });
563
+ writer.write({ type: "text-end", id: fallbackId });
564
+ writer.write({ type: "finish", finishReason: "stop" });
565
+ }
566
+ if (responseText) {
567
+ const notifyTiming = {
568
+ total: Date.now() - timing.start,
569
+ keywordExtraction: timing.keywordExtraction,
570
+ search: timing.search,
571
+ evidenceAnalysis: timing.evidenceAnalysis,
572
+ generation: timing.generation
573
+ };
574
+ const notifyModel = adapter ? {
575
+ name: adapter.model,
576
+ provider: env.AI_PROVIDER || void 0,
577
+ apiHost: env.AI_BASE_URL || void 0
578
+ } : void 0;
579
+ sendNotification({
580
+ env,
581
+ messages,
582
+ responseText,
583
+ relatedArticles,
584
+ model: notifyModel,
585
+ usage: tokenUsage,
586
+ timing: notifyTiming,
587
+ cacheKey,
588
+ waitUntil
589
+ });
590
+ }
591
+ }
592
+ });
593
+ return createUIMessageStreamResponse({
594
+ stream,
595
+ headers: {
596
+ "Access-Control-Allow-Origin": "*",
597
+ "Cache-Control": "no-cache"
598
+ }
599
+ });
600
+ }
601
+ export {
602
+ handleChatRequest
603
+ };
@@ -0,0 +1,46 @@
1
+ import { t } from "../utils/i18n.js";
2
+ const CORS_HEADERS = {
3
+ "Content-Type": "application/json",
4
+ "Access-Control-Allow-Origin": "*"
5
+ };
6
+ function chatError(code, error, status, options) {
7
+ const body = {
8
+ error,
9
+ code,
10
+ retryable: options?.retryable ?? false,
11
+ retryAfter: options?.retryAfter
12
+ };
13
+ const headers = { ...CORS_HEADERS };
14
+ if (options?.retryAfter) {
15
+ headers["Retry-After"] = String(options.retryAfter);
16
+ }
17
+ return new Response(JSON.stringify(body), { status, headers });
18
+ }
19
+ function te(key, lang, vars) {
20
+ return t(key, lang ?? "zh", vars);
21
+ }
22
+ const errors = {
23
+ methodNotAllowed: (lang) => chatError("METHOD_NOT_ALLOWED", lang === "en" ? "Method not allowed" : "\u65B9\u6CD5\u4E0D\u5141\u8BB8", 405),
24
+ invalidRequest: (detail, lang) => chatError("INVALID_REQUEST", detail ?? te("ai.error.format", lang), 400),
25
+ emptyMessage: (lang) => chatError("INVALID_REQUEST", te("ai.error.emptyMessage", lang), 400),
26
+ emptyContent: (lang) => chatError("INVALID_REQUEST", te("ai.error.emptyContent", lang), 400),
27
+ inputTooLong: (max, lang) => chatError("INPUT_TOO_LONG", te("ai.error.inputTooLong", lang, { max }), 400),
28
+ rateLimited: (retryAfter, lang) => chatError("RATE_LIMITED", te("ai.error.rateLimit", lang), 429, { retryable: true, retryAfter: retryAfter ?? 10 }),
29
+ timeout: (lang) => chatError("TIMEOUT", te("ai.error.timeout", lang), 504, { retryable: true }),
30
+ providerUnavailable: (lang) => chatError("PROVIDER_UNAVAILABLE", te("ai.error.unavailable", lang), 503, { retryable: true, retryAfter: 30 }),
31
+ internal: (detail, lang) => chatError("INTERNAL_ERROR", detail ?? te("ai.error.generic", lang), 500, { retryable: true, retryAfter: 5 })
32
+ };
33
+ function corsPreflightResponse() {
34
+ return new Response(null, {
35
+ headers: {
36
+ "Access-Control-Allow-Origin": "*",
37
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
38
+ "Access-Control-Allow-Headers": "Content-Type, x-session-id"
39
+ }
40
+ });
41
+ }
42
+ export {
43
+ chatError,
44
+ corsPreflightResponse,
45
+ errors
46
+ };
@@ -0,0 +1,49 @@
1
+ import {
2
+ preloadMetadata,
3
+ getAuthorContext,
4
+ getAllSummaries,
5
+ initArticleIndex,
6
+ initProjectIndex
7
+ } from "../index.js";
8
+ let initialized = false;
9
+ function initializeMetadata(config, env) {
10
+ if (initialized) return;
11
+ initialized = true;
12
+ preloadMetadata({
13
+ summaries: config.summaries,
14
+ authorContext: config.authorContext,
15
+ voiceProfile: config.voiceProfile,
16
+ factRegistry: config.factRegistry ?? null,
17
+ vectorIndex: config.vectorIndex ?? null
18
+ });
19
+ const authorCtx = getAuthorContext();
20
+ const allSummaries = getAllSummaries();
21
+ const summaryMap = new Map(allSummaries.map((s) => [s.slug, s]));
22
+ const siteUrl = config.siteUrl ?? env?.SITE_URL ?? "";
23
+ const articleDocs = (authorCtx?.posts ?? []).map((post) => {
24
+ const summary = summaryMap.get(post.id);
25
+ const baseUrl = post.url?.startsWith("http") ? "" : siteUrl;
26
+ return {
27
+ id: post.id,
28
+ title: post.title,
29
+ url: post.url ? `${baseUrl}${post.url}` : `${siteUrl}/${post.id}`,
30
+ excerpt: post.summary || summary?.summary || "",
31
+ content: [...post.keyPoints ?? [], ...summary?.keyPoints ?? []].join(" "),
32
+ categories: [post.category].filter(Boolean),
33
+ tags: post.tags ?? [],
34
+ keyPoints: [...post.keyPoints ?? [], ...summary?.keyPoints ?? []],
35
+ dateTime: post.date ? new Date(post.date).getTime() : 0,
36
+ lang: post.lang,
37
+ summary: summary?.summary
38
+ };
39
+ });
40
+ initArticleIndex(articleDocs);
41
+ initProjectIndex([]);
42
+ }
43
+ function resetMetadataInit() {
44
+ initialized = false;
45
+ }
46
+ export {
47
+ initializeMetadata,
48
+ resetMetadataInit
49
+ };