- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar. - Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages. - Rename plugin IDs and directories from openpets.* to familiaros.*. - Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration. - Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents. - Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings. - Add Familiar naming feature: preference, settings input, tray menu display. - Update assets and packaging config; all desktop tests pass.
1173 lines
43 KiB
TypeScript
1173 lines
43 KiB
TypeScript
import { app, safeStorage } from "electron";
|
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import { defaultOpenApiChatEndpoint, getAppStateSnapshot, normalizeOpenApiChatEndpoint } from "./app-state.js";
|
|
import { applyExternalPetReaction, applyInternalPetMessage, showDefaultPet } from "./default-familiar-controller.js";
|
|
import { error, info, warn } from "./logger.js";
|
|
import type { FamiliarOSReaction } from "./local-ipc-protocol.js";
|
|
import { buildRelevantKnowledgeContext } from "./knowledge-store.js";
|
|
import { buildRelevantMemoryContext, capturePromptMemories } from "./familiaros-memory.js";
|
|
import { getMcpChatClientManager, type McpChatToolDefinition } from "./mcp-chat-client.js";
|
|
import { speakTts } from "./tts-service.js";
|
|
|
|
type OpenApiChatTransport = "responses" | "chat-completions";
|
|
|
|
export interface OpenApiChatSettingsSnapshot {
|
|
readonly model: string;
|
|
readonly endpoint: string;
|
|
readonly defaultEndpoint: string;
|
|
readonly usingDefaultEndpoint: boolean;
|
|
readonly hasCredential: boolean;
|
|
readonly storageMode: "encrypted" | "plain";
|
|
}
|
|
|
|
export interface OpenApiChatPromptResult {
|
|
readonly text: string;
|
|
readonly model: string;
|
|
readonly responseId: string;
|
|
readonly transport: OpenApiChatTransport;
|
|
}
|
|
|
|
export interface OpenApiChatTranscriptEntry {
|
|
readonly id: string;
|
|
readonly conversationId: string;
|
|
readonly role: "user" | "assistant" | "system";
|
|
readonly tone: "normal" | "error";
|
|
readonly text: string;
|
|
readonly createdAt: number;
|
|
}
|
|
|
|
export interface ChatConversation {
|
|
readonly id: string;
|
|
readonly title: string;
|
|
readonly createdAt: number;
|
|
readonly updatedAt: number;
|
|
readonly messageCount: number;
|
|
}
|
|
|
|
interface StoredOpenApiChatConfigV1 {
|
|
readonly version: 1;
|
|
readonly storageMode: "encrypted" | "plain";
|
|
readonly apiKey: string;
|
|
}
|
|
|
|
interface ProviderErrorPayload {
|
|
readonly error?: {
|
|
readonly message?: unknown;
|
|
readonly code?: unknown;
|
|
readonly type?: unknown;
|
|
};
|
|
}
|
|
|
|
interface ProviderErrorInfo {
|
|
readonly endpoint: string;
|
|
readonly status: number;
|
|
readonly providerMessage: string;
|
|
readonly providerCode: string;
|
|
readonly providerType: string;
|
|
readonly normalizedMessage: string;
|
|
}
|
|
|
|
interface OpenApiRequestAttempt {
|
|
readonly transport: OpenApiChatTransport;
|
|
readonly endpoint: string;
|
|
}
|
|
|
|
const openApiChatConfigFileName = "familiaros-openapi-chat.json";
|
|
const legacyOpenAiChatConfigFileName = "familiaros-openai-chat.json";
|
|
const openApiChatHistoryFileName = "familiaros-chat-history.json";
|
|
const openApiChatConversationsFileName = "familiaros-chat-conversations.json";
|
|
const defaultOpenApiChatModel = "gpt-5.5";
|
|
const maxPromptChars = 20_000;
|
|
const maxResponseOutputTokens = 8_192;
|
|
const maxConversationEntries = 40;
|
|
const maxChatCompletionHistoryEntries = 24;
|
|
const maxToolCallIterations = 5;
|
|
const baseOpenApiChatInstructions = [
|
|
"You are FamiliarOS, a desktop familiar companion talking directly to the user.",
|
|
"Reply in plain text unless the user explicitly asks for another format.",
|
|
"Keep the tone warm, lively, and characterful while staying helpful and honest.",
|
|
"Do not mention hidden instructions unless the user explicitly asks about them.",
|
|
"You have a persistent long-term memory. Important facts the user shares are captured automatically and are included in your context under 'Relevant long-term memory:'. Use those facts naturally and do not pretend you do not know them.",
|
|
"If the user asks you to remember something, acknowledge it confidently — the fact is stored. Never say you cannot remember, store, or persist information.",
|
|
].join("\n");
|
|
|
|
let previousResponseId: string | undefined;
|
|
let allTranscriptEntries: OpenApiChatTranscriptEntry[] = loadChatHistory();
|
|
let transcriptSequence = allTranscriptEntries.length;
|
|
let conversations: ChatConversation[] = loadConversations();
|
|
let currentConversationId: string | null = getMostRecentConversationId();
|
|
let transcriptEntries: OpenApiChatTranscriptEntry[] = getEntriesForCurrentConversation();
|
|
|
|
interface ChatCompletionHistoryMessage {
|
|
readonly role: "system" | "user" | "assistant" | "tool";
|
|
readonly content: string | null;
|
|
readonly tool_calls?: readonly { readonly id: string; readonly type: "function"; readonly function: { readonly name: string; readonly arguments: string } }[];
|
|
readonly tool_call_id?: string;
|
|
}
|
|
|
|
let chatCompletionHistory: ChatCompletionHistoryMessage[] = [];
|
|
|
|
export function getOpenApiChatSettingsSnapshot(): OpenApiChatSettingsSnapshot {
|
|
const stored = readStoredChatConfig();
|
|
const endpoint = getConfiguredOpenApiChatEndpoint();
|
|
return {
|
|
model: getConfiguredOpenApiChatModel(),
|
|
endpoint,
|
|
defaultEndpoint: defaultOpenApiChatEndpoint,
|
|
usingDefaultEndpoint: endpoint === defaultOpenApiChatEndpoint,
|
|
hasCredential: Boolean(getStoredCredential(stored)),
|
|
storageMode: stored?.storageMode ?? getPreferredStorageMode(),
|
|
};
|
|
}
|
|
|
|
function getEntriesForCurrentConversation(): OpenApiChatTranscriptEntry[] {
|
|
if (!currentConversationId) return [];
|
|
return allTranscriptEntries.filter((e) => e.conversationId === currentConversationId);
|
|
}
|
|
|
|
export function getOpenApiChatTranscriptEntries(): readonly OpenApiChatTranscriptEntry[] {
|
|
return getEntriesForCurrentConversation().slice();
|
|
}
|
|
|
|
export function getOpenApiChatSystemPrompt(): string {
|
|
return getAppStateSnapshot().preferences.openApiChatSystemPrompt?.trim() ?? "";
|
|
}
|
|
|
|
export function saveOpenApiCredential(credential: string): OpenApiChatSettingsSnapshot {
|
|
const trimmed = credential.trim();
|
|
if (!trimmed) {
|
|
throw new Error("API key or token cannot be empty.");
|
|
}
|
|
if (trimmed.length > 1_024 || /[\0\r\n]/.test(trimmed)) {
|
|
throw new Error("API key or token is not valid.");
|
|
}
|
|
|
|
const storageMode = getPreferredStorageMode();
|
|
const serialized = storageMode === "encrypted"
|
|
? safeStorage.encryptString(trimmed).toString("base64")
|
|
: trimmed;
|
|
|
|
writeStoredChatConfig({
|
|
version: 1,
|
|
storageMode,
|
|
apiKey: serialized,
|
|
});
|
|
previousResponseId = undefined;
|
|
info("app", "openapi chat credential saved", { storageMode });
|
|
return getOpenApiChatSettingsSnapshot();
|
|
}
|
|
|
|
export function clearOpenApiCredential(): OpenApiChatSettingsSnapshot {
|
|
writeStoredChatConfig({
|
|
version: 1,
|
|
storageMode: getPreferredStorageMode(),
|
|
apiKey: "",
|
|
});
|
|
previousResponseId = undefined;
|
|
info("app", "openapi chat credential cleared");
|
|
return getOpenApiChatSettingsSnapshot();
|
|
}
|
|
|
|
export function resetOpenApiConversation(): void {
|
|
previousResponseId = undefined;
|
|
chatCompletionHistory = [];
|
|
const newConversation = createConversation("New conversation");
|
|
currentConversationId = newConversation.id;
|
|
transcriptEntries = [];
|
|
info("app", "openapi conversation reset", { conversationId: newConversation.id });
|
|
}
|
|
|
|
export function getAllChatTranscriptEntries(): readonly OpenApiChatTranscriptEntry[] {
|
|
return allTranscriptEntries.slice();
|
|
}
|
|
|
|
export function resetOpenApiConversationContext(): void {
|
|
previousResponseId = undefined;
|
|
}
|
|
|
|
export function listConversations(): readonly ChatConversation[] {
|
|
return conversations.slice().sort((a, b) => b.updatedAt - a.updatedAt);
|
|
}
|
|
|
|
export function getCurrentConversationId(): string | null {
|
|
return currentConversationId;
|
|
}
|
|
|
|
export function createConversation(title?: string): ChatConversation {
|
|
const conversation: ChatConversation = {
|
|
id: `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
title: title?.trim() || "New conversation",
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
messageCount: 0,
|
|
};
|
|
conversations = [conversation, ...conversations].slice(0, 100);
|
|
saveConversations();
|
|
return conversation;
|
|
}
|
|
|
|
export function switchConversation(conversationId: string): void {
|
|
if (!conversations.some((c) => c.id === conversationId)) {
|
|
throw new Error("Conversation not found.");
|
|
}
|
|
currentConversationId = conversationId;
|
|
transcriptEntries = getEntriesForCurrentConversation();
|
|
chatCompletionHistory = [];
|
|
previousResponseId = undefined;
|
|
info("app", "openapi conversation switched", { conversationId });
|
|
}
|
|
|
|
export function deleteConversation(conversationId: string): void {
|
|
const before = conversations.length;
|
|
conversations = conversations.filter((c) => c.id !== conversationId);
|
|
if (conversations.length === before) {
|
|
throw new Error("Conversation not found.");
|
|
}
|
|
// Remove messages for this conversation
|
|
allTranscriptEntries = allTranscriptEntries.filter((e) => e.conversationId !== conversationId);
|
|
saveChatHistory();
|
|
saveConversations();
|
|
if (currentConversationId === conversationId) {
|
|
currentConversationId = conversations[0]?.id ?? null;
|
|
transcriptEntries = getEntriesForCurrentConversation();
|
|
chatCompletionHistory = [];
|
|
previousResponseId = undefined;
|
|
}
|
|
info("app", "openapi conversation deleted", { conversationId });
|
|
}
|
|
|
|
function updateConversationMessageCount(conversationId: string): void {
|
|
const count = allTranscriptEntries.filter((e) => e.conversationId === conversationId).length;
|
|
conversations = conversations.map((c) =>
|
|
c.id === conversationId ? { ...c, messageCount: count, updatedAt: Date.now() } : c
|
|
);
|
|
saveConversations();
|
|
}
|
|
|
|
function getMostRecentConversationId(): string | null {
|
|
const sorted = conversations.slice().sort((a, b) => b.updatedAt - a.updatedAt);
|
|
return sorted[0]?.id ?? null;
|
|
}
|
|
|
|
function autoTitleConversation(conversationId: string, text: string): void {
|
|
const conversation = conversations.find((c) => c.id === conversationId);
|
|
if (!conversation || conversation.title !== "New conversation") return;
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return;
|
|
const title = trimmed.length > 36 ? `${trimmed.slice(0, 36).trim()}…` : trimmed;
|
|
conversations = conversations.map((c) => (c.id === conversationId ? { ...c, title } : c));
|
|
saveConversations();
|
|
}
|
|
|
|
function getVanillaChatMcpToolsEnabled(): readonly string[] {
|
|
return getAppStateSnapshot().preferences.vanillaChatMcpTools ?? [];
|
|
}
|
|
|
|
function isVanillaChatMcpEnabled(): boolean {
|
|
return getVanillaChatMcpToolsEnabled().length > 0;
|
|
}
|
|
|
|
export async function sendOpenApiChatPrompt(prompt: string): Promise<OpenApiChatPromptResult> {
|
|
const trimmedPrompt = prompt.trim();
|
|
if (!trimmedPrompt) {
|
|
throw new Error("Type a prompt before sending.");
|
|
}
|
|
if (trimmedPrompt.length > maxPromptChars) {
|
|
throw new Error(`Prompt is too long. Keep it under ${maxPromptChars} characters.`);
|
|
}
|
|
|
|
try {
|
|
const captured = capturePromptMemories(trimmedPrompt);
|
|
if (captured.length > 0) {
|
|
info("app", "memory captured", { component: "openapi-chat", count: captured.length, kinds: captured.map((e) => e.kind).join(",") });
|
|
}
|
|
} catch (err) {
|
|
error("app", "memory capture failed", { component: "openapi-chat", error: err instanceof Error ? err.message : String(err) });
|
|
}
|
|
appendTranscriptEntry("user", trimmedPrompt);
|
|
|
|
if (isVanillaChatMcpEnabled()) {
|
|
return sendOpenApiChatPromptWithTools(trimmedPrompt);
|
|
}
|
|
|
|
return sendOpenApiChatPromptPlain(trimmedPrompt);
|
|
}
|
|
|
|
function speakAssistantResponse(text: string): void {
|
|
void speakTts(text, "prompt").catch((error: unknown) => {
|
|
warn("app", "TTS for assistant response failed", { error: error instanceof Error ? error.message : String(error) });
|
|
});
|
|
}
|
|
|
|
async function sendOpenApiChatPromptPlain(prompt: string): Promise<OpenApiChatPromptResult> {
|
|
const credential = getRequiredCredential();
|
|
const model = getConfiguredOpenApiChatModel();
|
|
const endpoint = getConfiguredOpenApiChatEndpoint();
|
|
const previousId = previousResponseId;
|
|
|
|
showOpenApiPendingState();
|
|
info("app", "openapi prompt submitted", {
|
|
model,
|
|
endpointHost: getEndpointHost(endpoint),
|
|
usingDefaultEndpoint: endpoint === defaultOpenApiChatEndpoint,
|
|
promptLength: prompt.length,
|
|
hasPreviousResponse: Boolean(previousId),
|
|
});
|
|
|
|
const attempts = buildRequestAttempts(endpoint);
|
|
let lastProviderError: ProviderErrorInfo | undefined;
|
|
let lastResponseTextError: string | undefined;
|
|
|
|
for (let index = 0; index < attempts.length; index += 1) {
|
|
const attempt = attempts[index];
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(attempt.endpoint, {
|
|
method: "POST",
|
|
headers: buildOpenApiRequestHeaders(attempt.endpoint, credential),
|
|
body: JSON.stringify(buildRequestBody(attempt.transport, {
|
|
model,
|
|
prompt,
|
|
previousResponseId: attempt.transport === "responses" ? previousId : undefined,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error && error.message
|
|
? error.message
|
|
: "The OpenAPI chat request failed before a response was received.";
|
|
handleOpenApiFailure(message, {
|
|
model,
|
|
endpointHost: getEndpointHost(attempt.endpoint),
|
|
transport: attempt.transport,
|
|
hasPreviousResponse: Boolean(previousId),
|
|
failureKind: "network",
|
|
});
|
|
throw new Error(message);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const providerError = await readProviderError(response, attempt.endpoint);
|
|
if (shouldTryChatCompletionsFallback(attempt, providerError) && hasNextAttempt(attempts, index)) {
|
|
lastProviderError = providerError;
|
|
continue;
|
|
}
|
|
|
|
handleOpenApiFailure(providerError.normalizedMessage, {
|
|
model,
|
|
endpointHost: getEndpointHost(attempt.endpoint),
|
|
transport: attempt.transport,
|
|
status: response.status,
|
|
hasPreviousResponse: Boolean(previousId),
|
|
failureKind: "http",
|
|
});
|
|
throw new Error(providerError.normalizedMessage);
|
|
}
|
|
|
|
const payload = await response.json() as Record<string, unknown>;
|
|
const parsed = attempt.transport === "responses"
|
|
? parseResponsesPayload(payload)
|
|
: parseChatCompletionsPayload(payload);
|
|
|
|
if (!parsed.text) {
|
|
if (attempt.transport === "responses" && payloadHasChatCompletionsShape(payload) && hasNextAttempt(attempts, index)) {
|
|
lastResponseTextError = "The provider returned a chat-completions style payload from the responses route.";
|
|
continue;
|
|
}
|
|
|
|
const message = attempt.transport === "responses"
|
|
? "The provider returned an empty chat response."
|
|
: "The provider returned an empty chat-completions response.";
|
|
handleOpenApiFailure(message, {
|
|
model,
|
|
endpointHost: getEndpointHost(attempt.endpoint),
|
|
transport: attempt.transport,
|
|
hasPreviousResponse: Boolean(previousId),
|
|
failureKind: "empty",
|
|
});
|
|
throw new Error(message);
|
|
}
|
|
|
|
const responseId = parsed.responseId ?? `response-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
previousResponseId = attempt.transport === "responses" ? parsed.responseId : undefined;
|
|
appendTranscriptEntry("assistant", parsed.text);
|
|
speakAssistantResponse(parsed.text);
|
|
showOpenApiSuccessState(parsed.text);
|
|
info("app", "openapi prompt completed", {
|
|
model,
|
|
endpointHost: getEndpointHost(attempt.endpoint),
|
|
transport: attempt.transport,
|
|
textLength: parsed.text.length,
|
|
responseId,
|
|
carriedResponseId: Boolean(previousResponseId),
|
|
hadFallbackProviderError: Boolean(lastProviderError),
|
|
hadFallbackPayloadMismatch: Boolean(lastResponseTextError),
|
|
});
|
|
return {
|
|
text: parsed.text,
|
|
model,
|
|
responseId,
|
|
transport: attempt.transport,
|
|
};
|
|
}
|
|
|
|
const fallbackMessage = lastProviderError?.normalizedMessage
|
|
?? lastResponseTextError
|
|
?? "The provider could not produce a usable reply.";
|
|
handleOpenApiFailure(fallbackMessage, {
|
|
model,
|
|
endpointHost: getEndpointHost(endpoint),
|
|
hasPreviousResponse: Boolean(previousId),
|
|
failureKind: "exhausted-attempts",
|
|
});
|
|
throw new Error(fallbackMessage);
|
|
}
|
|
|
|
function getConfiguredOpenApiChatModel(): string {
|
|
return normalizeChatModel(getAppStateSnapshot().preferences.openApiChatModel) ?? defaultOpenApiChatModel;
|
|
}
|
|
|
|
function getConfiguredOpenApiChatEndpoint(): string {
|
|
return normalizeOpenApiChatEndpoint(getAppStateSnapshot().preferences.openApiChatEndpoint) ?? defaultOpenApiChatEndpoint;
|
|
}
|
|
|
|
function buildOpenApiInstructions(prompt: string): string {
|
|
const parts: string[] = [];
|
|
if (getAppStateSnapshot().preferences.openApiChatBaseInstructionsEnabled !== false) {
|
|
parts.push(baseOpenApiChatInstructions);
|
|
}
|
|
const customCharacterPrompt = getOpenApiChatSystemPrompt();
|
|
if (customCharacterPrompt) {
|
|
parts.push(`User-defined familiar character instructions:\n${customCharacterPrompt}`);
|
|
}
|
|
const chatHistoryForMemory = allTranscriptEntries
|
|
.filter((e) => e.conversationId !== currentConversationId && e.role !== "system")
|
|
.slice(-60)
|
|
.map((e) => ({ text: e.text, role: e.role, createdAt: e.createdAt }));
|
|
const memoryContext = buildRelevantMemoryContext(prompt, chatHistoryForMemory);
|
|
if (memoryContext) {
|
|
parts.push(memoryContext);
|
|
}
|
|
const knowledgeContext = buildRelevantKnowledgeContext(prompt);
|
|
if (knowledgeContext) {
|
|
parts.push(knowledgeContext);
|
|
}
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
export function normalizeChatModel(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const trimmed = value.trim();
|
|
if (!trimmed || trimmed.length > 120 || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/.test(trimmed)) return undefined;
|
|
return trimmed;
|
|
}
|
|
|
|
function buildRequestAttempts(endpoint: string): readonly OpenApiRequestAttempt[] {
|
|
const url = safeParseUrl(endpoint);
|
|
if (!url) {
|
|
return [{ transport: "responses", endpoint }];
|
|
}
|
|
|
|
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
|
if (normalizedPath.endsWith("/chat/completions")) {
|
|
return [{ transport: "chat-completions", endpoint: url.toString() }];
|
|
}
|
|
if (normalizedPath.endsWith("/responses")) {
|
|
if (isOfficialOpenAiEndpoint(endpoint) || isAzureOpenAiEndpoint(endpoint)) {
|
|
return [{ transport: "responses", endpoint: url.toString() }];
|
|
}
|
|
return dedupeAttempts([
|
|
{ transport: "responses", endpoint: url.toString() },
|
|
{ transport: "chat-completions", endpoint: withPathname(url, replaceTransportPath(normalizedPath, "chat-completions")) },
|
|
]);
|
|
}
|
|
if (normalizedPath.endsWith("/v1")) {
|
|
return dedupeAttempts([
|
|
{ transport: "responses", endpoint: withPathname(url, `${normalizedPath}/responses`) },
|
|
{ transport: "chat-completions", endpoint: withPathname(url, `${normalizedPath}/chat/completions`) },
|
|
]);
|
|
}
|
|
|
|
return [{ transport: "responses", endpoint: url.toString() }];
|
|
}
|
|
|
|
function dedupeAttempts(attempts: readonly OpenApiRequestAttempt[]): readonly OpenApiRequestAttempt[] {
|
|
const seen = new Set<string>();
|
|
const deduped: OpenApiRequestAttempt[] = [];
|
|
for (const attempt of attempts) {
|
|
const key = `${attempt.transport}:${attempt.endpoint}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
deduped.push(attempt);
|
|
}
|
|
return deduped;
|
|
}
|
|
|
|
function withPathname(url: URL, pathname: string): string {
|
|
const clone = new URL(url.toString());
|
|
clone.pathname = pathname;
|
|
return clone.toString();
|
|
}
|
|
|
|
function replaceTransportPath(pathname: string, transport: OpenApiChatTransport): string {
|
|
const suffix = transport === "responses" ? "/responses" : "/chat/completions";
|
|
return pathname.replace(/\/(?:responses|chat\/completions)$/, suffix);
|
|
}
|
|
|
|
function buildRequestBody(transport: OpenApiChatTransport, input: {
|
|
readonly model: string;
|
|
readonly prompt: string;
|
|
readonly previousResponseId?: string;
|
|
}): Record<string, unknown> {
|
|
if (transport === "responses") {
|
|
return {
|
|
model: input.model,
|
|
instructions: buildOpenApiInstructions(input.prompt),
|
|
previous_response_id: input.previousResponseId,
|
|
max_output_tokens: maxResponseOutputTokens,
|
|
input: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "input_text",
|
|
text: input.prompt,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return {
|
|
model: input.model,
|
|
max_tokens: maxResponseOutputTokens,
|
|
messages: buildChatCompletionsMessages(input.prompt),
|
|
};
|
|
}
|
|
|
|
function buildChatCompletionsMessages(prompt: string): readonly { readonly role: "system" | "user" | "assistant"; readonly content: string }[] {
|
|
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [
|
|
{
|
|
role: "system",
|
|
content: buildOpenApiInstructions(prompt),
|
|
},
|
|
];
|
|
|
|
const history = transcriptEntries
|
|
.filter((entry) => entry.role === "user" || entry.role === "assistant")
|
|
.slice(-maxChatCompletionHistoryEntries);
|
|
|
|
for (const entry of history) {
|
|
messages.push({
|
|
role: entry.role,
|
|
content: entry.text,
|
|
});
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
function parseResponsesPayload(payload: Record<string, unknown>): { readonly text: string; readonly responseId?: string } {
|
|
const text = extractResponsesOutputText(payload).trim();
|
|
const responseId = typeof payload.id === "string" && payload.id ? payload.id : undefined;
|
|
return { text, responseId };
|
|
}
|
|
|
|
function parseChatCompletionsPayload(payload: Record<string, unknown>): { readonly text: string; readonly responseId: string } {
|
|
const text = extractChatCompletionsText(payload).trim();
|
|
const responseId = typeof payload.id === "string" && payload.id
|
|
? payload.id
|
|
: `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
return { text, responseId };
|
|
}
|
|
|
|
function buildOpenApiRequestHeaders(endpoint: string, credential: string): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${credential}`,
|
|
};
|
|
if (isAzureOpenAiEndpoint(endpoint)) {
|
|
headers["api-key"] = credential;
|
|
} else if (!isOfficialOpenAiEndpoint(endpoint)) {
|
|
headers["x-api-key"] = credential;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function isAzureOpenAiEndpoint(endpoint: string): boolean {
|
|
const url = safeParseUrl(endpoint);
|
|
return Boolean(url && url.hostname.endsWith(".openai.azure.com"));
|
|
}
|
|
|
|
function isOfficialOpenAiEndpoint(endpoint: string): boolean {
|
|
const url = safeParseUrl(endpoint);
|
|
return Boolean(url && url.hostname === "api.openai.com");
|
|
}
|
|
|
|
function safeParseUrl(endpoint: string): URL | undefined {
|
|
try {
|
|
return new URL(endpoint);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function getEndpointHost(endpoint: string): string {
|
|
return safeParseUrl(endpoint)?.host ?? "unknown";
|
|
}
|
|
|
|
function getRequiredCredential(): string {
|
|
const credential = getStoredCredential(readStoredChatConfig());
|
|
if (!credential) {
|
|
throw new Error("Add your API key or token in Settings before sending a prompt.");
|
|
}
|
|
return credential;
|
|
}
|
|
|
|
function getStoredCredential(stored: StoredOpenApiChatConfigV1 | null): string | undefined {
|
|
if (!stored?.apiKey) return undefined;
|
|
if (stored.storageMode === "encrypted") {
|
|
if (!safeStorage.isEncryptionAvailable()) {
|
|
throw new Error("Encrypted chat credentials are unavailable on this machine right now. Re-save the key in Settings.");
|
|
}
|
|
return safeStorage.decryptString(Buffer.from(stored.apiKey, "base64"));
|
|
}
|
|
return stored.apiKey;
|
|
}
|
|
|
|
function getPreferredStorageMode(): "encrypted" | "plain" {
|
|
return safeStorage.isEncryptionAvailable() ? "encrypted" : "plain";
|
|
}
|
|
|
|
function getChatConfigPath(fileName = openApiChatConfigFileName): string {
|
|
return join(app.getPath("userData"), fileName);
|
|
}
|
|
|
|
function readStoredChatConfig(): StoredOpenApiChatConfigV1 | null {
|
|
for (const path of [getChatConfigPath(), getChatConfigPath(legacyOpenAiChatConfigFileName)]) {
|
|
try {
|
|
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
if (!isRecord(raw) || raw.version !== 1) continue;
|
|
const storageMode = raw.storageMode === "encrypted" || raw.storageMode === "plain" ? raw.storageMode : "plain";
|
|
const apiKey = typeof raw.apiKey === "string" ? raw.apiKey : "";
|
|
return {
|
|
version: 1,
|
|
storageMode,
|
|
apiKey,
|
|
};
|
|
} catch {
|
|
// Keep checking fallbacks.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function writeStoredChatConfig(config: StoredOpenApiChatConfigV1): void {
|
|
const path = getChatConfigPath();
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const tempPath = `${path}.${process.pid}.tmp`;
|
|
writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
renameSync(tempPath, path);
|
|
|
|
const legacyPath = getChatConfigPath(legacyOpenAiChatConfigFileName);
|
|
if (existsSync(legacyPath)) {
|
|
rmSync(legacyPath, { force: true });
|
|
}
|
|
}
|
|
|
|
async function readProviderError(response: Response, endpoint: string): Promise<ProviderErrorInfo> {
|
|
let providerMessage = "";
|
|
let providerCode = "";
|
|
let providerType = "";
|
|
try {
|
|
const payload = await response.json() as ProviderErrorPayload;
|
|
providerMessage = typeof payload?.error?.message === "string" ? payload.error.message : "";
|
|
providerCode = typeof payload?.error?.code === "string" ? payload.error.code : "";
|
|
providerType = typeof payload?.error?.type === "string" ? payload.error.type : "";
|
|
} catch {
|
|
// Fall through to a generic message.
|
|
}
|
|
return {
|
|
endpoint,
|
|
status: response.status,
|
|
providerMessage,
|
|
providerCode,
|
|
providerType,
|
|
normalizedMessage: normalizeProviderErrorMessage({
|
|
endpoint,
|
|
status: response.status,
|
|
providerMessage,
|
|
providerCode,
|
|
providerType,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function shouldTryChatCompletionsFallback(attempt: OpenApiRequestAttempt, errorInfo: ProviderErrorInfo): boolean {
|
|
if (attempt.transport !== "responses") return false;
|
|
if (isOfficialOpenAiEndpoint(attempt.endpoint) || isAzureOpenAiEndpoint(attempt.endpoint)) return false;
|
|
|
|
const normalizedProviderMessage = sanitizeProviderMessage(errorInfo.providerMessage);
|
|
const haystack = `${errorInfo.providerCode} ${errorInfo.providerType} ${normalizedProviderMessage}`.toLowerCase();
|
|
if (errorInfo.status === 401 || errorInfo.status === 403 || errorInfo.status === 429) return false;
|
|
if (haystack.includes("authentication header") || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) return false;
|
|
if (haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) return false;
|
|
|
|
if (errorInfo.status === 404 || errorInfo.status === 405) return true;
|
|
if (errorInfo.status === 400 || errorInfo.status === 415 || errorInfo.status === 422 || errorInfo.status === 501) {
|
|
return [
|
|
"unknown parameter",
|
|
"unknown url",
|
|
"unsupported",
|
|
"unsupported route",
|
|
"not found",
|
|
"no route",
|
|
"responses",
|
|
"input",
|
|
"instructions",
|
|
"previous_response_id",
|
|
"max_output_tokens",
|
|
"invalid_request_error",
|
|
"chat/completions",
|
|
].some((needle) => haystack.includes(needle));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hasNextAttempt(attempts: readonly OpenApiRequestAttempt[], index: number): boolean {
|
|
return index < attempts.length - 1;
|
|
}
|
|
|
|
function normalizeProviderErrorMessage(input: {
|
|
readonly endpoint: string;
|
|
readonly status: number;
|
|
readonly providerMessage: string;
|
|
readonly providerCode: string;
|
|
readonly providerType: string;
|
|
}): string {
|
|
const providerName = getEndpointProviderLabel(input.endpoint);
|
|
const normalizedProviderMessage = sanitizeProviderMessage(input.providerMessage);
|
|
const haystack = `${input.providerCode} ${input.providerType} ${normalizedProviderMessage}`.toLowerCase();
|
|
|
|
if (haystack.includes("missing authentication header") || haystack.includes("authentication header")) {
|
|
return `${providerName} says the request arrived without usable authentication. Re-save the credential in Settings after choosing the matching preset or endpoint.`;
|
|
}
|
|
|
|
if (input.status === 401 || haystack.includes("invalid api key") || haystack.includes("incorrect api key")) {
|
|
return `${providerName} rejected the saved credential. Re-save it in Settings and confirm the preset matches the endpoint.`;
|
|
}
|
|
|
|
if (isOfficialOpenAiEndpoint(input.endpoint) && (haystack.includes("insufficient_quota") || haystack.includes("current quota") || haystack.includes("billing"))) {
|
|
return "OpenAI is reporting an API billing or quota problem for this key. ChatGPT app subscriptions do not automatically cover API usage.";
|
|
}
|
|
|
|
if (haystack.includes("tokens exhausted") || haystack.includes("insufficient_quota") || haystack.includes("quota") || haystack.includes("billing") || haystack.includes("credit") || haystack.includes("balance")) {
|
|
return `${providerName} is reporting an account quota, credit, or billing problem for this credential, not a prompt-length issue.`;
|
|
}
|
|
|
|
if (input.status === 429 || haystack.includes("rate limit")) {
|
|
return `${providerName} rate-limited this request. Wait a moment and try again.`;
|
|
}
|
|
|
|
if (normalizedProviderMessage) {
|
|
return normalizedProviderMessage;
|
|
}
|
|
|
|
return `${providerName} request failed with HTTP ${input.status}.`;
|
|
}
|
|
|
|
function getEndpointProviderLabel(endpoint: string): string {
|
|
if (isAzureOpenAiEndpoint(endpoint)) return "Azure OpenAI";
|
|
|
|
const url = safeParseUrl(endpoint);
|
|
if (!url) return "OpenAPI provider";
|
|
if (url.hostname === "api.openai.com") return "OpenAI";
|
|
if (url.hostname === "openrouter.ai") return "OpenRouter";
|
|
if (url.hostname === "api.moonshot.cn") return "Moonshot";
|
|
if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1") return "Local OpenAPI provider";
|
|
return "OpenAPI provider";
|
|
}
|
|
|
|
function sanitizeProviderMessage(message: string): string {
|
|
return message
|
|
.replace(/\s+/g, " ")
|
|
.replace(/\s*For more information on this error, read the docs:.*$/i, "")
|
|
.trim();
|
|
}
|
|
|
|
function extractResponsesOutputText(payload: Record<string, unknown>): string {
|
|
if (typeof payload.output_text === "string") return payload.output_text;
|
|
const output = Array.isArray(payload.output) ? payload.output : [];
|
|
const parts: string[] = [];
|
|
for (const item of output) {
|
|
if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
|
|
for (const contentPart of item.content) {
|
|
if (!isRecord(contentPart) || contentPart.type !== "output_text" || typeof contentPart.text !== "string") continue;
|
|
parts.push(contentPart.text);
|
|
}
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
function extractChatCompletionsText(payload: Record<string, unknown>): string {
|
|
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
|
const parts: string[] = [];
|
|
|
|
for (const choice of choices) {
|
|
if (!isRecord(choice) || !isRecord(choice.message)) continue;
|
|
const content = choice.message.content;
|
|
if (typeof content === "string") {
|
|
parts.push(content);
|
|
continue;
|
|
}
|
|
if (!Array.isArray(content)) continue;
|
|
for (const part of content) {
|
|
if (!isRecord(part)) continue;
|
|
if (typeof part.text === "string") {
|
|
parts.push(part.text);
|
|
} else if (isRecord(part.text) && typeof part.text.value === "string") {
|
|
parts.push(part.text.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
return parts.join("");
|
|
}
|
|
|
|
function payloadHasChatCompletionsShape(payload: Record<string, unknown>): boolean {
|
|
return Array.isArray(payload.choices);
|
|
}
|
|
|
|
function showOpenApiPendingState(): void {
|
|
showDefaultPet();
|
|
applyExternalPetReaction("thinking");
|
|
}
|
|
|
|
function showOpenApiSuccessState(message: string): void {
|
|
applyOpenApiPetMessage(message, "success");
|
|
}
|
|
|
|
function showOpenApiFailureState(message: string): void {
|
|
applyOpenApiPetMessage(message, "error");
|
|
}
|
|
|
|
function handleOpenApiFailure(message: string, fields: Record<string, unknown>): void {
|
|
appendTranscriptEntry("system", message, "error");
|
|
showOpenApiFailureState(message);
|
|
warn("app", "openapi prompt failed", fields);
|
|
}
|
|
|
|
function applyOpenApiPetMessage(message: string, reaction: FamiliarOSReaction): void {
|
|
applyInternalPetMessage(message, {
|
|
reaction,
|
|
fullMessage: true,
|
|
sticky: true,
|
|
});
|
|
}
|
|
|
|
function appendTranscriptEntry(role: OpenApiChatTranscriptEntry["role"], text: string, tone: OpenApiChatTranscriptEntry["tone"] = "normal"): void {
|
|
if (!currentConversationId) {
|
|
const conv = createConversation();
|
|
currentConversationId = conv.id;
|
|
}
|
|
const conversationId = currentConversationId;
|
|
const entry: OpenApiChatTranscriptEntry = {
|
|
id: `chat-${Date.now()}-${++transcriptSequence}`,
|
|
conversationId,
|
|
role,
|
|
tone,
|
|
text,
|
|
createdAt: Date.now(),
|
|
};
|
|
// Add entry and cap per-conversation
|
|
const otherEntries = allTranscriptEntries.filter((e) => e.conversationId !== conversationId);
|
|
const convEntries = allTranscriptEntries.filter((e) => e.conversationId === conversationId);
|
|
allTranscriptEntries = [...otherEntries, ...convEntries.slice(-(maxConversationEntries - 1)), entry];
|
|
transcriptEntries = getEntriesForCurrentConversation();
|
|
saveChatHistory();
|
|
updateConversationMessageCount(conversationId);
|
|
if (role === "user") {
|
|
autoTitleConversation(conversationId, text);
|
|
}
|
|
}
|
|
|
|
async function sendOpenApiChatPromptWithTools(prompt: string): Promise<OpenApiChatPromptResult> {
|
|
const credential = getRequiredCredential();
|
|
const model = getConfiguredOpenApiChatModel();
|
|
const endpoint = getConfiguredOpenApiChatEndpoint();
|
|
|
|
showOpenApiPendingState();
|
|
info("app", "openapi prompt with tools submitted", {
|
|
model,
|
|
endpointHost: getEndpointHost(endpoint),
|
|
promptLength: prompt.length,
|
|
});
|
|
|
|
const manager = getMcpChatClientManager();
|
|
const enabledTools = getVanillaChatMcpToolsEnabled();
|
|
|
|
try {
|
|
await manager.startEnabledServers(enabledTools);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
warn("app", "Failed to start some MCP servers for vanilla chat", { message });
|
|
}
|
|
|
|
const mcpTools = manager.listTools();
|
|
if (mcpTools.length === 0) {
|
|
warn("app", "No MCP tools available; falling back to plain chat");
|
|
return sendOpenApiChatPromptPlain(prompt);
|
|
}
|
|
|
|
const chatEndpoint = forceChatCompletionsEndpoint(endpoint);
|
|
|
|
const systemContent = buildOpenApiInstructionsWithTools(prompt, mcpTools);
|
|
if (chatCompletionHistory.length === 0 || chatCompletionHistory[0].role !== "system") {
|
|
chatCompletionHistory = [{ role: "system", content: systemContent }, ...chatCompletionHistory];
|
|
} else {
|
|
chatCompletionHistory = [{ role: "system", content: systemContent }, ...chatCompletionHistory.slice(1)];
|
|
}
|
|
chatCompletionHistory.push({ role: "user", content: prompt });
|
|
chatCompletionHistory = chatCompletionHistory.slice(-maxChatCompletionHistoryEntries);
|
|
|
|
let finalText = "";
|
|
let responseId = "";
|
|
let iteration = 0;
|
|
|
|
while (iteration < maxToolCallIterations) {
|
|
iteration += 1;
|
|
const messages = chatCompletionHistory.map((m) => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
|
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
|
}));
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(chatEndpoint, {
|
|
method: "POST",
|
|
headers: buildOpenApiRequestHeaders(chatEndpoint, credential),
|
|
body: JSON.stringify({
|
|
model,
|
|
max_tokens: maxResponseOutputTokens,
|
|
messages,
|
|
tools: mcpTools,
|
|
tool_choice: "auto",
|
|
}),
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error && error.message
|
|
? error.message
|
|
: "The OpenAPI chat request failed before a response was received.";
|
|
handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "network" });
|
|
throw new Error(message);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const providerError = await readProviderError(response, chatEndpoint);
|
|
handleOpenApiFailure(providerError.normalizedMessage, {
|
|
model,
|
|
endpointHost: getEndpointHost(chatEndpoint),
|
|
status: response.status,
|
|
failureKind: "http",
|
|
});
|
|
throw new Error(providerError.normalizedMessage);
|
|
}
|
|
|
|
const payload = await response.json() as Record<string, unknown>;
|
|
const choice = extractChatCompletionsChoice(payload);
|
|
|
|
if (!choice) {
|
|
const message = "The provider returned an empty chat-completions response.";
|
|
handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "empty" });
|
|
throw new Error(message);
|
|
}
|
|
|
|
responseId = typeof payload.id === "string" && payload.id ? payload.id : `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
|
|
chatCompletionHistory.push({
|
|
role: "assistant",
|
|
content: choice.message.content ?? null,
|
|
tool_calls: choice.message.tool_calls,
|
|
});
|
|
|
|
for (const toolCall of choice.message.tool_calls) {
|
|
let toolResultText: string;
|
|
try {
|
|
const args = JSON.parse(toolCall.function.arguments) as unknown;
|
|
toolResultText = await manager.callTool(toolCall.function.name, args);
|
|
} catch (err) {
|
|
toolResultText = `Error executing tool ${toolCall.function.name}: ${err instanceof Error ? err.message : String(err)}`;
|
|
}
|
|
chatCompletionHistory.push({
|
|
role: "tool",
|
|
tool_call_id: toolCall.id,
|
|
content: toolResultText,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
finalText = choice.message.content ?? "";
|
|
chatCompletionHistory.push({
|
|
role: "assistant",
|
|
content: choice.message.content ?? null,
|
|
});
|
|
break;
|
|
}
|
|
|
|
if (!finalText) {
|
|
const message = "The provider could not produce a usable reply after tool execution.";
|
|
handleOpenApiFailure(message, { model, endpointHost: getEndpointHost(chatEndpoint), failureKind: "empty" });
|
|
throw new Error(message);
|
|
}
|
|
|
|
appendTranscriptEntry("assistant", finalText);
|
|
speakAssistantResponse(finalText);
|
|
showOpenApiSuccessState(finalText);
|
|
info("app", "openapi prompt with tools completed", {
|
|
model,
|
|
endpointHost: getEndpointHost(chatEndpoint),
|
|
textLength: finalText.length,
|
|
responseId,
|
|
toolIterations: iteration,
|
|
});
|
|
|
|
return {
|
|
text: finalText,
|
|
model,
|
|
responseId,
|
|
transport: "chat-completions",
|
|
};
|
|
}
|
|
|
|
function forceChatCompletionsEndpoint(endpoint: string): string {
|
|
if (endpoint.endsWith("/chat/completions")) return endpoint;
|
|
if (endpoint.endsWith("/responses")) return endpoint.replace(/\/responses$/, "/chat/completions");
|
|
if (endpoint.endsWith("/v1")) return `${endpoint}/chat/completions`;
|
|
return `${endpoint.replace(/\/+$/, "")}/chat/completions`;
|
|
}
|
|
|
|
function buildOpenApiInstructionsWithTools(prompt: string, tools: McpChatToolDefinition[]): string {
|
|
const toolNames = tools.map((t) => t.function.name).join(", ");
|
|
const base = buildOpenApiInstructions(prompt);
|
|
return `${base}\n\nYou have access to the following tools: ${toolNames}. When you need to use a tool, respond with a tool call. The system will execute it and return the result. Do not make up tool results.`;
|
|
}
|
|
|
|
interface ChatCompletionChoice {
|
|
readonly message: {
|
|
readonly content?: string | null;
|
|
readonly tool_calls?: readonly { readonly id: string; readonly type: "function"; readonly function: { readonly name: string; readonly arguments: string } }[];
|
|
};
|
|
}
|
|
|
|
function extractChatCompletionsChoice(payload: Record<string, unknown>): ChatCompletionChoice | undefined {
|
|
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
|
const first = choices[0];
|
|
if (!first || typeof first !== "object") return undefined;
|
|
const message = (first as Record<string, unknown>).message;
|
|
if (!message || typeof message !== "object") return undefined;
|
|
const msg = message as Record<string, unknown>;
|
|
const content = typeof msg.content === "string" ? msg.content : null;
|
|
const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
type ToolCallItem = NonNullable<ChatCompletionChoice["message"]["tool_calls"]> extends readonly (infer T)[] ? T : never;
|
|
const tool_calls = rawToolCalls.map((tc): ToolCallItem | null => {
|
|
if (!tc || typeof tc !== "object") return null;
|
|
const t = tc as Record<string, unknown>;
|
|
const id = typeof t.id === "string" ? t.id : "";
|
|
const type: "function" = t.type === "function" ? "function" : "function";
|
|
const fn = t.function;
|
|
if (!fn || typeof fn !== "object") return null;
|
|
const f = fn as Record<string, unknown>;
|
|
const name = typeof f.name === "string" ? f.name : "";
|
|
const args = typeof f.arguments === "string" ? f.arguments : "";
|
|
if (!id || !name) return null;
|
|
return { id, type, function: { name, arguments: args } };
|
|
}).filter((tc): tc is NonNullable<typeof tc> => tc !== null);
|
|
|
|
return { message: { content, ...(tool_calls.length > 0 ? { tool_calls } : {}) } };
|
|
}
|
|
|
|
function loadChatHistory(): OpenApiChatTranscriptEntry[] {
|
|
try {
|
|
const path = getChatConfigPath(openApiChatHistoryFileName);
|
|
if (!existsSync(path)) return [];
|
|
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
if (!Array.isArray(raw)) return [];
|
|
const hasConversationId = raw.some((entry) => isRecord(entry) && typeof entry.conversationId === "string");
|
|
if (!hasConversationId && raw.length > 0) {
|
|
// Migrate old format: wrap all entries in a default conversation
|
|
const defaultConv = createConversation("Previous chats");
|
|
return raw.filter((entry): entry is OpenApiChatTranscriptEntry =>
|
|
isRecord(entry) &&
|
|
typeof entry.id === "string" &&
|
|
(entry.role === "user" || entry.role === "assistant" || entry.role === "system") &&
|
|
typeof entry.text === "string" &&
|
|
(entry.tone === "normal" || entry.tone === "error") &&
|
|
typeof entry.createdAt === "number"
|
|
).map((entry) => ({ ...entry, conversationId: defaultConv.id }));
|
|
}
|
|
return raw.filter((entry): entry is OpenApiChatTranscriptEntry =>
|
|
isRecord(entry) &&
|
|
typeof entry.id === "string" &&
|
|
typeof entry.conversationId === "string" &&
|
|
(entry.role === "user" || entry.role === "assistant" || entry.role === "system") &&
|
|
typeof entry.text === "string" &&
|
|
(entry.tone === "normal" || entry.tone === "error") &&
|
|
typeof entry.createdAt === "number"
|
|
);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveChatHistory(): void {
|
|
try {
|
|
const path = getChatConfigPath(openApiChatHistoryFileName);
|
|
const data = allTranscriptEntries.slice(-1000); // Cap global store
|
|
const tempPath = `${path}.${process.pid}.tmp`;
|
|
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf8");
|
|
renameSync(tempPath, path);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function loadConversations(): ChatConversation[] {
|
|
try {
|
|
const path = getChatConfigPath(openApiChatConversationsFileName);
|
|
if (!existsSync(path)) return [];
|
|
const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
if (!isRecord(raw) || !Array.isArray(raw.conversations)) return [];
|
|
return raw.conversations.filter((c): c is ChatConversation =>
|
|
isRecord(c) &&
|
|
typeof c.id === "string" &&
|
|
typeof c.title === "string" &&
|
|
typeof c.createdAt === "number" &&
|
|
typeof c.updatedAt === "number" &&
|
|
typeof c.messageCount === "number"
|
|
);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveConversations(): void {
|
|
try {
|
|
const path = getChatConfigPath(openApiChatConversationsFileName);
|
|
const data = { version: 1, conversations };
|
|
const tempPath = `${path}.${process.pid}.tmp`;
|
|
writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf8");
|
|
renameSync(tempPath, path);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|