33const API = '' ; // same origin
44// Canonical external AI gateway for this project: Worker custom domain -> service `apis` / production.
55const AI_API = 'https://ai.bdfz.net/' ;
6+ const AI_CHAT_MODEL = 'gemini-flash-latest' ;
7+ const AI_REQUEST_TIMEOUT_MS = 20000 ;
68const IMG_CDN = 'https://img.rdfzer.com' ;
7- const DEFAULT_FRONTEND_VERSION = 'refactor- 2026.03.06-r8 ' ;
9+ const DEFAULT_FRONTEND_VERSION = '2026.03.06-r11 ' ;
810const FRONTEND_VERSION_FILE = '/assets/version.json' ;
911const AI_MEMORY_LIMIT = 12 ;
1012const DOWNLOADABLE_LIBRARY_BOOKS = 316 ;
@@ -57,6 +59,46 @@ function logClientAIChat({ query, userMessage, summary, provider, success, error
5759 } ) . catch ( ( ) => { } ) ;
5860}
5961
62+ async function fetchWithTimeout ( url , options = { } , timeoutMs = AI_REQUEST_TIMEOUT_MS ) {
63+ const controller = new AbortController ( ) ;
64+ const timer = setTimeout ( ( ) => controller . abort ( ) , timeoutMs ) ;
65+ try {
66+ return await fetch ( url , { ...options , signal : controller . signal } ) ;
67+ } catch ( error ) {
68+ if ( error . name === 'AbortError' ) {
69+ throw new Error ( `请求超时 (${ Math . round ( timeoutMs / 1000 ) } s)` ) ;
70+ }
71+ throw error ;
72+ } finally {
73+ clearTimeout ( timer ) ;
74+ }
75+ }
76+
77+ async function readJsonLike ( response ) {
78+ const raw = await response . text ( ) ;
79+ if ( ! raw ) return { data : { } , raw : '' } ;
80+ try {
81+ return { data : JSON . parse ( raw ) , raw } ;
82+ } catch ( _ ) {
83+ return { data : null , raw } ;
84+ }
85+ }
86+
87+ function responseErrorMessage ( response , data , raw , fallbackMessage ) {
88+ if ( data && typeof data === 'object' ) {
89+ if ( typeof data . detail === 'string' && data . detail . trim ( ) ) return data . detail . trim ( ) ;
90+ if ( typeof data . error === 'string' && data . error . trim ( ) ) return data . error . trim ( ) ;
91+ if ( typeof data . message === 'string' && data . message . trim ( ) ) return data . message . trim ( ) ;
92+ }
93+
94+ const normalizedRaw = String ( raw || '' ) . replace ( / \s + / g, ' ' ) . trim ( ) ;
95+ if ( ! normalizedRaw ) return `${ fallbackMessage } (${ response . status } )` ;
96+ if ( normalizedRaw . startsWith ( '<!DOCTYPE' ) || normalizedRaw . startsWith ( '<html' ) ) {
97+ return `${ fallbackMessage } (${ response . status } )` ;
98+ }
99+ return normalizedRaw . slice ( 0 , 180 ) ;
100+ }
101+
60102function renderAIStarters ( ) {
61103 if ( ! aiStarters ) return ;
62104 const subjectCount = Object . keys ( currentData ?. subject_counts || { } ) . length ;
@@ -81,7 +123,7 @@ function renderAIStarters() {
81123}
82124
83125async function fetchAIContext ( userMessage , history ) {
84- const res = await fetch ( `${ API } /api/chat/context` , {
126+ const res = await fetchWithTimeout ( `${ API } /api/chat/context` , {
85127 method : 'POST' ,
86128 headers : { 'Content-Type' : 'application/json' } ,
87129 body : JSON . stringify ( {
@@ -90,10 +132,11 @@ async function fetchAIContext(userMessage, history) {
90132 history,
91133 } ) ,
92134 } ) ;
93- if ( ! res . ok ) {
94- throw new Error ( `上下文构建失败 (${ res . status } )` ) ;
135+ const { data, raw } = await readJsonLike ( res ) ;
136+ if ( ! res . ok || ! data ) {
137+ throw new Error ( responseErrorMessage ( res , data , raw , '上下文构建失败' ) ) ;
95138 }
96- return await res . json ( ) ;
139+ return data ;
97140}
98141
99142function buildClientAIPrompt ( userMessage , contextPayload , history ) {
@@ -280,7 +323,7 @@ async function sendAIMessage(userMessage) {
280323 let usedBrowserFallback = false ;
281324
282325 try {
283- const res = await fetch ( `${ API } /api/chat` , {
326+ const res = await fetchWithTimeout ( `${ API } /api/chat` , {
284327 method : 'POST' ,
285328 headers : { 'Content-Type' : 'application/json' } ,
286329 body : JSON . stringify ( {
@@ -289,9 +332,9 @@ async function sendAIMessage(userMessage) {
289332 history,
290333 } ) ,
291334 } ) ;
292- const data = await res . json ( ) ;
293- if ( ! res . ok ) {
294- throw new Error ( data . detail || ` AI 对话失败 ( ${ res . status } )` ) ;
335+ const { data, raw } = await readJsonLike ( res ) ;
336+ if ( ! res . ok || ! data ) {
337+ throw new Error ( responseErrorMessage ( res , data , raw , ' AI 对话失败' ) ) ;
295338 }
296339 answer = data . answer || '' ;
297340 contextPayload = data . context || { } ;
@@ -300,14 +343,19 @@ async function sendAIMessage(userMessage) {
300343 console . warn ( 'Server-side chat failed, falling back to direct AI call.' , serverChatError ) ;
301344 const fullContext = await fetchAIContext ( cleanMessage , history ) ;
302345 const prompt = buildClientAIPrompt ( cleanMessage , fullContext , history ) ;
303- const aiRes = await fetch ( AI_API , {
346+ const aiRes = await fetchWithTimeout ( AI_API , {
304347 method : 'POST' ,
305348 headers : { 'Content-Type' : 'application/json' } ,
306- body : JSON . stringify ( { prompt } ) ,
349+ body : JSON . stringify ( {
350+ prompt,
351+ model : AI_CHAT_MODEL ,
352+ taskType : 'chat' ,
353+ thinkingLevel : 'low' ,
354+ } ) ,
307355 } ) ;
308- const aiData = await aiRes . json ( ) ;
309- if ( ! aiRes . ok || ! aiData . answer ) {
310- throw new Error ( aiData . error || serverChatError . message || ` AI 服务错误 ( ${ aiRes . status } )` ) ;
356+ const { data : aiData , raw : aiRaw } = await readJsonLike ( aiRes ) ;
357+ if ( ! aiRes . ok || ! aiData ? .answer ) {
358+ throw new Error ( responseErrorMessage ( aiRes , aiData , aiRaw , serverChatError . message || ' AI 服务错误' ) ) ;
311359 }
312360 answer = aiData . answer ;
313361 contextPayload = {
@@ -371,13 +419,13 @@ async function copyAIConversation() {
371419 await navigator . clipboard . writeText ( text ) ;
372420 if ( aiCopyBtn ) {
373421 const prev = aiCopyBtn . textContent ;
374- aiCopyBtn . textContent = '✅ 已复制 ' ;
422+ aiCopyBtn . textContent = '✓ ' ;
375423 setTimeout ( ( ) => { aiCopyBtn . textContent = prev ; } , 1200 ) ;
376424 }
377425 } catch ( _ ) {
378426 if ( aiCopyBtn ) {
379427 const prev = aiCopyBtn . textContent ;
380- aiCopyBtn . textContent = '❌ 复制失败 ' ;
428+ aiCopyBtn . textContent = '! ' ;
381429 setTimeout ( ( ) => { aiCopyBtn . textContent = prev ; } , 1200 ) ;
382430 }
383431 }
@@ -401,7 +449,7 @@ async function loadFrontendVersion() {
401449 // fallback to default version
402450 }
403451
404- footer . textContent = `AI 高中教材 · 开源项目 · MIT License · 前端重构版本 ${ version } ${ updatedAt ? ` · ${ updatedAt } ` : '' } ` ;
452+ footer . textContent = `重构版本 ${ version } ${ updatedAt ? ` · ${ updatedAt } ` : '' } ` ;
405453}
406454
407455loadFrontendVersion ( ) ;
@@ -531,15 +579,11 @@ async function loadTrending() {
531579 const data = await res . json ( ) ;
532580 const section = document . getElementById ( 'trending-section' ) ;
533581 const popularGroup = document . getElementById ( 'trending-popular' ) ;
534- const recentGroup = document . getElementById ( 'trending-recent' ) ;
535582 const popularTags = document . getElementById ( 'trending-popular-tags' ) ;
536- const recentTags = document . getElementById ( 'trending-recent-tags' ) ;
583+ if ( ! section || ! popularGroup || ! popularTags ) return ;
537584
538- let hasContent = false ;
539-
540- // Popular searches
585+ popularTags . innerHTML = '' ;
541586 if ( data . popular && data . popular . length > 0 ) {
542- popularTags . innerHTML = '' ;
543587 data . popular . slice ( 0 , 10 ) . forEach ( item => {
544588 const btn = document . createElement ( 'button' ) ;
545589 btn . className = 'trending-tag popular' ;
@@ -551,27 +595,11 @@ async function loadTrending() {
551595 popularTags . appendChild ( btn ) ;
552596 } ) ;
553597 popularGroup . classList . remove ( 'hidden' ) ;
554- hasContent = true ;
555- }
556-
557- // Recent searches
558- if ( data . recent && data . recent . length > 0 ) {
559- recentTags . innerHTML = '' ;
560- data . recent . slice ( 0 , 8 ) . forEach ( item => {
561- const btn = document . createElement ( 'button' ) ;
562- btn . className = 'trending-tag recent' ;
563- btn . textContent = item . query ;
564- btn . addEventListener ( 'click' , ( ) => {
565- searchInput . value = item . query ;
566- doSearch ( item . query ) ;
567- } ) ;
568- recentTags . appendChild ( btn ) ;
569- } ) ;
570- recentGroup . classList . remove ( 'hidden' ) ;
571- hasContent = true ;
598+ section . classList . remove ( 'hidden' ) ;
599+ } else {
600+ popularGroup . classList . add ( 'hidden' ) ;
601+ section . classList . add ( 'hidden' ) ;
572602 }
573-
574- if ( hasContent ) section . classList . remove ( 'hidden' ) ;
575603 } catch ( _ ) { /* silent */ }
576604}
577605
@@ -1812,14 +1840,19 @@ ${crossSubjectContext || '(未找到直接跨学科教材)'}
181218402. 只根据给定证据回答,不要编造教材内容。
181318413. 如果跨学科证据不足,必须明确写“跨学科证据不足”。` ;
18141842
1815- const aiRes = await fetch ( AI_API , {
1843+ const aiRes = await fetchWithTimeout ( AI_API , {
18161844 method : 'POST' ,
18171845 headers : { 'Content-Type' : 'application/json' } ,
1818- body : JSON . stringify ( { prompt } ) ,
1846+ body : JSON . stringify ( {
1847+ prompt,
1848+ model : AI_CHAT_MODEL ,
1849+ taskType : 'chat' ,
1850+ thinkingLevel : 'low' ,
1851+ } ) ,
18191852 } ) ;
1820- const aiData = await aiRes . json ( ) ;
1821- if ( ! aiRes . ok ) {
1822- throw new Error ( aiData . error || ` AI 服务错误 ( ${ aiRes . status } )` ) ;
1853+ const { data : aiData , raw : aiRaw } = await readJsonLike ( aiRes ) ;
1854+ if ( ! aiRes . ok || ! aiData ) {
1855+ throw new Error ( responseErrorMessage ( aiRes , aiData , aiRaw , ' AI 服务错误' ) ) ;
18231856 }
18241857
18251858 if ( aiData . answer ) {
0 commit comments