Skip to content

Commit c411bc4

Browse files
committed
quality: move ai functions in a separated file
1 parent 9337b84 commit c411bc4

2 files changed

Lines changed: 291 additions & 140 deletions

File tree

src/components/viewers/MonacoViewer.tsx

Lines changed: 19 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,23 @@ import Editor, { type Monaco, type OnMount } from '@monaco-editor/react'
2727
import { toast } from '@heroui/react'
2828
import type { FileNode } from '../../types'
2929
import { useAuthContext } from '../../context/AuthContext'
30-
import { decodeUtf8, fetchJson, parseJsonSafe, postJson } from '../../utils/http'
30+
import { decodeUtf8, fetchJson, parseJsonSafe } from '../../utils/http'
3131
import {
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) ──
4049
interface NavEntry { path: string; line: number; column: number }
@@ -141,81 +150,6 @@ function hashFromEncryptedPath(path?: string): string | null {
141150
const IMPL_KINDS = new Set(['function', 'method', 'class', 'struct', 'trait', 'interface'])
142151
const AI_CACHE_PREFIX = 'INDUSTRIAL-ANALYZER_monaco_ai_v1'
143152
const 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-zA-Z0-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-zA-Z0-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

220154
export 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(/#include\s+[<"]([^>"]+)[>"]/g) ?? []
431-
const includeNames = includeMatches
432-
.map((line) => line.match(/#include\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

Comments
 (0)