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 个修改的文件
包含
369 行增加
和
18 行删除
+369
-18
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
+0
-0
ipc_client.ts
src/ipc/ipc_client.ts
+48
-3
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,10 +55,24 @@ export function HomeChatInput({ onSubmit }: { onSubmit: () => void }) {
const
handleKeyPress
=
(
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
"Enter"
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
on
Submit
();
handleCustom
Submit
();
}
};
// 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
)
{
return
null
;
// Or loading state
}
...
...
@@ -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
差异被折叠。
点击展开。
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
// 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
,
}
satisfies
ChatStreamParams
)
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
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论