mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-21 00:17:44 +08:00
feat: add file attachment support to chat interface and API requests
This commit is contained in:
@@ -70,6 +70,7 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) {
|
|||||||
effectiveKey,
|
effectiveKey,
|
||||||
selectedAccount,
|
selectedAccount,
|
||||||
streamingMode,
|
streamingMode,
|
||||||
|
attachedFiles,
|
||||||
abortControllerRef,
|
abortControllerRef,
|
||||||
setLoading,
|
setLoading,
|
||||||
setIsStreaming,
|
setIsStreaming,
|
||||||
@@ -104,6 +105,11 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) {
|
|||||||
t={t}
|
t={t}
|
||||||
message={message}
|
message={message}
|
||||||
setMessage={setMessage}
|
setMessage={setMessage}
|
||||||
|
attachedFiles={attachedFiles}
|
||||||
|
setAttachedFiles={setAttachedFiles}
|
||||||
|
effectiveKey={effectiveKey}
|
||||||
|
selectedAccount={selectedAccount}
|
||||||
|
onMessage={onMessage}
|
||||||
response={response}
|
response={response}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Bot, Loader2, Send, Square, User, Zap } from 'lucide-react'
|
import { Bot, Loader2, Send, Square, User, Zap, Paperclip, X, FileIcon } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
export default function ChatPanel({
|
export default function ChatPanel({
|
||||||
t,
|
t,
|
||||||
message,
|
message,
|
||||||
setMessage,
|
setMessage,
|
||||||
|
attachedFiles = [],
|
||||||
|
setAttachedFiles,
|
||||||
|
effectiveKey,
|
||||||
|
selectedAccount,
|
||||||
|
onMessage,
|
||||||
response,
|
response,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
loading,
|
loading,
|
||||||
@@ -13,6 +19,57 @@ export default function ChatPanel({
|
|||||||
onRunTest,
|
onRunTest,
|
||||||
onStopGeneration,
|
onStopGeneration,
|
||||||
}) {
|
}) {
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState(false)
|
||||||
|
|
||||||
|
const handleFileSelect = async (e) => {
|
||||||
|
const files = Array.from(e.target.files)
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
if (!effectiveKey) {
|
||||||
|
onMessage('error', t('apiTester.missingApiKey') || 'Missing API Key')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingFiles(true)
|
||||||
|
for (const file of files) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
formData.append('purpose', 'assistants')
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${effectiveKey}`,
|
||||||
|
}
|
||||||
|
if (selectedAccount) {
|
||||||
|
headers['X-Ds2-Target-Account'] = selectedAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/v1/files', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
onMessage('error', err || 'File upload failed')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
setAttachedFiles(prev => [...prev, data])
|
||||||
|
} catch (error) {
|
||||||
|
onMessage('error', error.message || 'Network error during upload')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploadingFiles(false)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (id) => {
|
||||||
|
setAttachedFiles(prev => prev.filter(f => f.id !== id))
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="lg:col-span-9 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden min-h-0 flex-1 relative">
|
<div className="lg:col-span-9 flex flex-col bg-card border border-border rounded-xl shadow-sm overflow-hidden min-h-0 flex-1 relative">
|
||||||
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-8 custom-scrollbar scroll-smooth">
|
<div className="flex-1 overflow-y-auto p-4 lg:p-6 space-y-8 custom-scrollbar scroll-smooth">
|
||||||
@@ -70,9 +127,42 @@ export default function ChatPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 lg:p-6 border-t border-border bg-card">
|
<div className="p-4 lg:p-6 border-t border-border bg-card">
|
||||||
|
{attachedFiles.length > 0 && (
|
||||||
|
<div className="max-w-4xl mx-auto flex flex-wrap gap-2 mb-3">
|
||||||
|
{attachedFiles.map(file => (
|
||||||
|
<div key={file.id} className="flex items-center gap-2 bg-secondary/50 border border-border rounded-md px-2 py-1 text-xs text-secondary-foreground">
|
||||||
|
<FileIcon className="w-3 h-3 text-muted-foreground" />
|
||||||
|
<span className="truncate max-w-[150px]">{file.filename || file.id}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeFile(file.id)}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors ml-1"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="max-w-4xl mx-auto relative group">
|
<div className="max-w-4xl mx-auto relative group">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
multiple
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
<div className="absolute left-2 bottom-2 z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadingFiles || isStreaming}
|
||||||
|
className="p-2 text-muted-foreground hover:text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Attach files"
|
||||||
|
>
|
||||||
|
{uploadingFiles ? <Loader2 className="w-4 h-4 animate-spin" /> : <Paperclip className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full bg-[#09090b] border border-border rounded-xl pl-4 pr-12 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all resize-none custom-scrollbar placeholder:text-muted-foreground/50 text-foreground shadow-inner"
|
className="w-full bg-[#09090b] border border-border rounded-xl pl-12 pr-12 py-3 text-sm focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all resize-none custom-scrollbar placeholder:text-muted-foreground/50 text-foreground shadow-inner"
|
||||||
placeholder={t('apiTester.enterMessage')}
|
placeholder={t('apiTester.enterMessage')}
|
||||||
rows={1}
|
rows={1}
|
||||||
style={{ minHeight: '52px' }}
|
style={{ minHeight: '52px' }}
|
||||||
@@ -81,11 +171,13 @@ export default function ChatPanel({
|
|||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onRunTest()
|
if (!loading && !uploadingFiles && (message.trim() || attachedFiles.length > 0)) {
|
||||||
|
onRunTest()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 bottom-2">
|
<div className="absolute right-2 bottom-2 z-10">
|
||||||
{loading && isStreaming ? (
|
{loading && isStreaming ? (
|
||||||
<button onClick={onStopGeneration} className="p-2 text-muted-foreground hover:text-destructive transition-colors">
|
<button onClick={onStopGeneration} className="p-2 text-muted-foreground hover:text-destructive transition-colors">
|
||||||
<Square className="w-4 h-4 fill-current" />
|
<Square className="w-4 h-4 fill-current" />
|
||||||
@@ -93,7 +185,7 @@ export default function ChatPanel({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={onRunTest}
|
onClick={onRunTest}
|
||||||
disabled={loading || !message.trim()}
|
disabled={loading || uploadingFiles || (!message.trim() && attachedFiles.length === 0)}
|
||||||
className="p-2 text-primary hover:text-primary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
className="p-2 text-primary hover:text-primary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function useApiTesterState({ t }) {
|
|||||||
const [streamingThinking, setStreamingThinking] = useState('')
|
const [streamingThinking, setStreamingThinking] = useState('')
|
||||||
const [isStreaming, setIsStreaming] = useState(false)
|
const [isStreaming, setIsStreaming] = useState(false)
|
||||||
const [streamingMode, setStreamingMode] = useState(true)
|
const [streamingMode, setStreamingMode] = useState(true)
|
||||||
const [configExpanded, setConfigExpanded] = useState(false)
|
const [attachedFiles, setAttachedFiles] = useState([])
|
||||||
|
|
||||||
const abortControllerRef = useRef(null)
|
const abortControllerRef = useRef(null)
|
||||||
const defaultMessageRef = useRef(defaultMessage)
|
const defaultMessageRef = useRef(defaultMessage)
|
||||||
@@ -27,6 +27,8 @@ export function useApiTesterState({ t }) {
|
|||||||
setModel,
|
setModel,
|
||||||
message,
|
message,
|
||||||
setMessage,
|
setMessage,
|
||||||
|
attachedFiles,
|
||||||
|
setAttachedFiles,
|
||||||
apiKey,
|
apiKey,
|
||||||
setApiKey,
|
setApiKey,
|
||||||
selectedAccount,
|
selectedAccount,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export function useChatStreamClient({
|
|||||||
effectiveKey,
|
effectiveKey,
|
||||||
selectedAccount,
|
selectedAccount,
|
||||||
streamingMode,
|
streamingMode,
|
||||||
|
attachedFiles,
|
||||||
abortControllerRef,
|
abortControllerRef,
|
||||||
setLoading,
|
setLoading,
|
||||||
setIsStreaming,
|
setIsStreaming,
|
||||||
@@ -70,15 +71,21 @@ export function useChatStreamClient({
|
|||||||
headers['X-Ds2-Target-Account'] = selectedAccount
|
headers['X-Ds2-Target-Account'] = selectedAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: message }],
|
||||||
|
stream: streamingMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachedFiles && attachedFiles.length > 0) {
|
||||||
|
body.file_ids = attachedFiles.map(f => f.id)
|
||||||
|
}
|
||||||
|
|
||||||
const endpoint = streamingMode ? '/v1/chat/completions' : '/v1/chat/completions?__go=1'
|
const endpoint = streamingMode ? '/v1/chat/completions' : '/v1/chat/completions?__go=1'
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
model,
|
|
||||||
messages: [{ role: 'user', content: message }],
|
|
||||||
stream: streamingMode,
|
|
||||||
}),
|
|
||||||
signal: abortControllerRef.current.signal,
|
signal: abortControllerRef.current.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -150,6 +157,7 @@ export function useChatStreamClient({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
abortControllerRef,
|
abortControllerRef,
|
||||||
|
attachedFiles,
|
||||||
effectiveKey,
|
effectiveKey,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
message,
|
message,
|
||||||
|
|||||||
Reference in New Issue
Block a user