first commit

This commit is contained in:
ViperEkura 2026-03-24 12:12:03 +08:00
commit 3af47d48cf
23 changed files with 4076 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@ -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/**

89
README.md Normal file
View File

@ -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)。

36
backend/__init__.py Normal file
View File

@ -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

46
backend/models.py Normal file
View File

@ -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)

271
backend/routes.py Normal file
View File

@ -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)

6
backend/run.py Normal file
View File

@ -0,0 +1,6 @@
from backend import create_app
app = create_app()
if __name__ == "__main__":
app.run(debug=True, port=5000)

351
docs/Design.md Normal file
View File

@ -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 | 创建时间 |

42
frontend/.gitignore vendored Normal file
View File

@ -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/

13
frontend/index.html Normal file
View File

@ -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>

1347
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View File

@ -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"
}
}

View File

@ -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

287
frontend/src/App.vue Normal file
View File

@ -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>

124
frontend/src/api/index.js Normal file
View File

@ -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' })
},
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

5
frontend/src/main.js Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles/highlight.css'
createApp(App).mount('#app')

View File

@ -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);
}

15
frontend/vite.config.js Normal file
View File

@ -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,
},
},
},
})

19
pyproject.toml Normal file
View File

@ -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*"]