diff --git a/internal/adapter/openai/files_route_test.go b/internal/adapter/openai/files_route_test.go index 4db1ea5..6c8eb0b 100644 --- a/internal/adapter/openai/files_route_test.go +++ b/internal/adapter/openai/files_route_test.go @@ -16,6 +16,30 @@ import ( "ds2api/internal/deepseek" ) +type managedFilesAuthStub struct{} + +func (managedFilesAuthStub) Determine(_ *http.Request) (*auth.RequestAuth, error) { + return &auth.RequestAuth{ + UseConfigToken: true, + DeepSeekToken: "managed-token", + CallerID: "caller:test", + AccountID: "acct-123", + TriedAccounts: map[string]bool{}, + }, nil +} + +func (managedFilesAuthStub) DetermineCaller(_ *http.Request) (*auth.RequestAuth, error) { + return &auth.RequestAuth{ + UseConfigToken: true, + DeepSeekToken: "managed-token", + CallerID: "caller:test", + AccountID: "acct-123", + TriedAccounts: map[string]bool{}, + }, nil +} + +func (managedFilesAuthStub) Release(_ *auth.RequestAuth) {} + type filesRouteDSStub struct { lastReq deepseek.UploadFileRequest upload *deepseek.UploadFileResult @@ -115,6 +139,28 @@ func TestFilesRouteUploadSuccess(t *testing.T) { } } +func TestFilesRouteUploadIncludesAccountIDForManagedAccount(t *testing.T) { + ds := &filesRouteDSStub{} + h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: managedFilesAuthStub{}, DS: ds} + r := chi.NewRouter() + RegisterRoutes(r, h) + + req := newMultipartUploadRequest(t, "assistants", "notes.txt", []byte("hello world")) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode response failed: %v body=%s", err, rec.Body.String()) + } + if out["account_id"] != "acct-123" { + t.Fatalf("expected account_id acct-123, got %#v", out["account_id"]) + } +} + func TestFilesRouteRejectsNonMultipart(t *testing.T) { h := &Handler{Store: mockOpenAIConfig{wideInput: true}, Auth: streamStatusAuthStub{}, DS: &filesRouteDSStub{}} r := chi.NewRouter() diff --git a/internal/adapter/openai/handler_files.go b/internal/adapter/openai/handler_files.go index 5a86d24..8253135 100644 --- a/internal/adapter/openai/handler_files.go +++ b/internal/adapter/openai/handler_files.go @@ -61,12 +61,15 @@ func (h *Handler) UploadFile(w http.ResponseWriter, r *http.Request) { writeOpenAIError(w, http.StatusInternalServerError, "Failed to upload file.") return } + if result != nil && result.AccountID == "" { + result.AccountID = a.AccountID + } writeJSON(w, http.StatusOK, buildOpenAIFileObject(result)) } func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any { if result == nil { - return map[string]any{ + obj := map[string]any{ "id": "", "object": "file", "bytes": 0, @@ -76,8 +79,9 @@ func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any { "status": "uploaded", "status_details": nil, } + return obj } - return map[string]any{ + obj := map[string]any{ "id": result.ID, "object": "file", "bytes": result.Bytes, @@ -87,4 +91,8 @@ func buildOpenAIFileObject(result *deepseek.UploadFileResult) map[string]any { "status": result.Status, "status_details": nil, } + if result.AccountID != "" { + obj["account_id"] = result.AccountID + } + return obj } diff --git a/internal/deepseek/client_upload.go b/internal/deepseek/client_upload.go index 573a804..c494b7b 100644 --- a/internal/deepseek/client_upload.go +++ b/internal/deepseek/client_upload.go @@ -31,6 +31,7 @@ type UploadFileResult struct { Bytes int64 Status string Purpose string + AccountID string IsImage bool Raw map[string]any RawHeaders http.Header @@ -117,6 +118,9 @@ func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req Upload if result.Purpose == "" { result.Purpose = purpose } + if result.AccountID == "" { + result.AccountID = a.AccountID + } if result.ID == "" { return nil, errors.New("upload file succeeded without file id") } @@ -234,6 +238,9 @@ func extractUploadFileResult(resp map[string]any) *UploadFileResult { if result.Purpose == "" { result.Purpose = firstNonEmptyString(m, "purpose") } + if result.AccountID == "" { + result.AccountID = firstNonEmptyString(m, "account_id", "accountId", "owner_account_id", "ownerAccountId") + } if result.Bytes == 0 { result.Bytes = firstPositiveInt64(m, "bytes", "size", "file_size") } diff --git a/webui/src/features/apiTester/ApiTesterContainer.jsx b/webui/src/features/apiTester/ApiTesterContainer.jsx index fe80753..96e824a 100644 --- a/webui/src/features/apiTester/ApiTesterContainer.jsx +++ b/webui/src/features/apiTester/ApiTesterContainer.jsx @@ -109,6 +109,7 @@ export default function ApiTesterContainer({ config, onMessage, authFetch }) { setMessage={setMessage} attachedFiles={attachedFiles} setAttachedFiles={setAttachedFiles} + setSelectedAccount={setSelectedAccount} effectiveKey={effectiveKey} selectedAccount={selectedAccount} onMessage={onMessage} diff --git a/webui/src/features/apiTester/ChatPanel.jsx b/webui/src/features/apiTester/ChatPanel.jsx index 4633e37..86f865f 100644 --- a/webui/src/features/apiTester/ChatPanel.jsx +++ b/webui/src/features/apiTester/ChatPanel.jsx @@ -2,12 +2,15 @@ import { Bot, Loader2, Send, Square, User, Zap, Paperclip, X, FileIcon } from 'l import clsx from 'clsx' import { useRef, useState } from 'react' +import { getAttachedFileAccountIds } from './fileAccountBinding' + export default function ChatPanel({ t, message, setMessage, attachedFiles = [], setAttachedFiles, + setSelectedAccount, effectiveKey, selectedAccount, onMessage, @@ -32,6 +35,8 @@ export default function ChatPanel({ } setUploadingFiles(true) + const initialSelectedAccount = String(selectedAccount || '').trim() + let boundAccount = initialSelectedAccount for (const file of files) { const formData = new FormData() formData.append('file', file) @@ -40,8 +45,8 @@ export default function ChatPanel({ const headers = { 'Authorization': `Bearer ${effectiveKey}`, } - if (selectedAccount) { - headers['X-Ds2-Target-Account'] = selectedAccount + if (boundAccount) { + headers['X-Ds2-Target-Account'] = boundAccount } try { @@ -57,11 +62,18 @@ export default function ChatPanel({ } const data = await res.json() setAttachedFiles(prev => [...prev, data]) + const uploadedAccount = String(data?.account_id || '').trim() + if (!boundAccount && uploadedAccount) { + boundAccount = uploadedAccount + } } catch (error) { onMessage('error', error.message || 'Network error during upload') } } setUploadingFiles(false) + if (!initialSelectedAccount && boundAccount && setSelectedAccount) { + setSelectedAccount(boundAccount) + } if (fileInputRef.current) { fileInputRef.current.value = '' } @@ -70,6 +82,9 @@ export default function ChatPanel({ const removeFile = (id) => { setAttachedFiles(prev => prev.filter(f => f.id !== id)) } + + const attachmentAccountIds = getAttachedFileAccountIds(attachedFiles) + const attachmentAccountId = attachmentAccountIds.length === 1 ? attachmentAccountIds[0] : '' return (
@@ -143,6 +158,16 @@ export default function ChatPanel({ ))}
)} + {attachmentAccountIds.length > 1 && ( +
+ {t('apiTester.fileAccountConflict')} +
+ )} + {attachmentAccountId && ( +
+ {t('apiTester.attachmentAccountHint', { account: attachmentAccountId })} +
+ )}
0 ? ids[0] : '' +} diff --git a/webui/src/features/apiTester/useChatStreamClient.js b/webui/src/features/apiTester/useChatStreamClient.js index 8448967..6378ed3 100644 --- a/webui/src/features/apiTester/useChatStreamClient.js +++ b/webui/src/features/apiTester/useChatStreamClient.js @@ -1,5 +1,7 @@ import { useCallback } from 'react' +import { getAttachedFileAccountIds } from './fileAccountBinding' + export function useChatStreamClient({ t, onMessage, @@ -47,6 +49,20 @@ export function useChatStreamClient({ } }, [t]) + const resolveAttachmentAccount = useCallback(() => { + const ids = getAttachedFileAccountIds(attachedFiles) + if (ids.length > 1) { + return { + accountId: '', + error: t('apiTester.fileAccountConflict'), + } + } + return { + accountId: ids[0] || '', + error: '', + } + }, [attachedFiles, t]) + const runTest = useCallback(async () => { if (!effectiveKey) { onMessage('error', t('apiTester.missingApiKey')) @@ -63,12 +79,31 @@ export function useChatStreamClient({ abortControllerRef.current = new AbortController() try { + const selectedAccountId = String(selectedAccount || '').trim() + const attachmentBinding = resolveAttachmentAccount() + if (attachmentBinding.error) { + setResponse({ success: false, error: attachmentBinding.error }) + onMessage('error', attachmentBinding.error) + setLoading(false) + setIsStreaming(false) + return + } + if (attachmentBinding.accountId && selectedAccountId && selectedAccountId !== attachmentBinding.accountId) { + const errorMsg = t('apiTester.fileAccountMismatch', { account: attachmentBinding.accountId }) + setResponse({ success: false, error: errorMsg }) + onMessage('error', errorMsg) + setLoading(false) + setIsStreaming(false) + return + } + const requestAccount = selectedAccountId || attachmentBinding.accountId + const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${effectiveKey}`, } - if (selectedAccount) { - headers['X-Ds2-Target-Account'] = selectedAccount + if (requestAccount) { + headers['X-Ds2-Target-Account'] = requestAccount } const body = { @@ -104,6 +139,8 @@ export function useChatStreamClient({ const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' + let accumulatedThinking = '' + let accumulatedContent = '' while (true) { const { done, value } = await reader.read() @@ -126,9 +163,11 @@ export function useChatStreamClient({ if (choice?.delta) { const delta = choice.delta if (delta.reasoning_content) { + accumulatedThinking += delta.reasoning_content setStreamingThinking(prev => prev + delta.reasoning_content) } if (delta.content) { + accumulatedContent += delta.content setStreamingContent(prev => prev + delta.content) } } @@ -137,11 +176,26 @@ export function useChatStreamClient({ } } } + + setResponse({ + success: true, + status_code: res.status, + choices: [{ + finish_reason: 'stop', + index: 0, + message: { + role: 'assistant', + content: accumulatedContent, + reasoning_content: accumulatedThinking, + }, + }], + }) + onMessage('success', t('apiTester.testSuccess', { account: requestAccount || selectedAccountId || 'Auto', time: Math.max(0, Date.now() - startedAt) })) } else { const data = await res.json() setResponse({ success: true, status_code: res.status, ...data }) const elapsed = Math.max(0, Date.now() - startedAt) - onMessage('success', t('apiTester.testSuccess', { account: selectedAccount || 'Auto', time: elapsed })) + onMessage('success', t('apiTester.testSuccess', { account: requestAccount || 'Auto', time: elapsed })) } } catch (e) { if (e.name === 'AbortError') { @@ -163,6 +217,7 @@ export function useChatStreamClient({ message, model, onMessage, + resolveAttachmentAccount, selectedAccount, setIsStreaming, setLoading, diff --git a/webui/src/locales/en.json b/webui/src/locales/en.json index 0b305b3..f77b975 100644 --- a/webui/src/locales/en.json +++ b/webui/src/locales/en.json @@ -224,6 +224,9 @@ "apiKeyPlaceholder": "Enter a custom key", "modeManaged": "Managed key mode (uses account pool).", "modeDirect": "Direct token mode (requires a valid DeepSeek token).", + "attachmentAccountHint": "Attached files are bound to account {account}. Sending will reuse the same account.", + "fileAccountConflict": "Attached files came from different accounts. Clear them and upload again under one account.", + "fileAccountMismatch": "The selected account does not match the attachment account. Switch to the bound account or clear the attachments and try again.", "statusError": "Error", "reasoningTrace": "Reasoning Trace", "generating": "Generating response...", diff --git a/webui/src/locales/zh.json b/webui/src/locales/zh.json index d78b528..12f2d09 100644 --- a/webui/src/locales/zh.json +++ b/webui/src/locales/zh.json @@ -224,6 +224,9 @@ "apiKeyPlaceholder": "输入自定义密钥", "modeManaged": "当前使用托管 key 模式(会走账号池)。", "modeDirect": "当前使用直通 token 模式(需填写有效 DeepSeek token)。", + "attachmentAccountHint": "附件已绑定账号:{account},发送时会自动沿用同一账号。", + "fileAccountConflict": "附件来自不同账号,请先清空后重新上传。", + "fileAccountMismatch": "当前选择的账号与附件绑定账号不一致,请切换到绑定账号或清空附件后重试。", "statusError": "错误", "reasoningTrace": "思维链过程", "generating": "正在生成响应...",