Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qjclaw-dmg
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qjclaw-dmg
Commits
d21ff2dd
Commit
d21ff2dd
authored
May 09, 2026
by
edy
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(desktop): support home image fallback and dev dock icon
parent
43eb29a5
Changes
3
Show whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
319 additions
and
13 deletions
+319
-13
index.ts
apps/desktop/src/main/index.ts
+42
-6
ipc.ts
apps/desktop/src/main/ipc.ts
+277
-6
project-bundle.ts
apps/desktop/src/main/services/project-bundle.ts
+0
-1
No files found.
apps/desktop/src/main/index.ts
View file @
d21ff2dd
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) {
apps/desktop/src/main/ipc.ts
View file @
d21ff2dd
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
}
};
}
apps/desktop/src/main/services/project-bundle.ts
View file @
d21ff2dd
...
...
@@ -1368,4 +1368,3 @@ export class ProjectBundleService {
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment