Commit d21ff2dd authored by edy's avatar edy

feat(desktop): support home image fallback and dev dock icon

parent 43eb29a5
import path from "node:path";
import { access, appendFile, readFile, writeFile } from "node:fs/promises";
import { BrowserWindow, app } from "electron";
import { BrowserWindow, app, nativeImage } from "electron";
import { GatewayClient } from "@qjclaw/gateway-client";
import { RuntimeManager } from "@qjclaw/runtime-manager";
import type { AppConfig, RuntimeCloudFetchAction, RuntimeModePreference, SaveConfigInput, SystemSummary } from "@qjclaw/shared-types";
......@@ -212,6 +212,45 @@ async function countFilePatternMatches(filePath: string, pattern: string): Promi
}
}
async function firstExistingPath(paths: string[]): Promise<string | undefined> {
for (const candidate of paths) {
try {
await access(candidate);
return candidate;
} catch {
// Try the next candidate.
}
}
return undefined;
}
async function resolveDevelopmentApplicationIconPath(systemSummary: SystemSummary): Promise<string | undefined> {
if (systemSummary.isPackaged) {
return undefined;
}
const projectDir = path.resolve(systemSummary.appPath);
return firstExistingPath([
path.join(projectDir, "build", "icon.png"),
path.join(projectDir, "build", "icons", "brand-icon.png")
]);
}
async function configureDevelopmentDockIcon(systemSummary: SystemSummary): Promise<string | undefined> {
const iconPath = await resolveDevelopmentApplicationIconPath(systemSummary);
if (!iconPath || process.platform !== "darwin" || systemSummary.isPackaged || !app.dock) {
return iconPath;
}
const icon = nativeImage.createFromPath(iconPath);
if (!icon.isEmpty()) {
app.dock.setIcon(icon);
}
return iconPath;
}
function snapshotMainWindowState(window: BrowserWindow | null): Record<string, unknown> {
if (!window || window.isDestroyed()) {
return {
......@@ -2097,6 +2136,8 @@ async function bootstrap(): Promise<void> {
startupLogger = new StartupLogger(systemSummary.logsPath);
startupLoggerRef = startupLogger;
await traceBootstrap("when-ready", { isPackaged: systemSummary.isPackaged, userDataPath: systemSummary.userDataPath, logsPath: systemSummary.logsPath, smokeEnabled, smokeCloudBootstrapEnabled });
const developmentIconPath = await configureDevelopmentDockIcon(systemSummary);
await traceBootstrap("development-icon-configured", { developmentIconPath });
const configService = new AppConfigService(systemSummary.userDataPath);
const config = await configService.load();
......@@ -2469,8 +2510,3 @@ if (!hasSingleInstanceLock) {
import { randomUUID } from "node:crypto";
import { BrowserWindow, dialog, ipcMain, shell, type OpenDialogOptions, type WebContents } from "electron";
import { copyFile, mkdir } from "node:fs/promises";
import { copyFile, mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import {
IPC_CHANNELS,
......@@ -154,6 +154,7 @@ const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([
...IMAGE_ATTACHMENT_EXTENSIONS,
...DOCUMENT_ATTACHMENT_EXTENSIONS
]);
const BUILTIN_HOME_PROJECT_ID = "home-chat";
function inferAttachmentMimeType(localPath: string, name?: string): string {
const extension = path.extname(name || localPath).toLowerCase();
......@@ -336,6 +337,28 @@ function normalizeChatAttachments(attachments?: ChatAttachment[]): ChatAttachmen
});
}
function isHomeImageAttachmentRequest(sessionId: string, skillId?: string, attachments?: ChatAttachment[]): boolean {
if (skillId?.trim()) {
return false;
}
if (!sessionId.startsWith(`project:${BUILTIN_HOME_PROJECT_ID}:`)) {
return false;
}
const normalized = normalizeChatAttachments(attachments);
return normalized.length > 0 && normalized.every((attachment) => attachment.kind === "image");
}
function normalizeChatCompletionsUrl(rawBaseUrl: string): string {
const baseUrl = rawBaseUrl.trim().replace(/\/+$/, "");
if (!baseUrl) {
return "";
}
return baseUrl.endsWith("/chat/completions")
? baseUrl
: `${baseUrl}/chat/completions`;
}
async function materializeProjectAttachments(
projectRoot: string,
sessionId: string,
......@@ -726,11 +749,130 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return {
baseUrl,
modelId,
apiKey,
apiKeyConfigured: Boolean(apiKey),
missing
};
};
const readImageAttachmentDataUrls = async (attachments: ChatAttachment[]) => {
return Promise.all(attachments.map(async (attachment) => {
const buffer = await readFile(attachment.localPath);
const mimeType = attachment.mimeType?.trim() || inferAttachmentMimeType(attachment.localPath, attachment.name);
return `data:${mimeType};base64,${buffer.toString("base64")}`;
}));
};
const extractChatCompletionText = (payload: unknown): string => {
const response = payload as {
choices?: Array<{
message?: {
content?: unknown;
};
text?: unknown;
}>;
};
const content = response.choices?.[0]?.message?.content ?? response.choices?.[0]?.text;
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
return content.map((part) => {
if (typeof part === "string") {
return part;
}
if (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string") {
return (part as { text: string }).text;
}
return "";
}).join("").trim();
}
return "";
};
const requestCopywritingChatCompletion = async (input: {
prompt: string;
attachments: ChatAttachment[];
includeImages: boolean;
}): Promise<string> => {
const chatModel = await resolveConfiguredChatModel();
if (chatModel.missing.length > 0) {
throw new Error(`请先在客户端设置中配置文案模型(首页对话兜底):${chatModel.missing.join("")}`);
}
const imageUrls = input.includeImages
? await readImageAttachmentDataUrls(input.attachments)
: [];
const content = imageUrls.length > 0
? [
{ type: "text", text: input.prompt },
...imageUrls.map((url) => ({
type: "image_url",
image_url: { url }
}))
]
: input.prompt;
const response = await fetch(normalizeChatCompletionsUrl(chatModel.baseUrl), {
method: "POST",
headers: {
"Authorization": `Bearer ${chatModel.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: chatModel.modelId,
messages: [
{
role: "user",
content
}
]
})
});
const responseText = await response.text();
let responsePayload: unknown = null;
if (responseText.trim()) {
try {
responsePayload = JSON.parse(responseText);
} catch {
responsePayload = null;
}
}
if (!response.ok) {
const errorMessage = responsePayload && typeof responsePayload === "object"
? ((responsePayload as { error?: { message?: unknown }; message?: unknown }).error?.message
?? (responsePayload as { message?: unknown }).message)
: undefined;
throw new Error(typeof errorMessage === "string" && errorMessage.trim()
? errorMessage.trim()
: `Copywriting model request failed with HTTP ${response.status}.`);
}
const replyText = extractChatCompletionText(responsePayload);
if (!replyText) {
throw new Error("Copywriting model returned an empty response.");
}
return replyText;
};
const requestHomeImageChatCompletion = async (prompt: string, attachments: ChatAttachment[]): Promise<string> => {
try {
return await requestCopywritingChatCompletion({
prompt,
attachments,
includeImages: true
});
} catch (error) {
const chatModel = await resolveConfiguredChatModel();
if (chatModel.missing.length > 0) {
throw error;
}
return requestCopywritingChatCompletion({
prompt,
attachments: [],
includeImages: false
});
}
};
const resolveGatewayClientTarget = async (
config?: AppConfig,
inputToken?: string
......@@ -1416,6 +1558,40 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
statusDetail: overrides.statusDetail
});
const sendHomeImagePrompt = async (
sessionId: string,
prompt: string,
attachments?: ChatAttachment[]
) => {
const normalizedAttachments = normalizeChatAttachments(attachments);
const sessionState = await projectStore.getSessionState(sessionId);
const executionPolicy = await resolveExecutionPolicy(sessionState.projectId, undefined, "chat-fallback");
await projectStore.setSessionSelectedSkill(sessionId, null);
await projectStore.updateSessionLastActive(sessionId);
await ensureLocalTranscript(sessionId);
await projectStore.appendSessionMessage(sessionId, createChatMessage("user", prompt));
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, undefined);
try {
const replyContent = await requestHomeImageChatCompletion(prompt, normalizedAttachments);
const reply = createChatMessage("assistant", replyContent);
await projectStore.appendSessionMessage(sessionId, reply);
await projectStore.updateSessionLastActive(sessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(sessionId, reply.content, executionPolicy.modelId, undefined);
return {
sessionId,
reply,
executionPolicy
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runtimeCloudSupervisor.noteError("chat_send_failed", message, {
modelId: executionPolicy.modelId,
sessionId
});
throw error;
}
};
const ensureLocalTranscript = async (sessionId: string): Promise<ChatMessage[]> => {
const localMessages = await projectStore.listSessionMessages(sessionId);
if (localMessages.length > 0) {
......@@ -1549,6 +1725,10 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const listChatMessages = async (sessionId: string): Promise<ChatMessage[]> => ensureLocalTranscript(sessionId);
const sendPrompt = async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
if (isHomeImageAttachmentRequest(sessionId, skillId, attachments)) {
return sendHomeImagePrompt(sessionId, prompt, attachments);
}
const preparedExecution = await prepareProjectAwareExecution(sessionId, prompt, skillId, attachments);
const executionSessionId = preparedExecution.sessionState.sessionId;
const executionSkillId = preparedExecution.decision.kind === "skill" ? preparedExecution.decision.skillId : undefined;
......@@ -1693,7 +1873,103 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
}, 0);
};
const startHomeImageStream = async () => {
const normalizedAttachments = normalizeChatAttachments(attachments);
const sessionState = await projectStore.getSessionState(sessionId);
executionSessionId = sessionState.sessionId;
executionPolicy = await resolveExecutionPolicy(sessionState.projectId, undefined, "chat-fallback");
await projectStore.setSessionSelectedSkill(executionSessionId, null);
await projectStore.updateSessionLastActive(executionSessionId);
await ensureLocalTranscript(executionSessionId);
await projectStore.appendSessionMessage(executionSessionId, createChatMessage("user", prompt, {
id: userMessageId
}));
await queueAssistantTranscriptWrite(createChatMessage("assistant", "", {
id: assistantMessageId,
createdAt: assistantTranscript.createdAt,
streamState: "streaming",
statusLabel: "Question received, preparing response"
}));
runtimeCloudSupervisor.noteMessageReceived(executionSessionId, prompt, undefined);
const runId = randomUUID();
queueOrSend({
type: "status",
requestId,
sessionId: executionSessionId,
runId,
stage: "await-model",
label: "Question received, preparing response"
});
ready = true;
flushQueuedEvents({
type: "started",
requestId,
sessionId: executionSessionId,
runId,
executionPolicy: executionPolicy ?? undefined
});
void (async () => {
try {
const replyContent = await requestHomeImageChatCompletion(prompt, normalizedAttachments);
const reply = createChatMessage("assistant", replyContent, {
id: assistantMessageId
});
settled = true;
await updateAssistantTranscript((current) => ({
...current,
content: reply.content,
createdAt: reply.createdAt,
streamState: undefined,
statusLabel: undefined,
statusDetail: undefined
}));
await projectStore.updateSessionLastActive(executionSessionId).catch(() => undefined);
runtimeCloudSupervisor.noteMessageSent(executionSessionId, reply.content, executionPolicy?.modelId, undefined);
queueOrSend({
type: "completed",
requestId,
sessionId: executionSessionId,
runId,
reply,
executionPolicy: executionPolicy ?? undefined
});
} catch (error) {
settled = true;
const message = error instanceof Error ? error.message : String(error);
await updateAssistantTranscript((current) => ({
...current,
content: current.content.trim() ? current.content : message,
streamState: "error",
statusLabel: undefined,
statusDetail: undefined
}));
runtimeCloudSupervisor.noteError("chat_stream_failed", message, {
modelId: executionPolicy?.modelId,
sessionId: executionSessionId
});
queueOrSend({
type: "error",
requestId,
sessionId: executionSessionId,
runId,
message
});
}
})();
return {
requestId,
sessionId: executionSessionId,
runId,
userMessageId,
assistantMessageId,
executionPolicy: executionPolicy ?? undefined
};
};
try {
if (isHomeImageAttachmentRequest(sessionId, skillId, attachments)) {
return await startHomeImageStream();
}
const initialStatusLabel = skillId ? "Preparing project context and skill" : "Preparing project context";
queueOrSend({
type: "status",
......@@ -2401,8 +2677,3 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}
};
}
......@@ -1368,4 +1368,3 @@ export class ProjectBundleService {
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment