mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
Implement SSE streaming for API backends that support it (OpenAI Chat Completions and Anthropic Messages). During generation the view shows a live streaming preview with accumulated text. Adds a streaming toggle in settings (enabled by default). Non-streaming-capable formats (Gemini, OpenAI Responses) and CLI backends continue using the existing path. Change-Id: I3361a5ca5aec3e25a7d16e4fb585185f6fc0695e
481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
'use strict';
|
||
|
||
import { translate } from './i18n';
|
||
import {
|
||
ANTHROPIC_CARD_TOOL_NAME,
|
||
anthropicCardTool,
|
||
cardOutputSchema,
|
||
normalizeCardsPayload,
|
||
openAiJsonSchemaResponseFormat,
|
||
openAiResponsesTextFormat,
|
||
parseCardsJson,
|
||
} from './schema';
|
||
import {
|
||
API_FORMATS,
|
||
getApiAuthType,
|
||
getApiBaseUrl,
|
||
getApiFormat,
|
||
getApiKey,
|
||
getApiPreset,
|
||
modelForApi,
|
||
} from './settings';
|
||
import { deltaExtractorForFormat, type StreamProgress, streamingFetch } from './streaming';
|
||
import type { PluginSettings, RawCard } from './types';
|
||
|
||
function endpointUrl(baseUrl: string, suffixes: string[]) {
|
||
const base = baseUrl.replace(/\/+$/, '');
|
||
for (const suffix of suffixes) {
|
||
if (base.endsWith(suffix)) return base;
|
||
}
|
||
return base + suffixes[0];
|
||
}
|
||
|
||
function parseApiHeaders(raw: string, settings?: PluginSettings | null): Record<string, string> {
|
||
const text = (raw || '').trim();
|
||
if (!text) return {};
|
||
if (text.startsWith('{')) {
|
||
let parsed: Record<string, unknown>;
|
||
try {
|
||
parsed = JSON.parse(text);
|
||
} catch (e: any) {
|
||
throw new Error(translate(settings || null, 'errorCustomHeadersJsonParse', { error: e.message }));
|
||
}
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||
throw new Error(translate(settings || null, 'errorCustomHeadersJsonObject'));
|
||
}
|
||
const headers: Record<string, string> = {};
|
||
for (const [k, v] of Object.entries(parsed)) {
|
||
if (typeof v === 'string' && k.trim()) headers[k.trim()] = v;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
const headers: Record<string, string> = {};
|
||
for (const line of text.split(/\r?\n/)) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
const idx = trimmed.indexOf(':');
|
||
if (idx <= 0) {
|
||
throw new Error(translate(settings || null, 'errorCustomHeadersLineFormat'));
|
||
}
|
||
const key = trimmed.slice(0, idx).trim();
|
||
const value = trimmed.slice(idx + 1).trim();
|
||
if (key) headers[key] = value;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
function authHeaders(settings: PluginSettings): Record<string, string> {
|
||
const authType = getApiAuthType(settings);
|
||
if (authType === 'none') return {};
|
||
const key = getApiKey(settings);
|
||
if (!key) {
|
||
const envVar = (settings.apiKeyEnvVar || getApiPreset(settings).envVar || '').trim();
|
||
const hint = envVar ? translate(settings, 'errorApiKeyEnvHint', { envVar }) : '';
|
||
throw new Error(translate(settings, 'errorApiKeyMissing', { hint }));
|
||
}
|
||
if (authType === 'bearer') return { authorization: `Bearer ${key}` };
|
||
if (authType === 'x-api-key') return { 'x-api-key': key };
|
||
if (authType === 'x-goog-api-key') return { 'x-goog-api-key': key };
|
||
if (authType === 'api-key') return { 'api-key': key };
|
||
return { authorization: `Bearer ${key}` };
|
||
}
|
||
|
||
function buildApiHeaders(settings: PluginSettings, extra?: Record<string, string>): Record<string, string> {
|
||
return {
|
||
'content-type': 'application/json',
|
||
...authHeaders(settings),
|
||
...(extra || {}),
|
||
...parseApiHeaders(settings.apiHeaders, settings),
|
||
};
|
||
}
|
||
|
||
function responseJson(resp: any, label: string, settings?: PluginSettings | null) {
|
||
if (resp.json && typeof resp.json === 'object') return resp.json;
|
||
try {
|
||
return JSON.parse(resp.text || '{}');
|
||
} catch (_) {
|
||
throw new Error(
|
||
translate(settings || null, 'errorProviderNonJson', {
|
||
label,
|
||
excerpt: (resp.text || '').slice(0, 500),
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
async function requestJsonBody(
|
||
requestUrlImpl: any,
|
||
label: string,
|
||
url: string,
|
||
headers: Record<string, string>,
|
||
body: any,
|
||
settings?: PluginSettings | null,
|
||
) {
|
||
let resp: any;
|
||
try {
|
||
resp = await requestUrlImpl({
|
||
url,
|
||
method: 'POST',
|
||
headers,
|
||
body: JSON.stringify(body),
|
||
throw: false,
|
||
});
|
||
} catch (e: any) {
|
||
throw new Error(
|
||
translate(settings || null, 'errorProviderRequestFailed', {
|
||
label,
|
||
error: e.message || e,
|
||
}),
|
||
);
|
||
}
|
||
|
||
if (resp.status >= 400) {
|
||
throw new Error(
|
||
translate(settings || null, 'errorProviderApiStatus', {
|
||
label,
|
||
status: resp.status,
|
||
excerpt: (resp.text || '').slice(0, 500),
|
||
}),
|
||
);
|
||
}
|
||
return responseJson(resp, label, settings);
|
||
}
|
||
|
||
function shouldRetryWithoutStructuredOutput(error: any) {
|
||
const message = String(error?.message ? error.message : error);
|
||
if (!/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(message))
|
||
return false;
|
||
return /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test(
|
||
message,
|
||
);
|
||
}
|
||
|
||
async function requestJsonBodyWithStructuredFallback(
|
||
requestUrlImpl: any,
|
||
label: string,
|
||
url: string,
|
||
headers: Record<string, string>,
|
||
structuredBody: any,
|
||
fallbackBody: any,
|
||
settings?: PluginSettings | null,
|
||
) {
|
||
try {
|
||
return await requestJsonBody(requestUrlImpl, label, url, headers, structuredBody, settings);
|
||
} catch (e) {
|
||
if (!fallbackBody || !shouldRetryWithoutStructuredOutput(e)) throw e;
|
||
console.warn(`[parallel-reader] ${label} structured output rejected; retrying without structured output`, e);
|
||
return requestJsonBody(requestUrlImpl, label + ' fallback', url, headers, fallbackBody, settings);
|
||
}
|
||
}
|
||
|
||
function textFromContent(content: any): string {
|
||
if (typeof content === 'string') return content;
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.map((part) => {
|
||
if (typeof part === 'string') return part;
|
||
if (part && typeof part === 'object') return part.text || part.output_text || '';
|
||
return '';
|
||
})
|
||
.join('');
|
||
}
|
||
if (content && typeof content === 'object') {
|
||
return content.text || content.output_text || '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function textFromOpenAiResponses(json: any): string {
|
||
if (typeof json.output_text === 'string') return json.output_text;
|
||
const parts: string[] = [];
|
||
const walk = (value: any) => {
|
||
if (!value) return;
|
||
if (typeof value === 'string') return;
|
||
if (Array.isArray(value)) {
|
||
value.forEach(walk);
|
||
return;
|
||
}
|
||
if (typeof value === 'object') {
|
||
if (typeof value.text === 'string') parts.push(value.text);
|
||
if (typeof value.output_text === 'string') parts.push(value.output_text);
|
||
if (value.type === 'output_text' && typeof value.content === 'string') parts.push(value.content);
|
||
if (value.content) walk(value.content);
|
||
if (value.output) walk(value.output);
|
||
}
|
||
};
|
||
walk(json.output);
|
||
return parts.join('');
|
||
}
|
||
|
||
export function tokenLimitFieldForOpenAiChat(settings: PluginSettings): string {
|
||
const preset = getApiPreset(settings);
|
||
const format = API_FORMATS[getApiFormat(settings)];
|
||
return preset.tokenLimitField || format?.tokenLimitField || 'max_tokens';
|
||
}
|
||
|
||
export function buildAnthropicMessagesBody(
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
options?: { structured?: boolean },
|
||
) {
|
||
const structured = !options || options.structured !== false;
|
||
const body: any = {
|
||
model: modelForApi(settings),
|
||
max_tokens: Number(settings.apiMaxTokens) || 4096,
|
||
system,
|
||
messages: [{ role: 'user', content: user }],
|
||
};
|
||
if (structured) {
|
||
body.tools = [anthropicCardTool()];
|
||
body.tool_choice = { type: 'tool', name: ANTHROPIC_CARD_TOOL_NAME };
|
||
}
|
||
return body;
|
||
}
|
||
|
||
export function buildOpenAiChatBody(
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
options?: { structured?: boolean },
|
||
) {
|
||
const structured = !options || options.structured !== false;
|
||
const body: any = {
|
||
model: modelForApi(settings),
|
||
messages: [
|
||
{ role: 'system', content: system },
|
||
{ role: 'user', content: user },
|
||
],
|
||
};
|
||
body[tokenLimitFieldForOpenAiChat(settings)] = Number(settings.apiMaxTokens) || 4096;
|
||
if (structured) {
|
||
body.response_format = openAiJsonSchemaResponseFormat();
|
||
}
|
||
return body;
|
||
}
|
||
|
||
export function buildOpenAiResponsesBody(
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
options?: { structured?: boolean },
|
||
) {
|
||
const structured = !options || options.structured !== false;
|
||
const body: any = {
|
||
model: modelForApi(settings),
|
||
instructions: system,
|
||
input: user,
|
||
max_output_tokens: Number(settings.apiMaxTokens) || 4096,
|
||
};
|
||
if (structured) {
|
||
body.text = openAiResponsesTextFormat();
|
||
}
|
||
return body;
|
||
}
|
||
|
||
export function buildGeminiBody(
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
options?: { structured?: boolean },
|
||
) {
|
||
const structured = !options || options.structured !== false;
|
||
const generationConfig: any = {
|
||
temperature: 0,
|
||
maxOutputTokens: Number(settings.apiMaxTokens) || 4096,
|
||
};
|
||
if (structured) {
|
||
generationConfig.responseMimeType = 'application/json';
|
||
generationConfig.responseJsonSchema = cardOutputSchema(false);
|
||
}
|
||
return {
|
||
systemInstruction: { parts: [{ text: system }] },
|
||
contents: [{ role: 'user', parts: [{ text: user }] }],
|
||
generationConfig,
|
||
};
|
||
}
|
||
|
||
function cardsFromAnthropicToolUse(json: any, settings?: PluginSettings | null) {
|
||
const content = Array.isArray(json?.content) ? json.content : [];
|
||
const block = content.find((c: any) => c && c.type === 'tool_use' && c.name === ANTHROPIC_CARD_TOOL_NAME);
|
||
if (!block) return null;
|
||
if (typeof block.input === 'string') return parseCardsJson(block.input, settings);
|
||
if (block.input && typeof block.input === 'object') return normalizeCardsPayload(block.input);
|
||
return [];
|
||
}
|
||
|
||
async function summarizeViaAnthropicMessages(
|
||
requestUrlImpl: any,
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
) {
|
||
const url = endpointUrl(getApiBaseUrl(settings), ['/messages']);
|
||
const json = await requestJsonBodyWithStructuredFallback(
|
||
requestUrlImpl,
|
||
'Anthropic-compatible',
|
||
url,
|
||
buildApiHeaders(settings, { 'anthropic-version': '2023-06-01' }),
|
||
buildAnthropicMessagesBody(system, user, settings),
|
||
buildAnthropicMessagesBody(system, user, settings, { structured: false }),
|
||
settings,
|
||
);
|
||
|
||
const toolCards = cardsFromAnthropicToolUse(json, settings);
|
||
if (toolCards) return toolCards;
|
||
|
||
const text = (json.content || [])
|
||
.map((c: any) => textFromContent(c))
|
||
.join('')
|
||
.trim();
|
||
return parseCardsJson(text, settings);
|
||
}
|
||
|
||
async function summarizeViaOpenAiChat(requestUrlImpl: any, system: string, user: string, settings: PluginSettings) {
|
||
const url = endpointUrl(getApiBaseUrl(settings), ['/chat/completions']);
|
||
const json = await requestJsonBodyWithStructuredFallback(
|
||
requestUrlImpl,
|
||
'OpenAI-compatible Chat',
|
||
url,
|
||
buildApiHeaders(settings),
|
||
buildOpenAiChatBody(system, user, settings),
|
||
buildOpenAiChatBody(system, user, settings, { structured: false }),
|
||
settings,
|
||
);
|
||
const choice = (json.choices || [])[0] || {};
|
||
const text = textFromContent(choice.message?.content || choice.text || '').trim();
|
||
return parseCardsJson(text, settings);
|
||
}
|
||
|
||
async function summarizeViaOpenAiResponses(
|
||
requestUrlImpl: any,
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
) {
|
||
const url = endpointUrl(getApiBaseUrl(settings), ['/responses']);
|
||
const json = await requestJsonBodyWithStructuredFallback(
|
||
requestUrlImpl,
|
||
'OpenAI Responses',
|
||
url,
|
||
buildApiHeaders(settings),
|
||
buildOpenAiResponsesBody(system, user, settings),
|
||
buildOpenAiResponsesBody(system, user, settings, { structured: false }),
|
||
settings,
|
||
);
|
||
return parseCardsJson(textFromOpenAiResponses(json).trim(), settings);
|
||
}
|
||
|
||
async function summarizeViaGoogleGenerativeAi(
|
||
requestUrlImpl: any,
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
) {
|
||
const model = encodeURIComponent(modelForApi(settings));
|
||
let url = getApiBaseUrl(settings);
|
||
if (!/:generateContent(?:\?|$)/.test(url)) {
|
||
url = `${url.replace(/\/+$/, '')}/models/${model}:generateContent`;
|
||
}
|
||
const headers = buildApiHeaders(settings);
|
||
const json = await requestJsonBodyWithStructuredFallback(
|
||
requestUrlImpl,
|
||
'Google Gemini',
|
||
url,
|
||
headers,
|
||
buildGeminiBody(system, user, settings),
|
||
buildGeminiBody(system, user, settings, { structured: false }),
|
||
settings,
|
||
);
|
||
const candidate = (json.candidates || [])[0] || {};
|
||
const parts = candidate.content?.parts || [];
|
||
const text = parts
|
||
.map((p: any) => textFromContent(p))
|
||
.join('')
|
||
.trim();
|
||
return parseCardsJson(text, settings);
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Obsidian's requestUrl is not compatible with fetch
|
||
export async function summarizeViaApi(
|
||
requestUrlImpl: any,
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
): Promise<RawCard[]> {
|
||
const format = getApiFormat(settings);
|
||
switch (format) {
|
||
case 'openai-chat':
|
||
return summarizeViaOpenAiChat(requestUrlImpl, system, user, settings);
|
||
case 'openai-responses':
|
||
return summarizeViaOpenAiResponses(requestUrlImpl, system, user, settings);
|
||
case 'google-generative-ai':
|
||
return summarizeViaGoogleGenerativeAi(requestUrlImpl, system, user, settings);
|
||
default:
|
||
return summarizeViaAnthropicMessages(requestUrlImpl, system, user, settings);
|
||
}
|
||
}
|
||
|
||
export function supportsStreaming(settings: PluginSettings): boolean {
|
||
if (!settings.streaming) return false;
|
||
const format = getApiFormat(settings);
|
||
return !!deltaExtractorForFormat(format);
|
||
}
|
||
|
||
async function streamSummarizeViaOpenAiChat(
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
onProgress?: (progress: StreamProgress) => void,
|
||
signal?: AbortSignal,
|
||
) {
|
||
const url = endpointUrl(getApiBaseUrl(settings), ['/chat/completions']);
|
||
const headers = buildApiHeaders(settings);
|
||
const body = buildOpenAiChatBody(system, user, settings, { structured: false });
|
||
body.stream = true;
|
||
const extractor = deltaExtractorForFormat('openai-chat')!;
|
||
const text = await streamingFetch(url, headers, body, extractor, onProgress, signal, settings);
|
||
return parseCardsJson(text.trim(), settings);
|
||
}
|
||
|
||
async function streamSummarizeViaAnthropicMessages(
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
onProgress?: (progress: StreamProgress) => void,
|
||
signal?: AbortSignal,
|
||
) {
|
||
const url = endpointUrl(getApiBaseUrl(settings), ['/messages']);
|
||
const headers = buildApiHeaders(settings, { 'anthropic-version': '2023-06-01' });
|
||
const body = buildAnthropicMessagesBody(system, user, settings, { structured: false });
|
||
body.stream = true;
|
||
const extractor = deltaExtractorForFormat('anthropic-messages')!;
|
||
const text = await streamingFetch(url, headers, body, extractor, onProgress, signal, settings);
|
||
return parseCardsJson(text.trim(), settings);
|
||
}
|
||
|
||
export async function summarizeViaApiStreaming(
|
||
system: string,
|
||
user: string,
|
||
settings: PluginSettings,
|
||
onProgress?: (progress: StreamProgress) => void,
|
||
signal?: AbortSignal,
|
||
): Promise<RawCard[]> {
|
||
const format = getApiFormat(settings);
|
||
switch (format) {
|
||
case 'openai-chat':
|
||
return streamSummarizeViaOpenAiChat(system, user, settings, onProgress, signal);
|
||
case 'anthropic-messages':
|
||
return streamSummarizeViaAnthropicMessages(system, user, settings, onProgress, signal);
|
||
default:
|
||
throw new Error(`Streaming not supported for format: ${format}`);
|
||
}
|
||
}
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Obsidian's requestUrl is not compatible with fetch
|
||
export async function testApiBackend(requestUrlImpl: any, settings: PluginSettings): Promise<string> {
|
||
await summarizeViaApi(requestUrlImpl, '只输出 JSON:{"cards":[]}', '连通性测试:请原样输出 {"cards":[]}', settings);
|
||
const format = getApiFormat(settings);
|
||
return `${getApiPreset(settings).label} / ${API_FORMATS[format].label}`;
|
||
}
|