first commit
This commit is contained in:
commit
3af47d48cf
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Ignore everything
|
||||||
|
*
|
||||||
|
|
||||||
|
# # Allow directories
|
||||||
|
!*/
|
||||||
|
|
||||||
|
# Allow docs and settings
|
||||||
|
!/docs/*.md
|
||||||
|
!/README.md
|
||||||
|
!.gitignore
|
||||||
|
|
||||||
|
# Allow backend source
|
||||||
|
!*.py
|
||||||
|
!*.toml
|
||||||
|
|
||||||
|
# Allow frontend source
|
||||||
|
!frontend/
|
||||||
|
!frontend/package.json
|
||||||
|
!frontend/package-lock.json
|
||||||
|
!frontend/*.js
|
||||||
|
!frontend/*.html
|
||||||
|
!frontend/src/
|
||||||
|
!frontend/src/**/*.js
|
||||||
|
!frontend/src/**/*.vue
|
||||||
|
!frontend/src/**/*.css
|
||||||
|
!frontend/public/
|
||||||
|
!frontend/public/**
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Nano Claw
|
||||||
|
|
||||||
|
基于 GLM 大语言模型的对话应用,支持流式回复和思维链。
|
||||||
|
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 克隆并安装后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Nano-Claw
|
||||||
|
|
||||||
|
# 创建虚拟环境
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置
|
||||||
|
|
||||||
|
创建并编辑 `config.yml`,填入你的信息:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# GLM API
|
||||||
|
api_key: your-api-key-here
|
||||||
|
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
db_host: localhost
|
||||||
|
db_port: 3306
|
||||||
|
db_user: root
|
||||||
|
db_password: ""
|
||||||
|
db_name: glm_chat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysql -u root -p -e "CREATE DATABASE glm_chat CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 启动后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask --app backend run --port 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 启动前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 http://localhost:3000 即可使用。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── backend/ # Flask 后端
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── models.py # 数据模型
|
||||||
|
│ └── routes.py # API 路由
|
||||||
|
├── frontend/ # Vue 3 前端
|
||||||
|
│ └── src/
|
||||||
|
│ ├── api/ # API 请求层
|
||||||
|
│ └── components/ # UI 组件
|
||||||
|
├── docs/ # 文档
|
||||||
|
├── config.yml.example
|
||||||
|
└── pyproject.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 概览
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/conversations` | 创建会话 |
|
||||||
|
| GET | `/api/conversations` | 会话列表 |
|
||||||
|
| PATCH | `/api/conversations/:id` | 更新会话 |
|
||||||
|
| DELETE | `/api/conversations/:id` | 删除会话 |
|
||||||
|
| GET | `/api/conversations/:id/messages` | 消息列表 |
|
||||||
|
| POST | `/api/conversations/:id/messages` | 发送消息(支持 SSE 流式) |
|
||||||
|
| DELETE | `/api/conversations/:id/messages/:mid` | 删除消息 |
|
||||||
|
|
||||||
|
详细 API 文档见 [docs/design.md](docs/design.md)。
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
from flask import Flask
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
CONFIG_PATH = Path(__file__).parent.parent / "config.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
with open(CONFIG_PATH, encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
cfg = load_config()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = (
|
||||||
|
f"mysql+pymysql://{cfg['db_user']}:{cfg['db_password']}"
|
||||||
|
f"@{cfg.get('db_host', 'localhost')}:{cfg.get('db_port', 3306)}/{cfg['db_name']}"
|
||||||
|
f"?charset=utf8mb4"
|
||||||
|
)
|
||||||
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
from .models import User, Conversation, Message
|
||||||
|
from .routes import register_routes
|
||||||
|
register_routes(app)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
username = db.Column(db.String(50), unique=True, nullable=False)
|
||||||
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
phone = db.Column(db.String(20))
|
||||||
|
|
||||||
|
conversations = db.relationship("Conversation", backref="user", lazy="dynamic",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="Conversation.updated_at.desc()")
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(db.Model):
|
||||||
|
__tablename__ = "conversations"
|
||||||
|
|
||||||
|
id = db.Column(db.String(64), primary_key=True)
|
||||||
|
user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False)
|
||||||
|
title = db.Column(db.String(255), nullable=False, default="")
|
||||||
|
model = db.Column(db.String(64), nullable=False, default="glm-5")
|
||||||
|
system_prompt = db.Column(db.Text, default="")
|
||||||
|
temperature = db.Column(db.Float, nullable=False, default=1.0)
|
||||||
|
max_tokens = db.Column(db.Integer, nullable=False, default=65536)
|
||||||
|
thinking_enabled = db.Column(db.Boolean, default=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
messages = db.relationship("Message", backref="conversation", lazy="dynamic",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
order_by="Message.created_at.asc()")
|
||||||
|
|
||||||
|
|
||||||
|
class Message(db.Model):
|
||||||
|
__tablename__ = "messages"
|
||||||
|
|
||||||
|
id = db.Column(db.String(64), primary_key=True)
|
||||||
|
conversation_id = db.Column(db.String(64), db.ForeignKey("conversations.id"), nullable=False)
|
||||||
|
role = db.Column(db.String(16), nullable=False)
|
||||||
|
content = db.Column(db.Text, default="")
|
||||||
|
token_count = db.Column(db.Integer, default=0)
|
||||||
|
thinking_content = db.Column(db.Text, default="")
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import request, jsonify, Response, Blueprint
|
||||||
|
from . import db
|
||||||
|
from .models import Conversation, Message, User
|
||||||
|
from . import load_config
|
||||||
|
|
||||||
|
bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
GLM_API_URL = cfg.get("api_url")
|
||||||
|
GLM_API_KEY = cfg["api_key"]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Helpers ----------------------------------------------
|
||||||
|
|
||||||
|
def get_or_create_default_user():
|
||||||
|
user = User.query.filter_by(username="default").first()
|
||||||
|
if not user:
|
||||||
|
user = User(username="default", password="")
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def ok(data=None, message=None):
|
||||||
|
body = {"code": 0}
|
||||||
|
if data is not None:
|
||||||
|
body["data"] = data
|
||||||
|
if message is not None:
|
||||||
|
body["message"] = message
|
||||||
|
return jsonify(body)
|
||||||
|
|
||||||
|
|
||||||
|
def err(code, message):
|
||||||
|
return jsonify({"code": code, "message": message}), code
|
||||||
|
|
||||||
|
|
||||||
|
def to_dict(inst, **extra):
|
||||||
|
d = {c.name: getattr(inst, c.name) for c in inst.__table__.columns}
|
||||||
|
for k in ("created_at", "updated_at"):
|
||||||
|
if k in d and hasattr(d[k], "strftime"):
|
||||||
|
d[k] = d[k].strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
d.update(extra)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def build_glm_messages(conv):
|
||||||
|
msgs = []
|
||||||
|
if conv.system_prompt:
|
||||||
|
msgs.append({"role": "system", "content": conv.system_prompt})
|
||||||
|
for m in conv.messages:
|
||||||
|
msgs.append({"role": m.role, "content": m.content})
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
|
||||||
|
# -- Conversation CRUD ------------------------------------
|
||||||
|
|
||||||
|
@bp.route("/api/conversations", methods=["GET", "POST"])
|
||||||
|
def conversation_list():
|
||||||
|
if request.method == "POST":
|
||||||
|
d = request.json or {}
|
||||||
|
user = get_or_create_default_user()
|
||||||
|
conv = Conversation(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
user_id=user.id,
|
||||||
|
title=d.get("title", ""),
|
||||||
|
model=d.get("model", "glm-5"),
|
||||||
|
system_prompt=d.get("system_prompt", ""),
|
||||||
|
temperature=d.get("temperature", 1.0),
|
||||||
|
max_tokens=d.get("max_tokens", 65536),
|
||||||
|
thinking_enabled=d.get("thinking_enabled", False),
|
||||||
|
)
|
||||||
|
db.session.add(conv)
|
||||||
|
db.session.commit()
|
||||||
|
return ok(to_dict(conv))
|
||||||
|
|
||||||
|
cursor = request.args.get("cursor")
|
||||||
|
limit = min(int(request.args.get("limit", 20)), 100)
|
||||||
|
user = get_or_create_default_user()
|
||||||
|
q = Conversation.query.filter_by(user_id=user.id)
|
||||||
|
if cursor:
|
||||||
|
q = q.filter(Conversation.updated_at < (
|
||||||
|
db.session.query(Conversation.updated_at).filter_by(id=cursor).scalar() or datetime.utcnow))
|
||||||
|
rows = q.order_by(Conversation.updated_at.desc()).limit(limit + 1).all()
|
||||||
|
|
||||||
|
items = [to_dict(r, message_count=r.messages.count()) for r in rows[:limit]]
|
||||||
|
return ok({
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": items[-1]["id"] if len(rows) > limit else None,
|
||||||
|
"has_more": len(rows) > limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/conversations/<conv_id>", methods=["GET", "PATCH", "DELETE"])
|
||||||
|
def conversation_detail(conv_id):
|
||||||
|
conv = db.session.get(Conversation, conv_id)
|
||||||
|
if not conv:
|
||||||
|
return err(404, "conversation not found")
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
return ok(to_dict(conv))
|
||||||
|
|
||||||
|
if request.method == "DELETE":
|
||||||
|
db.session.delete(conv)
|
||||||
|
db.session.commit()
|
||||||
|
return ok(message="deleted")
|
||||||
|
|
||||||
|
d = request.json or {}
|
||||||
|
for k in ("title", "model", "system_prompt", "temperature", "max_tokens", "thinking_enabled"):
|
||||||
|
if k in d:
|
||||||
|
setattr(conv, k, d[k])
|
||||||
|
db.session.commit()
|
||||||
|
return ok(to_dict(conv))
|
||||||
|
|
||||||
|
|
||||||
|
# -- Messages ---------------------------------------------
|
||||||
|
|
||||||
|
@bp.route("/api/conversations/<conv_id>/messages", methods=["GET", "POST"])
|
||||||
|
def message_list(conv_id):
|
||||||
|
conv = db.session.get(Conversation, conv_id)
|
||||||
|
if not conv:
|
||||||
|
return err(404, "conversation not found")
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
cursor = request.args.get("cursor")
|
||||||
|
limit = min(int(request.args.get("limit", 50)), 100)
|
||||||
|
q = Message.query.filter_by(conversation_id=conv_id)
|
||||||
|
if cursor:
|
||||||
|
q = q.filter(Message.created_at < (
|
||||||
|
db.session.query(Message.created_at).filter_by(id=cursor).scalar() or datetime.utcnow))
|
||||||
|
rows = q.order_by(Message.created_at.asc()).limit(limit + 1).all()
|
||||||
|
|
||||||
|
items = [to_dict(r) for r in rows[:limit]]
|
||||||
|
return ok({
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": items[-1]["id"] if len(rows) > limit else None,
|
||||||
|
"has_more": len(rows) > limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
d = request.json or {}
|
||||||
|
content = (d.get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
return err(400, "content is required")
|
||||||
|
|
||||||
|
user_msg = Message(id=str(uuid.uuid4()), conversation_id=conv_id, role="user", content=content)
|
||||||
|
db.session.add(user_msg)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if d.get("stream", False):
|
||||||
|
return _stream_response(conv)
|
||||||
|
|
||||||
|
return _sync_response(conv)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
|
||||||
|
def delete_message(conv_id, msg_id):
|
||||||
|
conv = db.session.get(Conversation, conv_id)
|
||||||
|
if not conv:
|
||||||
|
return err(404, "conversation not found")
|
||||||
|
msg = db.session.get(Message, msg_id)
|
||||||
|
if not msg or msg.conversation_id != conv_id:
|
||||||
|
return err(404, "message not found")
|
||||||
|
db.session.delete(msg)
|
||||||
|
db.session.commit()
|
||||||
|
return ok(message="deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# -- Chat Completion ----------------------------------
|
||||||
|
|
||||||
|
def _call_glm(conv, stream=False):
|
||||||
|
body = {
|
||||||
|
"model": conv.model,
|
||||||
|
"messages": build_glm_messages(conv),
|
||||||
|
"max_tokens": conv.max_tokens,
|
||||||
|
"temperature": conv.temperature,
|
||||||
|
}
|
||||||
|
if conv.thinking_enabled:
|
||||||
|
body["thinking"] = {"type": "enabled"}
|
||||||
|
if stream:
|
||||||
|
body["stream"] = True
|
||||||
|
return requests.post(
|
||||||
|
GLM_API_URL,
|
||||||
|
headers={"Content-Type": "application/json", "Authorization": f"Bearer {GLM_API_KEY}"},
|
||||||
|
json=body, stream=stream, timeout=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_response(conv):
|
||||||
|
try:
|
||||||
|
resp = _call_glm(conv)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
return err(500, f"upstream error: {e}")
|
||||||
|
|
||||||
|
choice = result["choices"][0]
|
||||||
|
usage = result.get("usage", {})
|
||||||
|
msg = Message(
|
||||||
|
id=str(uuid.uuid4()), conversation_id=conv.id, role="assistant",
|
||||||
|
content=choice["message"]["content"],
|
||||||
|
token_count=usage.get("completion_tokens", 0),
|
||||||
|
thinking_content=choice["message"].get("reasoning_content", ""),
|
||||||
|
)
|
||||||
|
db.session.add(msg)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"message": to_dict(msg, thinking_content=msg.thinking_content or None),
|
||||||
|
"usage": {"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||||
|
"completion_tokens": usage.get("completion_tokens", 0),
|
||||||
|
"total_tokens": usage.get("total_tokens", 0)},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_response(conv):
|
||||||
|
def generate():
|
||||||
|
full_content = ""
|
||||||
|
full_thinking = ""
|
||||||
|
token_count = 0
|
||||||
|
msg_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = _call_glm(conv, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
for line in resp.iter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
line = line.decode("utf-8")
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
data_str = line[6:]
|
||||||
|
if data_str == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
delta = chunk["choices"][0].get("delta", {})
|
||||||
|
reasoning = delta.get("reasoning_content", "")
|
||||||
|
text = delta.get("content", "")
|
||||||
|
if reasoning:
|
||||||
|
full_thinking += reasoning
|
||||||
|
yield f"event: thinking\ndata: {json.dumps({'content': reasoning}, ensure_ascii=False)}\n\n"
|
||||||
|
if text:
|
||||||
|
full_content += text
|
||||||
|
yield f"event: message\ndata: {json.dumps({'content': text}, ensure_ascii=False)}\n\n"
|
||||||
|
usage = chunk.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
token_count = usage.get("completion_tokens", 0)
|
||||||
|
except Exception as e:
|
||||||
|
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
id=msg_id, conversation_id=conv.id, role="assistant",
|
||||||
|
content=full_content, token_count=token_count, thinking_content=full_thinking,
|
||||||
|
)
|
||||||
|
db.session.add(msg)
|
||||||
|
db.session.commit()
|
||||||
|
yield f"event: done\ndata: {json.dumps({'message_id': msg_id, 'token_count': token_count})}\n\n"
|
||||||
|
|
||||||
|
return Response(generate(), mimetype="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
|
def register_routes(app):
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from backend import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True, port=5000)
|
||||||
|
|
@ -0,0 +1,351 @@
|
||||||
|
# 对话系统后端 API 设计
|
||||||
|
|
||||||
|
## API 总览
|
||||||
|
|
||||||
|
### 会话管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `POST` | `/api/conversations` | 创建会话 |
|
||||||
|
| `GET` | `/api/conversations` | 获取会话列表 |
|
||||||
|
| `GET` | `/api/conversations/:id` | 获取会话详情 |
|
||||||
|
| `PATCH` | `/api/conversations/:id` | 更新会话 |
|
||||||
|
| `DELETE` | `/api/conversations/:id` | 删除会话 |
|
||||||
|
|
||||||
|
### 消息管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/conversations/:id/messages` | 获取消息列表 |
|
||||||
|
| `POST` | `/api/conversations/:id/messages` | 发送消息(对话补全,支持 `stream` 流式) |
|
||||||
|
| `DELETE` | `/api/conversations/:id/messages/:message_id` | 删除消息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 1. 会话管理
|
||||||
|
|
||||||
|
#### 创建会话
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/conversations
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "新对话",
|
||||||
|
"model": "glm-5",
|
||||||
|
"system_prompt": "你是一个有帮助的助手",
|
||||||
|
"temperature": 1.0,
|
||||||
|
"max_tokens": 65536,
|
||||||
|
"thinking_enabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": "conv_abc123",
|
||||||
|
"title": "新对话",
|
||||||
|
"model": "glm-5",
|
||||||
|
"system_prompt": "你是一个有帮助的助手",
|
||||||
|
"temperature": 1.0,
|
||||||
|
"max_tokens": 65536,
|
||||||
|
"thinking_enabled": false,
|
||||||
|
"created_at": "2026-03-24T10:00:00Z",
|
||||||
|
"updated_at": "2026-03-24T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 获取会话列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/conversations?cursor=conv_abc123&limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
| -------- | ------- | ----------------- |
|
||||||
|
| `cursor` | string | 分页游标,为空取首页 |
|
||||||
|
| `limit` | integer | 每页数量,默认 20,最大 100 |
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "conv_abc123",
|
||||||
|
"title": "新对话",
|
||||||
|
"model": "glm-5",
|
||||||
|
"created_at": "2026-03-24T10:00:00Z",
|
||||||
|
"updated_at": "2026-03-24T10:05:00Z",
|
||||||
|
"message_count": 6
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_cursor": "conv_def456",
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 获取会话详情
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/conversations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"id": "conv_abc123",
|
||||||
|
"title": "新对话",
|
||||||
|
"model": "glm-5",
|
||||||
|
"system_prompt": "你是一个有帮助的助手",
|
||||||
|
"temperature": 1.0,
|
||||||
|
"max_tokens": 65536,
|
||||||
|
"thinking_enabled": false,
|
||||||
|
"created_at": "2026-03-24T10:00:00Z",
|
||||||
|
"updated_at": "2026-03-24T10:05:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 更新会话
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/conversations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体(仅传需更新的字段):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "修改后的标题",
|
||||||
|
"system_prompt": "新的系统提示词",
|
||||||
|
"temperature": 0.8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:** 同获取会话详情
|
||||||
|
|
||||||
|
#### 删除会话
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/conversations/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 消息管理
|
||||||
|
|
||||||
|
#### 获取消息列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/conversations/:id/messages?cursor=msg_001&limit=50
|
||||||
|
```
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
| -------- | ------- | ----------------- |
|
||||||
|
| `cursor` | string | 分页游标 |
|
||||||
|
| `limit` | integer | 每页数量,默认 50,最大 100 |
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "msg_001",
|
||||||
|
"conversation_id": "conv_abc123",
|
||||||
|
"role": "user",
|
||||||
|
"content": "你好",
|
||||||
|
"token_count": 2,
|
||||||
|
"thinking_content": null,
|
||||||
|
"created_at": "2026-03-24T10:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "msg_002",
|
||||||
|
"conversation_id": "conv_abc123",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "你好!有什么可以帮你的?",
|
||||||
|
"token_count": 15,
|
||||||
|
"thinking_content": null,
|
||||||
|
"created_at": "2026-03-24T10:00:01Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"next_cursor": "msg_003",
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 发送消息(对话补全)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/conversations/:id/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "介绍一下你的能力",
|
||||||
|
"stream": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**流式响应 (stream=true):**
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Content-Type: text/event-stream
|
||||||
|
|
||||||
|
event: thinking
|
||||||
|
data: {"content": "用户想了解我的能力..."}
|
||||||
|
|
||||||
|
event: message
|
||||||
|
data: {"content": "我是"}
|
||||||
|
|
||||||
|
event: message
|
||||||
|
data: {"content": "智谱AI"}
|
||||||
|
|
||||||
|
event: message
|
||||||
|
data: {"content": "开发的大语言模型"}
|
||||||
|
|
||||||
|
event: done
|
||||||
|
data: {"message_id": "msg_003", "token_count": 200}
|
||||||
|
```
|
||||||
|
|
||||||
|
**非流式响应 (stream=false):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"message": {
|
||||||
|
"id": "msg_003",
|
||||||
|
"conversation_id": "conv_abc123",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "我是智谱AI开发的大语言模型...",
|
||||||
|
"token_count": 200,
|
||||||
|
"thinking_content": "用户想了解我的能力...",
|
||||||
|
"created_at": "2026-03-24T10:01:00Z"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 50,
|
||||||
|
"completion_tokens": 200,
|
||||||
|
"total_tokens": 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除消息
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /api/conversations/:id/messages/:message_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "deleted"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. SSE 事件说明
|
||||||
|
|
||||||
|
| 事件 | 说明 |
|
||||||
|
| ---------- | ------------------------------- |
|
||||||
|
| `thinking` | 思维链增量内容(启用时) |
|
||||||
|
| `message` | 回复内容的增量片段 |
|
||||||
|
| `error` | 错误信息 |
|
||||||
|
| `done` | 回复结束,携带完整 message_id 和 token 统计 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 错误码
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
| ----- | -------- |
|
||||||
|
| `0` | 成功 |
|
||||||
|
| `400` | 请求参数错误 |
|
||||||
|
| `401` | 未认证 |
|
||||||
|
| `403` | 无权限访问该资源 |
|
||||||
|
| `404` | 资源不存在 |
|
||||||
|
| `429` | 请求过于频繁 |
|
||||||
|
| `500` | 上游模型服务错误 |
|
||||||
|
| `503` | 服务暂时不可用 |
|
||||||
|
|
||||||
|
**错误响应格式:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"message": "conversation not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### ER 关系
|
||||||
|
|
||||||
|
```
|
||||||
|
User 1 ── * Conversation 1 ── * Message
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conversation(会话)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| ------------------ | ------------- | --------------------- |
|
||||||
|
| `id` | string (UUID) | 会话 ID |
|
||||||
|
| `user_id` | string | 所属用户 ID |
|
||||||
|
| `title` | string | 会话标题 |
|
||||||
|
| `model` | string | 使用的模型,默认 `glm-5` |
|
||||||
|
| `system_prompt` | string | 系统提示词 |
|
||||||
|
| `temperature` | float | 采样温度,默认 `1.0` |
|
||||||
|
| `max_tokens` | integer | 最大输出 token,默认 `65536` |
|
||||||
|
| `thinking_enabled` | boolean | 是否启用思维链,默认 `false` |
|
||||||
|
| `created_at` | datetime | 创建时间 |
|
||||||
|
| `updated_at` | datetime | 更新时间 |
|
||||||
|
|
||||||
|
### Message(消息)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| ------------------ | ------------- | ------------------------------- |
|
||||||
|
| `id` | string (UUID) | 消息 ID |
|
||||||
|
| `conversation_id` | string | 所属会话 ID |
|
||||||
|
| `role` | enum | `user` / `assistant` / `system` |
|
||||||
|
| `content` | string | 消息内容 |
|
||||||
|
| `token_count` | integer | token 消耗数 |
|
||||||
|
| `thinking_content` | string | 思维链内容(启用时) |
|
||||||
|
| `created_at` | datetime | 创建时间 |
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
|
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>GLM Chat</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "nano-claw",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"marked": "^15.0.0",
|
||||||
|
"highlight.js": "^11.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<circle cx="16" cy="16" r="14" fill="#4f46e5"/>
|
||||||
|
<text x="16" y="21" text-anchor="middle" fill="white" font-size="14" font-weight="bold" font-family="Arial">G</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
|
|
@ -0,0 +1,287 @@
|
||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<Sidebar
|
||||||
|
:conversations="conversations"
|
||||||
|
:current-id="currentConvId"
|
||||||
|
:loading="loadingConvs"
|
||||||
|
:has-more="hasMoreConvs"
|
||||||
|
@select="selectConversation"
|
||||||
|
@create="createConversation"
|
||||||
|
@delete="deleteConversation"
|
||||||
|
@load-more="loadMoreConversations"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatView
|
||||||
|
ref="chatViewRef"
|
||||||
|
:conversation="currentConv"
|
||||||
|
:messages="messages"
|
||||||
|
:streaming="streaming"
|
||||||
|
:streaming-content="streamContent"
|
||||||
|
:streaming-thinking="streamThinking"
|
||||||
|
:has-more-messages="hasMoreMessages"
|
||||||
|
:loading-more="loadingMessages"
|
||||||
|
@send-message="sendMessage"
|
||||||
|
@delete-message="deleteMessage"
|
||||||
|
@toggle-settings="showSettings = true"
|
||||||
|
@load-more-messages="loadMoreMessages"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsPanel
|
||||||
|
:visible="showSettings"
|
||||||
|
:conversation="currentConv"
|
||||||
|
@close="showSettings = false"
|
||||||
|
@save="saveSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import Sidebar from './components/Sidebar.vue'
|
||||||
|
import ChatView from './components/ChatView.vue'
|
||||||
|
import SettingsPanel from './components/SettingsPanel.vue'
|
||||||
|
import { conversationApi, messageApi } from './api'
|
||||||
|
|
||||||
|
const chatViewRef = ref(null)
|
||||||
|
|
||||||
|
// -- Conversations state --
|
||||||
|
const conversations = ref([])
|
||||||
|
const currentConvId = ref(null)
|
||||||
|
const loadingConvs = ref(false)
|
||||||
|
const hasMoreConvs = ref(false)
|
||||||
|
const nextConvCursor = ref(null)
|
||||||
|
|
||||||
|
// -- Messages state --
|
||||||
|
const messages = ref([])
|
||||||
|
const hasMoreMessages = ref(false)
|
||||||
|
const loadingMessages = ref(false)
|
||||||
|
const nextMsgCursor = ref(null)
|
||||||
|
|
||||||
|
// -- Streaming state --
|
||||||
|
const streaming = ref(false)
|
||||||
|
const streamContent = ref('')
|
||||||
|
const streamThinking = ref('')
|
||||||
|
|
||||||
|
// -- UI state --
|
||||||
|
const showSettings = ref(false)
|
||||||
|
|
||||||
|
const currentConv = computed(() =>
|
||||||
|
conversations.value.find(c => c.id === currentConvId.value) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- Load conversations --
|
||||||
|
async function loadConversations(reset = true) {
|
||||||
|
if (loadingConvs.value) return
|
||||||
|
loadingConvs.value = true
|
||||||
|
try {
|
||||||
|
const res = await conversationApi.list(reset ? null : nextConvCursor.value)
|
||||||
|
if (reset) {
|
||||||
|
conversations.value = res.data.items
|
||||||
|
} else {
|
||||||
|
conversations.value.push(...res.data.items)
|
||||||
|
}
|
||||||
|
nextConvCursor.value = res.data.next_cursor
|
||||||
|
hasMoreConvs.value = res.data.has_more
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load conversations:', e)
|
||||||
|
} finally {
|
||||||
|
loadingConvs.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreConversations() {
|
||||||
|
if (hasMoreConvs.value) loadConversations(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Create conversation --
|
||||||
|
async function createConversation() {
|
||||||
|
try {
|
||||||
|
const res = await conversationApi.create({ title: '新对话' })
|
||||||
|
conversations.value.unshift(res.data)
|
||||||
|
await selectConversation(res.data.id)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create conversation:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Select conversation --
|
||||||
|
async function selectConversation(id) {
|
||||||
|
currentConvId.value = id
|
||||||
|
messages.value = []
|
||||||
|
nextMsgCursor.value = null
|
||||||
|
hasMoreMessages.value = false
|
||||||
|
streamContent.value = ''
|
||||||
|
streamThinking.value = ''
|
||||||
|
await loadMessages(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Load messages --
|
||||||
|
async function loadMessages(reset = true) {
|
||||||
|
if (!currentConvId.value || loadingMessages.value) return
|
||||||
|
loadingMessages.value = true
|
||||||
|
try {
|
||||||
|
const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value)
|
||||||
|
if (reset) {
|
||||||
|
messages.value = res.data.items
|
||||||
|
} else {
|
||||||
|
messages.value = [...res.data.items, ...messages.value]
|
||||||
|
}
|
||||||
|
nextMsgCursor.value = res.data.next_cursor
|
||||||
|
hasMoreMessages.value = res.data.has_more
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load messages:', e)
|
||||||
|
} finally {
|
||||||
|
loadingMessages.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreMessages() {
|
||||||
|
if (hasMoreMessages.value) loadMessages(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Send message (streaming) --
|
||||||
|
async function sendMessage(content) {
|
||||||
|
if (!currentConvId.value || streaming.value) return
|
||||||
|
|
||||||
|
// Add user message optimistically
|
||||||
|
const userMsg = {
|
||||||
|
id: 'temp_' + Date.now(),
|
||||||
|
conversation_id: currentConvId.value,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
token_count: 0,
|
||||||
|
thinking_content: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
messages.value.push(userMsg)
|
||||||
|
|
||||||
|
streaming.value = true
|
||||||
|
streamContent.value = ''
|
||||||
|
streamThinking.value = ''
|
||||||
|
|
||||||
|
await messageApi.send(currentConvId.value, content, {
|
||||||
|
stream: true,
|
||||||
|
onThinking(text) {
|
||||||
|
streamThinking.value += text
|
||||||
|
},
|
||||||
|
onMessage(text) {
|
||||||
|
streamContent.value += text
|
||||||
|
},
|
||||||
|
async onDone(data) {
|
||||||
|
streaming.value = false
|
||||||
|
// Replace temp message and add assistant message from server
|
||||||
|
messages.value = messages.value.filter(m => m.id !== userMsg.id)
|
||||||
|
messages.value.push({
|
||||||
|
id: data.message_id,
|
||||||
|
conversation_id: currentConvId.value,
|
||||||
|
role: 'assistant',
|
||||||
|
content: streamContent.value,
|
||||||
|
token_count: data.token_count,
|
||||||
|
thinking_content: streamThinking.value || null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
streamContent.value = ''
|
||||||
|
streamThinking.value = ''
|
||||||
|
// Update conversation in list (move to top)
|
||||||
|
const idx = conversations.value.findIndex(c => c.id === currentConvId.value)
|
||||||
|
if (idx > 0) {
|
||||||
|
const [conv] = conversations.value.splice(idx, 1)
|
||||||
|
conv.message_count = (conv.message_count || 0) + 2
|
||||||
|
conversations.value.unshift(conv)
|
||||||
|
} else if (idx === 0) {
|
||||||
|
conversations.value[0].message_count = (conversations.value[0].message_count || 0) + 2
|
||||||
|
}
|
||||||
|
// Auto title: use first message if title is empty
|
||||||
|
if (conversations.value[0] && !conversations.value[0].title) {
|
||||||
|
try {
|
||||||
|
await conversationApi.update(currentConvId.value, { title: content.slice(0, 30) })
|
||||||
|
conversations.value[0].title = content.slice(0, 30)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError(msg) {
|
||||||
|
streaming.value = false
|
||||||
|
streamContent.value = ''
|
||||||
|
streamThinking.value = ''
|
||||||
|
console.error('Stream error:', msg)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Delete message --
|
||||||
|
async function deleteMessage(msgId) {
|
||||||
|
if (!currentConvId.value) return
|
||||||
|
try {
|
||||||
|
await messageApi.delete(currentConvId.value, msgId)
|
||||||
|
messages.value = messages.value.filter(m => m.id !== msgId)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete message:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Delete conversation --
|
||||||
|
async function deleteConversation(id) {
|
||||||
|
try {
|
||||||
|
await conversationApi.delete(id)
|
||||||
|
conversations.value = conversations.value.filter(c => c.id !== id)
|
||||||
|
if (currentConvId.value === id) {
|
||||||
|
currentConvId.value = conversations.value.length > 0 ? conversations.value[0].id : null
|
||||||
|
if (currentConvId.value) {
|
||||||
|
await selectConversation(currentConvId.value)
|
||||||
|
} else {
|
||||||
|
messages.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete conversation:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Save settings --
|
||||||
|
async function saveSettings(data) {
|
||||||
|
if (!currentConvId.value) return
|
||||||
|
try {
|
||||||
|
const res = await conversationApi.update(currentConvId.value, data)
|
||||||
|
const idx = conversations.value.findIndex(c => c.id === currentConvId.value)
|
||||||
|
if (idx !== -1) {
|
||||||
|
conversations.value[idx] = { ...conversations.value[idx], ...res.data }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save settings:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Init --
|
||||||
|
onMounted(() => {
|
||||||
|
loadConversations()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
async function request(url, options = {}) {
|
||||||
|
const res = await fetch(`${BASE}${url}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.code !== 0) {
|
||||||
|
throw new Error(data.message || 'Request failed')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conversationApi = {
|
||||||
|
list(cursor, limit = 20) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (cursor) params.set('cursor', cursor)
|
||||||
|
if (limit) params.set('limit', limit)
|
||||||
|
return request(`/conversations?${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
create(payload = {}) {
|
||||||
|
return request('/conversations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
return request(`/conversations/${id}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
update(id, payload) {
|
||||||
|
return request(`/conversations/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id) {
|
||||||
|
return request(`/conversations/${id}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageApi = {
|
||||||
|
list(convId, cursor, limit = 50) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (cursor) params.set('cursor', cursor)
|
||||||
|
if (limit) params.set('limit', limit)
|
||||||
|
return request(`/conversations/${convId}/messages?${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
send(convId, content, { stream = true, onThinking, onMessage, onDone, onError } = {}) {
|
||||||
|
if (!stream) {
|
||||||
|
return request(`/conversations/${convId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { content, stream: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/conversations/${convId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content, stream: true }),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(err.message || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
let currentEvent = ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
currentEvent = line.slice(7).trim()
|
||||||
|
} else if (line.startsWith('data: ')) {
|
||||||
|
const data = JSON.parse(line.slice(6))
|
||||||
|
if (currentEvent === 'thinking' && onThinking) {
|
||||||
|
onThinking(data.content)
|
||||||
|
} else if (currentEvent === 'message' && onMessage) {
|
||||||
|
onMessage(data.content)
|
||||||
|
} else if (currentEvent === 'done' && onDone) {
|
||||||
|
onDone(data)
|
||||||
|
} else if (currentEvent === 'error' && onError) {
|
||||||
|
onError(data.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError' && onError) {
|
||||||
|
onError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
promise.abort = () => controller.abort()
|
||||||
|
|
||||||
|
return promise
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(convId, msgId) {
|
||||||
|
return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
<template>
|
||||||
|
<div class="chat-view">
|
||||||
|
<div v-if="!conversation" class="welcome">
|
||||||
|
<div class="welcome-icon">G</div>
|
||||||
|
<h1>GLM Chat</h1>
|
||||||
|
<p>选择一个对话开始,或创建新对话</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="chat-title-area">
|
||||||
|
<h2 class="chat-title">{{ conversation.title || '新对话' }}</h2>
|
||||||
|
<span class="model-badge">{{ conversation.model }}</span>
|
||||||
|
<span v-if="conversation.thinking_enabled" class="thinking-badge">思考</span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-actions">
|
||||||
|
<button class="btn-icon" @click="$emit('toggleSettings')" title="设置">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="scrollContainer" class="messages-container" @scroll="onScroll">
|
||||||
|
<div v-if="hasMoreMessages" class="load-more-top">
|
||||||
|
<button @click="$emit('loadMoreMessages')" :disabled="loadingMore">
|
||||||
|
{{ loadingMore ? '加载中...' : '加载更早的消息' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="messages-list">
|
||||||
|
<MessageBubble
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
:role="msg.role"
|
||||||
|
:content="msg.content"
|
||||||
|
:thinking-content="msg.thinking_content"
|
||||||
|
:token-count="msg.token_count"
|
||||||
|
:created-at="msg.created_at"
|
||||||
|
:deletable="msg.role === 'user'"
|
||||||
|
@delete="$emit('deleteMessage', msg.id)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="streaming" class="message-bubble assistant">
|
||||||
|
<div class="avatar">G</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<div v-if="streamingThinking" class="thinking-content streaming-thinking">
|
||||||
|
{{ streamingThinking }}
|
||||||
|
</div>
|
||||||
|
<div class="message-content streaming-content">
|
||||||
|
{{ streamingContent || '...' }}
|
||||||
|
<span class="cursor-blink">|</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageInput
|
||||||
|
ref="inputRef"
|
||||||
|
:disabled="streaming"
|
||||||
|
@send="$emit('sendMessage', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, nextTick } from 'vue'
|
||||||
|
import MessageBubble from './MessageBubble.vue'
|
||||||
|
import MessageInput from './MessageInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
conversation: { type: Object, default: null },
|
||||||
|
messages: { type: Array, required: true },
|
||||||
|
streaming: { type: Boolean, default: false },
|
||||||
|
streamingContent: { type: String, default: '' },
|
||||||
|
streamingThinking: { type: String, default: '' },
|
||||||
|
hasMoreMessages: { type: Boolean, default: false },
|
||||||
|
loadingMore: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['sendMessage', 'deleteMessage', 'toggleSettings', 'loadMoreMessages'])
|
||||||
|
|
||||||
|
const scrollContainer = ref(null)
|
||||||
|
const inputRef = ref(null)
|
||||||
|
|
||||||
|
function scrollToBottom(smooth = true) {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = scrollContainer.value
|
||||||
|
if (el) {
|
||||||
|
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'instant' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll(e) {
|
||||||
|
if (e.target.scrollTop < 50 && props.hasMoreMessages && !props.loadingMore) {
|
||||||
|
// emit loadMore if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.messages.length, () => {
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.streamingContent, () => {
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.conversation?.id, () => {
|
||||||
|
if (props.conversation) {
|
||||||
|
nextTick(() => inputRef.value?.focus())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ scrollToBottom })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-view {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #0f172a;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome p {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f0;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(79, 70, 229, 0.15);
|
||||||
|
color: #a5b4fc;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #6ee7b7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-top {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-top button {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-top button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-list {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-thinking {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.streaming-content {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #e2e8f0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-blink {
|
||||||
|
animation: blink 0.8s infinite;
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
<template>
|
||||||
|
<div class="message-bubble" :class="[role]">
|
||||||
|
<div class="avatar">{{ role === 'user' ? 'U' : 'G' }}</div>
|
||||||
|
<div class="message-body">
|
||||||
|
<div v-if="thinkingContent" class="thinking-block">
|
||||||
|
<button class="thinking-toggle" @click="showThinking = !showThinking">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
|
||||||
|
</svg>
|
||||||
|
<span>思考过程</span>
|
||||||
|
<svg class="arrow" :class="{ open: showThinking }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="showThinking" class="thinking-content">{{ thinkingContent }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="message-content" v-html="renderedContent"></div>
|
||||||
|
<div class="message-footer">
|
||||||
|
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||||
|
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
||||||
|
<button v-if="role === 'assistant'" class="btn-copy" @click="copyContent" title="复制">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button v-if="deletable" class="btn-delete-msg" @click="$emit('delete')" title="删除">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
role: { type: String, required: true },
|
||||||
|
content: { type: String, default: '' },
|
||||||
|
thinkingContent: { type: String, default: '' },
|
||||||
|
tokenCount: { type: Number, default: 0 },
|
||||||
|
createdAt: { type: String, default: '' },
|
||||||
|
deletable: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['delete'])
|
||||||
|
|
||||||
|
const showThinking = ref(false)
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
highlight(code, lang) {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
return hljs.highlight(code, { language: lang }).value
|
||||||
|
}
|
||||||
|
return hljs.highlightAuto(code).value
|
||||||
|
},
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
if (!props.content) return ''
|
||||||
|
return marked.parse(props.content)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyContent() {
|
||||||
|
navigator.clipboard.writeText(props.content).catch(() => {})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-bubble {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble.user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user .avatar {
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant .avatar {
|
||||||
|
background: linear-gradient(135deg, #059669, #10b981);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
max-width: 720px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-block {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle .arrow {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-toggle .arrow.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking-content {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.6;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #e2e8f0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(p) {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(pre) {
|
||||||
|
background: #0d1117;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(pre code) {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(code) {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(ul),
|
||||||
|
.message-content :deep(ol) {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(blockquote) {
|
||||||
|
border-left: 3px solid rgba(79, 70, 229, 0.5);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 8px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(th),
|
||||||
|
.message-content :deep(td) {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content :deep(th) {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble:hover .message-footer {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-count,
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy,
|
||||||
|
.btn-delete-msg {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy:hover {
|
||||||
|
color: #a5b4fc;
|
||||||
|
background: rgba(165, 180, 252, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-msg:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
<template>
|
||||||
|
<div class="message-input">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<textarea
|
||||||
|
ref="textareaRef"
|
||||||
|
v-model="text"
|
||||||
|
placeholder="输入消息... (Shift+Enter 换行)"
|
||||||
|
rows="1"
|
||||||
|
@input="autoResize"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
:disabled="disabled"
|
||||||
|
></textarea>
|
||||||
|
<div class="input-actions">
|
||||||
|
<button
|
||||||
|
class="btn-send"
|
||||||
|
:class="{ active: text.trim() && !disabled }"
|
||||||
|
:disabled="!text.trim() || disabled"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-hint">基于 GLM 大语言模型,回复内容仅供参考</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['send'])
|
||||||
|
const text = ref('')
|
||||||
|
const textareaRef = ref(null)
|
||||||
|
|
||||||
|
function autoResize() {
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 200) + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const content = text.value.trim()
|
||||||
|
if (!content || props.disabled) return
|
||||||
|
emit('send', content)
|
||||||
|
text.value = ''
|
||||||
|
nextTick(() => {
|
||||||
|
autoResize()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
textareaRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-input {
|
||||||
|
padding: 16px 24px 12px;
|
||||||
|
background: #16213e;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper:focus-within {
|
||||||
|
border-color: rgba(79, 70, 229, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #475569;
|
||||||
|
cursor: not-allowed;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send.active {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send.active:hover {
|
||||||
|
background: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,379 @@
|
||||||
|
<template>
|
||||||
|
<Transition name="slide">
|
||||||
|
<div v-if="visible" class="settings-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h3>会话设置</h3>
|
||||||
|
<button class="btn-close" @click="$emit('close')">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>会话标题</label>
|
||||||
|
<input v-model="form.title" type="text" placeholder="输入标题..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>模型</label>
|
||||||
|
<select v-model="form.model">
|
||||||
|
<option value="glm-5">GLM-5</option>
|
||||||
|
<option value="glm-4-plus">GLM-4 Plus</option>
|
||||||
|
<option value="glm-4-flash">GLM-4 Flash</option>
|
||||||
|
<option value="glm-4-long">GLM-4 Long</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>系统提示词</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.system_prompt"
|
||||||
|
rows="4"
|
||||||
|
placeholder="设置 AI 的角色和行为..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<label>
|
||||||
|
温度
|
||||||
|
<span class="value-display">{{ form.temperature.toFixed(1) }}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.temperature"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
<div class="range-labels">
|
||||||
|
<span>精确</span>
|
||||||
|
<span>创意</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<label>
|
||||||
|
最大 Token
|
||||||
|
<span class="value-display">{{ form.max_tokens }}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.max_tokens"
|
||||||
|
type="range"
|
||||||
|
min="256"
|
||||||
|
max="65536"
|
||||||
|
step="256"
|
||||||
|
/>
|
||||||
|
<div class="range-labels">
|
||||||
|
<span>256</span>
|
||||||
|
<span>65536</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group toggle-group">
|
||||||
|
<label>启用思维链</label>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
:class="{ on: form.thinking_enabled }"
|
||||||
|
@click="form.thinking_enabled = !form.thinking_enabled"
|
||||||
|
>
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-footer">
|
||||||
|
<button class="btn-cancel" @click="$emit('close')">取消</button>
|
||||||
|
<button class="btn-save" @click="save">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
conversation: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'save'])
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
model: 'glm-5',
|
||||||
|
system_prompt: '',
|
||||||
|
temperature: 1.0,
|
||||||
|
max_tokens: 65536,
|
||||||
|
thinking_enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.visible, (val) => {
|
||||||
|
if (val && props.conversation) {
|
||||||
|
form.title = props.conversation.title || ''
|
||||||
|
form.model = props.conversation.model || 'glm-5'
|
||||||
|
form.system_prompt = props.conversation.system_prompt || ''
|
||||||
|
form.temperature = props.conversation.temperature ?? 1.0
|
||||||
|
form.max_tokens = props.conversation.max_tokens ?? 65536
|
||||||
|
form.thinking_enabled = props.conversation.thinking_enabled ?? false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
emit('save', { ...form })
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
width: 380px;
|
||||||
|
height: 100vh;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-display {
|
||||||
|
float: right;
|
||||||
|
color: #a5b4fc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"]:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
border-color: rgba(79, 70, 229, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select option {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4f46e5;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-group label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle.on .toggle-thumb {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-footer {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save:hover {
|
||||||
|
background: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-active,
|
||||||
|
.slide-leave-active {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-active .settings-panel,
|
||||||
|
.slide-leave-active .settings-panel {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-from .settings-panel,
|
||||||
|
.slide-leave-to .settings-panel {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
<template>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<button class="btn-new" @click="$emit('create')">
|
||||||
|
<span class="icon">+</span>
|
||||||
|
<span>新对话</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conversation-list" @scroll="onScroll">
|
||||||
|
<div
|
||||||
|
v-for="conv in conversations"
|
||||||
|
:key="conv.id"
|
||||||
|
class="conversation-item"
|
||||||
|
:class="{ active: conv.id === currentId }"
|
||||||
|
@click="$emit('select', conv.id)"
|
||||||
|
@contextmenu.prevent="onContextMenu($event, conv)"
|
||||||
|
>
|
||||||
|
<div class="conv-info">
|
||||||
|
<div class="conv-title">{{ conv.title || '新对话' }}</div>
|
||||||
|
<div class="conv-meta">
|
||||||
|
{{ conv.message_count || 0 }} 条消息 · {{ formatTime(conv.updated_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-delete" @click.stop="$emit('delete', conv.id)" title="删除">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"></polyline>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-more">加载中...</div>
|
||||||
|
<div v-if="!loading && conversations.length === 0" class="empty-hint">
|
||||||
|
暂无对话
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
conversations: { type: Array, required: true },
|
||||||
|
currentId: { type: String, default: null },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
hasMore: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select', 'create', 'delete', 'loadMore'])
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const isToday = d.toDateString() === now.toDateString()
|
||||||
|
if (isToday) {
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll(e) {
|
||||||
|
const el = e.target
|
||||||
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) {
|
||||||
|
emit('loadMore')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenu(e, conv) {
|
||||||
|
// right-click to delete
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(79, 70, 229, 0.15);
|
||||||
|
border: 1px dashed rgba(79, 70, 229, 0.4);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #a5b4fc;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new:hover {
|
||||||
|
background: rgba(79, 70, 229, 0.25);
|
||||||
|
border-color: rgba(79, 70, 229, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new .icon {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item.active {
|
||||||
|
background: rgba(79, 70, 229, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conv-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
opacity: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover .btn-delete {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-more,
|
||||||
|
.empty-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles/highlight.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
/* highlight.js - GitHub Dark theme override for code blocks */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
.hljs {
|
||||||
|
color: #e6edf3;
|
||||||
|
background: #0d1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #8b949e;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-selector-tag,
|
||||||
|
.hljs-type {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-addition {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-literal {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-builtin-name {
|
||||||
|
color: #ffa657;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-function .hljs-title,
|
||||||
|
.hljs-title.function_ {
|
||||||
|
color: #d2a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-variable,
|
||||||
|
.hljs-template-variable {
|
||||||
|
color: #ffa657;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-attribute {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-selector-class {
|
||||||
|
color: #7ee787;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-meta {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-deletion {
|
||||||
|
color: #ffa198;
|
||||||
|
background: rgba(248, 81, 73, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-addition {
|
||||||
|
color: #aff5b4;
|
||||||
|
background: rgba(46, 160, 67, 0.15);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
[project]
|
||||||
|
name = "nano-claw"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "nano-claw Backend"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.0",
|
||||||
|
"flask-sqlalchemy>=3.1",
|
||||||
|
"pymysql>=1.1",
|
||||||
|
"requests>=2.31",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["backend*"]
|
||||||
Loading…
Reference in New Issue