sequoia 1 týždeň pred
commit
0faf8f21ca
6 zmenil súbory, kde vykonal 2367 pridanie a 0 odobranie
  1. 6 0
      .gitignore
  2. 552 0
      fastchat.py
  3. 2 0
      start.sh
  4. 1161 0
      static/app.js
  5. 66 0
      static/index.html
  6. 580 0
      static/styles.css

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+blog/
+uploads/
+data/
+data_bak/
+__pycache__/
+nohup.out

+ 552 - 0
fastchat.py

@@ -0,0 +1,552 @@
+# -*- coding: utf-8 -*-
+import asyncio
+import base64
+import datetime
+import json
+import os
+import re
+import shutil
+import threading
+import uuid
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Union
+
+from fastapi import Body, FastAPI, HTTPException, UploadFile, File
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+
+from openai import OpenAI
+
+# =============================
+# 基础配置
+# =============================
+BASE_DIR = Path(__file__).resolve().parent
+DATA_DIR = BASE_DIR / "data"
+BACKUP_DIR = BASE_DIR / "data_bak"
+BLOG_DIR = BASE_DIR / "blog"
+UPLOAD_DIR = BASE_DIR / "uploads"
+STATIC_DIR = BASE_DIR / "static"
+SESSION_ID_FILE = DATA_DIR / "session_id.txt"
+
+# 默认上传文件下载地址,可通过环境变量覆盖
+DEFAULT_UPLOAD_BASE = os.getenv("UPLOAD_BASE_URL", "/download/")
+DOWNLOAD_BASE = DEFAULT_UPLOAD_BASE.rstrip("/")
+
+# 与 appchat.py 相同的模型与密钥配置(仅示例)
+default_key = "sk-re2NlaKIQn11ZNWzAbB6339cEbF94c6aAfC8B7Ab82879bEa"
+MODEL_KEYS: Dict[str, str] = {
+    "grok-3": default_key,
+    "grok-4": default_key,
+    "gpt-5.1-2025-11-13": default_key,
+    "gpt-5-2025-08-07": default_key,
+    "gpt-4o-mini": default_key,
+    # "gpt-4.1-mini-2025-04-14": default_key,
+    "o1-mini": default_key,
+    "o4-mini": default_key,
+    "deepseek-v3": default_key,
+    "deepseek-r1": default_key,
+    "gpt-4o-all": default_key,
+    # "gpt-5-mini-2025-08-07": default_key,
+    "o3-mini-all": default_key,
+}
+API_URL = "https://yunwu.ai/v1"
+
+client = OpenAI(api_key=default_key, base_url=API_URL)
+
+# 锁用于避免并发文件写入导致的数据损坏
+FILE_LOCK = asyncio.Lock()
+SESSION_LOCK = asyncio.Lock()
+
+MessageContent = Union[str, List[Dict[str, Any]]]
+SESSION_FILE_PATTERN = re.compile(r"chat_history_(\d+)\.json")
+
+
+def ensure_directories() -> None:
+    for path in [DATA_DIR, BACKUP_DIR, BLOG_DIR, UPLOAD_DIR, STATIC_DIR]:
+        path.mkdir(parents=True, exist_ok=True)
+
+
+def extract_session_id(path: Path) -> Optional[int]:
+    match = SESSION_FILE_PATTERN.search(path.name)
+    if match:
+        try:
+            return int(match.group(1))
+        except ValueError:
+            return None
+    return None
+
+
+def load_session_counter() -> int:
+    if SESSION_ID_FILE.exists():
+        try:
+            value = SESSION_ID_FILE.read_text(encoding="utf-8").strip()
+            return int(value) if value.isdigit() else 0
+        except Exception:
+            return 0
+    return 0
+
+
+def save_session_counter(value: int) -> None:
+    SESSION_ID_FILE.write_text(str(value), encoding="utf-8")
+
+
+def sync_session_counter_with_history() -> None:
+    max_session = load_session_counter()
+    for path in DATA_DIR.glob("chat_history_*.json"):
+        session_id = extract_session_id(path)
+        if session_id is not None and session_id > max_session:
+            max_session = session_id
+    save_session_counter(max_session)
+
+
+def text_from_content(content: MessageContent) -> str:
+    if isinstance(content, str):
+        return content
+    if isinstance(content, list):
+        pieces: List[str] = []
+        for part in content:
+            if part.get("type") == "text":
+                pieces.append(part.get("text", ""))
+        return " ".join(pieces)
+    return str(content)
+
+
+def extract_history_title(messages: List[Dict[str, Any]]) -> str:
+    """Return the first meaningful title extracted from user messages."""
+
+    for message in messages:
+        if message.get("role") != "user":
+            continue
+        title = text_from_content(message.get("content", "")).strip()
+        if title:
+            return title[:10]
+
+    if messages:
+        fallback = text_from_content(messages[0].get("content", "")).strip()
+        if fallback:
+            return fallback[:10]
+
+    return "空的聊天"[:10]
+
+
+def history_path(session_id: int) -> Path:
+    return DATA_DIR / f"chat_history_{session_id}.json"
+
+
+def build_download_url(filename: str) -> str:
+    base = DOWNLOAD_BASE or ""
+    return f"{base}/{filename}" if base else filename
+
+
+async def read_json_file(path: Path) -> List[Dict[str, Any]]:
+    def _read() -> List[Dict[str, Any]]:
+        with path.open("r", encoding="utf-8") as fp:
+            return json.load(fp)
+
+    return await asyncio.to_thread(_read)
+
+
+async def write_json_file(path: Path, payload: List[Dict[str, Any]]) -> None:
+    serialized = json.dumps(payload, ensure_ascii=False)
+
+    def _write() -> None:
+        with path.open("w", encoding="utf-8") as fp:
+            fp.write(serialized)
+
+    async with FILE_LOCK:
+        await asyncio.to_thread(_write)
+
+
+async def load_messages(session_id: int) -> List[Dict[str, Any]]:
+    path = history_path(session_id)
+    if not path.exists():
+        return []
+    try:
+        return await read_json_file(path)
+    except Exception:
+        return []
+
+
+async def save_messages(session_id: int, messages: List[Dict[str, Any]]) -> None:
+    path = history_path(session_id)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    await write_json_file(path, messages)
+
+
+async def get_latest_session() -> Dict[str, Any]:
+    history_files = sorted(DATA_DIR.glob("chat_history_*.json"), key=lambda p: p.stat().st_mtime)
+    if history_files:
+        latest = history_files[-1]
+        session_id = extract_session_id(latest)
+        if session_id is None:
+            session_id = await asyncio.to_thread(load_session_counter)
+        try:
+            messages = await read_json_file(latest)
+        except Exception:
+            messages = []
+        return {"session_id": session_id, "messages": messages}
+
+    session_id = await asyncio.to_thread(load_session_counter)
+    return {"session_id": session_id, "messages": []}
+
+
+async def increment_session_id() -> int:
+    async with SESSION_LOCK:
+        current = await asyncio.to_thread(load_session_counter)
+        next_session = current + 1
+        await asyncio.to_thread(save_session_counter, next_session)
+        return next_session
+
+
+async def list_history(page: int, page_size: int) -> Dict[str, Any]:
+    files = sorted(DATA_DIR.glob("chat_history_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
+    total = len(files)
+    start = max(page, 0) * page_size
+    end = start + page_size
+    items: List[Dict[str, Any]] = []
+
+    for path in files[start:end]:
+        session_id = extract_session_id(path)
+        if session_id is None:
+            continue
+        try:
+            messages = await read_json_file(path)
+        except Exception:
+            messages = []
+        title = extract_history_title(messages)
+        items.append({
+            "session_id": session_id,
+            "title": title,
+            "updated_at": datetime.datetime.fromtimestamp(path.stat().st_mtime).isoformat(),
+            "filename": path.name,
+        })
+
+    return {
+        "page": page,
+        "page_size": page_size,
+        "total": total,
+        "items": items,
+    }
+
+
+async def move_history_file(session_id: int) -> None:
+    src = history_path(session_id)
+    if not src.exists():
+        raise HTTPException(status_code=404, detail="历史记录不存在")
+    dst = BACKUP_DIR / src.name
+    dst.parent.mkdir(parents=True, exist_ok=True)
+
+    def _move() -> None:
+        shutil.move(str(src), str(dst))
+
+    async with FILE_LOCK:
+        await asyncio.to_thread(_move)
+
+
+async def delete_history_file(session_id: int) -> None:
+    target = history_path(session_id)
+    if not target.exists():
+        raise HTTPException(status_code=404, detail="历史记录不存在")
+
+    def _delete() -> None:
+        target.unlink(missing_ok=True)
+
+    async with FILE_LOCK:
+        await asyncio.to_thread(_delete)
+
+
+async def export_message_to_blog(content: MessageContent) -> str:
+    processed = text_from_content(content)
+    processed = processed.replace("\r\n", "\n")
+    timestamp = datetime.datetime.now().strftime("%m%d%H%M")
+    first_10 = (
+        processed[:10]
+        .replace(" ", "")
+        .replace("/", "")
+        .replace("\\", "")
+        .replace(":", "")
+        .replace("`", "")
+    )
+    filename = f"{timestamp}_{first_10 or 'export'}.txt"
+    path = BLOG_DIR / filename
+
+    def _write() -> None:
+        with path.open("w", encoding="utf-8") as fp:
+            fp.write(processed)
+
+    await asyncio.to_thread(_write)
+    return str(path)
+
+
+class MessageModel(BaseModel):
+    role: str
+    content: MessageContent
+
+
+class ChatRequest(BaseModel):
+    session_id: int
+    model: str
+    content: MessageContent
+    history_count: int = 0
+    stream: bool = True
+
+
+class HistoryActionRequest(BaseModel):
+    session_id: int
+
+
+class ExportRequest(BaseModel):
+    content: MessageContent
+
+
+class UploadResponseItem(BaseModel):
+    type: str
+    filename: str
+    data: Optional[str] = None
+    url: Optional[str] = None
+
+
+# 确保静态与数据目录在应用初始化前存在
+ensure_directories()
+
+
+app = FastAPI(title="ChatGPT-like Clone", version="1.0.0")
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
+
+
+@app.on_event("startup")
+async def on_startup() -> None:
+    ensure_directories()
+    await asyncio.to_thread(sync_session_counter_with_history)
+
+
+INDEX_HTML = STATIC_DIR / "index.html"
+
+
+@app.get("/", response_class=HTMLResponse)
+async def serve_index() -> str:
+    if not INDEX_HTML.exists():
+        raise HTTPException(status_code=404, detail="UI 未找到,请确认 static/index.html 是否存在")
+    return INDEX_HTML.read_text(encoding="utf-8")
+
+
+@app.get("/download/{filename}")
+async def download_file(filename: str) -> FileResponse:
+    target = UPLOAD_DIR / filename
+    if not target.exists():
+        raise HTTPException(status_code=404, detail="File not found")
+    return FileResponse(target, filename=filename)
+
+
+@app.get("/api/config")
+async def get_config() -> Dict[str, Any]:
+    models = list(MODEL_KEYS.keys())
+    return {
+        "title": "ChatGPT-like Clone",
+        "models": models,
+        "default_model": models[0] if models else "",
+        "output_modes": ["流式输出 (Stream)", "非流式输出 (Non-stream)"],
+        "upload_base_url": DOWNLOAD_BASE + "/" if DOWNLOAD_BASE else "",
+    }
+
+
+@app.get("/api/session/latest")
+async def api_latest_session() -> Dict[str, Any]:
+    return await get_latest_session()
+
+
+@app.get("/api/session/{session_id}")
+async def api_get_session(session_id: int) -> Dict[str, Any]:
+    messages = await load_messages(session_id)
+    path = history_path(session_id)
+    if not messages and not path.exists():
+        raise HTTPException(status_code=404, detail="会话不存在")
+    return {"session_id": session_id, "messages": messages}
+
+
+@app.post("/api/session/new")
+async def api_new_session() -> Dict[str, Any]:
+    session_id = await increment_session_id()
+    await save_messages(session_id, [])
+    return {"session_id": session_id, "messages": []}
+
+
+@app.get("/api/history")
+async def api_history(page: int = 0, page_size: int = 10) -> Dict[str, Any]:
+    return await list_history(page, page_size)
+
+
+@app.post("/api/history/move")
+async def api_move_history(payload: HistoryActionRequest) -> Dict[str, Any]:
+    await move_history_file(payload.session_id)
+    return {"status": "ok"}
+
+
+@app.delete("/api/history/{session_id}")
+async def api_delete_history(session_id: int) -> Dict[str, Any]:
+    await delete_history_file(session_id)
+    return {"status": "ok"}
+
+
+@app.post("/api/export")
+async def api_export_message(payload: ExportRequest) -> Dict[str, Any]:
+    path = await export_message_to_blog(payload.content)
+    return {"status": "ok", "path": path}
+
+
+@app.post("/api/upload")
+async def api_upload(files: List[UploadFile] = File(...)) -> List[UploadResponseItem]:
+    if not files:
+        return []
+
+    responses: List[UploadResponseItem] = []
+    for upload in files:
+        filename = upload.filename or "file"
+        safe_filename = Path(filename).name or "file"
+        content_type = (upload.content_type or "").lower()
+        data = await upload.read()
+
+        unique_name = f"{uuid.uuid4().hex}_{safe_filename}"
+        target_path = UPLOAD_DIR / unique_name
+
+        def _write() -> None:
+            with target_path.open("wb") as fp:
+                fp.write(data)
+
+        await asyncio.to_thread(_write)
+
+        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),
+                )
+            )
+        else:
+            responses.append(
+                UploadResponseItem(
+                    type="file",
+                    filename=safe_filename,
+                    url=build_download_url(unique_name),
+                )
+            )
+
+    return responses
+
+
+async def prepare_messages_for_completion(
+    messages: List[Dict[str, Any]],
+    user_content: MessageContent,
+    history_count: int,
+) -> List[Dict[str, Any]]:
+    if history_count > 0:
+        trimmed = messages[-history_count:]
+        if trimmed:
+            return trimmed
+    return [{"role": "user", "content": user_content}]
+
+
+async def save_assistant_message(session_id: int, messages: List[Dict[str, Any]], content: MessageContent) -> None:
+    messages.append({"role": "assistant", "content": content})
+    await save_messages(session_id, messages)
+
+
+@app.post("/api/chat")
+async def api_chat(payload: ChatRequest = Body(...)):
+    if payload.model not in MODEL_KEYS:
+        raise HTTPException(status_code=400, detail="未知的模型")
+
+    messages = await load_messages(payload.session_id)
+    user_message = {"role": "user", "content": payload.content}
+    messages.append(user_message)
+    await save_messages(payload.session_id, messages)
+
+    client.api_key = MODEL_KEYS[payload.model]
+
+    to_send = await prepare_messages_for_completion(messages, payload.content, max(payload.history_count, 0))
+
+    if payload.stream:
+        queue: "asyncio.Queue[Dict[str, Any]]" = asyncio.Queue()
+        aggregated: List[str] = []
+        loop = asyncio.get_running_loop()
+
+        def worker() -> None:
+            try:
+                response = client.chat.completions.create(
+                    model=payload.model,
+                    messages=to_send,
+                    stream=True,
+                )
+                for chunk in response:
+                    try:
+                        delta = chunk.choices[0].delta.content  # type: ignore[attr-defined]
+                    except (IndexError, AttributeError):
+                        delta = None
+                    if delta:
+                        aggregated.append(delta)
+                        asyncio.run_coroutine_threadsafe(queue.put({"type": "delta", "text": delta}), loop)
+                asyncio.run_coroutine_threadsafe(queue.put({"type": "complete"}), loop)
+            except Exception as exc:  # pragma: no cover - 网络调用
+                asyncio.run_coroutine_threadsafe(queue.put({"type": "error", "message": str(exc)}), loop)
+
+        threading.Thread(target=worker, daemon=True).start()
+
+        async def streamer():
+            try:
+                while True:
+                    item = await queue.get()
+                    if item["type"] == "delta":
+                        yield json.dumps(item, ensure_ascii=False) + "\n"
+                    elif item["type"] == "complete":
+                        assistant_text = "".join(aggregated)
+                        await save_assistant_message(payload.session_id, messages, assistant_text)
+                        yield json.dumps({"type": "end"}, ensure_ascii=False) + "\n"
+                        break
+                    elif item["type"] == "error":
+                        yield json.dumps(item, ensure_ascii=False) + "\n"
+                        break
+            except asyncio.CancelledError:  # pragma: no cover - 流被取消
+                raise
+
+        return StreamingResponse(streamer(), media_type="application/x-ndjson")
+
+    try:
+        completion = await asyncio.to_thread(
+            client.chat.completions.create,
+            model=payload.model,
+            messages=to_send,
+            stream=False,
+        )
+    except Exception as exc:  # pragma: no cover - 网络调用
+        raise HTTPException(status_code=500, detail=str(exc)) from exc
+
+    choice = completion.choices[0] if getattr(completion, "choices", None) else None  # type: ignore[attr-defined]
+    if not choice:
+        raise HTTPException(status_code=500, detail="响应格式不正确")
+
+    assistant_content = getattr(choice.message, "content", "")
+    if not assistant_content:
+        assistant_content = ""
+
+    await save_assistant_message(payload.session_id, messages, assistant_content)
+    return {"message": assistant_content}
+
+
+if __name__ == "__main__":
+    import uvicorn
+
+    uvicorn.run("fastchat:app", host="0.0.0.0", port=16016, reload=True)

