| |
| |
| |
|
|
| import * as d3 from "d3"; |
| import URLHandler from "../utils/URLHandler"; |
| import {cleanSpecials} from "../utils/Util"; |
| import * as semanticResultCache from "../utils/semanticResultCache"; |
| import { getSemanticMatchThreshold } from "../utils/semanticThresholdManager"; |
| import {AnalyzeResponse, AnalyzeResult, TokenWithOffset} from "./generatedSchemas"; |
|
|
| |
| export type BpeMergeReason = 'overlap' | 'digit'; |
|
|
| export type FrontendToken = TokenWithOffset & { |
| bpe_merged?: BpeMergeReason; |
| |
| bpe_merge_parts?: string[]; |
| }; |
| export interface FrontendAnalyzeResult extends AnalyzeResult { |
| bpe_strings: FrontendToken[]; |
| originalTokens: FrontendToken[]; |
| bpeBpeMergedTokens: FrontendToken[]; |
| originalText: string; |
| } |
|
|
| |
| export type AnalyzedText = FrontendAnalyzeResult; |
|
|
| |
| export type AnalysisData = AnalyzeResponse; |
| export type { AnalyzeResponse, TokenWithOffset }; |
|
|
| |
| export function isSemanticFromCache(res: unknown): boolean { |
| return !!(res as { __fromCache?: boolean } | null | undefined)?.__fromCache; |
| } |
|
|
| |
| export interface AnalyzeSemanticOptions { |
| onProgress?: (step: number, totalSteps: number, stage: string, percentage?: number) => void; |
| submode?: string; |
| fullMatchDegreeOnly?: boolean; |
| |
| debug_info?: boolean; |
| signal?: AbortSignal; |
| } |
|
|
| export type SemanticResult = { |
| success: boolean; |
| model?: string; |
| token_attention?: Array<{ offset: [number, number]; raw: string; score: number }>; |
| debug_info?: { abbrev?: string; topk_tokens?: string[]; topk_probs?: number[] }; |
| full_match_degree?: number; |
| message?: string; |
| }; |
|
|
| export class TextAnalysisAPI { |
| private adminToken: string | null = null; |
|
|
| constructor(private baseURL: string = null) { |
| if (this.baseURL == null) { |
| this.baseURL = URLHandler.basicURL(); |
| } |
| } |
|
|
| |
| |
| |
| public setAdminToken(token: string | null): void { |
| this.adminToken = token; |
| } |
|
|
| |
| |
| |
| private getHeaders(additionalHeaders?: Record<string, string>): Record<string, string> { |
| const headers: Record<string, string> = { |
| "Content-type": "application/json; charset=UTF-8", |
| ...additionalHeaders |
| }; |
| |
| |
| if (this.adminToken) { |
| headers['X-Admin-Token'] = this.adminToken; |
| } |
| |
| return headers; |
| } |
|
|
|
|
| public list_demos(path?: string): Promise<{ path: string, items: Array<{type: 'folder'|'file', name: string, path: string}> }> { |
| const url = this.baseURL + '/api/list_demos' + (path ? `?path=${encodeURIComponent(path)}` : ''); |
| return d3.json(url); |
| } |
|
|
| public save_demo(name: string, data: AnalyzeResponse, path: string = '/', overwrite: boolean = false): Promise<{ success: boolean, exists?: boolean, message?: string, file?: string }> { |
| return d3.json(this.baseURL + '/api/save_demo', { |
| method: "POST", |
| body: JSON.stringify({ name, data, path, overwrite }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| public delete_demo(file: string): Promise<{ success: boolean, message?: string }> { |
| return d3.json(this.baseURL + '/api/delete_demo', { |
| method: "POST", |
| body: JSON.stringify({ file }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| public move_demo(file: string, targetPath: string): Promise<{ success: boolean, message?: string }> { |
| return d3.json(this.baseURL + '/api/move_demo', { |
| method: "POST", |
| body: JSON.stringify({ file, target_path: targetPath }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| public move_folder(path: string, targetPath: string): Promise<{ success: boolean, message?: string }> { |
| return d3.json(this.baseURL + '/api/move_demo', { |
| method: "POST", |
| body: JSON.stringify({ path, target_path: targetPath }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| public rename_demo(file: string, newName: string): Promise<{ success: boolean, message?: string }> { |
| return d3.json(this.baseURL + '/api/rename_demo', { |
| method: "POST", |
| body: JSON.stringify({ file, new_name: newName }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| public rename_folder(path: string, newName: string): Promise<{ success: boolean, message?: string }> { |
| return d3.json(this.baseURL + '/api/rename_folder', { |
| method: "POST", |
| body: JSON.stringify({ path, new_name: newName }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| public delete_folder(path: string): Promise<{ success: boolean, message?: string }> { |
| return d3.json(this.baseURL + '/api/delete_folder', { |
| method: "POST", |
| body: JSON.stringify({ path }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| public list_all_folders(): Promise<{ folders: string[] }> { |
| return d3.json(this.baseURL + '/api/list_all_folders'); |
| } |
|
|
| public create_folder(parentPath: string, folderName: string): Promise<{ success: boolean, message?: string }> { |
| return d3.json(this.baseURL + '/api/create_folder', { |
| method: "POST", |
| body: JSON.stringify({ parent_path: parentPath, folder_name: folderName }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| |
| |
| |
| private buildAnalyzePayload( |
| model: string, |
| text: string, |
| bitmask: number[] = null, |
| stream: boolean = false |
| ): any { |
| const payload: any = { |
| model, |
| text: cleanSpecials(text) |
| }; |
| if (bitmask) { |
| payload['bitmask'] = bitmask; |
| } |
| if (stream) { |
| payload['stream'] = true; |
| } |
| return payload; |
| } |
|
|
| public analyze( |
| model: string, |
| text: string, |
| bitmask: number[] = null, |
| stream: boolean = false, |
| onProgress?: (step: number, totalSteps: number, stage: string, percentage?: number) => void |
| ): Promise<AnalyzeResponse> { |
| |
| if (stream) { |
| return this.analyzeWithProgress(model, text, onProgress); |
| } |
|
|
| |
| const payload = this.buildAnalyzePayload(model, text, bitmask, stream); |
| return d3.json(this.baseURL + '/api/analyze', { |
| method: "POST", |
| body: JSON.stringify(payload), |
| headers: { |
| "Content-type": "application/json; charset=UTF-8" |
| } |
| }).then((response: any) => { |
| |
| if (response && response.success === false) { |
| throw new Error(response.message || '分析失败'); |
| } |
| return response as AnalyzeResponse; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| public fetchUrlText(url: string): Promise<{success: boolean, text?: string, url?: string, char_count?: number, message?: string}> { |
| return d3.json(this.baseURL + '/api/fetch_url', { |
| method: "POST", |
| body: JSON.stringify({ url }), |
| headers: { |
| "Content-type": "application/json; charset=UTF-8" |
| } |
| }).then((response: any) => { |
| |
| if (response && response.success === false) { |
| throw new Error(response.message || 'URL 文本提取失败'); |
| } |
| return response; |
| }); |
| } |
|
|
| |
| |
| |
| public getVisitStats(): Promise<{ |
| success: boolean, |
| totals: { page_loads: number, active_visits: number }, |
| os: Record<string, number>, |
| page_sec: Record<string, number>, |
| api: Record<string, number>, |
| saved_at: string | null, |
| process_start_at?: string | null, |
| startup_base?: { |
| page_loads?: number, |
| active_visits?: number, |
| page_sec?: Record<string, number>, |
| api?: Record<string, number>, |
| os?: Record<string, number>, |
| }, |
| }> { |
| return d3.json(this.baseURL + '/api/visit_stats', { |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| |
| |
| |
| public getAvailableModels(): Promise<{ success: boolean, models: string[] }> { |
| return d3.json(this.baseURL + '/api/available_models'); |
| } |
|
|
| |
| |
| |
| public getCurrentModel(): Promise<{ |
| success: boolean, |
| model: string, |
| loading: boolean, |
| device_type: 'cpu' | 'cuda' | 'mps', |
| use_int8: boolean, |
| use_bfloat16: boolean |
| }> { |
| return d3.json(this.baseURL + '/api/current_model'); |
| } |
|
|
| |
| |
| |
| public switchModel( |
| model: string, |
| use_int8?: boolean, |
| use_bfloat16?: boolean |
| ): Promise<{ success: boolean, message?: string, model?: string }> { |
| return d3.json(this.baseURL + '/api/switch_model', { |
| method: "POST", |
| body: JSON.stringify({ |
| model, |
| use_int8: use_int8 || false, |
| use_bfloat16: use_bfloat16 || false |
| }), |
| headers: this.getHeaders() |
| }); |
| } |
|
|
| |
| |
| |
| |
| public async analyzeSemantic( |
| query: string, |
| text: string, |
| options?: AnalyzeSemanticOptions |
| ): Promise<SemanticResult> { |
| const { onProgress, submode, fullMatchDegreeOnly, debug_info: wantDebugInfo } = options ?? {}; |
| if (submode === 'hybrid') { |
| const r1 = await this.analyzeSemantic(query, text, { onProgress, submode: 'count', fullMatchDegreeOnly: true, debug_info: wantDebugInfo, signal: options?.signal }); |
| if (!r1?.success) return r1; |
| if ((r1.full_match_degree ?? 0) < getSemanticMatchThreshold()) { |
| return { ...r1, token_attention: [] } as SemanticResult; |
| } |
| const r2 = await this.analyzeSemantic(query, text, { onProgress, submode: 'fill_blank', debug_info: wantDebugInfo, signal: options?.signal }); |
| const fromCache = isSemanticFromCache(r1) && isSemanticFromCache(r2); |
| return { ...r2, full_match_degree: r1.full_match_degree, __fromCache: fromCache } as SemanticResult & { __fromCache?: boolean }; |
| } |
| const cacheSubmode = submode; |
| const cached = semanticResultCache.get(text, query, cacheSubmode); |
| if (cached && (fullMatchDegreeOnly || cached.token_attention)) return { ...cached, __fromCache: true } as SemanticResult & { __fromCache?: boolean }; |
| const stream = !!onProgress; |
| const payload: Record<string, unknown> = { query, text, stream }; |
| if (submode) payload.submode = submode; |
| if (fullMatchDegreeOnly) payload.full_match_degree_only = true; |
| if (wantDebugInfo) payload.debug_info = true; |
| const res: SemanticResult = stream |
| ? await this.fetchSSEStream<SemanticResult>('/api/analyze-semantic', payload, onProgress, 'Semantic analysis failed', options?.signal) |
| : await this.fetchSemanticJson('/api/analyze-semantic', payload, options?.signal); |
| if (res?.success) semanticResultCache.set(text, query, res, cacheSubmode); |
| return res; |
| } |
|
|
| private async fetchSemanticJson(path: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<SemanticResult> { |
| const res = await fetch(this.baseURL + path, { |
| method: 'POST', |
| headers: this.getHeaders(), |
| body: JSON.stringify(payload), |
| signal |
| }); |
| const data = await res.json(); |
| if (data && data.success === false) { |
| throw new Error(data.message || 'Semantic analysis failed'); |
| } |
| return data; |
| } |
|
|
| |
| |
| |
| private analyzeWithProgress( |
| model: string, |
| text: string, |
| onProgress?: (step: number, totalSteps: number, stage: string, percentage?: number) => void |
| ): Promise<AnalyzeResponse> { |
| return this.fetchSSEStream( |
| '/api/analyze', |
| this.buildAnalyzePayload(model, text, null, true), |
| onProgress, |
| '分析失败' |
| ); |
| } |
|
|
| |
| |
| |
| |
| private fetchSSEStream<T>( |
| path: string, |
| payload: any, |
| onProgress: (step: number, totalSteps: number, stage: string, percentage?: number) => void | undefined, |
| errorMessage: string, |
| signal?: AbortSignal |
| ): Promise<T> { |
| return new Promise((resolve, reject) => { |
| let settled = false; |
| const safeResolve = (v: T) => { if (!settled && !signal?.aborted) { settled = true; resolve(v); } }; |
| const safeReject = (e: unknown) => { if (!settled) { settled = true; reject(e); } }; |
|
|
| fetch(this.baseURL + path, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json; charset=UTF-8' }, |
| body: JSON.stringify(payload), |
| signal |
| }).then(response => { |
| if (!response.ok) { |
| throw new Error(`HTTP error! status: ${response.status}`); |
| } |
| const reader = response.body!.getReader(); |
| signal?.addEventListener('abort', () => reader.cancel(), { once: true }); |
|
|
| const decoder = new TextDecoder(); |
| let buffer = ''; |
|
|
| const processLine = (line: string) => { |
| if (settled || signal?.aborted) return; |
| this.processSSEMessage(line, onProgress, safeResolve as (v: any) => void, safeReject, errorMessage); |
| }; |
|
|
| const readChunk = (): Promise<void> => { |
| return reader.read().then(({ done, value }) => { |
| if (settled || signal?.aborted) return; |
| if (done) { |
| if (buffer.trim()) processLine(buffer); |
| return; |
| } |
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop() || ''; |
| for (const line of lines) { |
| if (line.startsWith('data: ')) processLine(line.slice(6)); |
| } |
| return readChunk(); |
| }); |
| }; |
| return readChunk(); |
| }).catch((e) => { |
| if (!settled) { settled = true; reject(e); } |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| private processSSEMessage( |
| data: string, |
| onProgress: (step: number, totalSteps: number, stage: string, percentage?: number) => void | undefined, |
| resolve: (value: any) => void, |
| reject: (reason?: any) => void, |
| errorMessage: string = '分析失败' |
| ): void { |
| try { |
| const parsed = JSON.parse(data); |
| if (parsed.type === 'progress') { |
| if (onProgress) { |
| onProgress(parsed.step, parsed.total_steps, parsed.stage, parsed.percentage); |
| } |
| } else if (parsed.type === 'result') { |
| const resultData = parsed.data; |
| if (resultData && resultData.success === false) { |
| reject(new Error(resultData.message || errorMessage)); |
| } else { |
| resolve(resultData); |
| } |
| } else if (parsed.type === 'error') { |
| reject(new Error(parsed.message || errorMessage)); |
| } |
| } catch (e) { |
| const msg = e instanceof SyntaxError |
| ? `SSE 数据解析失败:${e.message}(可能是后端返回了无效 JSON,如 NaN)` |
| : `SSE 消息处理失败:${e instanceof Error ? e.message : String(e)}`; |
| reject(new Error(msg)); |
| } |
| } |
|
|
|
|
| } |
|
|
|
|