Merge branch 'feat/database'

This commit is contained in:
SuperManTouX 2026-03-09 16:15:21 +08:00
commit 34e0bc4de7
25 changed files with 2518 additions and 939 deletions

13
.gitignore vendored
View File

@ -16,7 +16,13 @@ uploads
.venv
__pycache__
.claude
<<<<<<< HEAD
*.db
=======
.trae
.agent
.agents
>>>>>>> feat/database
# Editor directories and files
.vscode/*
@ -28,6 +34,7 @@ __pycache__
*.njsproj
*.sln
*.sw?
<<<<<<< HEAD
# Skills
.skills
@ -35,3 +42,9 @@ __pycache__
.agents
.trae
skills-lock.json
=======
*.db
server/data/*.db
skills-lock.json
>>>>>>> feat/database

View File

@ -30,7 +30,7 @@ class ModelInfo:
"maxTokens": self.max_tokens,
"provider": self.provider,
"supports_thinking": self.supports_thinking,
"supports_web_Search": self.supports_web_search,
"supports_web_search": self.supports_web_search,
"supports_vision": self.supports_vision,
"supports_files": self.supports_files,
}

View File

@ -138,6 +138,11 @@ class GLMAdapter(BaseAdapter):
logger.info(
f"[GLM] 深度思考已启用: extra_kwargs['thinking'] = {extra_kwargs['thinking']}"
)
else:
extra_kwargs["thinking"] = {"type": "disabled"}
logger.info(
f"[GLM] 深度思考已禁用: extra_kwargs['thinking'] = {extra_kwargs['thinking']}"
)
if extra_kwargs:
logger.info(

View File

@ -28,10 +28,10 @@ upload_dir.mkdir(exist_ok=True)
# ── 会话管理 ─────────────────────────────────────────────────────
async def get_conversations_handler():
async def get_conversations_handler(user_id: str = "default"):
"""获取所有对话处理器"""
db = get_db()
return db.list_conversations()
return db.list_conversations(user_id)
async def get_conversation_handler(conversation_id: str):
@ -65,8 +65,38 @@ async def save_conversation_handler(data: dict):
async def delete_conversation_handler(conversation_id: str):
"""删除对话处理器"""
"""删除对话处理器(同时删除关联的 OSS 文件)"""
db = get_db()
# 先获取会话数据,提取 OSS 文件 URL
conversation = db.get_conversation(conversation_id)
if not conversation:
raise HTTPException(status_code=404, detail="对话不存在")
# 提取所有 OSS 文件 URL
oss_urls = _extract_oss_urls_from_conversation(conversation)
# 删除 OSS 文件
if oss_urls:
try:
from utils.oss_uploader import delete_files, extract_object_key_from_url
object_keys = []
for url in oss_urls:
key = extract_object_key_from_url(url)
if key:
object_keys.append(key)
if object_keys:
result = delete_files(object_keys)
log_info(f"[删除会话] OSS 文件清理结果: 删除 {len(result['deleted'])} 个, 失败 {len(result['failed'])}")
if result['failed']:
log_error(f"[删除会话] OSS 文件删除失败: {result['failed']}")
except Exception as e:
log_error(f"[删除会话] OSS 文件删除异常: {e}")
# 继续删除会话,即使 OSS 删除失败
# 删除数据库记录
success = db.delete_conversation(conversation_id)
if success:
return {"success": True, "message": "删除成功"}
@ -74,6 +104,85 @@ async def delete_conversation_handler(conversation_id: str):
raise HTTPException(status_code=404, detail="对话不存在")
def _extract_oss_urls_from_conversation(conversation: dict) -> list:
"""
从会话消息中提取所有 OSS 文件 URL
消息结构:
- content.images: 图片附件列表
- content.files: 文件附件列表
每个附件包含 url 字段
"""
urls = []
messages = conversation.get("messages", [])
for message in messages:
content = message.get("content")
if not content:
continue
# content 可能是字符串(需要解析)或已解析的字典
if isinstance(content, str):
try:
content = json.loads(content)
except json.JSONDecodeError:
continue
# 提取图片附件
images = content.get("images", [])
for img in images:
url = img.get("url")
if url and url not in urls:
urls.append(url)
# 提取文件附件
files = content.get("files", [])
for f in files:
url = f.get("url")
if url and url not in urls:
urls.append(url)
return urls
async def update_conversation_handler(conversation_id: str, data: dict):
"""部分更新对话处理器"""
db = get_db()
result = db.update_conversation(conversation_id, data)
if result:
return result
else:
raise HTTPException(status_code=404, detail="对话不存在")
# ── 消息管理 ─────────────────────────────────────────────────────
async def add_message_handler(conversation_id: str, message: dict):
"""添加消息到对话处理器"""
db = get_db()
# 检查对话是否存在
existing = db.get_conversation(conversation_id)
if not existing:
raise HTTPException(status_code=404, detail="对话不存在")
return db.add_message(conversation_id, message)
async def update_message_handler(conversation_id: str, message_id: str, data: dict):
"""更新消息处理器"""
db = get_db()
# 检查对话是否存在
existing = db.get_conversation(conversation_id)
if not existing:
raise HTTPException(status_code=404, detail="对话不存在")
result = db.update_message(message_id, data)
if result:
return result
else:
raise HTTPException(status_code=404, detail="消息不存在")
# ── 文件上传 ─────────────────────────────────────────────────────

View File

@ -1,91 +1,3 @@
"""
数据库模块
from .db import Database, get_db, init_db
提供 SQLite 数据库连接和会话管理功能
"""
import os
import sqlite3
from pathlib import Path
from contextlib import contextmanager
from typing import Optional
# 默认数据库路径
DEFAULT_DB_PATH = Path(__file__).parent.parent / "data" / "chat.db"
def init_db(db_path: Optional[str] = None):
"""
初始化数据库
创建必要的表结构
"""
if db_path is None:
db_path = os.getenv("DB_PATH", str(DEFAULT_DB_PATH))
# 确保数据目录存在
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 创建会话表
cursor.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 创建消息表
cursor.execute("""
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT,
role TEXT,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
)
""")
# 创建文件表
cursor.execute("""
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
conversation_id TEXT,
filename TEXT,
file_path TEXT,
file_type TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
)
""")
conn.commit()
conn.close()
print(f"[数据库] 初始化完成: {db_path}")
@contextmanager
def get_db(db_path: Optional[str] = None):
"""
获取数据库连接的上下文管理器
用法:
with get_db() as db:
cursor = db.execute("SELECT * FROM conversations")
rows = cursor.fetchall()
"""
if db_path is None:
db_path = os.getenv("DB_PATH", str(DEFAULT_DB_PATH))
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
__all__ = ["Database", "get_db", "init_db"]

401
server/database/db.py Normal file
View File

@ -0,0 +1,401 @@
"""
SQLite 数据库模块 - 会话持久化存储
提供会话和消息的 CRUD 操作支持多用户预留 user_id 字段
"""
import json
import sqlite3
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
# 数据库文件路径
DB_PATH = Path(__file__).parent.parent / "data" / "chat.db"
# 线程本地存储,确保每个线程使用独立的连接
_thread_local = threading.local()
# 全局数据库实例
_db_instance: Optional["Database"] = None
class Database:
"""SQLite 数据库管理类"""
def __init__(self, db_path: Path):
self.db_path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_tables()
def _get_connection(self) -> sqlite3.Connection:
"""获取当前线程的数据库连接"""
if not hasattr(_thread_local, "connection"):
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
conn.row_factory = sqlite3.Row
# 启用外键约束
conn.execute("PRAGMA foreign_keys = ON")
_thread_local.connection = conn
return _thread_local.connection
def _init_tables(self):
"""初始化数据库表结构"""
conn = self._get_connection()
cursor = conn.cursor()
# 创建会话表
cursor.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
user_id TEXT DEFAULT 'default',
title TEXT DEFAULT '新对话',
created_at INTEGER,
updated_at INTEGER,
pinned INTEGER DEFAULT 0,
archived INTEGER DEFAULT 0,
settings TEXT
)
""")
# 创建消息表
cursor.execute("""
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
timestamp INTEGER,
feedback TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
)
""")
# 创建索引
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_messages_conversation
ON messages(conversation_id)
""")
# 检查并添加缺失的列(迁移旧数据库 - conversations 表)
cursor.execute("PRAGMA table_info(conversations)")
conv_columns = [col[1] for col in cursor.fetchall()]
conv_migrations = [
('user_id', "TEXT DEFAULT 'default'"),
('pinned', "INTEGER DEFAULT 0"),
('archived', "INTEGER DEFAULT 0"),
('settings', "TEXT"),
]
for col_name, col_def in conv_migrations:
if col_name not in conv_columns:
cursor.execute(f"ALTER TABLE conversations ADD COLUMN {col_name} {col_def}")
print(f"[数据库] conversations 表已添加 {col_name}")
# 检查并添加缺失的列(迁移旧数据库 - messages 表)
cursor.execute("PRAGMA table_info(messages)")
msg_columns = [col[1] for col in cursor.fetchall()]
msg_migrations = [
('timestamp', "INTEGER"),
('feedback', "TEXT"),
]
for col_name, col_def in msg_migrations:
if col_name not in msg_columns:
cursor.execute(f"ALTER TABLE messages ADD COLUMN {col_name} {col_def}")
print(f"[数据库] messages 表已添加 {col_name}")
# 创建 user_id 索引(在确保列存在后)
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_conversations_user
ON conversations(user_id)
""")
conn.commit()
# ── 会话 CRUD ─────────────────────────────────────────────────────
def create_conversation(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""创建新会话"""
conn = self._get_connection()
cursor = conn.cursor()
now = int(datetime.now(timezone.utc).timestamp() * 1000)
conv_id = data.get("id") or self._generate_id()
cursor.execute(
"""
INSERT INTO conversations (id, user_id, title, created_at, updated_at, pinned, archived, settings)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
conv_id,
data.get("user_id", "default"),
data.get("title", "新对话"),
data.get("createdAt", now),
now,
1 if data.get("pinned") else 0,
1 if data.get("archived") else 0,
json.dumps(data.get("settings")) if data.get("settings") else None,
),
)
# 插入消息(如果有)
messages = data.get("messages", [])
for msg in messages:
self._insert_message(cursor, conv_id, msg)
conn.commit()
return self.get_conversation(conv_id)
def get_conversation(self, conversation_id: str) -> Optional[Dict[str, Any]]:
"""获取单个会话(包含消息)"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM conversations WHERE id = ?", (conversation_id,)
)
row = cursor.fetchone()
if not row:
return None
return self._row_to_conversation(row, cursor)
def list_conversations(self, user_id: str = "default") -> List[Dict[str, Any]]:
"""列出用户的所有会话"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT * FROM conversations
WHERE user_id = ?
ORDER BY updated_at DESC
""",
(user_id,),
)
conversations = []
for row in cursor.fetchall():
conv = self._row_to_conversation(row, cursor, include_messages=False)
conversations.append(conv)
return conversations
def update_conversation(
self, conversation_id: str, data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""更新会话"""
conn = self._get_connection()
cursor = conn.cursor()
# 检查会话是否存在
cursor.execute(
"SELECT id FROM conversations WHERE id = ?", (conversation_id,)
)
if not cursor.fetchone():
return None
now = int(datetime.now(timezone.utc).timestamp() * 1000)
# 更新会话字段
update_fields = ["updated_at = ?"]
update_values = [now]
if "title" in data:
update_fields.append("title = ?")
update_values.append(data["title"])
if "pinned" in data:
update_fields.append("pinned = ?")
update_values.append(1 if data["pinned"] else 0)
if "archived" in data:
update_fields.append("archived = ?")
update_values.append(1 if data["archived"] else 0)
if "settings" in data:
update_fields.append("settings = ?")
update_values.append(json.dumps(data["settings"]))
update_values.append(conversation_id)
cursor.execute(
f"UPDATE conversations SET {', '.join(update_fields)} WHERE id = ?",
update_values,
)
# 更新消息(如果提供了 messages 字段)
if "messages" in data:
# 删除旧消息
cursor.execute(
"DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)
)
# 插入新消息
for msg in data["messages"]:
self._insert_message(cursor, conversation_id, msg)
conn.commit()
return self.get_conversation(conversation_id)
def delete_conversation(self, conversation_id: str) -> bool:
"""删除会话(级联删除消息)"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"DELETE FROM conversations WHERE id = ?", (conversation_id,)
)
conn.commit()
return cursor.rowcount > 0
# ── 消息操作 ───────────────────────────────────────────────────────
def add_message(self, conversation_id: str, message: Dict[str, Any]) -> Dict[str, Any]:
"""添加消息到会话"""
conn = self._get_connection()
cursor = conn.cursor()
msg = self._insert_message(cursor, conversation_id, message)
# 更新会话的 updated_at
now = int(datetime.now(timezone.utc).timestamp() * 1000)
cursor.execute(
"UPDATE conversations SET updated_at = ? WHERE id = ?",
(now, conversation_id),
)
conn.commit()
return msg
def update_message(self, message_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""更新消息"""
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM messages WHERE id = ?", (message_id,))
if not cursor.fetchone():
return None
update_fields = []
update_values = []
if "content" in data:
update_fields.append("content = ?")
update_values.append(json.dumps(data["content"]))
if "feedback" in data:
update_fields.append("feedback = ?")
update_values.append(json.dumps(data["feedback"]))
if not update_fields:
return self._get_message_by_id(message_id, cursor)
update_values.append(message_id)
cursor.execute(
f"UPDATE messages SET {', '.join(update_fields)} WHERE id = ?",
update_values,
)
conn.commit()
return self._get_message_by_id(message_id, cursor)
# ── 内部方法 ───────────────────────────────────────────────────────
def _insert_message(
self, cursor: sqlite3.Cursor, conversation_id: str, message: Dict[str, Any]
) -> Dict[str, Any]:
"""插入消息(内部方法)"""
msg_id = message.get("id") or self._generate_id()
timestamp = message.get("timestamp") or int(
datetime.now(timezone.utc).timestamp() * 1000
)
cursor.execute(
"""
INSERT INTO messages (id, conversation_id, role, content, timestamp, feedback)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
msg_id,
conversation_id,
message.get("role", "user"),
json.dumps(message.get("content", "")),
timestamp,
json.dumps(message.get("feedback")) if message.get("feedback") else None,
),
)
return {
"id": msg_id,
"role": message.get("role", "user"),
"content": message.get("content", ""),
"timestamp": timestamp,
"feedback": message.get("feedback"),
}
def _get_message_by_id(
self, message_id: str, cursor: sqlite3.Cursor
) -> Optional[Dict[str, Any]]:
"""根据 ID 获取消息"""
cursor.execute("SELECT * FROM messages WHERE id = ?", (message_id,))
row = cursor.fetchone()
return self._row_to_message(row) if row else None
def _row_to_conversation(
self, row: sqlite3.Row, cursor: sqlite3.Cursor, include_messages: bool = True
) -> Dict[str, Any]:
"""将数据库行转换为会话字典"""
conv = {
"id": row["id"],
"userId": row["user_id"],
"title": row["title"],
"createdAt": row["created_at"],
"updatedAt": row["updated_at"],
"pinned": bool(row["pinned"]),
"archived": bool(row["archived"]),
"settings": json.loads(row["settings"]) if row["settings"] else None,
}
if include_messages:
cursor.execute(
"SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp",
(row["id"],),
)
conv["messages"] = [
self._row_to_message(msg_row) for msg_row in cursor.fetchall()
]
return conv
def _row_to_message(self, row: sqlite3.Row) -> Dict[str, Any]:
"""将数据库行转换为消息字典"""
return {
"id": row["id"],
"role": row["role"],
"content": json.loads(row["content"]),
"timestamp": row["timestamp"],
"feedback": json.loads(row["feedback"]) if row["feedback"] else None,
}
def _generate_id(self) -> str:
"""生成唯一 ID"""
import uuid
return str(uuid.uuid4())
def init_db():
"""初始化数据库(应用启动时调用)"""
global _db_instance
if _db_instance is None:
_db_instance = Database(DB_PATH)
print(f"[数据库] SQLite 初始化完成: {DB_PATH}")
def get_db() -> Database:
"""获取数据库实例"""
global _db_instance
if _db_instance is None:
init_db()
return _db_instance

View File

@ -54,12 +54,15 @@ init_db()
load_dotenv()
# ── 会话管理路由处理器 ────────────────────────────────────────────────
from api.conversation_routes import (delete_conversation_handler,
from api.conversation_routes import (add_message_handler,
delete_conversation_handler,
get_conversation_handler,
get_conversations_handler,
save_conversation_handler,
serve_upload_handler,
stop_generation_handler,
update_conversation_handler,
update_message_handler,
upload_file_handler)
# ── OpenAI 兼容网关初始化 ───────────────────────────────────────────────
@ -170,8 +173,8 @@ async def get_models():
@app.get("/api/chat-ui/conversations")
async def get_conversations():
return await get_conversations_handler()
async def get_conversations(user_id: str = "default"):
return await get_conversations_handler(user_id)
@app.get("/api/chat-ui/conversations/{conversation_id}")
@ -189,6 +192,21 @@ async def delete_conversation(conversation_id: str):
return await delete_conversation_handler(conversation_id)
@app.put("/api/chat-ui/conversations/{conversation_id}")
async def update_conversation(conversation_id: str, request: Request):
return await update_conversation_handler(conversation_id, await request.json())
@app.post("/api/chat-ui/conversations/{conversation_id}/messages")
async def add_message(conversation_id: str, request: Request):
return await add_message_handler(conversation_id, await request.json())
@app.put("/api/chat-ui/conversations/{conversation_id}/messages/{message_id}")
async def update_message(conversation_id: str, message_id: str, request: Request):
return await update_message_handler(conversation_id, message_id, await request.json())
@app.post("/api/chat-ui/upload")
async def upload_file(file: UploadFile = File(...)):
return await upload_file_handler(file=file)

45
server/middleware/auth.py Normal file
View File

@ -0,0 +1,45 @@
"""
认证中间件 - 预留接口
当前返回默认用户未来可集成 JWTOAuth 等认证系统
"""
from typing import Optional
def get_current_user_id(request) -> str:
"""
从请求中获取当前用户 ID预留
当前返回默认用户 'default'
未来可集成 JWTOAuth
Args:
request: FastAPI Request 对象
Returns:
用户 ID 字符串
"""
# TODO: 实现 token 验证逻辑
# 示例:
# auth_header = request.headers.get("Authorization")
# if auth_header and auth_header.startswith("Bearer "):
# token = auth_header[7:]
# user_id = verify_token(token)
# return user_id
return "default"
def get_current_user(request) -> dict:
"""
获取当前用户完整信息预留
Returns:
用户信息字典
"""
return {
"id": get_current_user_id(request),
"name": None,
"email": None
}

View File

@ -57,11 +57,12 @@ def _get_client() -> oss.Client:
return oss.Client(cfg)
def _generate_object_key(filename: str, prefix: str = "uploads") -> str:
def _generate_object_key(filename: str, prefix: str = "chat-ui") -> str:
"""
根据文件名生成唯一的 OSS 对象 Key
格式: {prefix}/{日期}/{uuid}_{原始文件名}
"""
# TODO: 需要按用户ID分目录
date_str = datetime.now().strftime("%Y%m%d")
unique_id = uuid.uuid4().hex[:8]
safe_name = Path(filename).name # 只取文件名,去掉路径
@ -80,7 +81,7 @@ def _build_url(object_key: str) -> str:
def upload_file(
file_path: str,
object_key: Optional[str] = None,
prefix: str = "uploads",
prefix: str = "chat-ui",
) -> dict:
"""
上传本地文件到 OSS
@ -204,6 +205,99 @@ def upload_fileobj(
)
def delete_file(object_key: str) -> bool:
"""
删除 OSS 上的单个文件
参数:
object_key: OSS 对象路径 "uploads/20240301/abc123_file.jpg"
返回:
True 表示删除成功False 表示失败
"""
try:
client = _get_client()
result = client.delete_object(
oss.DeleteObjectRequest(
bucket=OSS_BUCKET_NAME,
key=object_key,
)
)
return result.status_code == 204
except Exception as e:
print(f"[OSS] 删除文件失败: {object_key}, 错误: {e}")
return False
def delete_files(object_keys: list) -> dict:
"""
批量删除 OSS 上的文件
参数:
object_keys: OSS 对象路径列表
返回:
{
"deleted": ["成功删除的 object_key 列表"],
"failed": ["删除失败的 object_key 列表"],
}
"""
deleted = []
failed = []
for key in object_keys:
if delete_file(key):
deleted.append(key)
else:
failed.append(key)
return {"deleted": deleted, "failed": failed}
def extract_object_key_from_url(url: str) -> Optional[str]:
"""
OSS URL 中提取 object_key
参数:
url: OSS 文件的完整 URL
返回:
object_key None如果不是有效的 OSS URL
"""
if not url:
return None
# 支持两种 URL 格式:
# 1. 自定义域名: OSS_URL_PREFIX/object_key
# 2. 默认域名: https://bucket.endpoint/object_key
try:
# 移除查询参数
url_path = url.split("?")[0]
if OSS_URL_PREFIX:
# 自定义域名格式
prefix = OSS_URL_PREFIX.rstrip("/")
if url_path.startswith(prefix):
return url_path[len(prefix) + 1:] # +1 去掉开头的 /
# 默认域名格式: https://bucket.endpoint/object_key
endpoint = OSS_ENDPOINT.replace("https://", "").replace("http://", "")
default_prefix = f"https://{OSS_BUCKET_NAME}.{endpoint}/"
if url_path.startswith(default_prefix):
return url_path[len(default_prefix):]
# 也尝试匹配 http 版本
http_prefix = f"http://{OSS_BUCKET_NAME}.{endpoint}/"
if url_path.startswith(http_prefix):
return url_path[len(http_prefix):]
return None
except Exception:
return None
# ────────────────────────────────────────────────────────────────
# 命令行入口python -m utils.oss_uploader --file <路径>
# ────────────────────────────────────────────────────────────────

View File

@ -44,7 +44,8 @@ import ShortcutsModal from "@/components/modals/ShortcutsModal.vue";
import SettingsModal from "@/components/modals/SettingsModal.vue";
import ConversationSettingsModal from "@/components/modals/ConversationSettingsModal.vue";
import { Check, AlertCircle, Info } from "@/components/icons";
import { useAuthStore } from "./stores/auth";
const authStore = useAuthStore();
// Stores
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
@ -126,10 +127,13 @@ useKeyboard(
//
onMounted(() => {
//
if (chatStore.conversations.length === 0) {
chatStore.createConversation();
}
authStore.init();
console.log(authStore.token);
// //
// if (chatStore.conversations.length === 0) {
// chatStore.createConversation();
// }
});
// 使

View File

@ -52,6 +52,7 @@ import { ref, computed, watch, nextTick, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import ChatHeader from "./ChatHeader.vue";
import MessageList from "./MessageList.vue";
import ChatInput from "@/components/input/ChatInput.vue";
@ -65,6 +66,7 @@ defineEmits<{
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const authStore = useAuthStore();
const { currentConversation, isStreaming } = storeToRefs(chatStore);
const { settings, sidebarCollapsed } = storeToRefs(settingsStore);
@ -164,6 +166,12 @@ async function handleSend(
systemPrompt?: string;
},
) {
//
if (!authStore.isAuthenticated) {
window.$toast?.('请先登录', 'error');
return;
}
console.log("handleSend", text, attachments, options);
//
const uploadingAttachments = attachments.filter((a) => a.uploading);
@ -196,7 +204,7 @@ async function handleSend(
//
if (!currentConversation.value) {
chatStore.createConversation();
await chatStore.createConversation();
}
//
@ -212,7 +220,7 @@ async function handleSend(
.map((m: any) => ({ role: m.role, content: m.content.text }));
//
chatStore.addMessage(MessageRole.USER, {
await chatStore.addMessage(MessageRole.USER, {
type: MessageType.TEXT,
text,
images: attachments.filter((a) => a.type === "image"),
@ -220,7 +228,7 @@ async function handleSend(
});
// AI
const aiMessage = chatStore.addMessage(MessageRole.ASSISTANT, {
const aiMessage = await chatStore.addMessage(MessageRole.ASSISTANT, {
type: MessageType.TEXT,
text: "",
});
@ -337,6 +345,12 @@ function handleStop() {
//
async function handleRetry(messageId: string) {
//
if (!authStore.isAuthenticated) {
window.$toast?.('请先登录', 'error');
return;
}
const message = messages.value.find((m: any) => m.id === messageId);
if (!message || message.role !== MessageRole.ASSISTANT) return;

File diff suppressed because it is too large Load Diff

View File

@ -215,20 +215,20 @@ const modelSelect = ref(localStorage.getItem("modelSelect") || "");
const currentModelId = ref(settingsStore.getSelectedModelId());
onMounted(() => {
chatApi.getModels().then((res: any) => {
availableModels.value = res;
//
const model = availableModels.value?.find(
(m: any) => m.id === currentModelId.value,
);
if (model) {
modelSelect.value = model.name;
} else if (availableModels.value.length > 0) {
modelSelect.value = availableModels.value[0].name;
currentModelId.value = availableModels.value[0].id;
}
localStorage.setItem("modelSelect", modelSelect.value);
});
// chatApi.getModels().then((res: any) => {
// availableModels.value = res;
// //
// const model = availableModels.value?.find(
// (m: any) => m.id === currentModelId.value,
// );
// if (model) {
// modelSelect.value = model.name;
// } else if (availableModels.value.length > 0) {
// modelSelect.value = availableModels.value[0].name;
// currentModelId.value = availableModels.value[0].id;
// }
// localStorage.setItem("modelSelect", modelSelect.value);
// });
});
//

View File

@ -409,10 +409,10 @@ const availableModels: any = ref([]);
const defaultModel: any = ref(localStorage.getItem("defaultModel"));
onMounted(() => {
chatApi.getModels().then((res: any) => {
availableModels.value = res;
if (!defaultModel.value) defaultModel.value = res[0].name;
});
// chatApi.getModels().then((res: any) => {
// availableModels.value = res;
// if (!defaultModel.value) defaultModel.value = res[0].name;
// });
});
const activeTab = ref("appearance");

View File

@ -2,6 +2,8 @@
* Chat UI API
*
*/
import { getAuthHeaders } from './request';
// API 端点定义(固定)
const API_ENDPOINTS = {
// 发送消息(流式)
@ -153,7 +155,7 @@ class ChatApi {
{
method: "POST",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
Accept: "text/event-stream",
},
body: JSON.stringify(openAiRequest),
@ -244,9 +246,7 @@ class ChatApi {
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
body: JSON.stringify(requestBody),
});
@ -264,9 +264,7 @@ class ChatApi {
async stopChat(messageId?: string) {
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: getAuthHeaders(),
});
}
@ -326,8 +324,13 @@ class ChatApi {
const formData = new FormData();
formData.append("file", file);
// 获取认证 headers但不包含 Content-Type让浏览器为 FormData 自动设置)
const authHeaders = getAuthHeaders();
const { 'Content-Type': _, ...headersWithoutContentType } = authHeaders;
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
method: "POST",
headers: headersWithoutContentType,
body: formData,
});

