@@ -27,14 +27,23 @@ import Editor, { type Monaco, type OnMount } from '@monaco-editor/react'
2727import { toast } from '@heroui/react'
2828import type { FileNode } from '../../types'
2929import { useAuthContext } from '../../context/AuthContext'
30- import { decodeUtf8 , fetchJson , parseJsonSafe , postJson } from '../../utils/http'
30+ import { decodeUtf8 , fetchJson , parseJsonSafe } from '../../utils/http'
3131import {
3232 loadAiConfig ,
33- resolveAiEndpoint ,
3433 hasAiAuth ,
3534 is403Error ,
36- streamChatCompletion ,
3735} from '../../services/aiSettings'
36+ import {
37+ collectExtraContext ,
38+ fetchAiCompletion ,
39+ fetchAiSanitizedCode ,
40+ inlineCommentTargetLanguage ,
41+ MAX_CONTEXT_CHARS ,
42+ MAX_TRANSLATE_CHUNK_CHARS ,
43+ MAX_TRANSLATE_CHUNKS ,
44+ normalizedInterfaceLanguage ,
45+ splitTextIntoChunks ,
46+ } from './aiHelpers'
3847
3948// ── Navigation history (module-level: persists across component remounts on file change) ──
4049interface NavEntry { path : string ; line : number ; column : number }
@@ -141,81 +150,6 @@ function hashFromEncryptedPath(path?: string): string | null {
141150const IMPL_KINDS = new Set ( [ 'function' , 'method' , 'class' , 'struct' , 'trait' , 'interface' ] )
142151const AI_CACHE_PREFIX = 'INDUSTRIAL-ANALYZER_monaco_ai_v1'
143152const MAX_PROXY_REQ_PER_MIN = 10
144- const MAX_CONTEXT_CHARS = 80_000
145- const MAX_TRANSLATE_CHUNK_CHARS = 9_000
146- const MAX_TRANSLATE_CHUNKS = 6
147-
148- function splitTextIntoChunks ( input : string , chunkSize : number ) : string [ ] {
149- if ( ! input ) return [ '' ]
150- if ( input . length <= chunkSize ) return [ input ]
151-
152- const lines = input . split ( '\n' )
153- const chunks : string [ ] = [ ]
154- let current = ''
155-
156- for ( const line of lines ) {
157- const lineWithBreak = current . length === 0 ? line : `\n${ line } `
158- if ( current . length + lineWithBreak . length <= chunkSize ) {
159- current += lineWithBreak
160- continue
161- }
162- if ( current . length > 0 ) chunks . push ( current )
163- if ( line . length <= chunkSize ) {
164- current = line
165- continue
166- }
167-
168- let start = 0
169- while ( start < line . length ) {
170- const part = line . slice ( start , start + chunkSize )
171- if ( part . length === chunkSize ) chunks . push ( part )
172- else current = part
173- start += chunkSize
174- }
175- if ( start >= line . length && line . length % chunkSize === 0 ) current = ''
176- }
177-
178- if ( current . length > 0 ) chunks . push ( current )
179- return chunks
180- }
181-
182- function stripAiCodeWrappers ( raw : string ) : string {
183- let cleaned = raw . trim ( )
184-
185- const fullFence = cleaned . match ( / ^ ` ` ` [ a - z A - Z 0 - 9 _ - ] * \s * \n ( [ \s \S ] * ?) \n ` ` ` \s * $ / )
186- if ( fullFence ?. [ 1 ] ) return fullFence [ 1 ]
187-
188- const firstFenceStart = cleaned . indexOf ( '```' )
189- if ( firstFenceStart >= 0 ) {
190- const rest = cleaned . slice ( firstFenceStart )
191- const fenced = rest . match ( / ^ ` ` ` [ a - z A - Z 0 - 9 _ - ] * \s * \n ( [ \s \S ] * ?) \n ` ` ` / )
192- if ( fenced ?. [ 1 ] ) return fenced [ 1 ]
193- }
194-
195- const lines = cleaned . split ( '\n' )
196- if ( lines . length > 1 ) {
197- const first = lines [ 0 ] . toLowerCase ( )
198- const isPreamble = first . includes ( 'translated' ) || first . includes ( 'source code' ) || first . includes ( 'here is' )
199- if ( isPreamble ) {
200- cleaned = lines . slice ( 1 ) . join ( '\n' ) . trim ( )
201- }
202- }
203-
204- return cleaned
205- }
206-
207- function normalizedInterfaceLanguage ( lang : string | undefined ) : 'fr' | 'en' | 'zh' {
208- const normalized = ( lang ?? '' ) . toLowerCase ( )
209- if ( normalized . startsWith ( 'fr' ) ) return 'fr'
210- if ( normalized . startsWith ( 'zh' ) ) return 'zh'
211- return 'en'
212- }
213-
214- function inlineCommentTargetLanguage ( lang : 'fr' | 'en' | 'zh' ) : string {
215- if ( lang === 'fr' ) return 'French'
216- if ( lang === 'zh' ) return 'Chinese'
217- return 'English'
218- }
219153
220154export default function MonacoViewer ( {
221155 node,
@@ -379,39 +313,6 @@ export default function MonacoViewer({
379313 * Throws an `Error` on non-2xx HTTP responses; callers can use
380314 * {@link is403Error} to distinguish authentication failures.
381315 */
382- const fetchAiCompletion = useCallback ( async (
383- messages : Array < { role : 'system' | 'user' ; content : string } > ,
384- onChunk ?: ( chunk : string ) => void ,
385- ) : Promise < string > => {
386- const config = loadAiConfig ( )
387- const { url, model, apiKey } = resolveAiEndpoint ( config , privateKey )
388- const requestBody = {
389- model,
390- temperature : 0.2 ,
391- top_p : 1 ,
392- max_tokens : 8192 ,
393- messages,
394- }
395-
396- if ( config . streaming ) {
397- return streamChatCompletion ( url , requestBody , apiKey , onChunk )
398- }
399-
400- const body = await postJson < { choices ?: Array < { message ?: { content ?: string } } > } > (
401- url ,
402- { ...requestBody , stream : false } ,
403- { headers : { Authorization : `Bearer ${ apiKey } ` } } ,
404- )
405- const content = body . choices ?. [ 0 ] ?. message ?. content ?. trim ( )
406- if ( ! content ) throw new Error ( 'Empty model response' )
407- return content
408- } , [ privateKey ] )
409-
410- const fetchAiSanitizedCode = useCallback ( async ( messages : Array < { role : 'system' | 'user' ; content : string } > ) : Promise < string > => {
411- const content = await fetchAiCompletion ( messages )
412- return stripAiCodeWrappers ( content )
413- } , [ fetchAiCompletion ] )
414-
415316 const openAiLoadingToast = useCallback ( ( message : string ) => {
416317 if ( aiLoadingToastIdRef . current !== null ) toast . close ( aiLoadingToastIdRef . current )
417318 aiLoadingToastIdRef . current = toast ( message , {
@@ -426,30 +327,6 @@ export default function MonacoViewer({
426327 aiLoadingToastIdRef . current = null
427328 } , [ ] )
428329
429- const collectExtraContext = useCallback ( async ( ) : Promise < string > => {
430- const includeMatches = text . match ( / # i n c l u d e \s + [ < " ] ( [ ^ > " ] + ) [ > " ] / g) ?? [ ]
431- const includeNames = includeMatches
432- . map ( ( line ) => line . match ( / # i n c l u d e \s + [ < " ] ( [ ^ > " ] + ) [ > " ] / ) ?. [ 1 ] ?? '' )
433- . filter ( Boolean )
434- const targets = allNodes
435- . filter ( ( candidate ) => candidate . path && candidate . path !== node . path )
436- . filter ( ( candidate ) => includeNames . some ( ( inc ) => candidate . name . endsWith ( inc ) ) )
437- . slice ( 0 , 4 )
438-
439- let remainingChars = Math . max ( 0 , MAX_CONTEXT_CHARS - text . length )
440- const chunks : string [ ] = [ ]
441- for ( const target of targets ) {
442- if ( remainingChars <= 500 || ! target . path ) break
443- const decrypted = await decryptFile ( target . path )
444- if ( ! decrypted ) continue
445- const decoded = new TextDecoder ( 'utf-8' , { fatal : false } ) . decode ( decrypted )
446- const clipped = decoded . slice ( 0 , remainingChars )
447- remainingChars -= clipped . length
448- chunks . push ( `\n\n### Related file: ${ target . path } \n${ clipped } ` )
449- }
450- return chunks . join ( '' )
451- } , [ allNodes , decryptFile , node . path , text ] )
452-
453330 const downloadText = useCallback ( ( filename : string , content : string ) => {
454331 const blob = new Blob ( [ content ] , { type : 'text/plain;charset=utf-8' } )
455332 const url = URL . createObjectURL ( blob )
@@ -486,7 +363,7 @@ export default function MonacoViewer({
486363 break
487364 }
488365
489- const translatedChunk = await fetchAiSanitizedCode ( [
366+ const translatedChunk = await fetchAiSanitizedCode ( privateKey , [
490367 {
491368 role : 'system' ,
492369 content : 'Translate comments in the provided source code to English. Keep code tokens, identifiers, literals, spacing, and line structure unchanged. Do not wrap with markdown code fences. Do not add introductory or explanatory text. Return only source code.' ,
@@ -538,11 +415,12 @@ export default function MonacoViewer({
538415 setIsExplaining ( true )
539416 openAiLoadingToast ( t ( 'viewer.monaco.ai_toast_explaining' ) )
540417 setStatusMessage ( t ( 'viewer.monaco.ai_explaining' ) )
541- const extraContext = await collectExtraContext ( )
418+ const extraContext = await collectExtraContext ( { text , allNodes , nodePath : node . path , decryptFile } )
542419 // Open overlay immediately so streaming content appears progressively.
543420 setAiOverlay ( { mode : 'file-explanation' , title : t ( 'viewer.monaco.ai_explain_file' ) , content : '' } )
544421 let streamed = ''
545422 const explanation = await fetchAiCompletion (
423+ privateKey ,
546424 [
547425 {
548426 role : 'system' ,
@@ -553,7 +431,7 @@ export default function MonacoViewer({
553431 content : `### Main file: ${ node . path ?? node . name } \n${ text . slice ( 0 , MAX_CONTEXT_CHARS ) } ${ extraContext } ` ,
554432 } ,
555433 ] ,
556- ( chunk ) => {
434+ ( chunk : string ) => {
557435 streamed += chunk
558436 setAiOverlay ( ( prev ) => prev ? { ...prev , content : streamed } : null )
559437 } ,
@@ -600,7 +478,7 @@ export default function MonacoViewer({
600478 break
601479 }
602480
603- const annotatedChunk = await fetchAiSanitizedCode ( [
481+ const annotatedChunk = await fetchAiSanitizedCode ( privateKey , [
604482 {
605483 role : 'system' ,
606484 content : `You are mentoring a junior developer. Add concise inline comments in ${ targetLang } . Keep the original code behavior exactly unchanged. You may add comments and can translate existing comments into ${ targetLang } if needed. Do not rename identifiers. Do not remove logic. Preserve formatting and line structure as much as possible. Return only source code without markdown fences or introductory text.` ,
@@ -681,6 +559,7 @@ export default function MonacoViewer({
681559 setAiOverlay ( { mode : 'selection-explanation' , title : overlayTitle , content : '' } )
682560 let streamed = ''
683561 const explanation = await fetchAiCompletion (
562+ privateKey ,
684563 [
685564 {
686565 role : 'system' ,
@@ -691,7 +570,7 @@ export default function MonacoViewer({
691570 content : `File: ${ node . path ?? node . name } \n\nSelected code:\n${ selectedText . slice ( 0 , 12_000 ) } \n\nNearby context:\n${ surrounding . join ( '\n' ) . slice ( 0 , 12_000 ) } ` ,
692571 } ,
693572 ] ,
694- ( chunk ) => {
573+ ( chunk : string ) => {
695574 streamed += chunk
696575 setAiOverlay ( ( prev ) => prev ? { ...prev , content : streamed } : null )
697576 } ,
0 commit comments