refactor: 统一消息存储为 JSON 结构
This commit is contained in:
parent
beb1a03d92
commit
46cacc7fa2
|
|
@ -66,36 +66,13 @@ class Message(db.Model):
|
|||
conversation_id = db.Column(db.String(64), db.ForeignKey("conversations.id"),
|
||||
nullable=False, index=True)
|
||||
role = db.Column(db.String(16), nullable=False) # user, assistant, system, tool
|
||||
content = db.Column(LongText, default="") # LongText for long conversations
|
||||
# Unified JSON structure:
|
||||
# User: {"text": "...", "attachments": [{"name": "a.py", "extension": "py", "content": "..."}]}
|
||||
# Assistant: {"text": "...", "thinking": "...", "tool_calls": [{"id": "...", "name": "...", "arguments": "...", "result": "..."}]}
|
||||
content = db.Column(LongText, default="")
|
||||
token_count = db.Column(db.Integer, default=0)
|
||||
thinking_content = db.Column(LongText, default="")
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
|
||||
|
||||
# Tool call support - relation to ToolCall table
|
||||
tool_calls = db.relationship("ToolCall", backref="message", lazy="dynamic",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ToolCall.call_index.asc()")
|
||||
|
||||
|
||||
class ToolCall(db.Model):
|
||||
"""Tool call record - separate table, follows database normalization"""
|
||||
__tablename__ = "tool_calls"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
message_id = db.Column(db.String(64), db.ForeignKey("messages.id"),
|
||||
nullable=False, index=True)
|
||||
call_id = db.Column(db.String(64), nullable=False) # Tool call ID
|
||||
call_index = db.Column(db.Integer, nullable=False, default=0) # Call order
|
||||
tool_name = db.Column(db.String(64), nullable=False) # Tool name
|
||||
arguments = db.Column(LongText, nullable=False) # Call arguments JSON
|
||||
result = db.Column(LongText) # Execution result JSON
|
||||
execution_time = db.Column(db.Float, default=0) # Execution time (seconds)
|
||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
db.Index("ix_tool_calls_message_call", "message_id", "call_index"),
|
||||
)
|
||||
|
||||
|
||||
class TokenUsage(db.Model):
|
||||
__tablename__ = "token_usage"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Message API routes"""
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request
|
||||
|
|
@ -45,11 +46,23 @@ def message_list(conv_id):
|
|||
|
||||
# POST - create message and get AI response
|
||||
d = request.json or {}
|
||||
content = (d.get("content") or "").strip()
|
||||
if not content:
|
||||
return err(400, "content is required")
|
||||
text = (d.get("text") or "").strip()
|
||||
attachments = d.get("attachments") # [{"name": "a.py", "extension": "py", "content": "..."}]
|
||||
|
||||
user_msg = Message(id=str(uuid.uuid4()), conversation_id=conv_id, role="user", content=content)
|
||||
if not text and not attachments:
|
||||
return err(400, "text or attachments is required")
|
||||
|
||||
# Build content JSON structure
|
||||
content_json = {"text": text}
|
||||
if attachments:
|
||||
content_json["attachments"] = attachments
|
||||
|
||||
user_msg = Message(
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conv_id,
|
||||
role="user",
|
||||
content=json.dumps(content_json, ensure_ascii=False),
|
||||
)
|
||||
db.session.add(user_msg)
|
||||
db.session.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import json
|
|||
import uuid
|
||||
from flask import current_app, Response
|
||||
from backend import db
|
||||
from backend.models import Conversation, Message, ToolCall
|
||||
from backend.models import Conversation, Message
|
||||
from backend.tools import registry, ToolExecutor
|
||||
from backend.utils.helpers import (
|
||||
get_or_create_default_user,
|
||||
|
|
@ -61,19 +61,24 @@ class ChatService:
|
|||
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||
completion_tokens = usage.get("completion_tokens", 0)
|
||||
|
||||
# Build content JSON
|
||||
content_json = {
|
||||
"text": message.get("content", ""),
|
||||
}
|
||||
if message.get("reasoning_content"):
|
||||
content_json["thinking"] = message["reasoning_content"]
|
||||
if all_tool_calls:
|
||||
content_json["tool_calls"] = self._build_tool_calls_json(all_tool_calls, all_tool_results)
|
||||
|
||||
# Create message
|
||||
msg = Message(
|
||||
id=str(uuid.uuid4()),
|
||||
conversation_id=conv.id,
|
||||
role="assistant",
|
||||
content=message.get("content", ""),
|
||||
content=json.dumps(content_json, ensure_ascii=False),
|
||||
token_count=completion_tokens,
|
||||
thinking_content=message.get("reasoning_content", ""),
|
||||
)
|
||||
db.session.add(msg)
|
||||
|
||||
# Create tool call records
|
||||
self._save_tool_calls(msg.id, all_tool_calls, all_tool_results)
|
||||
db.session.commit()
|
||||
|
||||
user = get_or_create_default_user()
|
||||
|
|
@ -86,8 +91,15 @@ class ChatService:
|
|||
conversation_id=conv.id, role="user"
|
||||
).order_by(Message.created_at.asc()).first()
|
||||
if user_msg and user_msg.content:
|
||||
suggested_title = user_msg.content.strip()[:30]
|
||||
if not suggested_title:
|
||||
# Parse content JSON to get text
|
||||
try:
|
||||
content_data = json.loads(user_msg.content)
|
||||
title_text = content_data.get("text", "")[:30]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
title_text = user_msg.content.strip()[:30]
|
||||
if title_text:
|
||||
suggested_title = title_text
|
||||
else:
|
||||
suggested_title = "新对话"
|
||||
conv.title = suggested_title
|
||||
db.session.commit()
|
||||
|
|
@ -254,18 +266,23 @@ class ChatService:
|
|||
|
||||
suggested_title = None
|
||||
with app.app_context():
|
||||
# Build content JSON
|
||||
content_json = {
|
||||
"text": full_content,
|
||||
}
|
||||
if full_thinking:
|
||||
content_json["thinking"] = full_thinking
|
||||
if all_tool_calls:
|
||||
content_json["tool_calls"] = self._build_tool_calls_json(all_tool_calls, all_tool_results)
|
||||
|
||||
msg = Message(
|
||||
id=msg_id,
|
||||
conversation_id=conv_id,
|
||||
role="assistant",
|
||||
content=full_content,
|
||||
content=json.dumps(content_json, ensure_ascii=False),
|
||||
token_count=token_count,
|
||||
thinking_content=full_thinking,
|
||||
)
|
||||
db.session.add(msg)
|
||||
|
||||
# Create tool call records
|
||||
self._save_tool_calls(msg_id, all_tool_calls, all_tool_results)
|
||||
db.session.commit()
|
||||
|
||||
user = get_or_create_default_user()
|
||||
|
|
@ -279,16 +296,22 @@ class ChatService:
|
|||
conversation_id=conv_id, role="user"
|
||||
).order_by(Message.created_at.asc()).first()
|
||||
if user_msg and user_msg.content:
|
||||
# Use first 30 chars of user message as title
|
||||
suggested_title = user_msg.content.strip()[:30]
|
||||
if not suggested_title:
|
||||
# Parse content JSON to get text
|
||||
try:
|
||||
content_data = json.loads(user_msg.content)
|
||||
title_text = content_data.get("text", "")[:30]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
title_text = user_msg.content.strip()[:30]
|
||||
if title_text:
|
||||
suggested_title = title_text
|
||||
else:
|
||||
suggested_title = "新对话"
|
||||
# Refresh conv to avoid stale state
|
||||
db.session.refresh(conv)
|
||||
conv.title = suggested_title
|
||||
db.session.commit()
|
||||
else:
|
||||
suggested_title = None
|
||||
else:
|
||||
suggested_title = None
|
||||
|
||||
yield f"event: done\ndata: {json.dumps({'message_id': msg_id, 'token_count': token_count, 'suggested_title': suggested_title}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
|
@ -306,63 +329,59 @@ class ChatService:
|
|||
}
|
||||
)
|
||||
|
||||
def _save_tool_calls(self, message_id: str, tool_calls: list, tool_results: list) -> None:
|
||||
"""Save tool calls to database"""
|
||||
def _build_tool_calls_json(self, tool_calls: list, tool_results: list) -> list:
|
||||
"""Build tool calls JSON structure"""
|
||||
result = []
|
||||
for i, tc in enumerate(tool_calls):
|
||||
result_content = tool_results[i]["content"] if i < len(tool_results) else None
|
||||
|
||||
# Parse result to extract execution_time if present
|
||||
# Parse result to extract success/skipped status
|
||||
success = True
|
||||
skipped = False
|
||||
execution_time = 0
|
||||
if result_content:
|
||||
try:
|
||||
result_data = json.loads(result_content)
|
||||
success = result_data.get("success", True)
|
||||
skipped = result_data.get("skipped", False)
|
||||
execution_time = result_data.get("execution_time", 0)
|
||||
except:
|
||||
pass
|
||||
|
||||
tool_call = ToolCall(
|
||||
message_id=message_id,
|
||||
call_id=tc.get("id", ""),
|
||||
call_index=i,
|
||||
tool_name=tc["function"]["name"],
|
||||
arguments=tc["function"]["arguments"],
|
||||
result=result_content,
|
||||
execution_time=execution_time,
|
||||
)
|
||||
db.session.add(tool_call)
|
||||
result.append({
|
||||
"id": tc.get("id", ""),
|
||||
"name": tc["function"]["name"],
|
||||
"arguments": tc["function"]["arguments"],
|
||||
"result": result_content,
|
||||
"success": success,
|
||||
"skipped": skipped,
|
||||
"execution_time": execution_time,
|
||||
})
|
||||
return result
|
||||
|
||||
def _message_to_dict(self, msg: Message) -> dict:
|
||||
"""Convert message to dict with tool calls"""
|
||||
result = to_dict(msg, thinking_content=msg.thinking_content or None)
|
||||
"""Convert message to dict, parsing JSON content"""
|
||||
result = to_dict(msg)
|
||||
|
||||
# Add tool calls if any
|
||||
tool_calls = msg.tool_calls.all() if msg.tool_calls else []
|
||||
if tool_calls:
|
||||
result["tool_calls"] = []
|
||||
for tc in tool_calls:
|
||||
# Parse result to extract success/skipped status
|
||||
success = True
|
||||
skipped = False
|
||||
if tc.result:
|
||||
try:
|
||||
result_data = json.loads(tc.result)
|
||||
success = result_data.get("success", True)
|
||||
skipped = result_data.get("skipped", False)
|
||||
except:
|
||||
pass
|
||||
# Parse content JSON
|
||||
if msg.content:
|
||||
try:
|
||||
content_data = json.loads(msg.content)
|
||||
if isinstance(content_data, dict):
|
||||
result["text"] = content_data.get("text", "")
|
||||
if content_data.get("attachments"):
|
||||
result["attachments"] = content_data["attachments"]
|
||||
if content_data.get("thinking"):
|
||||
result["thinking"] = content_data["thinking"]
|
||||
if content_data.get("tool_calls"):
|
||||
result["tool_calls"] = content_data["tool_calls"]
|
||||
else:
|
||||
result["text"] = msg.content
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
result["text"] = msg.content
|
||||
|
||||
result["tool_calls"].append({
|
||||
"id": tc.call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.tool_name,
|
||||
"arguments": tc.arguments,
|
||||
},
|
||||
"result": tc.result,
|
||||
"success": success,
|
||||
"skipped": skipped,
|
||||
"execution_time": tc.execution_time,
|
||||
})
|
||||
if "text" not in result:
|
||||
result["text"] = ""
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -47,37 +47,30 @@ def to_dict(inst, **extra):
|
|||
|
||||
|
||||
def message_to_dict(msg: Message) -> dict:
|
||||
"""Convert message to dict with tool calls"""
|
||||
result = to_dict(msg, thinking_content=msg.thinking_content or None)
|
||||
"""Convert message to dict, parsing JSON content"""
|
||||
result = to_dict(msg)
|
||||
|
||||
# Add tool calls if any
|
||||
tool_calls = msg.tool_calls.all() if msg.tool_calls else []
|
||||
if tool_calls:
|
||||
result["tool_calls"] = []
|
||||
for tc in tool_calls:
|
||||
# Parse result to extract success/skipped status
|
||||
success = True
|
||||
skipped = False
|
||||
if tc.result:
|
||||
try:
|
||||
result_data = json.loads(tc.result)
|
||||
success = result_data.get("success", True)
|
||||
skipped = result_data.get("skipped", False)
|
||||
except:
|
||||
pass
|
||||
# Parse content JSON
|
||||
if msg.content:
|
||||
try:
|
||||
content_data = json.loads(msg.content)
|
||||
if isinstance(content_data, dict):
|
||||
# Extract all fields from JSON
|
||||
result["text"] = content_data.get("text", "")
|
||||
if content_data.get("attachments"):
|
||||
result["attachments"] = content_data["attachments"]
|
||||
if content_data.get("thinking"):
|
||||
result["thinking"] = content_data["thinking"]
|
||||
if content_data.get("tool_calls"):
|
||||
result["tool_calls"] = content_data["tool_calls"]
|
||||
else:
|
||||
# Fallback: plain text
|
||||
result["text"] = msg.content
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
result["text"] = msg.content
|
||||
|
||||
result["tool_calls"].append({
|
||||
"id": tc.call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.tool_name,
|
||||
"arguments": tc.arguments,
|
||||
},
|
||||
"result": tc.result,
|
||||
"success": success,
|
||||
"skipped": skipped,
|
||||
"execution_time": tc.execution_time,
|
||||
})
|
||||
if "text" not in result:
|
||||
result["text"] = ""
|
||||
|
||||
return result
|
||||
|
||||
|
|
@ -113,5 +106,29 @@ def build_glm_messages(conv):
|
|||
# Query messages directly to avoid detached instance warning
|
||||
messages = Message.query.filter_by(conversation_id=conv.id).order_by(Message.created_at.asc()).all()
|
||||
for m in messages:
|
||||
msgs.append({"role": m.role, "content": m.content})
|
||||
# Build full content from JSON structure
|
||||
full_content = m.content
|
||||
try:
|
||||
content_data = json.loads(m.content)
|
||||
if isinstance(content_data, dict):
|
||||
text = content_data.get("text", "")
|
||||
attachments = content_data.get("attachments", [])
|
||||
|
||||
# Build full content with attachments
|
||||
parts = []
|
||||
if text:
|
||||
parts.append(text)
|
||||
|
||||
for att in attachments:
|
||||
filename = att.get("name", "")
|
||||
file_content = att.get("content", "")
|
||||
if filename and file_content:
|
||||
parts.append(f"```{filename}\n{file_content}\n```")
|
||||
|
||||
full_content = "\n\n".join(parts) if parts else ""
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Plain text, use as-is
|
||||
pass
|
||||
|
||||
msgs.append({"role": m.role, "content": full_content})
|
||||
return msgs
|
||||
|
|
|
|||
|
|
@ -109,20 +109,6 @@ classDiagram
|
|||
+String role
|
||||
+LongText content
|
||||
+Integer token_count
|
||||
+LongText thinking_content
|
||||
+DateTime created_at
|
||||
+relationship tool_calls
|
||||
}
|
||||
|
||||
class ToolCall {
|
||||
+Integer id
|
||||
+String message_id
|
||||
+String call_id
|
||||
+Integer call_index
|
||||
+String tool_name
|
||||
+LongText arguments
|
||||
+LongText result
|
||||
+Float execution_time
|
||||
+DateTime created_at
|
||||
}
|
||||
|
||||
|
|
@ -139,10 +125,42 @@ classDiagram
|
|||
|
||||
User "1" --> "*" Conversation : 拥有
|
||||
Conversation "1" --> "*" Message : 包含
|
||||
Message "1" --> "*" ToolCall : 触发
|
||||
User "1" --> "*" TokenUsage : 消耗
|
||||
```
|
||||
|
||||
### Message Content JSON 结构
|
||||
|
||||
`content` 字段统一使用 JSON 格式存储:
|
||||
|
||||
**User 消息:**
|
||||
```json
|
||||
{
|
||||
"text": "用户输入的文本内容",
|
||||
"attachments": [
|
||||
{"name": "utils.py", "extension": "py", "content": "def hello()..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Assistant 消息:**
|
||||
```json
|
||||
{
|
||||
"text": "AI 回复的文本内容",
|
||||
"thinking": "思考过程(可选)",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_xxx",
|
||||
"name": "read_file",
|
||||
"arguments": "{\"path\": \"...\"}",
|
||||
"result": "{\"content\": \"...\"}",
|
||||
"success": true,
|
||||
"skipped": false,
|
||||
"execution_time": 0.5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 服务层
|
||||
|
||||
```mermaid
|
||||
|
|
@ -155,10 +173,9 @@ classDiagram
|
|||
+Integer MAX_ITERATIONS
|
||||
+sync_response(conv, tools_enabled) Response
|
||||
+stream_response(conv, tools_enabled) Response
|
||||
-_save_tool_calls(msg_id, calls, results) void
|
||||
-_build_tool_calls_json(calls, results) list
|
||||
-_message_to_dict(msg) dict
|
||||
-_process_tool_calls_delta(delta, list) list
|
||||
-_emit_process_step(event, data) void
|
||||
}
|
||||
|
||||
class GLMClient {
|
||||
|
|
@ -351,23 +368,8 @@ iteration 2:
|
|||
| `id` | String(64) | UUID 主键 |
|
||||
| `conversation_id` | String(64) | 外键关联 Conversation |
|
||||
| `role` | String(16) | user/assistant/system/tool |
|
||||
| `content` | LongText | 消息内容 |
|
||||
| `content` | LongText | JSON 格式内容(见上方结构说明) |
|
||||
| `token_count` | Integer | Token 数量 |
|
||||
| `thinking_content` | LongText | 思维链内容 |
|
||||
| `created_at` | DateTime | 创建时间 |
|
||||
|
||||
### ToolCall(工具调用)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | Integer | 自增主键 |
|
||||
| `message_id` | String(64) | 外键关联 Message |
|
||||
| `call_id` | String(64) | LLM 返回的工具调用 ID |
|
||||
| `call_index` | Integer | 消息内的调用顺序 |
|
||||
| `tool_name` | String(64) | 工具名称 |
|
||||
| `arguments` | LongText | JSON 参数 |
|
||||
| `result` | LongText | JSON 结果 |
|
||||
| `execution_time` | Float | 执行时间(秒) |
|
||||
| `created_at` | DateTime | 创建时间 |
|
||||
|
||||
### TokenUsage(Token 使用统计)
|
||||
|
|
|
|||
|
|
@ -186,19 +186,21 @@ function loadMoreMessages() {
|
|||
}
|
||||
|
||||
// -- Send message (streaming) --
|
||||
async function sendMessage(content) {
|
||||
async function sendMessage(data) {
|
||||
if (!currentConvId.value || streaming.value) return
|
||||
|
||||
const convId = currentConvId.value // 保存当前对话ID
|
||||
const text = data.text || ''
|
||||
const attachments = data.attachments || null
|
||||
|
||||
// Add user message optimistically
|
||||
const userMsg = {
|
||||
id: 'temp_' + Date.now(),
|
||||
conversation_id: convId,
|
||||
role: 'user',
|
||||
content,
|
||||
text,
|
||||
attachments: attachments ? attachments.map(a => ({ name: a.name, extension: a.extension })) : null,
|
||||
token_count: 0,
|
||||
thinking_content: null,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
|
|
@ -209,7 +211,7 @@ async function sendMessage(content) {
|
|||
streamToolCalls.value = []
|
||||
streamProcessSteps.value = []
|
||||
|
||||
currentStreamPromise = messageApi.send(convId, content, {
|
||||
currentStreamPromise = messageApi.send(convId, { text, attachments }, {
|
||||
stream: true,
|
||||
toolsEnabled: toolsEnabled.value,
|
||||
onThinkingStart() {
|
||||
|
|
@ -288,11 +290,11 @@ async function sendMessage(content) {
|
|||
id: data.message_id,
|
||||
conversation_id: convId,
|
||||
role: 'assistant',
|
||||
content: streamContent.value,
|
||||
token_count: data.token_count,
|
||||
thinking_content: streamThinking.value || null,
|
||||
text: streamContent.value,
|
||||
thinking: streamThinking.value || null,
|
||||
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
||||
process_steps: streamProcessSteps.value.filter(Boolean),
|
||||
token_count: data.token_count,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
streamContent.value = ''
|
||||
|
|
@ -426,11 +428,11 @@ async function regenerateMessage(msgId) {
|
|||
id: data.message_id,
|
||||
conversation_id: convId,
|
||||
role: 'assistant',
|
||||
content: streamContent.value,
|
||||
token_count: data.token_count,
|
||||
thinking_content: streamThinking.value || null,
|
||||
text: streamContent.value,
|
||||
thinking: streamThinking.value || null,
|
||||
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
||||
process_steps: streamProcessSteps.value.filter(Boolean),
|
||||
token_count: data.token_count,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
streamContent.value = ''
|
||||
|
|
|
|||
|
|
@ -95,11 +95,11 @@ export const messageApi = {
|
|||
return request(`/conversations/${convId}/messages?${params}`)
|
||||
},
|
||||
|
||||
send(convId, content, { stream = true, toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) {
|
||||
send(convId, data, { stream = true, toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) {
|
||||
if (!stream) {
|
||||
return request(`/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
body: { content, stream: false, tools_enabled: toolsEnabled },
|
||||
body: { text: data.text, attachments: data.attachments, stream: false, tools_enabled: toolsEnabled },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ export const messageApi = {
|
|||
const res = await fetch(`${BASE}/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, stream: true, tools_enabled: toolsEnabled }),
|
||||
body: JSON.stringify({ text: data.text, attachments: data.attachments, stream: true, tools_enabled: toolsEnabled }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -35,14 +35,14 @@
|
|||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:role="msg.role"
|
||||
:content="msg.content"
|
||||
:thinking-content="msg.thinking_content"
|
||||
:text="msg.text"
|
||||
:thinking-content="msg.thinking"
|
||||
:tool-calls="msg.tool_calls"
|
||||
:process-steps="msg.process_steps"
|
||||
:tool-name="msg.name"
|
||||
:token-count="msg.token_count"
|
||||
:created-at="msg.created_at"
|
||||
:deletable="msg.role === 'user'"
|
||||
:attachments="msg.attachments"
|
||||
@delete="$emit('deleteMessage', msg.id)"
|
||||
@regenerate="$emit('regenerateMessage', msg.id)"
|
||||
/>
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
ref="inputRef"
|
||||
:disabled="streaming"
|
||||
:tools-enabled="toolsEnabled"
|
||||
@send="$emit('sendMessage', $event)"
|
||||
@send="handleSend"
|
||||
@toggle-tools="$emit('toggleTools', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -99,7 +99,7 @@ const props = defineProps({
|
|||
toolsEnabled: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
|
||||
const emit = defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
|
||||
|
||||
const scrollContainer = ref(null)
|
||||
const inputRef = ref(null)
|
||||
|
|
@ -109,6 +109,10 @@ const renderedStreamContent = computed(() => {
|
|||
return renderMarkdown(props.streamingContent)
|
||||
})
|
||||
|
||||
function handleSend(data) {
|
||||
emit('sendMessage', data)
|
||||
}
|
||||
|
||||
function scrollToBottom(smooth = true) {
|
||||
nextTick(() => {
|
||||
const el = scrollContainer.value
|
||||
|
|
@ -255,7 +259,7 @@ defineExpose({ scrollToBottom })
|
|||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1 1 auto; /* 弹性高度,自动填充 */
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
width: 100%;
|
||||
|
|
@ -272,6 +276,10 @@ defineExpose({ scrollToBottom })
|
|||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.load-more-top {
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
|
|
@ -379,15 +387,6 @@ defineExpose({ scrollToBottom })
|
|||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.streaming-content :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@
|
|||
<div v-if="role === 'user'" class="avatar">user</div>
|
||||
<div v-else class="avatar">claw</div>
|
||||
<div class="message-container">
|
||||
<!-- 附件列表 -->
|
||||
<div v-if="attachments && attachments.length > 0" class="attachments-list">
|
||||
<div v-for="(file, index) in attachments" :key="index" class="attachment-item">
|
||||
<span class="attachment-icon">{{ file.extension }}</span>
|
||||
<span class="attachment-name">{{ file.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<ProcessBlock
|
||||
v-if="thinkingContent || (toolCalls && toolCalls.length > 0) || (processSteps && processSteps.length > 0)"
|
||||
|
|
@ -49,7 +56,8 @@ import ProcessBlock from './ProcessBlock.vue'
|
|||
|
||||
const props = defineProps({
|
||||
role: { type: String, required: true },
|
||||
content: { type: String, default: '' },
|
||||
text: { type: String, default: '' },
|
||||
content: { type: String, default: '' }, // Keep for backward compatibility
|
||||
thinkingContent: { type: String, default: '' },
|
||||
toolCalls: { type: Array, default: () => [] },
|
||||
processSteps: { type: Array, default: () => [] },
|
||||
|
|
@ -57,13 +65,16 @@ const props = defineProps({
|
|||
tokenCount: { type: Number, default: 0 },
|
||||
createdAt: { type: String, default: '' },
|
||||
deletable: { type: Boolean, default: false },
|
||||
attachments: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
defineEmits(['delete', 'regenerate'])
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return renderMarkdown(props.content)
|
||||
// Use 'text' field (new format), fallback to 'content' (old format/assistant messages)
|
||||
const displayContent = props.text || props.content || ''
|
||||
if (!displayContent) return ''
|
||||
return renderMarkdown(displayContent)
|
||||
})
|
||||
|
||||
function formatTime(iso) {
|
||||
|
|
@ -108,6 +119,40 @@ function copyContent() {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachments-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-code);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-bubble.assistant .message-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
<template>
|
||||
<div class="message-input">
|
||||
<div class="input-wrapper">
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="uploadedFiles.length > 0" class="file-list">
|
||||
<div v-for="(file, index) in uploadedFiles" :key="index" class="file-item">
|
||||
<span class="file-icon">{{ getFileIcon(file.extension) }}</span>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<button class="btn-remove-file" @click="removeFile(index)" title="移除">
|
||||
<svg width="14" height="14" 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>
|
||||
|
||||
<div class="input-container">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="text"
|
||||
|
|
@ -10,29 +24,48 @@
|
|||
@keydown="onKeydown"
|
||||
:disabled="disabled"
|
||||
></textarea>
|
||||
<div class="input-actions">
|
||||
<button
|
||||
class="btn-tool"
|
||||
:class="{ active: toolsEnabled }"
|
||||
:disabled="disabled"
|
||||
@click="toggleTools"
|
||||
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<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 class="input-footer">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.h,.hpp,.yaml,.yml,.toml,.ini,.csv,.sql,.sh,.bat,.log,.vue,.svelte,.go,.rs,.rb,.php,.swift,.kt,.scala,.lua,.r,.dart,.scala"
|
||||
@change="handleFileUpload"
|
||||
style="display: none"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<button
|
||||
class="btn-tool"
|
||||
:class="{ active: toolsEnabled }"
|
||||
:disabled="disabled"
|
||||
@click="toggleTools"
|
||||
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-upload"
|
||||
:disabled="disabled"
|
||||
@click="triggerFileUpload"
|
||||
title="上传文件"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-send"
|
||||
:class="{ active: canSend }"
|
||||
:disabled="!canSend || 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>
|
||||
<div class="input-hint">AI 助手回复内容仅供参考</div>
|
||||
|
|
@ -40,7 +73,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
disabled: { type: Boolean, default: false },
|
||||
|
|
@ -50,6 +83,10 @@ const props = defineProps({
|
|||
const emit = defineEmits(['send', 'toggleTools'])
|
||||
const text = ref('')
|
||||
const textareaRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const uploadedFiles = ref([])
|
||||
|
||||
const canSend = computed(() => text.value.trim() || uploadedFiles.value.length > 0)
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaRef.value
|
||||
|
|
@ -66,10 +103,23 @@ function onKeydown(e) {
|
|||
}
|
||||
|
||||
function send() {
|
||||
const content = text.value.trim()
|
||||
if (!content || props.disabled) return
|
||||
emit('send', content)
|
||||
if (props.disabled || !canSend.value) return
|
||||
|
||||
const messageText = text.value.trim()
|
||||
|
||||
// 发送内容和附件
|
||||
emit('send', {
|
||||
text: messageText,
|
||||
attachments: uploadedFiles.value.length > 0 ? uploadedFiles.value.map(f => ({
|
||||
name: f.name,
|
||||
extension: f.extension,
|
||||
content: f.content,
|
||||
})) : null,
|
||||
})
|
||||
|
||||
// 清空
|
||||
text.value = ''
|
||||
uploadedFiles.value = []
|
||||
nextTick(() => {
|
||||
autoResize()
|
||||
})
|
||||
|
|
@ -79,6 +129,43 @@ function toggleTools() {
|
|||
emit('toggleTools', !props.toolsEnabled)
|
||||
}
|
||||
|
||||
function triggerFileUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFileUpload(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result
|
||||
if (typeof content === 'string') {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase() || ''
|
||||
uploadedFiles.value.push({
|
||||
name: file.name,
|
||||
content,
|
||||
extension,
|
||||
})
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
console.error('文件读取失败')
|
||||
}
|
||||
reader.readAsText(file)
|
||||
|
||||
// 清空 input 以便重复上传同一文件
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
uploadedFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function getFileIcon(extension) {
|
||||
return `.${extension}`
|
||||
}
|
||||
|
||||
function focus() {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
|
@ -94,31 +181,93 @@ defineExpose({ focus })
|
|||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px 6px 8px;
|
||||
background: var(--bg-code);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
border-color: var(--border-medium);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-remove-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-remove-file:hover {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
padding: 12px;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
.input-container:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
min-height: 36px;
|
||||
max-height: 200px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
|
|
@ -129,14 +278,21 @@ textarea:disabled {
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.input-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
.btn-tool,
|
||||
.btn-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
|
|
@ -147,12 +303,27 @@ textarea:disabled {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-tool:hover {
|
||||
.btn-upload {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: #8b5cf6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-tool:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-tool.active {
|
||||
|
|
@ -160,23 +331,30 @@ textarea:disabled {
|
|||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.btn-tool:disabled {
|
||||
.btn-tool.active:hover:not(:disabled) {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-upload:hover:not(:disabled) {
|
||||
background: #8b5cf6;
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-upload:active:not(:disabled) {
|
||||
background: #7c3aed;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-tool:disabled,
|
||||
.btn-upload:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--bg-code);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-send.active {
|
||||
|
|
@ -187,6 +365,13 @@ textarea:disabled {
|
|||
|
||||
.btn-send.active:hover {
|
||||
background: var(--accent-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.btn-send.active:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
|
|
|
|||
|
|
@ -280,10 +280,33 @@ onMounted(loadModels)
|
|||
|
||||
.form-group select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
background-size: 16px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.form-group select:hover {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.form-group select:focus {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-group select option {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-group select {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a0a0a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.form-row {
|
||||
|
|
@ -295,27 +318,6 @@ onMounted(loadModels)
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group input[type="range"] {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--bg-code);
|
||||
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: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--bg-primary);
|
||||
}
|
||||
|
||||
.range-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles/global.css'
|
||||
import './styles/highlight.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
/* 共享滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Textarea 滚动条 */
|
||||
textarea::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
textarea::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Textarea resize 手柄修复 */
|
||||
textarea::-webkit-resizer {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Range 滑块样式 */
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-medium);
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 按钮基础样式 */
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: var(--bg-code);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 表单元素基础样式 */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
Loading…
Reference in New Issue