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
03af76f2
Commit
03af76f2
authored
May 09, 2026
by
edy
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix chat attachment message display
parent
3fd16a36
Changes
8
Show whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
282 additions
and
22 deletions
+282
-22
ipc.ts
apps/desktop/src/main/ipc.ts
+40
-13
index.ts
apps/desktop/src/preload/index.ts
+1
-1
MessageList.tsx
apps/ui/src/features/chat/MessageList.tsx
+118
-2
useChatStreamingController.ts
apps/ui/src/features/chat/useChatStreamingController.ts
+1
-1
chat-utils.ts
apps/ui/src/lib/chat-utils.ts
+4
-3
mock-desktop-api.ts
apps/ui/src/lib/mock-desktop-api.ts
+1
-0
components.css
apps/ui/src/styles/components.css
+114
-1
index.ts
packages/shared-types/src/index.ts
+3
-1
No files found.
apps/desktop/src/main/ipc.ts
View file @
03af76f2
...
...
@@ -763,6 +763,21 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
}));
};
const readImageAttachmentDataUrl = async (attachment: ChatAttachment): Promise<string | null> => {
const normalized = normalizeChatAttachmentCandidate(attachment);
if (!normalized || normalized.kind !== "
image
") {
return null;
}
try {
const buffer = await readFile(normalized.localPath);
const mimeType = normalized.mimeType?.trim() || inferAttachmentMimeType(normalized.localPath, normalized.name);
return `data:${mimeType};base64,${buffer.toString("
base64
")}`;
} catch {
return null;
}
};
const extractChatCompletionText = (payload: unknown): string => {
const response = payload as {
choices?: Array<{
...
...
@@ -1548,15 +1563,19 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
role: ChatMessage["
role
"],
content: string,
overrides: Partial<ChatMessage> = {}
): ChatMessage => ({
): ChatMessage => {
const attachments = normalizeChatAttachments(overrides.attachments);
return {
id: overrides.id ?? randomUUID(),
role,
content,
createdAt: overrides.createdAt ?? new Date().toISOString(),
...(attachments.length ? { attachments } : {}),
streamState: overrides.streamState,
statusLabel: overrides.statusLabel,
statusDetail: overrides.statusDetail
});
};
};
const sendHomeImagePrompt = async (
sessionId: string,
...
...
@@ -1569,7 +1588,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await projectStore.setSessionSelectedSkill(sessionId, null);
await projectStore.updateSessionLastActive(sessionId);
await ensureLocalTranscript(sessionId);
await projectStore.appendSessionMessage(sessionId, createChatMessage("
user
", prompt));
await projectStore.appendSessionMessage(sessionId, createChatMessage("
user
", prompt, {
attachments: normalizedAttachments
}));
runtimeCloudSupervisor.noteMessageReceived(sessionId, prompt, undefined);
try {
const replyContent = await requestHomeImageChatCompletion(prompt, normalizedAttachments);
...
...
@@ -1735,7 +1756,9 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
const shouldScheduleContextRefresh = shouldRefreshProjectContextAfterExecution(preparedExecution.decision);
await projectStore.updateSessionLastActive(executionSessionId);
await ensureLocalTranscript(executionSessionId);
await projectStore.appendSessionMessage(executionSessionId, createChatMessage("
user
", prompt));
await projectStore.appendSessionMessage(executionSessionId, createChatMessage("
user
", prompt, {
attachments: preparedExecution.attachments
}));
runtimeCloudSupervisor.noteMessageReceived(executionSessionId, prompt, executionSkillId);
try {
if (preparedExecution.decision.kind === "
workspace
-
entry
") {
...
...
@@ -1882,7 +1905,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await projectStore.updateSessionLastActive(executionSessionId);
await ensureLocalTranscript(executionSessionId);
await projectStore.appendSessionMessage(executionSessionId, createChatMessage("
user
", prompt, {
id: userMessageId
id: userMessageId,
attachments: normalizedAttachments
}));
await queueAssistantTranscriptWrite(createChatMessage("
assistant
", "", {
id: assistantMessageId,
...
...
@@ -2007,7 +2031,8 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
await projectStore.updateSessionLastActive(executionSessionId);
await ensureLocalTranscript(executionSessionId);
await projectStore.appendSessionMessage(executionSessionId, createChatMessage("
user
", prompt, {
id: userMessageId
id: userMessageId,
attachments: preparedExecution.attachments
}));
await queueAssistantTranscriptWrite(createChatMessage("
assistant
", "", {
id: assistantMessageId,
...
...
@@ -2538,6 +2563,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.chatListMessages, async (_event, sessionId: string) => listChatMessages(sessionId));
ipcMain.handle(IPC_CHANNELS.chatPickAttachments, async (event) => pickAttachments(BrowserWindow.fromWebContents(event.sender)));
ipcMain.handle(IPC_CHANNELS.chatPickImageAttachment, async (event) => pickImageAttachment(BrowserWindow.fromWebContents(event.sender)));
ipcMain.handle(IPC_CHANNELS.chatReadImageAttachmentDataUrl, async (_event, attachment: ChatAttachment) => readImageAttachmentDataUrl(attachment));
ipcMain.handle(IPC_CHANNELS.chatSendPrompt, async (_event, sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => {
return sendPrompt(sessionId, prompt, skillId, attachments);
});
...
...
@@ -2656,6 +2682,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
listMessages: (sessionId: string) => listChatMessages(sessionId),
pickAttachments: async () => pickAttachments(BrowserWindow.getFocusedWindow() ?? null),
pickImageAttachment: async () => pickImageAttachment(BrowserWindow.getFocusedWindow() ?? null),
readImageAttachmentDataUrl: async (attachment: ChatAttachment) => readImageAttachmentDataUrl(attachment),
sendPrompt: async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => sendPrompt(sessionId, prompt, skillId, attachments),
streamPrompt: async (sessionId: string, prompt: string, skillId?: string, attachments?: ChatAttachment[]) => streamPrompt(sessionId, prompt, skillId, attachments),
onStreamEvent: (listener) => {
...
...
apps/desktop/src/preload/index.ts
View file @
03af76f2
...
...
@@ -87,6 +87,7 @@ const desktopApi: DesktopApi = {
listMessages
:
(
sessionId
:
string
)
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatListMessages
,
sessionId
),
pickAttachments
:
()
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatPickAttachments
),
pickImageAttachment
:
()
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatPickImageAttachment
),
readImageAttachmentDataUrl
:
(
attachment
:
ChatAttachment
)
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatReadImageAttachmentDataUrl
,
attachment
),
sendPrompt
:
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[])
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatSendPrompt
,
sessionId
,
prompt
,
skillId
,
attachments
),
streamPrompt
:
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[])
=>
ipcRenderer
.
invoke
(
IPC_CHANNELS
.
chatStreamPrompt
,
sessionId
,
prompt
,
skillId
,
attachments
),
onStreamEvent
:
(
listener
:
ChatStreamListener
)
=>
{
...
...
@@ -109,4 +110,3 @@ const smokeEnabled = process.argv.includes("--qjc-smoke") || Boolean(process.env
contextBridge
.
exposeInMainWorld
(
"qjcDesktop"
,
desktopApi
);
contextBridge
.
exposeInMainWorld
(
"qjcSmokeEnabled"
,
smokeEnabled
);
apps/ui/src/features/chat/MessageList.tsx
View file @
03af76f2
import
type
{
ChatMessage
}
from
"@qjclaw/shared-types"
import
type
{
ReactNode
,
RefObject
,
UIEvent
}
from
"react"
import
type
{
ChatAttachment
,
ChatMessage
}
from
"@qjclaw/shared-types"
import
{
useEffect
,
useMemo
,
useState
,
type
ReactNode
,
type
RefObject
,
type
UIEvent
}
from
"react"
import
{
desktopApi
}
from
"../../lib/desktop-api"
import
{
getTraceLineClassName
,
getTraceLineLabels
}
from
"./messageTraceDisplay"
import
type
{
MessageTraceState
}
from
"./useMessageTraces"
...
...
@@ -59,6 +60,120 @@ interface MessageListProps {
onToggleMessageReaction
:
(
messageId
:
string
,
reaction
:
MessageReaction
)
=>
void
}
function
getAttachmentKey
(
attachment
:
ChatAttachment
,
index
:
number
):
string
{
return
`
${
attachment
.
localPath
||
attachment
.
name
}
:
${
index
}
`
}
function
getAttachmentTypeLabel
(
attachment
:
ChatAttachment
):
string
{
const
mimeType
=
attachment
.
mimeType
?.
toLowerCase
()
??
""
const
extension
=
attachment
.
name
.
split
(
"."
).
pop
()?.
toLowerCase
()
??
""
if
(
attachment
.
kind
===
"image"
||
mimeType
.
startsWith
(
"image/"
))
{
return
"IMAGE"
}
if
(
mimeType
.
includes
(
"pdf"
)
||
extension
===
"pdf"
)
{
return
"PDF"
}
if
(
mimeType
.
includes
(
"mpeg"
)
||
mimeType
.
includes
(
"audio"
)
||
extension
===
"mp3"
)
{
return
"MP3"
}
if
([
"doc"
,
"docx"
].
includes
(
extension
))
{
return
"DOC"
}
if
([
"xls"
,
"xlsx"
,
"csv"
,
"tsv"
].
includes
(
extension
))
{
return
"SHEET"
}
if
([
"ppt"
,
"pptx"
].
includes
(
extension
))
{
return
"PPT"
}
if
([
"txt"
,
"md"
,
"json"
].
includes
(
extension
))
{
return
"TEXT"
}
return
"FILE"
}
function
AttachmentFileCard
({
attachment
}:
{
attachment
:
ChatAttachment
})
{
const
label
=
getAttachmentTypeLabel
(
attachment
)
return
(
<
div
className=
"message-attachment-file-card"
title=
{
attachment
.
name
}
>
<
span
className=
"message-attachment-file-icon"
aria
-
hidden=
"true"
>
<
span
/>
</
span
>
<
span
className=
"message-attachment-file-body"
>
<
span
className=
"message-attachment-file-name"
>
{
attachment
.
name
}
</
span
>
<
span
className=
"message-attachment-file-type"
>
{
label
}
</
span
>
</
span
>
</
div
>
)
}
function
ImageAttachmentPreview
({
attachment
}:
{
attachment
:
ChatAttachment
})
{
const
[
previewUrl
,
setPreviewUrl
]
=
useState
<
string
|
null
>
(
null
)
const
[
previewFailed
,
setPreviewFailed
]
=
useState
(
false
)
useEffect
(()
=>
{
let
cancelled
=
false
setPreviewUrl
(
null
)
setPreviewFailed
(
false
)
void
desktopApi
.
chat
.
readImageAttachmentDataUrl
(
attachment
)
.
then
((
dataUrl
)
=>
{
if
(
cancelled
)
{
return
}
if
(
dataUrl
)
{
setPreviewUrl
(
dataUrl
)
}
else
{
setPreviewFailed
(
true
)
}
})
.
catch
(()
=>
{
if
(
!
cancelled
)
{
setPreviewFailed
(
true
)
}
})
return
()
=>
{
cancelled
=
true
}
},
[
attachment
.
kind
,
attachment
.
localPath
,
attachment
.
mimeType
,
attachment
.
name
])
if
(
!
previewUrl
||
previewFailed
)
{
return
<
AttachmentFileCard
attachment=
{
attachment
}
/>
}
return
(
<
figure
className=
"message-attachment-image"
>
<
img
src=
{
previewUrl
}
alt=
{
attachment
.
name
}
loading=
"lazy"
onError=
{
()
=>
setPreviewFailed
(
true
)
}
/>
<
figcaption
>
{
attachment
.
name
}
</
figcaption
>
</
figure
>
)
}
function
MessageAttachmentStrip
({
attachments
}:
{
attachments
:
ChatAttachment
[]
|
undefined
})
{
const
visibleAttachments
=
useMemo
(
()
=>
(
Array
.
isArray
(
attachments
)
?
attachments
.
filter
((
attachment
)
=>
attachment
.
localPath
&&
attachment
.
name
)
:
[]),
[
attachments
]
)
if
(
!
visibleAttachments
.
length
)
{
return
null
}
return
(
<
div
className=
"message-attachment-strip"
aria
-
label=
"消息附件"
>
{
visibleAttachments
.
map
((
attachment
,
index
)
=>
(
<
div
key=
{
getAttachmentKey
(
attachment
,
index
)
}
className=
"message-attachment-item"
>
{
attachment
.
kind
===
"image"
?
(
<
ImageAttachmentPreview
attachment=
{
attachment
}
/>
)
:
(
<
AttachmentFileCard
attachment=
{
attachment
}
/>
)
}
</
div
>
))
}
</
div
>
)
}
export
function
MessageList
({
messages
,
viewMode
,
...
...
@@ -139,6 +254,7 @@ export function MessageList({
</
p
>
)
)
:
null
}
{
message
.
role
===
"user"
?
<
MessageAttachmentStrip
attachments=
{
message
.
attachments
}
/>
:
null
}
{
hasTrace
?
(
<
div
className=
"message-trace"
>
<
button
type=
"button"
className=
"trace-inline-toggle"
onClick=
{
()
=>
onTraceExpandedChange
(
message
.
id
,
!
isTraceExpanded
)
}
>
...
...
apps/ui/src/features/chat/useChatStreamingController.ts
View file @
03af76f2
...
...
@@ -449,7 +449,7 @@ export function useChatStreamingController(deps: UseChatStreamingControllerDeps)
}
const
renderedPrompt
=
trimmedPrompt
||
(
attachmentsToSend
?.
length
?
buildAttachmentPromptSummary
(
attachmentsToSend
)
:
""
)
const
userMessage
=
buildUserMessage
(
renderedPrompt
)
const
userMessage
=
buildUserMessage
(
renderedPrompt
,
attachmentsToSend
)
const
assistantMessage
=
buildAssistantPlaceholder
(
ui
.
preparingReply
)
setSendPhase
(
"preparing"
)
...
...
apps/ui/src/lib/chat-utils.ts
View file @
03af76f2
import
type
{
ChatMessage
}
from
"@qjclaw/shared-types"
import
type
{
Chat
Attachment
,
Chat
Message
}
from
"@qjclaw/shared-types"
import
{
COMPOSER_TEXTAREA_DEFAULT_MIN_HEIGHT
,
COMPOSER_TEXTAREA_MAX_HEIGHT
,
...
...
@@ -120,12 +120,13 @@ export function appendSmokeStatusLabel(currentLabels: string[] | undefined, labe
return
[...
labels
,
trimmed
].
slice
(
-
20
)
}
export
function
buildUserMessage
(
content
:
string
):
UiChatMessage
{
export
function
buildUserMessage
(
content
:
string
,
attachments
?:
ChatAttachment
[]
):
UiChatMessage
{
return
{
id
:
createClientMessageId
(
"user"
),
role
:
"user"
,
content
,
createdAt
:
new
Date
().
toISOString
()
createdAt
:
new
Date
().
toISOString
(),
attachments
}
}
...
...
apps/ui/src/lib/mock-desktop-api.ts
View file @
03af76f2
...
...
@@ -385,6 +385,7 @@ export const mockDesktopApi = {
listMessages
:
async
()
=>
[],
pickAttachments
:
async
()
=>
[],
pickImageAttachment
:
async
()
=>
null
,
readImageAttachmentDataUrl
:
async
()
=>
null
,
sendPrompt
:
async
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
_attachments
?:
ChatAttachment
[])
=>
({
sessionId
:
sessionId
||
"project:xiaohongshu:default"
,
reply
:
{
id
:
"reply-1"
,
role
:
"assistant"
,
content
:
"Mock: "
+
prompt
,
createdAt
:
new
Date
().
toISOString
()
},
executionPolicy
:
{
source
:
"client-config"
,
modelId
:
"qwen3.6-plus"
,
modelLabel
:
"qwen3.6-plus"
,
routingMode
:
"platform-managed"
,
skillId
,
skillName
:
skillId
,
message
:
"mock"
}
}),
streamPrompt
:
async
(
_sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
_attachments
?:
ChatAttachment
[])
=>
{
const
requestId
=
createClientMessageId
(
"mock-request"
);
...
...
apps/ui/src/styles/components.css
View file @
03af76f2
...
...
@@ -323,6 +323,120 @@
line-height
:
1.82
;
}
.message-attachment-strip
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
8px
;
max-width
:
100%
;
}
.message-attachment-item
{
min-width
:
0
;
}
.message-attachment-image
{
width
:
220px
;
max-width
:
100%
;
margin
:
0
;
overflow
:
hidden
;
border
:
1px
solid
rgba
(
148
,
163
,
184
,
0.34
);
border-radius
:
8px
;
background
:
#ffffff
;
color
:
#334155
;
}
.message-attachment-image
img
{
display
:
block
;
width
:
100%
;
aspect-ratio
:
4
/
3
;
object-fit
:
cover
;
background
:
#e2e8f0
;
}
.message-attachment-image
figcaption
{
padding
:
6px
8px
;
overflow
:
hidden
;
color
:
#334155
;
font-size
:
12px
;
line-height
:
1.35
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.message-attachment-file-card
{
display
:
grid
;
grid-template-columns
:
34px
minmax
(
0
,
1
fr
);
align-items
:
center
;
gap
:
10px
;
width
:
260px
;
max-width
:
100%
;
min-height
:
52px
;
padding
:
8px
10px
;
border
:
1px
solid
rgba
(
148
,
163
,
184
,
0.34
);
border-radius
:
8px
;
background
:
rgba
(
255
,
255
,
255
,
0.78
);
color
:
#1e293b
;
}
.message-attachment-file-icon
{
position
:
relative
;
width
:
34px
;
height
:
36px
;
border
:
1px
solid
rgba
(
37
,
99
,
235
,
0.24
);
border-radius
:
6px
;
background
:
linear-gradient
(
180deg
,
#eff6ff
0%
,
#dbeafe
100%
);
}
.message-attachment-file-icon
::after
{
content
:
""
;
position
:
absolute
;
top
:
-1px
;
right
:
-1px
;
width
:
10px
;
height
:
10px
;
border-left
:
1px
solid
rgba
(
37
,
99
,
235
,
0.24
);
border-bottom
:
1px
solid
rgba
(
37
,
99
,
235
,
0.24
);
border-bottom-left-radius
:
4px
;
background
:
#ffffff
;
}
.message-attachment-file-icon
span
{
position
:
absolute
;
left
:
8px
;
right
:
8px
;
bottom
:
9px
;
height
:
2px
;
border-radius
:
999px
;
background
:
#2563eb
;
box-shadow
:
0
-6px
0
rgba
(
37
,
99
,
235
,
0.58
);
}
.message-attachment-file-body
{
min-width
:
0
;
display
:
grid
;
gap
:
3px
;
}
.message-attachment-file-name
{
overflow
:
hidden
;
font-size
:
13px
;
font-weight
:
600
;
line-height
:
1.35
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.message-attachment-file-type
{
width
:
fit-content
;
padding
:
1px
6px
;
border-radius
:
6px
;
background
:
#dbeafe
;
color
:
#1d4ed8
;
font-size
:
11px
;
font-weight
:
700
;
line-height
:
1.45
;
}
.markdown-body
{
display
:
grid
;
gap
:
14px
;
...
...
@@ -1072,4 +1186,3 @@ select:focus-visible {
.custom-scrollbar
::-webkit-scrollbar-thumb:hover
{
background
:
#94a3b8
;
}
packages/shared-types/src/index.ts
View file @
03af76f2
...
...
@@ -34,6 +34,7 @@
chatListMessages
:
"chat:list-messages"
,
chatPickAttachments
:
"chat:pick-attachments"
,
chatPickImageAttachment
:
"chat:pick-image-attachment"
,
chatReadImageAttachmentDataUrl
:
"chat:read-image-attachment-data-url"
,
chatSendPrompt
:
"chat:send-prompt"
,
chatStreamPrompt
:
"chat:stream-prompt"
,
chatStreamEvent
:
"chat:stream-event"
,
...
...
@@ -461,6 +462,7 @@ export interface ChatMessage {
role
:
MessageRole
;
content
:
string
;
createdAt
:
string
;
attachments
?:
ChatAttachment
[];
streamState
?:
"streaming"
|
"error"
;
statusLabel
?:
string
;
statusDetail
?:
string
;
...
...
@@ -914,6 +916,7 @@ export interface DesktopApi {
listMessages
(
sessionId
:
string
):
Promise
<
ChatMessage
[]
>
;
pickAttachments
():
Promise
<
ChatAttachment
[]
>
;
pickImageAttachment
():
Promise
<
ChatAttachment
|
null
>
;
readImageAttachmentDataUrl
(
attachment
:
ChatAttachment
):
Promise
<
string
|
null
>
;
sendPrompt
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[]):
Promise
<
PromptResult
>
;
streamPrompt
(
sessionId
:
string
,
prompt
:
string
,
skillId
?:
string
,
attachments
?:
ChatAttachment
[]):
Promise
<
ChatStreamPromptResult
>
;
onStreamEvent
(
listener
:
ChatStreamListener
):
()
=>
void
;
...
...
@@ -923,4 +926,3 @@ export interface DesktopApi {
exportSnapshot
():
Promise
<
DiagnosticsExportResult
>
;
};
}
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