+ 2 - 0
start.sh

@@ -0,0 +1,2 @@
+#!/bin/bash
+nohup uvicorn fastchat:app --host 0.0.0.0 --port 18018 &

+ 1161 - 0
static/app.js

@@ -0,0 +1,1161 @@
+(function () {
+    'use strict';
+
+    const state = {
+        config: null,
+        sessionId: null,
+        messages: [],
+        expandedMessages: new Set(),
+        historyPage: 0,
+        historyPageSize: 9999,
+        historyTotal: 0,
+        historyItems: [],
+        model: '',
+        outputMode: '流式输出 (Stream)',
+        historyCount: 0,
+        searchQuery: '',
+        streaming: false,
+    };
+
+    const dom = {};
+
+    document.addEventListener('DOMContentLoaded', init);
+
+    async function init() {
+        cacheDom();
+        bindEvents();
+
+        try {
+            await loadConfig();
+            const querySessionId = getSessionIdFromUrl();
+            let loadedFromQuery = false;
+
+            if (querySessionId !== null) {
+                try {
+                    await loadSession(querySessionId, { silent: true, updateUrl: true, replaceUrl: true });
+                    loadedFromQuery = true;
+                } catch (err) {
+                    console.warn('Failed to load session from URL parameter:', err);
+                    showToast('指定的会话不存在,已加载最新会话。', 'error');
+                }
+            }
+
+            if (!loadedFromQuery) {
+                await loadLatestSession({ updateUrl: true, replaceUrl: true });
+            }
+
+            await loadHistory();
+        } catch (err) {
+            showToast(err.message || '初始化失败', 'error');
+        }
+
+        renderSidebar();
+        renderMessages();
+        renderHistory();
+    }
+
+    function cacheDom() {
+        dom.modelSelect = document.getElementById('model-select');
+        dom.outputMode = document.getElementById('output-mode');
+        dom.searchInput = document.getElementById('search-input');
+        dom.searchFeedback = document.getElementById('search-feedback');
+        dom.historyRange = document.getElementById('history-range');
+        dom.historyRangeLabel = document.getElementById('history-range-label');
+        dom.historyRangeValue = document.getElementById('history-range-value');
+        dom.historyList = document.getElementById('history-list');
+        dom.historyCount = document.getElementById('history-count');
+        dom.historyPrev = document.getElementById('history-prev');
+        dom.historyNext = document.getElementById('history-next');
+        dom.newChatButton = document.getElementById('new-chat-btn');
+        dom.chatMessages = document.getElementById('chat-messages');
+        dom.chatForm = document.getElementById('chat-form');
+        dom.chatInput = document.getElementById('chat-input');
+        dom.sendButton = document.getElementById('send-btn');
+        dom.fileInput = document.getElementById('file-input');
+        dom.chatStatus = document.getElementById('chat-status');
+        dom.toast = document.getElementById('toast');
+
+        if (dom.sendButton && !dom.sendButton.dataset.defaultText) {
+            dom.sendButton.dataset.defaultText = dom.sendButton.textContent || '发送';
+        }
+    }
+
+    function bindEvents() {
+        dom.modelSelect.addEventListener('change', () => {
+            state.model = dom.modelSelect.value;
+        });
+
+        dom.outputMode.addEventListener('change', () => {
+            state.outputMode = dom.outputMode.value;
+        });
+
+        dom.searchInput.addEventListener('input', () => {
+            state.searchQuery = dom.searchInput.value.trim();
+            state.expandedMessages = new Set();
+            renderMessages();
+        });
+
+        dom.historyRange.addEventListener('input', () => {
+            state.historyCount = Number(dom.historyRange.value || 0);
+            updateHistorySlider();
+        });
+
+        dom.historyPrev.addEventListener('click', async () => {
+            if (state.historyPage > 0) {
+                state.historyPage -= 1;
+                await loadHistory();
+            }
+        });
+
+        dom.historyNext.addEventListener('click', async () => {
+            const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
+            if (state.historyPage < totalPages - 1) {
+                state.historyPage += 1;
+                await loadHistory();
+            }
+        });
+
+        dom.newChatButton.addEventListener('click', async () => {
+            if (state.streaming) {
+                showToast('请等待当前回复完成后再新建会话', 'error');
+                return;
+            }
+            try {
+                const data = await fetchJSON('/api/session/new', { method: 'POST' });
+                state.sessionId = data.session_id;
+                state.messages = [];
+                state.historyCount = 0;
+                state.searchQuery = '';
+                dom.searchInput.value = '';
+                state.expandedMessages = new Set();
+                state.historyPage = 0;
+                renderSidebar();
+                renderMessages();
+                renderHistory();
+                updateSessionInUrl(state.sessionId, { replace: false });
+                showToast('当前会话已清空。', 'success');
+                await loadHistory();
+            } catch (err) {
+                showToast(err.message || '新建会话失败', 'error');
+            }
+        });
+
+        dom.chatForm.addEventListener('submit', handleSubmitMessage);
+
+        dom.chatInput.addEventListener('keydown', (event) => {
+            if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
+                event.preventDefault();
+                if (typeof dom.chatForm.requestSubmit === 'function') {
+                    dom.chatForm.requestSubmit();
+                } else if (dom.sendButton) {
+                    dom.sendButton.click();
+                }
+            }
+        });
+
+        window.addEventListener('popstate', handlePopState);
+    }
+
+    async function loadConfig() {
+        const config = await fetchJSON('/api/config');
+        state.config = config;
+        const models = Array.isArray(config.models) ? config.models : [];
+        state.model = config.default_model || models[0] || '';
+        populateSelect(dom.modelSelect, models, state.model);
+        populateSelect(dom.outputMode, config.output_modes || [], state.outputMode);
+    }
+
+    function populateSelect(selectEl, values, selected) {
+        selectEl.innerHTML = '';
+        values.forEach((value) => {
+            const option = document.createElement('option');
+            option.value = value;
+            option.textContent = value;
+            if (value === selected) {
+                option.selected = true;
+            }
+            selectEl.appendChild(option);
+        });
+        if (!values.length) {
+            const option = document.createElement('option');
+            option.value = '';
+            option.textContent = '无可用选项';
+            selectEl.appendChild(option);
+        }
+    }
+
+    async function loadLatestSession(options = {}) {
+        const { updateUrl = true, replaceUrl = false } = options;
+        const data = await fetchJSON('/api/session/latest');
+        state.sessionId = typeof data.session_id === 'number' ? data.session_id : 0;
+        state.messages = Array.isArray(data.messages) ? data.messages : [];
+        state.expandedMessages = new Set();
+        state.historyCount = Math.min(state.historyCount, state.messages.length);
+        state.searchQuery = '';
+        dom.searchInput.value = '';
+        state.historyPage = 0;
+        renderSidebar();
+        renderMessages();
+        renderHistory();
+
+        if (updateUrl) {
+            updateSessionInUrl(state.sessionId, { replace: replaceUrl });
+        }
+    }
+
+    async function loadSession(sessionId, options = {}) {
+        const { silent = false, updateUrl = true, replaceUrl = false } = options;
+
+        if (state.streaming) {
+            if (!silent) {
+                showToast('请等待当前回复完成后再切换会话', 'error');
+            }
+            return false;
+        }
+
+        try {
+            const data = await fetchJSON(`/api/session/${sessionId}`);
+            state.sessionId = data.session_id;
+            state.messages = Array.isArray(data.messages) ? data.messages : [];
+            state.historyCount = Math.min(state.historyCount, state.messages.length);
+            state.expandedMessages = new Set();
+            state.searchQuery = '';
+            dom.searchInput.value = '';
+            renderSidebar();
+            renderMessages();
+            renderHistory();
+
+            if (updateUrl) {
+                updateSessionInUrl(state.sessionId, { replace: replaceUrl });
+            }
+
+            return true;
+        } catch (err) {
+            if (!silent) {
+                showToast(err.message || '加载会话失败', 'error');
+            }
+            throw err;
+        }
+    }
+
+    async function loadHistory() {
+        try {
+            const data = await fetchJSON(`/api/history?page=${state.historyPage}&page_size=${state.historyPageSize}`);
+            const total = data.total || 0;
+            const items = Array.isArray(data.items) ? data.items : [];
+            if (state.historyPage > 0 && items.length === 0 && total > 0) {
+                const maxPage = Math.max(0, Math.ceil(total / state.historyPageSize) - 1);
+                if (state.historyPage > maxPage) {
+                    state.historyPage = maxPage;
+                    await loadHistory();
+                    return;
+                }
+            }
+            state.historyTotal = total;
+            state.historyItems = items;
+            renderHistory();
+        } catch (err) {
+            showToast(err.message || '获取历史记录失败', 'error');
+        }
+    }
+
+    function renderSidebar() {
+        if (state.config) {
+            populateSelect(dom.modelSelect, state.config.models || [], state.model);
+            populateSelect(dom.outputMode, state.config.output_modes || [], state.outputMode);
+        }
+        updateHistorySlider();
+        updateSearchFeedback();
+    }
+
+    function updateHistorySlider() {
+        const total = state.messages.length;
+        dom.historyRange.max = String(total);
+        state.historyCount = Math.min(state.historyCount, total);
+        dom.historyRange.value = String(state.historyCount);
+        dom.historyRangeLabel.textContent = `选择使用的历史消息数量(共${total}条)`;
+        dom.historyRangeValue.textContent = `您选择的历史消息数量是: ${state.historyCount}`;
+    }
+
+    function updateSearchFeedback() {
+        if (!state.searchQuery) {
+            dom.searchFeedback.textContent = '无匹配。';
+            return;
+        }
+        const matches = state.messages.filter((msg) => messageMatches(msg.content, state.searchQuery)).length;
+        dom.searchFeedback.textContent = `共找到 ${matches} 条匹配。`;
+    }
+
+    function setStatus(message, stateClass) {
+        if (!dom.chatStatus) {
+            return;
+        }
+        dom.chatStatus.textContent = message || '';
+        dom.chatStatus.classList.remove('running', 'error');
+        if (!message) {
+            return;
+        }
+        if (stateClass) {
+            dom.chatStatus.classList.add(stateClass);
+        }
+    }
+
+    function setStreaming(active) {
+        state.streaming = active;
+        if (dom.sendButton) {
+            dom.sendButton.disabled = active;
+            const defaultText = dom.sendButton.dataset.defaultText || '发送';
+            dom.sendButton.textContent = active ? '发送中…' : defaultText;
+        }
+        if (dom.newChatButton) {
+            dom.newChatButton.disabled = active;
+        }
+        if (active) {
+            setStatus('正在生成回复…', 'running');
+        }
+    }
+
+    function renderHistory() {
+        if (dom.historyCount) {
+            const total = Number.isFinite(state.historyTotal) ? state.historyTotal : 0;
+            dom.historyCount.textContent = `共 ${total} 条`;
+        }
+
+        dom.historyList.innerHTML = '';
+        if (!state.historyItems.length) {
+            const empty = document.createElement('div');
+            empty.className = 'sidebar-help';
+            empty.textContent = '无记录。';
+            dom.historyList.appendChild(empty);
+        } else {
+            state.historyItems.forEach((item) => {
+                const row = document.createElement('div');
+                row.className = 'history-row';
+                row.dataset.sessionId = String(item.session_id);
+                row.setAttribute('role', 'listitem');
+                if (item.session_id === state.sessionId) {
+                    row.classList.add('active');
+                }
+
+                const loadLink = document.createElement('a');
+                loadLink.className = 'history-title-link';
+                loadLink.href = buildSessionUrl(item.session_id);
+                const displayTitle = (item.title && item.title.trim()) ? item.title.trim() : `会话 #${item.session_id}`;
+                const primary = document.createElement('span');
+                primary.className = 'history-title-text';
+                primary.textContent = displayTitle;
+                loadLink.appendChild(primary);
+
+                const subtitle = document.createElement('span');
+                subtitle.className = 'history-subtitle';
+                subtitle.textContent = item.filename ? item.filename : `会话 #${item.session_id}`;
+                loadLink.appendChild(subtitle);
+                loadLink.title = `会话 #${item.session_id} · 点击加载`;
+
+                loadLink.addEventListener('click', async (event) => {
+                    const isModified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0;
+                    if (isModified) {
+                        return;
+                    }
+                    event.preventDefault();
+                    try {
+                        await loadSession(item.session_id, { replaceUrl: false });
+                    } catch (err) {
+                        console.warn('Failed to load session from history list:', err);
+                    }
+                });
+                row.appendChild(loadLink);
+
+                const moveButton = document.createElement('button');
+                moveButton.className = 'history-icon-button';
+                moveButton.type = 'button';
+                moveButton.textContent = '📦';
+                moveButton.title = '移动到备份文件夹';
+                moveButton.addEventListener('click', async (event) => {
+                    event.stopPropagation();
+                    try {
+                        await fetchJSON('/api/history/move', {
+                            method: 'POST',
+                            body: { session_id: item.session_id },
+                        });
+                        showToast('已移动到备份。', 'success');
+                        await loadHistory();
+                    } catch (err) {
+                        showToast(err.message || '移动失败', 'error');
+                    }
+                });
+                row.appendChild(moveButton);
+
+                const deleteButton = document.createElement('button');
+                deleteButton.className = 'history-icon-button';
+                deleteButton.type = 'button';
+                deleteButton.textContent = '❌';
+                deleteButton.title = '删除';
+                deleteButton.addEventListener('click', async (event) => {
+                    event.stopPropagation();
+                    try {
+                        await fetchJSON(`/api/history/${item.session_id}`, { method: 'DELETE' });
+                        showToast('已删除。', 'success');
+                        if (item.session_id === state.sessionId) {
+                            await loadLatestSession();
+                        }
+                        await loadHistory();
+                    } catch (err) {
+                        showToast(err.message || '删除失败', 'error');
+                    }
+                });
+                row.appendChild(deleteButton);
+
+                dom.historyList.appendChild(row);
+            });
+        }
+
+        const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1;
+        dom.historyPrev.disabled = state.historyPage <= 0;
+        dom.historyNext.disabled = state.historyPage >= totalPages - 1;
+    }
+
+    function renderMessages() {
+        dom.chatMessages.innerHTML = '';
+        const total = state.messages.length;
+        const searching = Boolean(state.searchQuery);
+
+        state.messages.forEach((message, index) => {
+            const wrapper = document.createElement('div');
+            wrapper.className = `message ${message.role === 'assistant' ? 'assistant' : 'user'}`;
+            wrapper.dataset.index = String(index);
+
+            const header = document.createElement('div');
+            header.className = 'message-header';
+            header.textContent = message.role === 'assistant' ? 'Assistant' : 'User';
+            wrapper.appendChild(header);
+
+            const contentEl = document.createElement('div');
+            contentEl.className = 'message-content';
+            const expanded = state.expandedMessages.has(index);
+            const shouldClamp = !searching && index < total - 1 && !expanded;
+            if (shouldClamp) {
+                contentEl.classList.add('clamped');
+            }
+
+            const query = state.searchQuery && messageMatches(message.content, state.searchQuery)
+                ? state.searchQuery
+                : '';
+            renderContent(message.content, contentEl, query);
+            wrapper.appendChild(contentEl);
+
+            const actions = document.createElement('div');
+            actions.className = 'message-actions';
+
+            if (!searching && index < total - 1) {
+                const toggleButton = document.createElement('button');
+                toggleButton.className = 'message-button';
+                toggleButton.textContent = expanded ? '<<' : '>>';
+                toggleButton.addEventListener('click', () => {
+                    if (expanded) {
+                        state.expandedMessages.delete(index);
+                    } else {
+                        state.expandedMessages.add(index);
+                    }
+                    renderMessages();
+                });
+                actions.appendChild(toggleButton);
+            }
+
+            if (message.role === 'assistant') {
+                const exportButton = document.createElement('button');
+                exportButton.className = 'message-button';
+                exportButton.textContent = '导出';
+                exportButton.addEventListener('click', async () => {
+                    try {
+                        await fetchJSON('/api/export', {
+                            method: 'POST',
+                            body: { content: message.content },
+                        });
+                        showToast('已导出到 blog 文件夹。', 'success');
+                    } catch (err) {
+                        showToast(err.message || '导出失败', 'error');
+                    }
+                });
+                actions.appendChild(exportButton);
+            }
+
+            wrapper.appendChild(actions);
+            dom.chatMessages.appendChild(wrapper);
+        });
+
+        updateSearchFeedback();
+        scrollToBottom();
+    }
+
+    function renderContent(content, container, query) {
+        container.innerHTML = '';
+        const highlightQuery = query || '';
+
+        if (typeof content === 'string' || content === null || content === undefined) {
+            renderMarkdownContent(container, String(content || ''));
+            applyHighlight(container, highlightQuery);
+            return;
+        }
+
+        if (Array.isArray(content)) {
+            content.forEach((part) => {
+                if (part && part.type === 'text') {
+                    const textContainer = document.createElement('div');
+                    renderMarkdownContent(textContainer, String(part.text || ''));
+                    container.appendChild(textContainer);
+                } else if (part && part.type === 'image_url') {
+                    const url = part.image_url && part.image_url.url ? part.image_url.url : '';
+                    const img = document.createElement('img');
+                    img.src = url;
+                    img.alt = '上传的图片';
+                    img.loading = 'lazy';
+                    container.appendChild(img);
+                } else {
+                    const fallback = document.createElement('pre');
+                    fallback.textContent = JSON.stringify(part, null, 2);
+                    container.appendChild(fallback);
+                }
+            });
+            applyHighlight(container, highlightQuery);
+            return;
+        }
+
+        const pre = document.createElement('pre');
+        pre.textContent = typeof content === 'object' ? JSON.stringify(content, null, 2) : String(content || '');
+        container.appendChild(pre);
+        applyHighlight(container, highlightQuery);
+    }
+
+    function renderMarkdownContent(container, text) {
+        const normalized = String(text || '').replace(/\r\n/g, '\n');
+        const lines = normalized.split('\n');
+        let paragraphBuffer = [];
+        let listBuffer = [];
+        let blockquoteBuffer = [];
+        let inCodeBlock = false;
+        let codeLang = '';
+        let codeBuffer = [];
+
+        const flushParagraph = () => {
+            if (!paragraphBuffer.length) {
+                return;
+            }
+            const paragraphText = paragraphBuffer.join('\n');
+            const paragraph = document.createElement('p');
+            appendInlineMarkdown(paragraph, paragraphText);
+            container.appendChild(paragraph);
+            paragraphBuffer = [];
+        };
+
+        const flushList = () => {
+            if (!listBuffer.length) {
+                return;
+            }
+            const list = document.createElement('ul');
+            listBuffer.forEach((item) => {
+                const li = document.createElement('li');
+                appendInlineMarkdown(li, item);
+                list.appendChild(li);
+            });
+            container.appendChild(list);
+            listBuffer = [];
+        };
+
+        const flushBlockquote = () => {
+            if (!blockquoteBuffer.length) {
+                return;
+            }
+            const blockquote = document.createElement('blockquote');
+            const textContent = blockquoteBuffer.join('\n');
+            appendInlineMarkdown(blockquote, textContent);
+            container.appendChild(blockquote);
+            blockquoteBuffer = [];
+        };
+
+        const flushCode = () => {
+            const pre = document.createElement('pre');
+            const code = document.createElement('code');
+            if (codeLang) {
+                code.dataset.lang = codeLang;
+                code.className = `language-${codeLang}`;
+            }
+            code.textContent = codeBuffer.join('\n');
+            pre.appendChild(code);
+            container.appendChild(pre);
+            codeBuffer = [];
+            codeLang = '';
+            inCodeBlock = false;
+        };
+
+        lines.forEach((rawLine) => {
+            const line = rawLine;
+            const fenceMatch = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
+            if (fenceMatch) {
+                if (inCodeBlock) {
+                    flushCode();
+                } else {
+                    flushParagraph();
+                    flushList();
+                    flushBlockquote();
+                    inCodeBlock = true;
+                    codeLang = fenceMatch[1] ? fenceMatch[1].toLowerCase() : '';
+                    codeBuffer = [];
+                }
+                return;
+            }
+
+            if (inCodeBlock) {
+                codeBuffer.push(line);
+                return;
+            }
+
+            const listMatch = line.match(/^\s*[-*+]\s+(.*)$/);
+            if (listMatch) {
+                flushParagraph();
+                flushBlockquote();
+                listBuffer.push(listMatch[1]);
+                return;
+            }
+
+            const blockquoteMatch = line.match(/^>\s?(.*)$/);
+            if (blockquoteMatch) {
+                flushParagraph();
+                flushList();
+                blockquoteBuffer.push(blockquoteMatch[1]);
+                return;
+            }
+
+            if (!line.trim()) {
+                flushParagraph();
+                flushList();
+                flushBlockquote();
+                return;
+            }
+
+            const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
+            if (headingMatch) {
+                flushParagraph();
+                flushList();
+                flushBlockquote();
+                const level = Math.min(headingMatch[1].length, 6);
+                const heading = document.createElement(`h${level}`);
+                appendInlineMarkdown(heading, headingMatch[2]);
+                container.appendChild(heading);
+                return;
+            }
+
+            paragraphBuffer.push(line);
+        });
+
+        if (inCodeBlock) {
+            flushCode();
+        }
+        flushParagraph();
+        flushList();
+        flushBlockquote();
+    }
+
+    function appendInlineMarkdown(parent, text) {
+        const pattern = /(!?\[[^\]]*\]\([^\)]+\)|`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~)/g;
+        let lastIndex = 0;
+        let match;
+        while ((match = pattern.exec(text)) !== null) {
+            if (match.index > lastIndex) {
+                appendTextNode(parent, text.slice(lastIndex, match.index));
+            }
+            appendMarkdownToken(parent, match[0]);
+            lastIndex = pattern.lastIndex;
+        }
+        if (lastIndex < text.length) {
+            appendTextNode(parent, text.slice(lastIndex));
+        }
+    }
+
+    function appendMarkdownToken(parent, token) {
+        if (token.startsWith('`') && token.endsWith('`')) {
+            const code = document.createElement('code');
+            code.textContent = token.slice(1, -1);
+            parent.appendChild(code);
+            return;
+        }
+        if (token.startsWith('**') && token.endsWith('**')) {
+            const strong = document.createElement('strong');
+            appendInlineMarkdown(strong, token.slice(2, -2));
+            parent.appendChild(strong);
+            return;
+        }
+        if (token.startsWith('*') && token.endsWith('*')) {
+            const em = document.createElement('em');
+            appendInlineMarkdown(em, token.slice(1, -1));
+            parent.appendChild(em);
+            return;
+        }
+        if (token.startsWith('~~') && token.endsWith('~~')) {
+            const del = document.createElement('del');
+            appendInlineMarkdown(del, token.slice(2, -2));
+            parent.appendChild(del);
+            return;
+        }
+        if (token.startsWith('![')) {
+            const match = token.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/);
+            if (match) {
+                const img = document.createElement('img');
+                img.alt = match[1];
+                img.src = match[2];
+                img.loading = 'lazy';
+                parent.appendChild(img);
+                return;
+            }
+        }
+        if (token.startsWith('[')) {
+            const match = token.match(/^\[([^\]]+)\]\(([^\)]+)\)$/);
+            if (match) {
+                const anchor = document.createElement('a');
+                anchor.href = match[2];
+                anchor.target = '_blank';
+                anchor.rel = 'noopener noreferrer';
+                anchor.textContent = match[1];
+                parent.appendChild(anchor);
+                return;
+            }
+        }
+        appendTextNode(parent, token);
+    }
+
+    function appendTextNode(parent, text) {
+        if (!text) {
+            return;
+        }
+        const fragments = String(text).split(/(\n)/);
+        fragments.forEach((fragment) => {
+            if (fragment === '\n') {
+                parent.appendChild(document.createElement('br'));
+            } else if (fragment) {
+                parent.appendChild(document.createTextNode(fragment));
+            }
+        });
+    }
+
+    function clearHighlights(root) {
+        if (!root) {
+            return;
+        }
+        root.querySelectorAll('mark.hl').forEach((mark) => {
+            const parent = mark.parentNode;
+            if (!parent) {
+                return;
+            }
+            while (mark.firstChild) {
+                parent.insertBefore(mark.firstChild, mark);
+            }
+            parent.removeChild(mark);
+            parent.normalize();
+        });
+    }
+
+    function applyHighlight(root, query) {
+        if (!root) {
+            return;
+        }
+        clearHighlights(root);
+        if (!query) {
+            return;
+        }
+        const lowerQuery = query.toLowerCase();
+        const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
+        const matches = [];
+        while (walker.nextNode()) {
+            const node = walker.currentNode;
+            if (!node || !node.nodeValue || !node.nodeValue.trim()) {
+                continue;
+            }
+            const text = node.nodeValue;
+            const lowerText = text.toLowerCase();
+            let index = lowerText.indexOf(lowerQuery);
+            while (index !== -1) {
+                matches.push({ node, start: index, end: index + query.length });
+                index = lowerText.indexOf(lowerQuery, index + query.length);
+            }
+        }
+
+        for (let i = matches.length - 1; i >= 0; i -= 1) {
+            const { node, start, end } = matches[i];
+            if (!node || !node.parentNode) {
+                continue;
+            }
+            const range = document.createRange();
+            range.setStart(node, start);
+            range.setEnd(node, end);
+            const mark = document.createElement('mark');
+            mark.className = 'hl';
+            range.surroundContents(mark);
+        }
+    }
+
+    function messageMatches(content, query) {
+        if (!query) {
+            return false;
+        }
+        const lower = query.toLowerCase();
+        if (typeof content === 'string') {
+            return content.toLowerCase().includes(lower);
+        }
+        if (Array.isArray(content)) {
+            return content.some((part) => {
+                if (!part || part.type !== 'text') {
+                    return false;
+                }
+                return String(part.text || '').toLowerCase().includes(lower);
+            });
+        }
+        try {
+            return JSON.stringify(content).toLowerCase().includes(lower);
+        } catch (err) {
+            return false;
+        }
+    }
+
+    async function handleSubmitMessage(event) {
+        event.preventDefault();
+        if (state.streaming) {
+            showToast('请等待当前回复完成', 'error');
+            return;
+        }
+
+        const text = dom.chatInput.value.trim();
+        const files = dom.fileInput.files;
+        if (!text && (!files || files.length === 0)) {
+            showToast('请输入内容或上传文件', 'error');
+            return;
+        }
+
+        let uploads = [];
+        const hasFiles = files && files.length > 0;
+        if (hasFiles) {
+            try {
+                setStatus('正在上传文件…', 'running');
+                uploads = await uploadAttachments(files);
+            } catch (err) {
+                const message = err.message || '文件上传失败';
+                setStatus(message, 'error');
+                showToast(message, 'error');
+                return;
+            }
+        }
+
+        const { content } = buildUserContent(text, uploads);
+        if (!hasContent(content)) {
+            setStatus('内容不能为空', 'error');
+            showToast('内容不能为空', 'error');
+            return;
+        }
+
+        setStatus('');
+        state.expandedMessages = new Set();
+        const userMessage = { role: 'user', content };
+        state.messages.push(userMessage);
+        renderMessages();
+        scrollToBottom();
+
+        dom.chatInput.value = '';
+        dom.fileInput.value = '';
+
+        const assistantMessage = { role: 'assistant', content: '' };
+        state.messages.push(assistantMessage);
+        const assistantIndex = state.messages.length - 1;
+        renderMessages();
+        scrollToBottom();
+
+        const payload = {
+            session_id: state.sessionId ?? 0,
+            model: state.model,
+            content,
+            history_count: state.historyCount,
+            stream: state.outputMode === '流式输出 (Stream)',
+        };
+
+        setStreaming(true);
+        try {
+            if (payload.stream) {
+                await streamAssistantReply(payload, assistantMessage, assistantIndex);
+            } else {
+                const data = await fetchJSON('/api/chat', {
+                    method: 'POST',
+                    body: payload,
+                });
+                assistantMessage.content = data.message || '';
+                updateMessageContent(assistantIndex, assistantMessage.content);
+                showToast('已生成回复', 'success');
+                setStatus('');
+            }
+        } catch (err) {
+            state.messages.splice(assistantIndex, 1);
+            renderMessages();
+            const message = err.message || '发送失败';
+            setStatus(message, 'error');
+            showToast(message, 'error');
+        } finally {
+            try {
+                state.historyPage = 0;
+                await loadHistory();
+            } catch (historyErr) {
+                console.error('刷新历史记录失败', historyErr);
+            } finally {
+                updateHistorySlider();
+                setStreaming(false);
+            }
+        }
+    }
+
+    function hasContent(content) {
+        if (typeof content === 'string') {
+            return Boolean(content.trim());
+        }
+        if (Array.isArray(content)) {
+            return content.length > 1 || (content[0] && String(content[0].text || '').trim());
+        }
+        return Boolean(content);
+    }
+
+    async function uploadAttachments(fileList) {
+        if (!fileList || fileList.length === 0) {
+            return [];
+        }
+        const formData = new FormData();
+        Array.from(fileList).forEach((file) => formData.append('files', file));
+        const response = await fetch('/api/upload', {
+            method: 'POST',
+            body: formData,
+        });
+        if (!response.ok) {
+            throw new Error('文件上传失败');
+        }
+        return await response.json();
+    }
+
+    function buildUserContent(text, uploads) {
+        const results = Array.isArray(uploads) ? uploads : [];
+        if (!results.length) {
+            return { content: text };
+        }
+
+        const contentParts = [{ type: 'text', text: text }];
+        let additionalPrompt = '';
+
+        results.forEach((item) => {
+            if (item.type === 'image' && item.data) {
+                contentParts.push({
+                    type: 'image_url',
+                    image_url: { url: item.data },
+                });
+            } else if (item.type === 'file' && item.url) {
+                additionalPrompt += `本次提问包含:${item.url} 文件\n`;
+            }
+        });
+
+        const promptSuffix = additionalPrompt.trim();
+        if (contentParts.length > 1) {
+            const base = contentParts[0].text || '';
+            contentParts[0].text = promptSuffix ? `${base}\n${promptSuffix}`.trim() : base;
+            return { content: contentParts };
+        }
+
+        let combined = text || '';
+        if (promptSuffix) {
+            combined = combined ? `${combined}\n${promptSuffix}` : promptSuffix;
+        }
+        return { content: combined.trim() };
+    }
+
+    async function streamAssistantReply(payload, assistantMessage, assistantIndex) {
+        const response = await fetch('/api/chat', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(payload),
+        });
+        if (!response.ok || !response.body) {
+            const errorText = await safeReadText(response);
+            throw new Error(errorText || '生成失败');
+        }
+
+        const reader = response.body.getReader();
+        const decoder = new TextDecoder('utf-8');
+        let buffer = '';
+        let done = false;
+
+        while (!done) {
+            const { value, done: streamDone } = await reader.read();
+            done = streamDone;
+            if (value) {
+                buffer += decoder.decode(value, { stream: !done });
+                let newlineIndex = buffer.indexOf('\n');
+                while (newlineIndex !== -1) {
+                    const line = buffer.slice(0, newlineIndex).trim();
+                    buffer = buffer.slice(newlineIndex + 1);
+                    if (line) {
+                        const status = handleStreamLine(line, assistantMessage, assistantIndex);
+                        if (status === 'end') {
+                            return;
+                        }
+                    }
+                    newlineIndex = buffer.indexOf('\n');
+                }
+            }
+        }
+
+        setStatus('');
+    }
+
+    function handleStreamLine(line, assistantMessage, assistantIndex) {
+        let payload;
+        try {
+            payload = JSON.parse(line);
+        } catch (err) {
+            return;
+        }
+
+        if (payload.type === 'delta') {
+            if (typeof assistantMessage.content !== 'string') {
+                assistantMessage.content = '';
+            }
+            assistantMessage.content += payload.text || '';
+            updateMessageContent(assistantIndex, assistantMessage.content);
+            scrollToBottom();
+            return null;
+        } else if (payload.type === 'end') {
+            showToast('已生成回复', 'success');
+            setStatus('');
+            return 'end';
+        } else if (payload.type === 'error') {
+            throw new Error(payload.message || '生成失败');
+        }
+    }
+
+    function updateMessageContent(index, content) {
+        const selector = `.message[data-index="${index}"] .message-content`;
+        const node = dom.chatMessages.querySelector(selector);
+        if (!node) {
+            renderMessages();
+            return;
+        }
+        node.classList.remove('clamped');
+        renderContent(content, node, state.searchQuery && messageMatches(content, state.searchQuery) ? state.searchQuery : '');
+    }
+
+    function scrollToBottom() {
+        dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight;
+    }
+
+    function getSessionIdFromUrl() {
+        const params = new URLSearchParams(window.location.search);
+        const value = params.get('session');
+        if (!value) {
+            return null;
+        }
+        const parsed = Number(value);
+        return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
+    }
+
+    function buildSessionUrl(sessionId) {
+        const current = new URL(window.location.href);
+        if (Number.isInteger(sessionId) && sessionId >= 0) {
+            current.searchParams.set('session', String(sessionId));
+        } else {
+            current.searchParams.delete('session');
+        }
+        current.hash = '';
+        const search = current.searchParams.toString();
+        return `${current.pathname}${search ? `?${search}` : ''}`;
+    }
+
+    function updateSessionInUrl(sessionId, options = {}) {
+        if (!window.history || typeof window.history.replaceState !== 'function') {
+            return;
+        }
+        const { replace = false } = options;
+        const target = buildSessionUrl(sessionId);
+        const stateData = { sessionId };
+        if (replace) {
+            window.history.replaceState(stateData, '', target);
+        } else {
+            window.history.pushState(stateData, '', target);
+        }
+    }
+
+    async function handlePopState(event) {
+        if (state.streaming) {
+            return;
+        }
+
+        const stateSessionId = event.state && Number.isInteger(event.state.sessionId)
+            ? event.state.sessionId
+            : getSessionIdFromUrl();
+
+        try {
+            if (stateSessionId !== null) {
+                await loadSession(stateSessionId, { silent: true, updateUrl: false });
+            } else {
+                await loadLatestSession({ updateUrl: false });
+            }
+            await loadHistory();
+        } catch (err) {
+            console.warn('Failed to restore session from history navigation:', err);
+        }
+    }
+
+    async function fetchJSON(url, options = {}) {
+        const opts = { ...options };
+        opts.headers = { ...(opts.headers || {}) };
+        if (opts.body && !(opts.body instanceof FormData) && typeof opts.body !== 'string') {
+            opts.headers['Content-Type'] = 'application/json';
+            opts.body = JSON.stringify(opts.body);
+        }
+        const response = await fetch(url, opts);
+        if (!response.ok) {
+            const message = await readErrorMessage(response);
+            throw new Error(message || '请求失败');
+        }
+        if (response.status === 204) {
+            return {};
+        }
+        const text = await response.text();
+        return text ? JSON.parse(text) : {};
+    }
+
+    async function readErrorMessage(response) {
+        const text = await safeReadText(response);
+        if (!text) {
+            return response.statusText;
+        }
+        try {
+            const data = JSON.parse(text);
+            return data.detail || data.message || text;
+        } catch (err) {
+            return text;
+        }
+    }
+
+    async function safeReadText(response) {
+        try {
+            return await response.text();
+        } catch (err) {
+            return '';
+        }
+    }
+
+    let toastTimer;
+    function showToast(message, type = 'success') {
+        if (!dom.toast) {
+            return;
+        }
+        dom.toast.textContent = message;
+        dom.toast.classList.remove('hidden', 'success', 'error', 'show');
+        dom.toast.classList.add(type, 'show');
+        clearTimeout(toastTimer);
+        toastTimer = setTimeout(() => {
+            dom.toast.classList.remove('show');
+            toastTimer = setTimeout(() => dom.toast.classList.add('hidden'), 300);
+        }, 2500);
+    }
+})();

+ 66 - 0
static/index.html

@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>ChatGPT-like Clone</title>
+    <link rel="stylesheet" href="/static/styles.css">
+</head>
+<body>
+    <div class="app-shell">
+        <aside class="sidebar">
+            <div class="sidebar-scroll">
+                <section class="sidebar-section">
+                    <label for="model-select" class="sidebar-label">选择模型</label>
+                    <select id="model-select" class="sidebar-input"></select>
+                </section>
+                <section class="sidebar-section">
+                    <label for="output-mode" class="sidebar-label">选择输出模式</label>
+                    <select id="output-mode" class="sidebar-input"></select>
+                </section>
+                <section class="sidebar-section">
+                    <label for="search-input" class="sidebar-label">搜索当前对话内容</label>
+                    <input id="search-input" type="text" class="sidebar-input" placeholder="输入关键字...">
+                    <div id="search-feedback" class="sidebar-help">无匹配。</div>
+                </section>
+                <section class="sidebar-section">
+                    <label for="history-range" class="sidebar-label" id="history-range-label">选择使用的历史消息数量</label>
+                    <input id="history-range" type="range" min="0" max="0" value="0" class="sidebar-range">
+                    <div id="history-range-value" class="sidebar-help">您选择的历史消息数量是: 0</div>
+                </section>
+                <section class="sidebar-section sidebar-actions">
+                    <button id="new-chat-btn" class="primary-button">New Chat</button>
+                </section>
+            </div>
+            <div class="sidebar-history" aria-label="历史聊天记录">
+                <div class="sidebar-history-header">
+                    <h3 class="sidebar-heading">历史聊天记录</h3>
+                    <span id="history-count" class="history-count" aria-live="polite"></span>
+                </div>
+                <div id="history-list" class="history-list" role="list"></div>
+                <div class="history-pagination">
+                    <button id="history-prev" class="secondary-button">上一页</button>
+                    <button id="history-next" class="secondary-button">下一页</button>
+                </div>
+            </div>
+        </aside>
+        <main class="main-panel">
+            <header class="app-header">
+                <h1>ChatGPT-like Clone</h1>
+            </header>
+            <section id="chat-messages" class="chat-messages"></section>
+            <form id="chat-form" class="chat-form">
+                <textarea id="chat-input" placeholder="What is up?" rows="3" class="chat-textarea"></textarea>
+                <div class="chat-form-footer">
+                    <label for="file-input" class="file-input-label">选择文件</label>
+                    <input id="file-input" type="file" multiple accept=".jpg,.jpeg,.png,.txt,.pdf,.doc,.docx" class="file-input">
+                    <button type="submit" id="send-btn" class="primary-button">发送</button>
+                </div>
+                <div id="chat-status" class="chat-status" aria-live="polite"></div>
+            </form>
+        </main>
+    </div>
+    <div id="toast" class="toast hidden"></div>
+    <script defer src="/static/app.js"></script>
+</body>
+</html>

+ 580 - 0
static/styles.css

@@ -0,0 +1,580 @@
+:root {
+    color-scheme: light dark;
+    --bg-main: #f5f6f8;
+    --bg-sidebar: #ffffff;
+    --bg-panel: #ffffff;
+    --border-color: #d8dce4;
+    --primary: #1f77ff;
+    --primary-text: #ffffff;
+    --accent: #0d6efd;
+    --text-color: #1a1a1a;
+    --secondary-text: #6c757d;
+    --user-message: #e9f2ff;
+    --assistant-message: #f1f3f5;
+    --shadow: 0 2px 12px rgba(15, 23, 42, 0.08);
+}
+
+* {
+    box-sizing: border-box;
+}
+
+body {
+    margin: 0;
+    font-family: "PingFang SC", "Microsoft YaHei", "Helvetica", "Arial", sans-serif;
+    background: var(--bg-main);
+    color: var(--text-color);
+    height: 100vh;
+    display: flex;
+}
+
+.app-shell {
+    display: flex;
+    flex: 1;
+    max-height: 100vh;
+    width: 100%;
+}
+
+.sidebar {
+    width: 320px;
+    background: var(--bg-sidebar);
+    border-right: 1px solid var(--border-color);
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+}
+
+.sidebar-scroll {
+    flex: 1;
+    padding: 10px 16px 2px;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 18px;
+}
+
+.sidebar-history {
+    border-top: 1px solid var(--border-color);
+    padding: 3px ;
+    background: #fff;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    overflow: auto; /* 允许滚动 */
+    scrollbar-width: none; /* Firefox */
+}
+.sidebar-history::-webkit-scrollbar { 
+    display: none; /* Chrome 和 Safari */
+}
+
+.sidebar-history-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+}
+
+.history-count {
+    font-size: 12px;
+    color: var(--secondary-text);
+}
+
+.sidebar-section {
+    margin: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+}
+
+.sidebar-actions {
+    flex-direction: row;
+    align-items: center;
+}
+
+.sidebar-actions .primary-button,
+.sidebar-actions .secondary-button {
+    flex: 1;
+}
+
+.sidebar-label {
+    font-size: 14px;
+    font-weight: 600;
+    color: var(--secondary-text);
+}
+
+.sidebar-input,
+.sidebar-range {
+    width: 100%;
+    padding: 8px 10px;
+    border: 1px solid var(--border-color);
+    border-radius: 8px;
+    font-size: 14px;
+    background: #fff;
+    color: inherit;
+}
+
+.sidebar-range {
+    height: 4px;
+    padding: 0;
+}
+
+.sidebar-help {
+    font-size: 13px;
+    color: var(--secondary-text);
+}
+
+.sidebar-heading {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 600;
+}
+
+.primary-button,
+.secondary-button {
+    border: none;
+    border-radius: 8px;
+    padding: 10px 14px;
+    font-size: 14px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+}
+
+.primary-button {
+    background: var(--primary);
+    color: var(--primary-text);
+}
+
+.primary-button:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+}
+
+.primary-button:hover:not(:disabled) {
+    background: var(--accent);
+}
+
+.secondary-button {
+    background: #f1f3f5;
+    color: var(--text-color);
+    border: 1px solid var(--border-color);
+}
+
+.secondary-button:hover:not(:disabled) {
+    background: #e6e9ef;
+}
+
+.history-list {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    max-height: 240px;
+    overflow-y: auto;
+}
+
+.history-row {
+    display: grid;
+    grid-template-columns: 1fr auto auto;
+    gap: 4px;
+    align-items: center;
+    padding: 4px 10px;
+    border: 1px solid var(--border-color);
+    border-radius: 8px;
+    background: #fff;
+}
+
+.history-row.active {
+    border-color: var(--accent);
+    box-shadow: 0 0 0 1px rgba(13, 110, 253, 0.12);
+}
+
+.history-title-link {
+    border: none;
+    background: none;
+    padding: 0;
+    margin: 0;
+    font-size: 14px;
+    font-weight: 500;
+    text-align: left;
+    color: var(--accent);
+    cursor: pointer;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 2px;
+    width: 100%;
+    text-decoration: none;
+}
+
+.history-title-link:hover,
+.history-title-link:focus-visible {
+    text-decoration: underline;
+}
+
+.history-row.active .history-title-link {
+    color: var(--text-color);
+    font-weight: 600;
+}
+
+.history-title-text,
+.history-subtitle {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 100%;
+}
+
+.history-subtitle {
+    font-size: 11px;
+    color: var(--secondary-text);
+}
+
+.history-icon-button {
+    border: none;
+    background: #f8f9fb;
+    border-radius: 6px;
+    padding: 6px 8px;
+    cursor: pointer;
+    font-size: 16px;
+    line-height: 1;
+}
+
+.history-icon-button:hover {
+    background: #e6e9ef;
+}
+
+.history-pagination {
+    display: flex;
+    justify-content: space-between;
+    gap: 2px;
+    margin-bottom: 5px;
+    margin-left: 10px;
+    margin-right: 10px;
+}
+
+.history-pagination .secondary-button {
+    padding: 3px 3px;
+    font-size: 11px;
+    border-radius: 6px;
+}
+
+.main-panel {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    background: var(--bg-panel);
+    box-shadow: var(--shadow);
+}
+
+.app-header {
+    padding: 20px 24px;
+    border-bottom: 1px solid var(--border-color);
+}
+
+.app-header h1 {
+    margin: 0;
+    font-size: 22px;
+    font-weight: 600;
+}
+
+.chat-messages {
+    flex: 1;
+    padding: 24px;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+    background: linear-gradient(180deg, rgba(243, 247, 255, 0.8), rgba(255, 255, 255, 0.9));
+}
+
+.message {
+    padding: 16px;
+    border-radius: 12px;
+    border: 1px solid var(--border-color);
+    max-width: 860px;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+}
+
+.message.user {
+    background: var(--user-message);
+    align-self: flex-end;
+}
+
+.message.assistant {
+    background: var(--assistant-message);
+    align-self: flex-start;
+}
+
+.message-header {
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--secondary-text);
+}
+
+.message-content {
+    font-size: 14px;
+    line-height: 1.6;
+    color: var(--text-color);
+    white-space: normal;
+    word-break: break-word;
+}
+
+.message-content ul {
+    margin: 8px 0 8px 18px;
+    padding-left: 16px;
+}
+
+.message-content li {
+    margin-bottom: 4px;
+}
+
+.message-content blockquote {
+    margin: 10px 0;
+    padding-left: 14px;
+    border-left: 3px solid var(--border-color);
+    color: var(--secondary-text);
+}
+
+.message-content h1,
+.message-content h2,
+.message-content h3,
+.message-content h4,
+.message-content h5,
+.message-content h6 {
+    margin: 12px 0 6px;
+    font-weight: 600;
+}
+
+.message-content h1 { font-size: 20px; }
+.message-content h2 { font-size: 18px; }
+.message-content h3 { font-size: 16px; }
+.message-content h4 { font-size: 15px; }
+.message-content h5,
+.message-content h6 { font-size: 14px; }
+
+.message-content pre {
+    background: #0d1117;
+    color: #e6edf3;
+    padding: 12px;
+    border-radius: 10px;
+    overflow-x: auto;
+    font-family: "JetBrains Mono", "Fira Code", "Menlo", "Consolas", monospace;
+    font-size: 13px;
+    line-height: 1.5;
+    margin: 10px 0;
+}
+
+.message-content code {
+    font-family: "JetBrains Mono", "Fira Code", "Menlo", "Consolas", monospace;
+    background: rgba(13, 110, 253, 0.12);
+    color: #0d47a1;
+    padding: 2px 6px;
+    border-radius: 6px;
+    font-size: 13px;
+}
+
+.message-content pre code {
+    display: block;
+    background: transparent;
+    color: inherit;
+    padding: 0;
+}
+
+.message-content img {
+    max-width: 100%;
+    border-radius: 8px;
+    margin: 6px 0;
+}
+
+.message-actions {
+    display: flex;
+    justify-content: flex-end;
+    gap: 6px;
+}
+
+.message-button {
+    border: none;
+    background: var(--primary);
+    color: var(--primary-text);
+    border-radius: 6px;
+    padding: 3px 8px;
+    font-size: 11px;
+    font-weight: 500;
+    cursor: pointer;
+    white-space: nowrap;
+    transition: background 0.2s ease;
+}
+
+.message-button:hover {
+    background: var(--accent);
+}
+
+.chat-form {
+    padding: 16px 20px;
+    border-top: 1px solid var(--border-color);
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    background: #fff;
+}
+
+.chat-textarea {
+    width: 100%;
+    border-radius: 12px;
+    border: 1px solid var(--border-color);
+    padding: 10px;
+    font-size: 14px;
+    resize: vertical;
+    min-height: 70px;
+}
+
+.chat-form-footer {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+
+.file-input-label {
+    display: inline-flex;
+    align-items: center;
+    padding: 6px 12px;
+    border-radius: 8px;
+    border: 1px dashed var(--border-color);
+    color: var(--secondary-text);
+    cursor: pointer;
+    background: #fafbfc;
+}
+
+.file-input {
+    display: none;
+}
+
+.chat-status {
+    min-height: 20px;
+    font-size: 13px;
+    color: var(--secondary-text);
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.chat-status.running {
+    color: var(--accent);
+}
+
+.chat-status.running::before {
+    content: "";
+    width: 14px;
+    height: 14px;
+    border-radius: 50%;
+    border: 2px solid currentColor;
+    border-top-color: transparent;
+    animation: chat-spin 0.8s linear infinite;
+}
+
+.chat-status.error {
+    color: #dc3545;
+}
+
+.toast {
+    position: fixed;
+    bottom: 24px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: rgba(33, 37, 41, 0.9);
+    color: #fff;
+    padding: 12px 18px;
+    border-radius: 12px;
+    font-size: 14px;
+    box-shadow: var(--shadow);
+    opacity: 0;
+    transition: opacity 0.3s ease, transform 0.3s ease;
+    pointer-events: none;
+    z-index: 999;
+}
+
+.toast.show {
+    opacity: 1;
+    transform: translate(-50%, 0);
+}
+
+.toast.success {
+    background: #28a745;
+}
+
+.toast.error {
+    background: #dc3545;
+}
+
+.hidden {
+    display: none;
+}
+
+.clamped {
+    display: -webkit-box;
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+mark.hl {
+    background: #fff3a3;
+    color: inherit;
+    padding: 0 2px;
+    border-radius: 3px;
+}
+
+@keyframes chat-spin {
+    from {
+        transform: rotate(0deg);
+    }
+    to {
+        transform: rotate(360deg);
+    }
+}
+
+@media (max-width: 960px) {
+    .app-shell {
+        flex-direction: column;
+    }
+
+    .sidebar {
+        width: 100%;
+        border-right: none;
+        border-bottom: 1px solid var(--border-color);
+    }
+
+    .sidebar-scroll {
+        display: grid;
+        grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+        gap: 16px;
+    }
+
+    .main-panel {
+        box-shadow: none;
+    }
+
+    .history-list {
+        max-height: 180px;
+    }
+
+    .sidebar-actions {
+        flex-direction: column;
+    }
+}
+
+@media (max-width: 600px) {
+    .chat-form-footer {
+        flex-direction: column;
+        align-items: stretch;
+    }
+
+    .chat-form-footer .primary-button {
+        width: 100%;
+    }
+
+    .file-input-label {
+        width: 100%;
+        justify-content: center;
+    }
+}