InfoLens / client /src /ts /api /completionsClient.ts
dqy08's picture
initial beta release
494c9e4
import URLHandler from '../utils/URLHandler';
import * as completionResultCache from '../utils/completionResultCache';
import type { TokenWithOffset } from './generatedSchemas';
/** 与 server.yaml basePath `/api` + `/v1/completions` 一致 */
const COMPLETIONS_PATH = '/api/v1/completions';
const COMPLETIONS_PROMPT_PATH = '/api/v1/completions/prompt';
const COMPLETIONS_STOP_PATH = '/api/v1/completions/stop';
/** 与 server_openai_definitions.yaml OpenAICompletionsRequest 对齐的最小类型 */
export type OpenAICompletionsRequest = {
model: string;
prompt: string;
max_tokens?: number;
temperature?: number;
top_p?: number;
stop?: string | string[];
[key: string]: unknown;
};
export type OpenAICompletionChoice = {
text?: string;
index?: number;
finish_reason?: string | null;
};
/** 与 server_openai_definitions InfoRadarCompletionPayload 对齐 */
export type InfoRadarCompletionPayload = {
bpe_strings: TokenWithOffset[];
};
/** 与 OpenAICompletionsResponse 对齐(SSE 末条 result 的 data 与此同形) */
export type OpenAICompletionsResponse = {
id: string;
object: 'text_completion';
created: number;
model: string;
choices: OpenAICompletionChoice[];
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
/** 续写 token 级 real_topk / pred_topk,与主站信息密度分析字段一致 */
info_radar?: InfoRadarCompletionPayload;
};
/**
* 单用户串行:通知后端全局停止续写(不 await,避免阻塞 UI)。
* Chat 页 Stop 仅调用此函数而不断开 fetch,以便仍收到末条 SSE result(含 info_radar)。
*/
export function postCompletionsStop(): void {
const url = URLHandler.basicURL() + COMPLETIONS_STOP_PATH;
void fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: '{}'
}).catch(() => {
/* 忽略:Stop 与 SSE 并行,失败时生成仍可能靠墙钟或其它路径结束 */
});
}
export type PostCompletionsPromptOptions = {
signal?: AbortSignal;
};
/**
* POST /v1/completions/prompt:将用户原文套用 chat template,返回实际送入续写的完整 prompt。
*/
export async function postCompletionsPrompt(
body: { model: string; prompt: string; system?: string },
options: PostCompletionsPromptOptions = {}
): Promise<{ prompt_used: string }> {
const { signal } = options;
const url = URLHandler.basicURL() + COMPLETIONS_PROMPT_PATH;
const payload: { model: string; prompt: string; system?: string } = {
model: body.model,
prompt: body.prompt
};
if (body.system !== undefined) {
payload.system = body.system;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(payload),
signal
});
const text = await res.text();
let parsed: { success?: boolean; message?: string; prompt_used?: string };
try {
parsed = JSON.parse(text) as typeof parsed;
} catch {
throw new Error(`POST ${COMPLETIONS_PROMPT_PATH} failed: ${res.status} ${text.slice(0, 500)}`);
}
if (!res.ok) {
const msg =
typeof parsed.message === 'string' ? parsed.message : text.slice(0, 500);
throw new Error(msg || `POST ${COMPLETIONS_PROMPT_PATH} failed: ${res.status}`);
}
if (typeof parsed.prompt_used !== 'string' || !parsed.prompt_used.length) {
throw new Error('completions/prompt response missing prompt_used');
}
return { prompt_used: parsed.prompt_used };
}
export type PostCompletionsOptions = {
signal?: AbortSignal;
/** 续写增量文本 */
onDelta?: (text: string, streamEnd: boolean) => void;
/** 与请求体 `prompt` 一致(prompt_used);仅 Chat 等需缓存时传入 */
cacheKey?: completionResultCache.CompletionResultCacheKey;
/** 为 true 时跳过命中本地缓存,与 Chat 页「Force retry」一致 */
forceRefresh?: boolean;
};
/**
* POST /v1/completions:响应恒为 SSE(delta… → result)。
* 末条 result 的 data 为 OpenAICompletionsResponse;可与拼接的 delta 对照校验。
*/
export async function postCompletions(
body: OpenAICompletionsRequest,
options: PostCompletionsOptions = {}
): Promise<OpenAICompletionsResponse> {
const { signal, onDelta, cacheKey, forceRefresh } = options;
const modelName = body.model;
return new Promise((resolve, reject) => {
let settled = false;
let streamedText = '';
const safeReject = (e: unknown) => {
if (!settled) {
settled = true;
reject(e);
}
};
const safeResolve = (v: OpenAICompletionsResponse) => {
if (settled) return;
if (signal?.aborted) {
safeReject(new DOMException('Aborted', 'AbortError'));
return;
}
settled = true;
if (typeof v.choices?.[0]?.text === 'string' && cacheKey) {
void completionResultCache.save(cacheKey, v, 'complete');
}
resolve(v);
};
const rejectIfAborted = (): boolean => {
if (!signal?.aborted) return false;
if (cacheKey && streamedText.length > 0) {
void completionResultCache.save(
cacheKey,
{
id: `partial-${Date.now()}`,
object: 'text_completion',
created: Math.floor(Date.now() / 1000),
model: modelName,
choices: [{ text: streamedText, index: 0, finish_reason: 'abort' }],
},
'partial'
);
}
safeReject(new DOMException('Aborted', 'AbortError'));
return true;
};
if (cacheKey) {
void (async () => {
try {
if (forceRefresh) {
await completionResultCache.removeCachedEntryByContentKey(
completionResultCache.contentKeyForPrompt(cacheKey.prompt)
);
}
const cached = forceRefresh ? undefined : await completionResultCache.get(cacheKey);
if (cached) {
await completionResultCache.touch(cacheKey);
queueMicrotask(() => {
if (settled) return;
if (rejectIfAborted()) return;
const text = cached.choices?.[0]?.text;
if (typeof text !== 'string') {
safeReject(new Error('completions cache: invalid choices[0].text'));
return;
}
onDelta?.(text, true);
if (settled) return;
if (signal?.aborted) {
safeReject(new DOMException('Aborted', 'AbortError'));
return;
}
settled = true;
resolve(cached);
});
return;
}
} catch (e) {
console.warn('[completions] read cache failed:', e);
}
fetchRemote();
})();
return;
}
fetchRemote();
function fetchRemote(): void {
fetch(URLHandler.basicURL() + COMPLETIONS_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(body),
signal
})
.then((response) => {
if (!response.ok) {
return response.text().then((t) => {
throw new Error(`POST ${COMPLETIONS_PATH} failed: ${response.status} ${t.slice(0, 500)}`);
});
}
const reader = response.body!.getReader();
signal?.addEventListener('abort', () => reader.cancel(), { once: true });
const decoder = new TextDecoder();
let buffer = '';
const processDataLine = (jsonStr: string) => {
if (settled) return;
if (rejectIfAborted()) return;
let parsed: {
type?: string;
text?: string;
stream_end?: boolean;
data?: OpenAICompletionsResponse;
message?: string;
};
try {
parsed = JSON.parse(jsonStr) as typeof parsed;
} catch (e) {
safeReject(
new Error(
`SSE event JSON parse failed: ${
e instanceof SyntaxError ? e.message : String(e)
}`
)
);
return;
}
if (parsed.type === 'delta') {
const delta = parsed.text ?? '';
streamedText += delta;
onDelta?.(delta, Boolean(parsed.stream_end));
} else if (parsed.type === 'result') {
const data = parsed.data;
if (data && typeof data === 'object' && 'choices' in data) {
safeResolve(data as OpenAICompletionsResponse);
} else {
safeReject(new Error('completions stream: invalid result payload'));
}
} else if (parsed.type === 'error') {
safeReject(new Error(parsed.message || 'completions stream failed'));
}
};
const readChunk = (): Promise<void> => {
return reader.read().then(({ done, value }) => {
if (settled) return;
if (rejectIfAborted()) return;
if (done) {
if (buffer.trim()) {
const line = buffer;
if (line.startsWith('data: ')) processDataLine(line.slice(6));
}
if (!settled) {
safeReject(new Error('completions stream ended without result'));
}
return;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) processDataLine(line.slice(6));
}
return readChunk();
});
};
return readChunk();
})
.catch((e) => {
if (!settled) safeReject(e);
});
}
});
}