View File

@ -0,0 +1,64 @@
/**
* -
*
* JWTOAuth
*/
export interface AuthUser {
id: string;
name?: string;
email?: string;
}
// Token 存储 key
const AUTH_TOKEN_KEY = 'auth_token';
export const authService = {
/**
*
*/
getCurrentUser(): AuthUser | null {
// TODO: 从 token 解析用户信息
return { id: 'default' };
},
/**
* token
*/
getToken(): string | null {
return localStorage.getItem(AUTH_TOKEN_KEY);
},
/**
* token
*/
setToken(token: string): void {
localStorage.setItem(AUTH_TOKEN_KEY, token);
},
/**
*
*/
clearAuth(): void {
localStorage.removeItem(AUTH_TOKEN_KEY);
},
/**
* true
*/
isAuthenticated(): boolean {
// TODO: 实现真实的认证检查
return true;
},
/**
* Authorization header
*/
getAuthHeader(): Record<string, string> {
const token = this.getToken();
if (token) {
return { Authorization: `Bearer ${token}` };
}
return {};
}
};

View File

@ -0,0 +1,302 @@
/**
* API
*
* API
*/
import { getAuthHeaders } from './request';
import { useAuthStore } from '@/stores/auth';
import type { Conversation, Message, MessageContent, ConversationSettings } from '@/types/chat';
// API 端点
const API_BASE = '/api/chat-ui';
const ENDPOINTS = {
CONVERSATIONS: `${API_BASE}/conversations`,
CONVERSATION: (id: string) => `${API_BASE}/conversations/${id}`,
CONVERSATION_MESSAGES: (id: string) => `${API_BASE}/conversations/${id}/messages`,
};
// 后端返回的对话数据格式
interface BackendConversation {
id: string;
userId?: string;
title: string;
createdAt: number;
updatedAt: number;
pinned: boolean;
archived: boolean;
settings?: ConversationSettings;
messages?: BackendMessage[];
}
// 后端返回的消息数据格式
interface BackendMessage {
id: string;
role: string;
content: MessageContent;
timestamp: number;
feedback?: {
liked?: boolean;
disliked?: boolean;
copied?: boolean;
};
}
/**
*
*/
function getHeaders(): Record<string, string> {
return getAuthHeaders();
}
/**
*
*/
function transformConversation(backendConv: BackendConversation): Conversation {
return {
id: backendConv.id,
title: backendConv.title,
createdAt: backendConv.createdAt,
updatedAt: backendConv.updatedAt,
pinned: backendConv.pinned,
archived: backendConv.archived,
settings: backendConv.settings,
messages: (backendConv.messages || []).map(transformMessage),
};
}
/**
*
*/
function transformMessage(backendMsg: BackendMessage): Message {
return {
id: backendMsg.id,
role: backendMsg.role as 'user' | 'assistant' | 'system',
content: backendMsg.content,
timestamp: backendMsg.timestamp,
feedback: backendMsg.feedback,
isStreaming: false,
} as Message;
}
/**
*
*/
function toBackendFormat(conversation: Partial<Conversation>, userId?: string): Record<string, unknown> {
const data: Record<string, unknown> = {};
if (conversation.id !== undefined) data.id = conversation.id;
if (userId !== undefined) data.user_id = userId; // 后端使用下划线命名
if (conversation.title !== undefined) data.title = conversation.title;
if (conversation.createdAt !== undefined) data.createdAt = conversation.createdAt;
if (conversation.updatedAt !== undefined) data.updatedAt = conversation.updatedAt;
if (conversation.pinned !== undefined) data.pinned = conversation.pinned;
if (conversation.archived !== undefined) data.archived = conversation.archived;
if (conversation.settings !== undefined) data.settings = conversation.settings;
if (conversation.messages !== undefined) {
data.messages = conversation.messages.map(msg => ({
id: msg.id,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp,
feedback: msg.feedback,
}));
}
return data;
}
/**
* API
*/
export const conversationApi = {
/**
*
*/
async fetchConversations(): Promise<Conversation[]> {
const authStore = useAuthStore();
const userId = authStore.userId;
// 构建 URL添加 user_id 查询参数
const url = userId
? `${ENDPOINTS.CONVERSATIONS}?user_id=${encodeURIComponent(userId)}`
: ENDPOINTS.CONVERSATIONS;
const response = await fetch(url, {
method: 'GET',
headers: getHeaders(),
});
if (!response.ok) {
throw new Error(`获取对话列表失败: HTTP ${response.status}`);
}
const data: BackendConversation[] = await response.json();
return data.map(transformConversation);
},
/**
*
*/
async fetchConversation(id: string): Promise<Conversation> {
const response = await fetch(ENDPOINTS.CONVERSATION(id), {
method: 'GET',
headers: getHeaders(),
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('对话不存在');
}
throw new Error(`获取对话失败: HTTP ${response.status}`);
}
const data: BackendConversation = await response.json();
return transformConversation(data);
},
/**
*
*/
async createConversation(data: Partial<Conversation>): Promise<Conversation> {
const authStore = useAuthStore();
const userId = authStore.userId || undefined;
const response = await fetch(ENDPOINTS.CONVERSATIONS, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(toBackendFormat(data, userId)),
});
if (!response.ok) {
throw new Error(`创建对话失败: HTTP ${response.status}`);
}
const result: BackendConversation = await response.json();
return transformConversation(result);
},
/**
*
*/
async updateConversation(id: string, data: Partial<Conversation>): Promise<Conversation> {
const response = await fetch(ENDPOINTS.CONVERSATION(id), {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(toBackendFormat(data)),
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('对话不存在');
}
throw new Error(`更新对话失败: HTTP ${response.status}`);
}
const result: BackendConversation = await response.json();
return transformConversation(result);
},
/**
*
*/
async saveConversation(conversation: Conversation): Promise<Conversation> {
const data = toBackendFormat(conversation);
const response = await fetch(ENDPOINTS.CONVERSATIONS, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`保存对话失败: HTTP ${response.status}`);
}
const result: BackendConversation = await response.json();
return transformConversation(result);
},
/**
*
*/
async deleteConversation(id: string): Promise<void> {
const response = await fetch(ENDPOINTS.CONVERSATION(id), {
method: 'DELETE',
headers: getHeaders(),
});
if (!response.ok) {
if (response.status === 404) {
// 对话已不存在,视为成功
return;
}
throw new Error(`删除对话失败: HTTP ${response.status}`);
}
},
/**
*
*/
async addMessage(conversationId: string, message: Partial<Message>): Promise<Message> {
const response = await fetch(ENDPOINTS.CONVERSATION_MESSAGES(conversationId), {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({
id: message.id,
role: message.role,
content: message.content,
timestamp: message.timestamp,
feedback: message.feedback,
}),
});
if (!response.ok) {
throw new Error(`添加消息失败: HTTP ${response.status}`);
}
const result: BackendMessage = await response.json();
return transformMessage(result);
},
/**
*
*/
async updateMessage(conversationId: string, messageId: string, data: Partial<Message>): Promise<Message> {
const response = await fetch(`${ENDPOINTS.CONVERSATION(conversationId)}/messages/${messageId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({
content: data.content,
feedback: data.feedback,
}),
});
if (!response.ok) {
throw new Error(`更新消息失败: HTTP ${response.status}`);
}
const result: BackendMessage = await response.json();
return transformMessage(result);
},
/**
*
*/
async migrateConversations(conversations: Conversation[]): Promise<{ success: number; failed: number }> {
let success = 0;
let failed = 0;
for (const conversation of conversations) {
try {
await this.saveConversation(conversation);
success++;
} catch (e) {
console.error(`迁移对话失败 [${conversation.id}]:`, e);
failed++;
}
}
return { success, failed };
},
};

100
src/services/request.ts Normal file
View File

@ -0,0 +1,100 @@
/**
*
*
* Pinia store token
*/
import { useAuthStore } from '@/stores/auth';
/**
* token Pinia store
*/
function getToken(): string | null {
const authStore = useAuthStore();
return authStore.token;
}
/**
*
*
* @param url -
* @param options - fetch
* @returns Response
*
* @example
* // GET 请求
* const response = await apiRequest('/api/users');
* const data = await response.json();
*
* // POST 请求
* const response = await apiRequest('/api/users', {
* method: 'POST',
* body: JSON.stringify({ name: 'John' })
* });
*/
export async function apiRequest(
url: string,
options: RequestInit = {}
): Promise<Response> {
const token = getToken();
// 判断是否为 FormData不设置 Content-Type 让浏览器自动处理
const isFormData = options.body instanceof FormData;
// 合并默认配置
const config: RequestInit = {
...options,
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers,
},
};
const response = await fetch(url, config);
// 401 认证失败提示
if (response.status === 401) {
window.$toast?.('认证失败,请重新登录', 'error');
}
return response;
}
/**
* JSON
*
* @param url -
* @param options - fetch
* @returns JSON
*
* @example
* const users = await apiRequestJson<User[]>('/api/users');
*/
export async function apiRequestJson<T = unknown>(
url: string,
options: RequestInit = {}
): Promise<T> {
const response = await apiRequest(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `HTTP ${response.status}`);
}
return response.json();
}
/**
* headers
* headers
*/
export function getAuthHeaders(): Record<string, string> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}

127
src/stores/auth.ts Normal file
View File

@ -0,0 +1,127 @@
/**
*
*/
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { UserInfo } from '@/types/chat';
// MARK: dev 默认 token当 URL 无 token 参数时使用)
const DEV_DEFAULT_TOKEN = '';
// 认证接口返回格式
interface AuthResponse {
code: string;
msg: string;
success: boolean;
timestamp: number;
data: UserInfo | null;
}
// 认证接口
const AUTH_CHECK_URL = '/api/auth/check/checkTokenRn';
export const useAuthStore = defineStore('auth', () => {
// 状态
const token = ref<string | null>(null);
const user = ref<UserInfo | null>(null);
const isInitialized = ref(false);
// 计算属性
const isAuthenticated = computed(() => !!token.value);
const userId = computed(() => user.value?.username || null); // username 用于 OSS 路径和数据库 user_id
/**
* token
*/
async function checkToken(tokenToCheck: string): Promise<UserInfo | null> {
try {
const response = await fetch(`${AUTH_CHECK_URL}/${tokenToCheck}`);
if (!response.ok) {
return null;
}
const data: AuthResponse = await response.json();
if (data.success && data.data) {
window.$toast?.(`登录成功, 欢迎 ${data.data.nickname || data.data.username}`, 'success');
return data.data;
}else{
window.$toast?.('[Auth] Token 验证失败:Token无效');
}
return null;
} catch (error) {
console.error('[Auth] Token 验证失败:', error);
return null;
}
}
/**
* - URL token
*/
async function init() {
const searchParams = new URLSearchParams(window.location.search);
const urlToken = searchParams.get('token');
// 获取 tokenURL > localStorage > 默认值
const tokenValue = urlToken
|| localStorage.getItem('DEV_DEFAULT_TOKEN')
|| DEV_DEFAULT_TOKEN;
if (!tokenValue) {
isInitialized.value = true;
window.$toast?.('未登录,请先登录', 'error');
return;
}
// 验证 token
const userInfo = await checkToken(tokenValue);
if (userInfo) {
token.value = tokenValue;
user.value = userInfo;
} else {
// 验证失败,清空
token.value = null;
user.value = null;
}
isInitialized.value = true;
}
/**
*
*/
function setUser(userInfo: UserInfo) {
user.value = userInfo;
}
/**
* header
*/
function getAuthHeader(): Record<string, string> {
if (token.value) {
return { Authorization: `Bearer ${token.value}` };
}
return {};
}
// 初始化(不等待,让调用方通过 isInitialized 判断)
init();
return {
// 状态
token,
user,
isAuthenticated,
userId,
isInitialized,
// 方法
setUser,
getAuthHeader,
init,
};
});

View File

@ -8,6 +8,7 @@ import type {
} from "@/types/chat";
import { MessageRole } from "@/types/chat";
import { generateId, extractTitleFromMessage } from "@/utils/helpers";
import { conversationApi } from "@/services/conversationApi";
export const useChatStore = defineStore("chat", () => {
// 状态
@ -15,6 +16,8 @@ export const useChatStore = defineStore("chat", () => {
const currentConversationId = ref<string | null>(null);
const isStreaming = ref(false);
const streamController = ref<AbortController | null>(null);
const isInitialized = ref(false);
const isLoading = ref(false);
// 计算属性
const currentConversation = computed(() => {
@ -40,8 +43,43 @@ export const useChatStore = defineStore("chat", () => {
return sortedConversations.value.filter((c) => !c.pinned && !c.archived);
});
// 方法
function createConversation(): string {
// 初始化方法 - 从后端 API 加载数据
async function initializeFromApi() {
if (isInitialized.value || isLoading.value) return;
isLoading.value = true;
try {
const loadedConversations = await conversationApi.fetchConversations();
conversations.value = loadedConversations;
// 恢复当前对话 ID从 localStorage 或选择第一个)
const storedId = localStorage.getItem("chat-current-id");
if (storedId && conversations.value.find((c) => c.id === storedId)) {
currentConversationId.value = storedId;
} else if (conversations.value.length > 0) {
currentConversationId.value = conversations.value[0].id;
}
isInitialized.value = true;
} catch (error) {
console.error("Failed to initialize from API:", error);
// 如果 API 失败,尝试从 localStorage 加载(降级处理)
loadFromStorage();
} finally {
isLoading.value = false;
}
}
// 保存当前对话 ID 到 localStorage
function saveCurrentId() {
localStorage.setItem(
"chat-current-id",
currentConversationId.value || ""
);
}
// 创建对话
async function createConversation(): Promise<string> {
const newConversation: Conversation = {
id: generateId(),
title: "新对话",
@ -53,89 +91,171 @@ export const useChatStore = defineStore("chat", () => {
settings: undefined,
};
// 乐观更新
conversations.value.unshift(newConversation);
currentConversationId.value = newConversation.id;
saveToStorage();
saveCurrentId();
// 异步保存到后端
try {
const saved = await conversationApi.createConversation(newConversation);
// 更新本地数据(以防后端修改了某些字段)
const index = conversations.value.findIndex((c) => c.id === newConversation.id);
if (index !== -1) {
conversations.value[index] = saved;
}
} catch (error) {
console.error("Failed to create conversation:", error);
// 回滚乐观更新
const index = conversations.value.findIndex((c) => c.id === newConversation.id);
if (index !== -1) {
conversations.value.splice(index, 1);
}
throw error;
}
return newConversation.id;
}
function deleteConversation(id: string) {
// 删除对话
async function deleteConversation(id: string) {
const index = conversations.value.findIndex((c) => c.id === id);
if (index !== -1) {
conversations.value.splice(index, 1);
if (index === -1) return;
if (currentConversationId.value === id) {
currentConversationId.value = conversations.value[0]?.id || null;
}
// 保存引用以便回滚
const deletedConversation = conversations.value[index];
saveToStorage();
// 乐观更新
conversations.value.splice(index, 1);
if (currentConversationId.value === id) {
currentConversationId.value = conversations.value[0]?.id || null;
saveCurrentId();
}
// 异步删除
try {
await conversationApi.deleteConversation(id);
} catch (error) {
console.error("Failed to delete conversation:", error);
// 回滚
conversations.value.splice(index, 0, deletedConversation);
throw error;
}
}
function selectConversation(id: string) {
// 选择对话
async function selectConversation(id: string) {
currentConversationId.value = id;
saveCurrentId();
// 如果对话没有加载消息,从后端加载
const conversation = conversations.value.find((c) => c.id === id);
if (conversation && (!conversation.messages || conversation.messages.length === 0)) {
try {
const loaded = await conversationApi.fetchConversation(id);
const index = conversations.value.findIndex((c) => c.id === id);
if (index !== -1) {
conversations.value[index] = loaded;
}
} catch (error) {
console.error("Failed to load conversation:", error);
}
}
}
function togglePinConversation(id: string) {
// 置顶对话
async function togglePinConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
if (!conversation) return;
// 乐观更新
conversation.pinned = !conversation.pinned;
// 异步保存
try {
await conversationApi.updateConversation(id, { pinned: conversation.pinned });
} catch (error) {
console.error("Failed to toggle pin:", error);
// 回滚
conversation.pinned = !conversation.pinned;
saveToStorage();
throw error;
}
}
function renameConversation(id: string, newTitle: string) {
// 重命名对话
async function renameConversation(id: string, newTitle: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.title = newTitle;
conversation.updatedAt = Date.now();
saveToStorage();
if (!conversation) return;
const oldTitle = conversation.title;
conversation.title = newTitle;
conversation.updatedAt = Date.now();
// 异步保存
try {
await conversationApi.updateConversation(id, { title: newTitle });
} catch (error) {
console.error("Failed to rename conversation:", error);
// 回滚
conversation.title = oldTitle;
throw error;
}
}
function updateConversationSettings(
// 更新对话设置
async function updateConversationSettings(
id: string,
convSettings: ConversationSettings,
convSettings: ConversationSettings
) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.settings = { ...conversation.settings, ...convSettings };
conversation.updatedAt = Date.now();
saveToStorage();
if (!conversation) return;
const oldSettings = conversation.settings;
conversation.settings = { ...conversation.settings, ...convSettings };
conversation.updatedAt = Date.now();
// 异步保存
try {
await conversationApi.updateConversation(id, { settings: conversation.settings });
} catch (error) {
console.error("Failed to update settings:", error);
// 回滚
conversation.settings = oldSettings;
throw error;
}
}
function addMessage(
// 添加消息
async function addMessage(
role: MessageRole,
content: MessageContent,
conversationId?: string,
): Message {
const targetId = conversationId || currentConversationId.value;
conversationId?: string
): Promise<Message> {
let targetId = conversationId || currentConversationId.value;
if (!targetId) {
createConversation();
await createConversation();
targetId = currentConversationId.value;
}
const conversation = conversations.value.find(
(c) => c.id === (targetId || currentConversationId.value),
);
const conversation = conversations.value.find((c) => c.id === targetId);
if (!conversation) {
throw new Error("Conversation not found");
}
const message: any = {
const message: Message = {
id: generateId(),
role,
content,
timestamp: Date.now(),
isStreaming: false,
};
} as Message;
// 乐观更新
conversation.messages.push(message);
conversation.updatedAt = Date.now();
// 如果是第一条用户消息,更新标题
if (
role === MessageRole.USER &&
conversation.messages.length === 1 &&
@ -144,21 +264,64 @@ export const useChatStore = defineStore("chat", () => {
conversation.title = extractTitleFromMessage(content.text);
}
saveToStorage();
// 异步保存(使用增量更新)
try {
// 确保 targetId 不为空
if (targetId) {
// 发送消息到后端,不等待完成
conversationApi.addMessage(targetId, message).catch((error) => {
console.error("Failed to save message:", error);
});
// 如果标题更新了,也保存标题
if (
role === MessageRole.USER &&
conversation.messages.length === 1
) {
conversationApi.updateConversation(targetId, { title: conversation.title }).catch((error) => {
console.error("Failed to update title:", error);
});
}
}
} catch (error) {
console.error("Failed to add message:", error);
}
return message;
}
function updateMessage(messageId: string, updates: Partial<Message>) {
// 更新消息
async function updateMessage(messageId: string, updates: Partial<Message>) {
const conversation = currentConversation.value;
if (!conversation) return;
if (!conversation) {
console.warn("[updateMessage] No current conversation");
return;
}
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
Object.assign(message, updates);
saveToStorage();
if (!message) {
console.warn("[updateMessage] Message not found:", messageId);
return;
}
// 乐观更新
Object.assign(message, updates);
// 异步保存
try {
console.log("[updateMessage] Saving to backend:", {
conversationId: conversation.id,
messageId,
content: updates.content,
});
await conversationApi.updateMessage(conversation.id, messageId, updates);
console.log("[updateMessage] Save successful");
} catch (error) {
console.error("Failed to update message:", error);
}
}
// 更新消息内容(流式更新时使用,不触发 API 调用)
function updateMessageContent(messageId: string, text: string) {
const conversation = currentConversation.value;
if (!conversation) return;
@ -169,24 +332,49 @@ export const useChatStore = defineStore("chat", () => {
}
}
function setMessageFeedback(
// 保存整个对话(用于流式结束后)
async function saveConversation(conversationId: string) {
const conversation = conversations.value.find((c) => c.id === conversationId);
if (!conversation) return;
try {
await conversationApi.updateConversation(conversationId, {
messages: conversation.messages,
updatedAt: Date.now()
});
} catch (error) {
console.error("Failed to save conversation:", error);
}
}
// 设置消息反馈
async function setMessageFeedback(
messageId: string,
feedback: "like" | "dislike" | null,
feedback: "like" | "dislike" | null
) {
const conversation = currentConversation.value;
if (!conversation) return;
const message = conversation.messages.find((m) => m.id === messageId);
if (message) {
message.feedback = {
liked: feedback === "like",
disliked: feedback === "dislike",
copied: message.feedback?.copied,
};
saveToStorage();
if (!message) return;
message.feedback = {
liked: feedback === "like",
disliked: feedback === "dislike",
copied: message.feedback?.copied,
};
// 异步保存
try {
await conversationApi.updateMessage(conversation.id, messageId, {
feedback: message.feedback
});
} catch (error) {
console.error("Failed to save feedback:", error);
}
}
// 设置消息已复制
function setMessageCopied(messageId: string) {
const conversation = currentConversation.value;
if (!conversation) return;
@ -200,11 +388,13 @@ export const useChatStore = defineStore("chat", () => {
}
}
// 开始流式输出
function startStreaming() {
isStreaming.value = true;
streamController.value = new AbortController();
}
// 停止流式输出
function stopStreaming() {
isStreaming.value = false;
if (streamController.value) {
@ -213,30 +403,23 @@ export const useChatStore = defineStore("chat", () => {
}
}
function clearConversation(id: string) {
// 清空对话消息
async function clearConversation(id: string) {
const conversation = conversations.value.find((c) => c.id === id);
if (conversation) {
conversation.messages = [];
conversation.updatedAt = Date.now();
saveToStorage();
}
}
if (!conversation) return;
function saveToStorage() {
conversation.messages = [];
conversation.updatedAt = Date.now();
// 异步保存
try {
localStorage.setItem(
"chat-conversations",
JSON.stringify(conversations.value),
);
localStorage.setItem(
"chat-current-id",
currentConversationId.value || "",
);
} catch (e) {
console.error("Failed to save to storage:", e);
await conversationApi.updateConversation(id, { messages: [] });
} catch (error) {
console.error("Failed to clear conversation:", error);
}
}
// 降级:从 localStorage 加载(仅在 API 不可用时使用)
function loadFromStorage() {
try {
const stored = localStorage.getItem("chat-conversations");
@ -255,17 +438,40 @@ export const useChatStore = defineStore("chat", () => {
}
}
loadFromStorage();
// 保存到 localStorage降级模式使用
function saveToStorage() {
try {
localStorage.setItem(
"chat-conversations",
JSON.stringify(conversations.value)
);
localStorage.setItem(
"chat-current-id",
currentConversationId.value || ""
);
} catch (e) {
console.error("Failed to save to storage:", e);
}
}
// 初始化
initializeFromApi();
return {
// 状态
conversations,
currentConversationId,
isStreaming,
streamController,
isInitialized,
isLoading,
// 计算属性
currentConversation,
sortedConversations,
pinnedConversations,
recentConversations,
// 方法
initializeFromApi,
createConversation,
deleteConversation,
selectConversation,
@ -275,11 +481,13 @@ export const useChatStore = defineStore("chat", () => {
addMessage,
updateMessage,
updateMessageContent,
saveConversation,
setMessageFeedback,
setMessageCopied,
startStreaming,
stopStreaming,
clearConversation,
loadFromStorage,
saveToStorage,
};
});
});

View File

@ -16,7 +16,7 @@ export const useSettingsStore = defineStore("settings", () => {
compactMode: false,
// AI 默认设置
defaultModel: "glm-4.6",
defaultModel: "glm-4.6v",
defaultTemperature: 0.7,
defaultMaxTokens: 4096,
defaultSystemPrompt: "你是一个有帮助的 AI 助手。",

View File

@ -146,3 +146,13 @@ export interface AIModel {
provider: string;
icon?: string;
}
// 用户信息
export interface UserInfo {
id: string;
username?: string;
nickname?: string;
email?: string;
avatar?: string;
[key: string]: unknown;
}

140
src/utils/migrateData.ts Normal file
View File

@ -0,0 +1,140 @@
/**
*
*
* localStorage SQLite
*/
import { conversationApi } from '@/services/conversationApi';
import type { Conversation } from '@/types/chat';
const OLD_CONVERSATIONS_KEY = 'chat-conversations';
const MIGRATION_FLAG_KEY = 'chat-migration-completed';
export interface MigrationResult {
success: boolean;
total: number;
migrated: number;
failed: number;
message: string;
}
/**
*
*/
export function isMigrationCompleted(): boolean {
return localStorage.getItem(MIGRATION_FLAG_KEY) === 'true';
}
/**
*
*/
function markMigrationCompleted() {
localStorage.setItem(MIGRATION_FLAG_KEY, 'true');
}
/**
* localStorage
*/
function getOldConversations(): Conversation[] {
try {
const stored = localStorage.getItem(OLD_CONVERSATIONS_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.error('Failed to read old conversations:', e);
}
return [];
}
/**
*
*/
async function migrateConversation(conversation: Conversation): Promise<boolean> {
try {
await conversationApi.saveConversation(conversation);
return true;
} catch (error) {
console.error(`Failed to migrate conversation ${conversation.id}:`, error);
return false;
}
}
/**
*
*/
export async function migrateData(): Promise<MigrationResult> {
// 检查是否已迁移
if (isMigrationCompleted()) {
return {
success: true,
total: 0,
migrated: 0,
failed: 0,
message: '迁移已完成,无需重复执行',
};
}
// 读取旧数据
const oldConversations = getOldConversations();
if (oldConversations.length === 0) {
markMigrationCompleted();
return {
success: true,
total: 0,
migrated: 0,
failed: 0,
message: '没有需要迁移的数据',
};
}
// 迁移数据
let migrated = 0;
let failed = 0;
for (const conversation of oldConversations) {
const success = await migrateConversation(conversation);
if (success) {
migrated++;
} else {
failed++;
}
}
// 迁移完成后清理
if (migrated === oldConversations.length) {
// 全部成功,清理旧数据
localStorage.removeItem(OLD_CONVERSATIONS_KEY);
markMigrationCompleted();
}
return {
success: failed === 0,
total: oldConversations.length,
migrated,
failed,
message: failed === 0
? `成功迁移 ${migrated} 条对话`
: `迁移完成:成功 ${migrated} 条,失败 ${failed}`,
};
}
/**
* localStorage
*/
export function cleanupOldData() {
localStorage.removeItem(OLD_CONVERSATIONS_KEY);
// 保留 chat-current-id因为它仍在使用
}
/**
*
*/
export function getMigrationStatus() {
return {
completed: isMigrationCompleted(),
hasOldData: localStorage.getItem(OLD_CONVERSATIONS_KEY) !== null,
oldDataCount: getOldConversations().length,
};
}

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/components/icons/index.ts","./src/composables/useKeyboard.ts","./src/services/api.ts","./src/stores/chat.ts","./src/stores/settings.ts","./src/types/chat.ts","./src/utils/helpers.ts","./src/App.vue","./src/components/chat/ChatHeader.vue","./src/components/chat/ChatMain.vue","./src/components/chat/MessageList.vue","./src/components/chat/WelcomeScreen.vue","./src/components/input/AttachmentPreview.vue","./src/components/input/ChatInput.vue","./src/components/message/CodeBlock.vue","./src/components/message/MessageActions.vue","./src/components/message/MessageBubble.vue","./src/components/message/components/EChartsContainerNode.vue","./src/components/message/components/Loading.vue","./src/components/message/components/ThinkingNode.vue","./src/components/modals/ConversationSettingsModal.vue","./src/components/modals/SearchModal.vue","./src/components/modals/SettingsModal.vue","./src/components/modals/ShortcutsModal.vue","./src/components/sidebar/ChatSidebar.vue","./src/components/sidebar/ConversationItem.vue","./src/components/ui/FormSelect.vue","./src/components/ui/FormSlider.vue","./src/components/ui/FormSwitch.vue"],"errors":true,"version":"5.9.3"}
{"root":["./src/main.ts","./src/components/icons/index.ts","./src/composables/useKeyboard.ts","./src/services/api.ts","./src/services/authService.ts","./src/services/conversationApi.ts","./src/services/request.ts","./src/stores/auth.ts","./src/stores/chat.ts","./src/stores/settings.ts","./src/types/chat.ts","./src/utils/helpers.ts","./src/utils/migrateData.ts","./src/App.vue","./src/components/chat/ChatHeader.vue","./src/components/chat/ChatMain.vue","./src/components/chat/MessageList.vue","./src/components/chat/WelcomeScreen.vue","./src/components/input/AttachmentPreview.vue","./src/components/input/ChatInput.vue","./src/components/message/CodeBlock.vue","./src/components/message/MessageActions.vue","./src/components/message/MessageBubble.vue","./src/components/message/components/EChartsContainerNode.vue","./src/components/message/components/Loading.vue","./src/components/message/components/ThinkingNode.vue","./src/components/modals/ConversationSettingsModal.vue","./src/components/modals/SearchModal.vue","./src/components/modals/SettingsModal.vue","./src/components/modals/ShortcutsModal.vue","./src/components/sidebar/ChatSidebar.vue","./src/components/sidebar/ConversationItem.vue","./src/components/ui/FormSelect.vue","./src/components/ui/FormSlider.vue","./src/components/ui/FormSwitch.vue"],"errors":true,"version":"5.9.3"}

View File

@ -21,6 +21,10 @@ export default defineConfig({
target: "http://localhost:8000", // Python服务器端口
changeOrigin: true,
},
"/api/auth": {
target: "https://sxwz.xueai.art",
changeOrigin: true,
},
},
},
build: {