浏览代码

Model ate

sequoia00 3 天之前
父节点
当前提交
dc844c0a5c
共有 5 个文件被更改,包括 213 次插入11 次删除
  1. 1 1
      chatfast/config.py
  2. 73 8
      fastchat.py
  3. 92 2
      static/app.js
  4. 1 0
      static/index.html
  5. 46 0
      static/styles.css

+ 1 - 1
chatfast/config.py

@@ -37,7 +37,7 @@ default_key = "sk-re2NlaKIQn11ZNWzAbB6339cEbF94c6aAfC8B7Ab82879bEa"
 MODEL_KEYS = {
     "grok-3": default_key,
     "grok-4.1": default_key,
-    "gpt-5.1-all": default_key,
+    "gpt-5.1-chat-2025-11-13": default_key,
     "gpt-4o-mini": default_key,
     "o1-mini": default_key,
     "o4-mini": default_key,

+ 73 - 8
fastchat.py

@@ -2,9 +2,11 @@
 import asyncio
 import base64
 import json
+import mimetypes
 import threading
 import uuid
 from typing import Any, Dict, List, Optional
+from urllib.parse import urlparse
 
 from fastapi import Body, Depends, FastAPI, HTTPException, UploadFile, File
 from fastapi.middleware.cors import CORSMiddleware
@@ -13,6 +15,7 @@ from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel
 
 from openai import OpenAI
+from pathlib import Path
 
 from chatfast.api import admin_router, auth_router, export_router
 from chatfast.config import API_URL, DOWNLOAD_BASE, MODEL_KEYS, STATIC_DIR, UPLOAD_DIR
@@ -66,8 +69,69 @@ class HistoryActionRequest(BaseModel):
 class UploadResponseItem(BaseModel):
     type: str
     filename: str
-    data: Optional[str] = None
     url: Optional[str] = None
+    path: Optional[str] = None
+
+
+def _is_data_url(value: str) -> bool:
+    return value.startswith("data:")
+
+
+def _resolve_upload_path(reference: str) -> Optional[Path]:
+    if not reference or _is_data_url(reference):
+        return None
+    parsed = urlparse(reference)
+    candidate = Path(parsed.path).name if parsed.scheme else Path(reference).name
+    if not candidate:
+        return None
+    return UPLOAD_DIR / candidate
+
+
+async def _inline_local_image(reference: str) -> str:
+    if not reference or _is_data_url(reference):
+        return reference
+    file_path = _resolve_upload_path(reference)
+    if not file_path or not file_path.exists():
+        return reference
+    try:
+        data = await asyncio.to_thread(file_path.read_bytes)
+    except OSError:
+        return reference
+    mime = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
+    encoded = base64.b64encode(data).decode("utf-8")
+    return f"data:{mime};base64,{encoded}"
+
+
+async def _prepare_content_for_model(content: MessageContent) -> MessageContent:
+    if not isinstance(content, list):
+        return content
+    prepared: List[Dict[str, Any]] = []
+    for part in content:
+        if not isinstance(part, dict):
+            prepared.append(part)
+            continue
+        if part.get("type") != "image_url":
+            prepared.append({**part})
+            continue
+        image_data = dict(part.get("image_url") or {})
+        url_value = str(image_data.get("url") or "")
+        new_url = await _inline_local_image(url_value)
+        if new_url:
+            image_data["url"] = new_url
+        prepared.append({"type": "image_url", "image_url": image_data})
+    return prepared
+
+
+async def _prepare_messages_for_model(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+    prepared: List[Dict[str, Any]] = []
+    for message in messages:
+        prepared.append(
+            {
+                "role": message.get("role", ""),
+                "content": await _prepare_content_for_model(message.get("content")),
+            }
+        )
+    return prepared
 
 
 # 确保静态与数据目录在应用初始化前存在
@@ -191,15 +255,14 @@ async def api_upload(
 
         await asyncio.to_thread(_write)
 
+        download_url = build_download_url(unique_name)
         if content_type.startswith("image/"):
-            encoded = base64.b64encode(data).decode("utf-8")
-            data_url = f"data:{content_type};base64,{encoded}"
             responses.append(
                 UploadResponseItem(
                     type="image",
                     filename=safe_filename,
-                    data=data_url,
-                    url=build_download_url(unique_name),
+                    url=download_url,
+                    path=unique_name,
                 )
             )
         else:
@@ -207,7 +270,8 @@ async def api_upload(
                 UploadResponseItem(
                     type="file",
                     filename=safe_filename,
-                    url=build_download_url(unique_name),
+                    url=download_url,
+                    path=unique_name,
                 )
             )
 
@@ -230,6 +294,7 @@ async def api_chat(payload: ChatRequest = Body(...), current_user: UserInfo = De
     client.api_key = MODEL_KEYS[payload.model]
 
     to_send = await prepare_messages_for_completion(messages, payload.content, max(payload.history_count, 0))
+    model_messages = await _prepare_messages_for_model(to_send)
 
     if payload.stream:
         queue: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue()
@@ -240,7 +305,7 @@ async def api_chat(payload: ChatRequest = Body(...), current_user: UserInfo = De
             try:
                 response = client.chat.completions.create(
                     model=payload.model,
-                    messages=to_send,
+                    messages=model_messages,
                     stream=True,
                 )
                 for chunk in response:
@@ -282,7 +347,7 @@ async def api_chat(payload: ChatRequest = Body(...), current_user: UserInfo = De
         completion = await asyncio.to_thread(
             client.chat.completions.create,
             model=payload.model,
-            messages=to_send,
+            messages=model_messages,
             stream=False,
         )
     except Exception as exc:  # pragma: no cover - 网络调用

+ 92 - 2
static/app.js

@@ -26,6 +26,7 @@
         adminExports: [],
         activeAbortController: null,
         userMenuOpen: false,
+        selectedAttachments: [],
     };
 
     const dom = {};
@@ -89,6 +90,7 @@
         dom.chatInput = document.getElementById('chat-input');
         dom.sendButton = document.getElementById('send-btn');
         dom.fileInput = document.getElementById('file-input');
+        dom.attachmentPreview = document.getElementById('attachment-preview');
         dom.expandAllButton = document.getElementById('expand-all-btn');
         dom.chatStatus = document.getElementById('chat-status');
         dom.sessionIndicator = document.getElementById('session-indicator');
@@ -179,6 +181,10 @@
             updateHistorySlider();
         });
 
+        if (dom.fileInput) {
+            dom.fileInput.addEventListener('change', handleFileSelection);
+        }
+
         dom.historyPrev.addEventListener('click', async () => {
             if (state.historyPage > 0) {
                 state.historyPage -= 1;
@@ -284,6 +290,88 @@
         });
     }
 
+    function handleFileSelection() {
+        if (!dom.fileInput) {
+            return;
+        }
+        updateAttachmentPreview(dom.fileInput.files);
+    }
+
+    function updateAttachmentPreview(fileList) {
+        revokeAttachmentPreviewUrls();
+        const files = fileList ? Array.from(fileList) : [];
+        if (!files.length) {
+            state.selectedAttachments = [];
+            renderAttachmentPreview([]);
+            return;
+        }
+
+        state.selectedAttachments = files.map((file) => {
+            const isImage = (file.type || '').startsWith('image/');
+            const attachment = {
+                type: isImage ? 'image' : 'file',
+                name: file.name || '未命名文件',
+            };
+            if (isImage) {
+                attachment.previewUrl = URL.createObjectURL(file);
+            }
+            return attachment;
+        });
+        renderAttachmentPreview(state.selectedAttachments);
+    }
+
+    function renderAttachmentPreview(items) {
+        if (!dom.attachmentPreview) {
+            return;
+        }
+        dom.attachmentPreview.innerHTML = '';
+        if (!items || !items.length) {
+            dom.attachmentPreview.classList.add('hidden');
+            return;
+        }
+        dom.attachmentPreview.classList.remove('hidden');
+        items.forEach((item) => {
+            const container = document.createElement('div');
+            container.className = 'attachment-preview-item';
+
+            if (item.type === 'image' && item.previewUrl) {
+                const img = document.createElement('img');
+                img.className = 'attachment-thumb';
+                img.src = item.previewUrl;
+                img.alt = item.name || '预览图';
+                container.appendChild(img);
+            } else {
+                const icon = document.createElement('div');
+                icon.className = 'attachment-file-icon';
+                icon.textContent = '📄';
+                container.appendChild(icon);
+            }
+
+            const text = document.createElement('div');
+            text.className = 'attachment-filename';
+            text.textContent = item.name || '文件';
+            container.appendChild(text);
+            dom.attachmentPreview.appendChild(container);
+        });
+    }
+
+    function clearAttachmentPreview() {
+        revokeAttachmentPreviewUrls();
+        state.selectedAttachments = [];
+        renderAttachmentPreview([]);
+    }
+
+    function revokeAttachmentPreviewUrls() {
+        if (!state.selectedAttachments || !state.selectedAttachments.length) {
+            return;
+        }
+        state.selectedAttachments.forEach((item) => {
+            if (item.previewUrl) {
+                URL.revokeObjectURL(item.previewUrl);
+            }
+        });
+    }
+
     function resetChatState() {
         state.sessionId = null;
         state.sessionNumber = null;
@@ -297,6 +385,7 @@
         state.adminUsers = [];
         state.adminExports = [];
         state.activeAbortController = null;
+        clearAttachmentPreview();
         setUserMenuOpen(false);
         renderSidebar();
         renderMessages();
@@ -1790,6 +1879,7 @@
 
         dom.chatInput.value = '';
         dom.fileInput.value = '';
+        clearAttachmentPreview();
 
         const assistantMessage = { role: 'assistant', content: '' };
         state.messages.push(assistantMessage);
@@ -1887,10 +1977,10 @@
         let additionalPrompt = '';
 
         results.forEach((item) => {
-            if (item.type === 'image' && item.data) {
+            if (item.type === 'image' && item.url) {
                 contentParts.push({
                     type: 'image_url',
-                    image_url: { url: item.data },
+                    image_url: { url: item.url },
                 });
             } else if (item.type === 'file' && item.url) {
                 additionalPrompt += `本次提问包含:${item.url} 文件\n`;

+ 1 - 0
static/index.html

@@ -111,6 +111,7 @@
                     <button type="submit" id="send-btn" class="primary-button">发送</button>
                     <button type="button" id="end-chat-btn" class="secondary-button danger hidden" title="提前结束此次对话">提前结束</button>
                 </div>
+                <div id="attachment-preview" class="attachment-preview hidden" aria-live="polite"></div>
                 <div id="chat-status" class="chat-status" aria-live="polite"></div>
             </form>
         </main>

+ 46 - 0
static/styles.css

@@ -886,6 +886,52 @@ body {
     display: none;
 }
 
+.attachment-preview {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    padding-top: 4px;
+}
+
+.attachment-preview-item {
+    display: inline-flex;
+    align-items: center;
+    gap: 8px;
+    padding: 6px 10px;
+    border: 1px solid var(--border-color);
+    border-radius: 10px;
+    background: #f8fafc;
+}
+
+.attachment-thumb {
+    width: 48px;
+    height: 48px;
+    object-fit: cover;
+    border-radius: 8px;
+    border: 1px solid var(--border-color);
+    background: #fff;
+}
+
+.attachment-file-icon {
+    width: 48px;
+    height: 48px;
+    border-radius: 8px;
+    background: #e9ecf2;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 22px;
+}
+
+.attachment-filename {
+    font-size: 13px;
+    color: var(--secondary-text);
+    max-width: 260px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
 .chat-status {
     min-height: 20px;
     font-size: 13px;