Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
B
bit-pm
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
燕伟桐
bit-pm
Commits
ac8ef73b
Unverified
提交
ac8ef73b
authored
5月 05, 2025
作者:
Will Chen
提交者:
GitHub
5月 05, 2025
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
Support image/file attachments (#80)
上级
0108ff1a
隐藏空白字符变更
内嵌
并排
正在显示
10 个修改的文件
包含
620 行增加
和
34 行删除
+620
-34
AttachmentsList.tsx
src/components/chat/AttachmentsList.tsx
+62
-0
ChatInput.tsx
src/components/chat/ChatInput.tsx
+73
-4
DragDropOverlay.tsx
src/components/chat/DragDropOverlay.tsx
+18
-0
HomeChatInput.tsx
src/components/chat/HomeChatInput.tsx
+83
-6
useAttachments.ts
src/hooks/useAttachments.ts
+58
-0
useStreamChat.ts
src/hooks/useStreamChat.ts
+7
-1
chat_stream_handlers.ts
src/ipc/handlers/chat_stream_handlers.ts
+241
-6
ipc_client.ts
src/ipc/ipc_client.ts
+58
-13
ipc_types.ts
src/ipc/ipc_types.ts
+5
-0
home.tsx
src/pages/home.tsx
+15
-4
没有找到文件。
src/components/chat/AttachmentsList.tsx
0 → 100644
浏览文件 @
ac8ef73b
import
{
FileText
,
X
}
from
"lucide-react"
;
import
{
useEffect
}
from
"react"
;
interface
AttachmentsListProps
{
attachments
:
File
[];
onRemove
:
(
index
:
number
)
=>
void
;
}
export
function
AttachmentsList
({
attachments
,
onRemove
,
}:
AttachmentsListProps
)
{
if
(
attachments
.
length
===
0
)
return
null
;
return
(
<
div
className=
"px-2 pt-2 flex flex-wrap gap-1"
>
{
attachments
.
map
((
file
,
index
)
=>
(
<
div
key=
{
index
}
className=
"flex items-center bg-muted rounded-md px-2 py-1 text-xs gap-1"
title=
{
`${file.name} (${(file.size / 1024).toFixed(1)}KB)`
}
>
{
file
.
type
.
startsWith
(
"image/"
)
?
(
<
div
className=
"relative group"
>
<
img
src=
{
URL
.
createObjectURL
(
file
)
}
alt=
{
file
.
name
}
className=
"w-5 h-5 object-cover rounded"
onLoad=
{
(
e
)
=>
URL
.
revokeObjectURL
((
e
.
target
as
HTMLImageElement
).
src
)
}
onError=
{
(
e
)
=>
URL
.
revokeObjectURL
((
e
.
target
as
HTMLImageElement
).
src
)
}
/>
<
div
className=
"absolute hidden group-hover:block top-6 left-0 z-10"
>
<
img
src=
{
URL
.
createObjectURL
(
file
)
}
alt=
{
file
.
name
}
className=
"max-w-[200px] max-h-[200px] object-contain bg-white p-1 rounded shadow-lg"
onLoad=
{
(
e
)
=>
URL
.
revokeObjectURL
((
e
.
target
as
HTMLImageElement
).
src
)
}
/>
</
div
>
</
div
>
)
:
(
<
FileText
size=
{
12
}
/>
)
}
<
span
className=
"truncate max-w-[120px]"
>
{
file
.
name
}
</
span
>
<
button
onClick=
{
()
=>
onRemove
(
index
)
}
className=
"hover:bg-muted-foreground/20 rounded-full p-0.5"
aria
-
label=
"Remove attachment"
>
<
X
size=
{
12
}
/>
</
button
>
</
div
>
))
}
</
div
>
);
}
src/components/chat/ChatInput.tsx
浏览文件 @
ac8ef73b
...
...
@@ -16,6 +16,7 @@ import {
ChevronsUpDown
,
ChevronsDownUp
,
BarChart2
,
Paperclip
,
}
from
"lucide-react"
;
import
type
React
from
"react"
;
import
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
"react"
;
...
...
@@ -54,6 +55,9 @@ import {
}
from
"../ui/tooltip"
;
import
{
useNavigate
}
from
"@tanstack/react-router"
;
import
{
useVersions
}
from
"@/hooks/useVersions"
;
import
{
useAttachments
}
from
"@/hooks/useAttachments"
;
import
{
AttachmentsList
}
from
"./AttachmentsList"
;
import
{
DragDropOverlay
}
from
"./DragDropOverlay"
;
const
showTokenBarAtom
=
atom
(
false
);
...
...
@@ -73,6 +77,20 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const
setIsPreviewOpen
=
useSetAtom
(
isPreviewOpenAtom
);
const
[
showTokenBar
,
setShowTokenBar
]
=
useAtom
(
showTokenBarAtom
);
// Use the attachments hook
const
{
attachments
,
fileInputRef
,
isDraggingOver
,
handleAttachmentClick
,
handleFileChange
,
removeAttachment
,
handleDragOver
,
handleDragLeave
,
handleDrop
,
clearAttachments
,
}
=
useAttachments
();
// Use the hook to fetch the proposal
const
{
proposalResult
,
...
...
@@ -118,13 +136,25 @@ export function ChatInput({ chatId }: { chatId?: number }) {
};
const
handleSubmit
=
async
()
=>
{
if
(
!
inputValue
.
trim
()
||
isStreaming
||
!
chatId
)
{
if
(
(
!
inputValue
.
trim
()
&&
attachments
.
length
===
0
)
||
isStreaming
||
!
chatId
)
{
return
;
}
const
currentInput
=
inputValue
;
setInputValue
(
""
);
await
streamMessage
({
prompt
:
currentInput
,
chatId
});
// Send message with attachments and clear them after sending
await
streamMessage
({
prompt
:
currentInput
,
chatId
,
attachments
,
redo
:
false
,
});
clearAttachments
();
posthog
.
capture
(
"chat:submit"
);
};
...
...
@@ -236,7 +266,14 @@ export function ChatInput({ chatId }: { chatId?: number }) {
</
div
>
)
}
<
div
className=
"p-4"
>
<
div
className=
"flex flex-col border border-border rounded-lg bg-(--background-lighter) shadow-sm"
>
<
div
className=
{
`relative flex flex-col border border-border rounded-lg bg-(--background-lighter) shadow-sm ${
isDraggingOver ? "ring-2 ring-blue-500 border-blue-500" : ""
}`
}
onDragOver=
{
handleDragOver
}
onDragLeave=
{
handleDragLeave
}
onDrop=
{
handleDrop
}
>
{
/* Only render ChatInputActions if proposal is loaded */
}
{
proposal
&&
proposalResult
?.
chatId
===
chatId
&&
(
<
ChatInputActions
...
...
@@ -255,6 +292,16 @@ export function ChatInput({ chatId }: { chatId?: number }) {
isRejecting=
{
isRejecting
}
/>
)
}
{
/* Use the AttachmentsList component */
}
<
AttachmentsList
attachments=
{
attachments
}
onRemove=
{
removeAttachment
}
/>
{
/* Use the DragDropOverlay component */
}
<
DragDropOverlay
isDraggingOver=
{
isDraggingOver
}
/>
<
div
className=
"flex items-start space-x-2 "
>
<
textarea
ref=
{
textareaRef
}
...
...
@@ -266,6 +313,25 @@ export function ChatInput({ chatId }: { chatId?: number }) {
style=
{
{
resize
:
"none"
}
}
disabled=
{
isStreaming
}
/>
{
/* File attachment button */
}
<
button
onClick=
{
handleAttachmentClick
}
className=
"px-2 py-2 mt-1 mr-1 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
disabled=
{
isStreaming
}
title=
"Attach files"
>
<
Paperclip
size=
{
20
}
/>
</
button
>
<
input
type=
"file"
ref=
{
fileInputRef
}
onChange=
{
handleFileChange
}
className=
"hidden"
multiple
accept=
".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
{
isStreaming
?
(
<
button
onClick=
{
handleCancel
}
...
...
@@ -277,7 +343,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
)
:
(
<
button
onClick=
{
handleSubmit
}
disabled=
{
!
inputValue
.
trim
()
||
!
isAnyProviderSetup
()
}
disabled=
{
(
!
inputValue
.
trim
()
&&
attachments
.
length
===
0
)
||
!
isAnyProviderSetup
()
}
className=
"px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
>
<
SendIcon
size=
{
20
}
/>
...
...
src/components/chat/DragDropOverlay.tsx
0 → 100644
浏览文件 @
ac8ef73b
import
{
Paperclip
}
from
"lucide-react"
;
interface
DragDropOverlayProps
{
isDraggingOver
:
boolean
;
}
export
function
DragDropOverlay
({
isDraggingOver
}:
DragDropOverlayProps
)
{
if
(
!
isDraggingOver
)
return
null
;
return
(
<
div
className=
"absolute inset-0 bg-blue-100/30 dark:bg-blue-900/30 flex items-center justify-center rounded-lg z-10 pointer-events-none"
>
<
div
className=
"bg-background p-4 rounded-lg shadow-lg text-center"
>
<
Paperclip
className=
"mx-auto mb-2 text-blue-500"
/>
<
p
className=
"text-sm font-medium"
>
Drop files to attach
</
p
>
</
div
>
</
div
>
);
}
src/components/chat/HomeChatInput.tsx
浏览文件 @
ac8ef73b
import
{
SendIcon
,
StopCircleIcon
,
X
}
from
"lucide-react"
;
import
{
SendIcon
,
StopCircleIcon
,
X
,
Paperclip
,
Loader2
}
from
"lucide-react"
;
import
type
React
from
"react"
;
import
{
useEffect
,
useRef
,
useState
}
from
"react"
;
import
{
ModelPicker
}
from
"@/components/ModelPicker"
;
...
...
@@ -6,14 +6,39 @@ import { useSettings } from "@/hooks/useSettings";
import
{
homeChatInputValueAtom
}
from
"@/atoms/chatAtoms"
;
// Use a different atom for home input
import
{
useAtom
}
from
"jotai"
;
import
{
useStreamChat
}
from
"@/hooks/useStreamChat"
;
import
{
useAttachments
}
from
"@/hooks/useAttachments"
;
import
{
AttachmentsList
}
from
"./AttachmentsList"
;
import
{
DragDropOverlay
}
from
"./DragDropOverlay"
;
import
{
usePostHog
}
from
"posthog-js/react"
;
import
{
HomeSubmitOptions
}
from
"@/pages/home"
;
export
function
HomeChatInput
({
onSubmit
}:
{
onSubmit
:
()
=>
void
})
{
export
function
HomeChatInput
({
onSubmit
,
}:
{
onSubmit
:
(
options
?:
HomeSubmitOptions
)
=>
void
;
})
{
const
posthog
=
usePostHog
();
const
[
inputValue
,
setInputValue
]
=
useAtom
(
homeChatInputValueAtom
);
const
textareaRef
=
useRef
<
HTMLTextAreaElement
>
(
null
);
const
{
settings
,
updateSettings
,
isAnyProviderSetup
}
=
useSettings
();
const
{
streamMessage
,
isStreaming
,
setIsStreaming
}
=
useStreamChat
({
hasChatId
:
false
,
});
// eslint-disable-line @typescript-eslint/no-unused-vars
// Use the attachments hook
const
{
attachments
,
fileInputRef
,
isDraggingOver
,
handleAttachmentClick
,
handleFileChange
,
removeAttachment
,
handleDragOver
,
handleDragLeave
,
handleDrop
,
clearAttachments
,
}
=
useAttachments
();
const
adjustHeight
=
()
=>
{
const
textarea
=
textareaRef
.
current
;
if
(
textarea
)
{
...
...
@@ -30,8 +55,22 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
const
handleKeyPress
=
(
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
"Enter"
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
onSubmit
();
handleCustomSubmit
();
}
};
// Custom submit function that wraps the provided onSubmit
const
handleCustomSubmit
=
()
=>
{
if
((
!
inputValue
.
trim
()
&&
attachments
.
length
===
0
)
||
isStreaming
)
{
return
;
}
// Call the parent's onSubmit handler with attachments
onSubmit
({
attachments
});
// Clear attachments as part of submission process
clearAttachments
();
posthog
.
capture
(
"chat:home_submit"
);
};
if
(
!
settings
)
{
...
...
@@ -41,7 +80,23 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
return
(
<>
<
div
className=
"p-4"
>
<
div
className=
"flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm"
>
<
div
className=
{
`relative flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm ${
isDraggingOver ? "ring-2 ring-blue-500 border-blue-500" : ""
}`
}
onDragOver=
{
handleDragOver
}
onDragLeave=
{
handleDragLeave
}
onDrop=
{
handleDrop
}
>
{
/* Attachments list */
}
<
AttachmentsList
attachments=
{
attachments
}
onRemove=
{
removeAttachment
}
/>
{
/* Drag and drop overlay */
}
<
DragDropOverlay
isDraggingOver=
{
isDraggingOver
}
/>
<
div
className=
"flex items-start space-x-2 "
>
<
textarea
ref=
{
textareaRef
}
...
...
@@ -53,6 +108,25 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
style=
{
{
resize
:
"none"
}
}
disabled=
{
isStreaming
}
// Should ideally reflect if *any* stream is happening
/>
{
/* File attachment button */
}
<
button
onClick=
{
handleAttachmentClick
}
className=
"px-2 py-2 mt-1 mr-1 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
disabled=
{
isStreaming
}
title=
"Attach files"
>
<
Paperclip
size=
{
20
}
/>
</
button
>
<
input
type=
"file"
ref=
{
fileInputRef
}
onChange=
{
handleFileChange
}
className=
"hidden"
multiple
accept=
".jpg,.jpeg,.png,.gif,.webp,.txt,.md,.js,.ts,.html,.css,.json,.csv"
/>
{
isStreaming
?
(
<
button
className=
"px-2 py-2 mt-1 mr-2 text-(--sidebar-accent-fg) rounded-lg opacity-50 cursor-not-allowed"
// Indicate disabled state
...
...
@@ -62,8 +136,11 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
</
button
>
)
:
(
<
button
onClick=
{
onSubmit
}
disabled=
{
!
inputValue
.
trim
()
||
!
isAnyProviderSetup
()
}
onClick=
{
handleCustomSubmit
}
disabled=
{
(
!
inputValue
.
trim
()
&&
attachments
.
length
===
0
)
||
!
isAnyProviderSetup
()
}
className=
"px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
title=
"Start new chat"
>
...
...
src/hooks/useAttachments.ts
0 → 100644
浏览文件 @
ac8ef73b
import
{
useState
,
useRef
}
from
"react"
;
export
function
useAttachments
()
{
const
[
attachments
,
setAttachments
]
=
useState
<
File
[]
>
([]);
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
isDraggingOver
,
setIsDraggingOver
]
=
useState
(
false
);
const
handleAttachmentClick
=
()
=>
{
fileInputRef
.
current
?.
click
();
};
const
handleFileChange
=
(
e
:
React
.
ChangeEvent
<
HTMLInputElement
>
)
=>
{
if
(
e
.
target
.
files
&&
e
.
target
.
files
.
length
>
0
)
{
const
files
=
Array
.
from
(
e
.
target
.
files
);
setAttachments
((
attachments
)
=>
[...
attachments
,
...
files
]);
}
};
const
removeAttachment
=
(
index
:
number
)
=>
{
setAttachments
(
attachments
.
filter
((
_
,
i
)
=>
i
!==
index
));
};
const
handleDragOver
=
(
e
:
React
.
DragEvent
)
=>
{
e
.
preventDefault
();
setIsDraggingOver
(
true
);
};
const
handleDragLeave
=
()
=>
{
setIsDraggingOver
(
false
);
};
const
handleDrop
=
(
e
:
React
.
DragEvent
)
=>
{
e
.
preventDefault
();
setIsDraggingOver
(
false
);
if
(
e
.
dataTransfer
.
files
&&
e
.
dataTransfer
.
files
.
length
>
0
)
{
const
files
=
Array
.
from
(
e
.
dataTransfer
.
files
);
setAttachments
((
attachments
)
=>
[...
attachments
,
...
files
]);
}
};
const
clearAttachments
=
()
=>
{
setAttachments
([]);
};
return
{
attachments
,
fileInputRef
,
isDraggingOver
,
handleAttachmentClick
,
handleFileChange
,
removeAttachment
,
handleDragOver
,
handleDragLeave
,
handleDrop
,
clearAttachments
,
};
}
src/hooks/useStreamChat.ts
浏览文件 @
ac8ef73b
...
...
@@ -52,12 +52,17 @@ export function useStreamChat({
prompt
,
chatId
,
redo
,
attachments
,
}:
{
prompt
:
string
;
chatId
:
number
;
redo
?:
boolean
;
attachments
?:
File
[];
})
=>
{
if
(
!
prompt
.
trim
()
||
!
chatId
)
{
if
(
(
!
prompt
.
trim
()
&&
(
!
attachments
||
attachments
.
length
===
0
))
||
!
chatId
)
{
return
;
}
...
...
@@ -68,6 +73,7 @@ export function useStreamChat({
IpcClient
.
getInstance
().
streamMessage
(
prompt
,
{
chatId
,
redo
,
attachments
,
onUpdate
:
(
updatedMessages
:
Message
[])
=>
{
if
(
!
hasIncrementedStreamCount
)
{
setStreamCount
((
streamCount
)
=>
streamCount
+
1
);
...
...
src/ipc/handlers/chat_stream_handlers.ts
浏览文件 @
ac8ef73b
import
{
ipcMain
}
from
"electron"
;
import
{
CoreMessage
,
streamText
}
from
"ai"
;
import
{
CoreMessage
,
TextPart
,
ImagePart
,
streamText
}
from
"ai"
;
import
{
db
}
from
"../../db"
;
import
{
chats
,
messages
}
from
"../../db/schema"
;
import
{
and
,
eq
,
isNull
}
from
"drizzle-orm"
;
...
...
@@ -22,6 +22,11 @@ import {
getSupabaseClientCode
,
}
from
"../../supabase_admin/supabase_context"
;
import
{
SUMMARIZE_CHAT_SYSTEM_PROMPT
}
from
"../../prompts/summarize_chat_system_prompt"
;
import
*
as
fs
from
"fs"
;
import
*
as
path
from
"path"
;
import
*
as
os
from
"os"
;
import
*
as
crypto
from
"crypto"
;
import
{
stat
,
readFile
,
writeFile
,
mkdir
,
unlink
}
from
"fs/promises"
;
const
logger
=
log
.
scope
(
"chat_stream_handlers"
);
...
...
@@ -31,6 +36,44 @@ const activeStreams = new Map<number, AbortController>();
// Track partial responses for cancelled streams
const
partialResponses
=
new
Map
<
number
,
string
>
();
// Directory for storing temporary files
const
TEMP_DIR
=
path
.
join
(
os
.
tmpdir
(),
"dyad-attachments"
);
// Common helper functions
const
TEXT_FILE_EXTENSIONS
=
[
".md"
,
".txt"
,
".json"
,
".csv"
,
".js"
,
".ts"
,
".html"
,
".css"
,
];
async
function
isTextFile
(
filePath
:
string
):
Promise
<
boolean
>
{
const
ext
=
path
.
extname
(
filePath
).
toLowerCase
();
return
TEXT_FILE_EXTENSIONS
.
includes
(
ext
);
}
// Ensure the temp directory exists
if
(
!
fs
.
existsSync
(
TEMP_DIR
))
{
fs
.
mkdirSync
(
TEMP_DIR
,
{
recursive
:
true
});
}
// First, define the proper content types to match ai SDK
type
TextContent
=
{
type
:
"text"
;
text
:
string
;
};
type
ImageContent
=
{
type
:
"image"
;
image
:
Buffer
;
};
type
MessageContent
=
TextContent
|
ImageContent
;
export
function
registerChatStreamHandlers
()
{
ipcMain
.
handle
(
"chat:stream"
,
async
(
event
,
req
:
ChatStreamParams
)
=>
{
try
{
...
...
@@ -87,13 +130,50 @@ export function registerChatStreamHandlers() {
}
}
// Add user message to database
// Process attachments if any
let
attachmentInfo
=
""
;
let
attachmentPaths
:
string
[]
=
[];
if
(
req
.
attachments
&&
req
.
attachments
.
length
>
0
)
{
attachmentInfo
=
"
\
n
\
nAttachments:
\
n"
;
for
(
const
attachment
of
req
.
attachments
)
{
// Generate a unique filename
const
hash
=
crypto
.
createHash
(
"md5"
)
.
update
(
attachment
.
name
+
Date
.
now
())
.
digest
(
"hex"
);
const
fileExtension
=
path
.
extname
(
attachment
.
name
);
const
filename
=
`
${
hash
}${
fileExtension
}
`
;
const
filePath
=
path
.
join
(
TEMP_DIR
,
filename
);
// Extract the base64 data (remove the data:mime/type;base64, prefix)
const
base64Data
=
attachment
.
data
.
split
(
";base64,"
).
pop
()
||
""
;
await
writeFile
(
filePath
,
Buffer
.
from
(
base64Data
,
"base64"
));
attachmentPaths
.
push
(
filePath
);
attachmentInfo
+=
`-
${
attachment
.
name
}
(
${
attachment
.
type
}
)\n`
;
// If it's a text-based file, try to include the content
if
(
await
isTextFile
(
filePath
))
{
try
{
attachmentInfo
+=
`<dyad-text-attachment filename="
${
attachment
.
name
}
" type="
${
attachment
.
type
}
" path="
${
filePath
}
">
</dyad-text-attachment>
\n\n`
;
}
catch
(
err
)
{
logger
.
error
(
`Error reading file content:
${
err
}
`
);
}
}
}
}
// Add user message to database with attachment info
const
userPrompt
=
req
.
prompt
+
(
attachmentInfo
?
attachmentInfo
:
""
);
await
db
.
insert
(
messages
)
.
values
({
chatId
:
req
.
chatId
,
role
:
"user"
,
content
:
req
.
p
rompt
,
content
:
userP
rompt
,
})
.
returning
();
...
...
@@ -188,7 +268,28 @@ export function registerChatStreamHandlers() {
if
(
isSummarizeIntent
)
{
systemPrompt
=
SUMMARIZE_CHAT_SYSTEM_PROMPT
;
}
let
chatMessages
=
[
// Update the system prompt for images if there are image attachments
const
hasImageAttachments
=
req
.
attachments
&&
req
.
attachments
.
some
((
attachment
)
=>
attachment
.
type
.
startsWith
(
"image/"
)
);
if
(
hasImageAttachments
)
{
systemPrompt
+=
`
# Image Analysis Capabilities
This conversation includes one or more image attachments. When the user uploads images:
1. If the user explicitly asks for analysis, description, or information about the image, please analyze the image content.
2. Describe what you see in the image if asked.
3. You can use images as references when the user has coding or design-related questions.
4. For diagrams or wireframes, try to understand the content and structure shown.
5. For screenshots of code or errors, try to identify the issue or explain the code.
`
;
}
let
chatMessages
:
CoreMessage
[]
=
[
{
role
:
"user"
,
content
:
"This is my codebase. "
+
codebaseInfo
,
...
...
@@ -197,8 +298,26 @@ export function registerChatStreamHandlers() {
role
:
"assistant"
,
content
:
"OK, got it. I'm ready to help"
,
},
...
messageHistory
,
]
satisfies
CoreMessage
[];
...
messageHistory
.
map
((
msg
)
=>
({
role
:
msg
.
role
as
"user"
|
"assistant"
|
"system"
,
content
:
msg
.
content
,
})),
];
// Check if the last message should include attachments
if
(
chatMessages
.
length
>=
2
&&
attachmentPaths
.
length
>
0
)
{
const
lastUserIndex
=
chatMessages
.
length
-
2
;
const
lastUserMessage
=
chatMessages
[
lastUserIndex
];
if
(
lastUserMessage
.
role
===
"user"
)
{
// Replace the last message with one that includes attachments
chatMessages
[
lastUserIndex
]
=
await
prepareMessageWithAttachments
(
lastUserMessage
,
attachmentPaths
);
}
}
if
(
isSummarizeIntent
)
{
const
previousChat
=
await
db
.
query
.
chats
.
findFirst
({
where
:
eq
(
chats
.
id
,
parseInt
(
req
.
prompt
.
split
(
"="
)[
1
])),
...
...
@@ -217,6 +336,8 @@ export function registerChatStreamHandlers() {
}
satisfies
CoreMessage
,
];
}
// When calling streamText, the messages need to be properly formatted for mixed content
const
{
textStream
}
=
streamText
({
maxTokens
:
getMaxTokens
(
settings
.
selectedModel
),
temperature
:
0
,
...
...
@@ -374,6 +495,24 @@ export function registerChatStreamHandlers() {
}
}
// Clean up any temporary files
if
(
attachmentPaths
.
length
>
0
)
{
for
(
const
filePath
of
attachmentPaths
)
{
try
{
// We don't immediately delete files because they might be needed for reference
// Instead, schedule them for deletion after some time
setTimeout
(
async
()
=>
{
if
(
fs
.
existsSync
(
filePath
))
{
await
unlink
(
filePath
);
logger
.
log
(
`Deleted temporary file:
${
filePath
}
`
);
}
},
30
*
60
*
1000
);
// Delete after 30 minutes
}
catch
(
error
)
{
logger
.
error
(
`Error scheduling file deletion:
${
error
}
`
);
}
}
}
// Return the chat ID for backwards compatibility
return
req
.
chatId
;
}
catch
(
error
)
{
...
...
@@ -418,3 +557,99 @@ export function formatMessages(
.
map
((
m
)
=>
`<message role="
${
m
.
role
}
">
${
m
.
content
}
</message>`
)
.
join
(
"
\
n"
);
}
// Helper function to replace text attachment placeholders with full content
async
function
replaceTextAttachmentWithContent
(
text
:
string
,
filePath
:
string
,
fileName
:
string
):
Promise
<
string
>
{
try
{
if
(
await
isTextFile
(
filePath
))
{
// Read the full content
const
fullContent
=
await
readFile
(
filePath
,
"utf-8"
);
// Replace the placeholder tag with the full content
const
escapedPath
=
filePath
.
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
"
\\
$&"
);
const
tagPattern
=
new
RegExp
(
`<dyad-text-attachment filename="[^"]*" type="[^"]*" path="
${
escapedPath
}
">\\s*<\\/dyad-text-attachment>`
,
"g"
);
const
replacedText
=
text
.
replace
(
tagPattern
,
`Full content of
${
fileName
}
:\n\`\`\`\n
${
fullContent
}
\n\`\`\``
);
logger
.
log
(
`Replaced text attachment content for:
${
fileName
}
- length before:
${
text
.
length
}
- length after:
${
replacedText
.
length
}
`
);
return
replacedText
;
}
return
text
;
}
catch
(
error
)
{
logger
.
error
(
`Error processing text file:
${
error
}
`
);
return
text
;
}
}
// Helper function to convert traditional message to one with proper image attachments
async
function
prepareMessageWithAttachments
(
message
:
CoreMessage
,
attachmentPaths
:
string
[]
):
Promise
<
CoreMessage
>
{
let
textContent
=
message
.
content
;
// Get the original text content
if
(
typeof
textContent
!==
"string"
)
{
logger
.
warn
(
"Message content is not a string - shouldn't happen but using message as-is"
);
return
message
;
}
// Process text file attachments - replace placeholder tags with full content
for
(
const
filePath
of
attachmentPaths
)
{
const
fileName
=
path
.
basename
(
filePath
);
textContent
=
await
replaceTextAttachmentWithContent
(
textContent
,
filePath
,
fileName
);
}
// For user messages with attachments, create a content array
const
contentParts
:
(
TextPart
|
ImagePart
)[]
=
[];
// Add the text part first with possibly modified content
contentParts
.
push
({
type
:
"text"
,
text
:
textContent
,
});
// Add image parts for any image attachments
for
(
const
filePath
of
attachmentPaths
)
{
const
ext
=
path
.
extname
(
filePath
).
toLowerCase
();
if
([
".jpg"
,
".jpeg"
,
".png"
,
".gif"
,
".webp"
].
includes
(
ext
))
{
try
{
// Read the file as a buffer
const
imageBuffer
=
await
readFile
(
filePath
);
// Add the image to the content parts
contentParts
.
push
({
type
:
"image"
,
image
:
imageBuffer
,
});
logger
.
log
(
`Added image attachment:
${
filePath
}
`
);
}
catch
(
error
)
{
logger
.
error
(
`Error reading image file:
${
error
}
`
);
}
}
}
// Return the message with the content array
return
{
role
:
"user"
,
content
:
contentParts
,
};
}
src/ipc/ipc_client.ts
浏览文件 @
ac8ef73b
...
...
@@ -240,26 +240,71 @@ export class IpcClient {
options
:
{
chatId
:
number
;
redo
?:
boolean
;
attachments
?:
File
[];
onUpdate
:
(
messages
:
Message
[])
=>
void
;
onEnd
:
(
response
:
ChatResponseEnd
)
=>
void
;
onError
:
(
error
:
string
)
=>
void
;
}
):
void
{
const
{
chatId
,
onUpdate
,
onEnd
,
onError
,
redo
}
=
options
;
const
{
chatId
,
redo
,
attachments
,
onUpdate
,
onEnd
,
onError
}
=
options
;
this
.
chatStreams
.
set
(
chatId
,
{
onUpdate
,
onEnd
,
onError
});
// Use invoke to start the stream and pass the chatId
this
.
ipcRenderer
.
invoke
(
"chat:stream"
,
{
prompt
,
chatId
,
redo
,
}
satisfies
ChatStreamParams
)
.
catch
((
err
)
=>
{
showError
(
err
);
onError
(
String
(
err
));
this
.
chatStreams
.
delete
(
chatId
);
});
// Handle file attachments if provided
if
(
attachments
&&
attachments
.
length
>
0
)
{
// Process each file and convert to base64
Promise
.
all
(
attachments
.
map
(
async
(
file
)
=>
{
return
new
Promise
<
{
name
:
string
;
type
:
string
;
data
:
string
}
>
(
(
resolve
,
reject
)
=>
{
const
reader
=
new
FileReader
();
reader
.
onload
=
()
=>
{
resolve
({
name
:
file
.
name
,
type
:
file
.
type
,
data
:
reader
.
result
as
string
,
});
};
reader
.
onerror
=
()
=>
reject
(
new
Error
(
`Failed to read file:
${
file
.
name
}
`
));
reader
.
readAsDataURL
(
file
);
}
);
})
)
.
then
((
fileDataArray
)
=>
{
// Use invoke to start the stream and pass the chatId and attachments
this
.
ipcRenderer
.
invoke
(
"chat:stream"
,
{
prompt
,
chatId
,
redo
,
attachments
:
fileDataArray
,
})
.
catch
((
err
)
=>
{
showError
(
err
);
onError
(
String
(
err
));
this
.
chatStreams
.
delete
(
chatId
);
});
})
.
catch
((
err
)
=>
{
showError
(
err
);
onError
(
String
(
err
));
this
.
chatStreams
.
delete
(
chatId
);
});
}
else
{
// No attachments, proceed normally
this
.
ipcRenderer
.
invoke
(
"chat:stream"
,
{
prompt
,
chatId
,
redo
,
})
.
catch
((
err
)
=>
{
showError
(
err
);
onError
(
String
(
err
));
this
.
chatStreams
.
delete
(
chatId
);
});
}
}
// Method to cancel an ongoing stream
...
...
src/ipc/ipc_types.ts
浏览文件 @
ac8ef73b
...
...
@@ -14,6 +14,11 @@ export interface ChatStreamParams {
chatId
:
number
;
prompt
:
string
;
redo
?:
boolean
;
attachments
?:
Array
<
{
name
:
string
;
type
:
string
;
data
:
string
;
// Base64 encoded file data
}
>
;
}
export
interface
ChatResponseEnd
{
...
...
src/pages/home.tsx
浏览文件 @
ac8ef73b
...
...
@@ -25,6 +25,11 @@ import { useTheme } from "@/contexts/ThemeContext";
import
{
Button
}
from
"@/components/ui/button"
;
import
{
ExternalLink
}
from
"lucide-react"
;
// Adding an export for attachments
export
interface
HomeSubmitOptions
{
attachments
?:
File
[];
}
export
default
function
HomePage
()
{
const
[
inputValue
,
setInputValue
]
=
useAtom
(
homeChatInputValueAtom
);
const
navigate
=
useNavigate
();
...
...
@@ -91,8 +96,10 @@ export default function HomePage() {
}
},
[
appId
,
navigate
]);
const
handleSubmit
=
async
()
=>
{
if
(
!
inputValue
.
trim
())
return
;
const
handleSubmit
=
async
(
options
?:
HomeSubmitOptions
)
=>
{
const
attachments
=
options
?.
attachments
||
[];
if
(
!
inputValue
.
trim
()
&&
attachments
.
length
===
0
)
return
;
try
{
setIsLoading
(
true
);
...
...
@@ -101,8 +108,12 @@ export default function HomePage() {
name
:
generateCuteAppName
(),
});
// Stream the message
streamMessage
({
prompt
:
inputValue
,
chatId
:
result
.
chatId
});
// Stream the message with attachments
streamMessage
({
prompt
:
inputValue
,
chatId
:
result
.
chatId
,
attachments
,
});
await
new
Promise
((
resolve
)
=>
setTimeout
(
resolve
,
2000
));
setInputValue
(
""
);
...
...
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论