Commit 03af76f2 authored by edy's avatar edy

fix chat attachment message display

parent 3fd16a36
......@@ -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) => {
......
......@@ -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);
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)}>
......
......@@ -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")
......
import type { ChatMessage } from "@qjclaw/shared-types"
import type { ChatAttachment, ChatMessage } 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
}
}
......
......@@ -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");
......
......@@ -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, 1fr);
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;
}
......@@ -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>;
};
}
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