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"),
|
conversation_id = db.Column(db.String(64), db.ForeignKey("conversations.id"),
|
||||||
nullable=False, index=True)
|
nullable=False, index=True)
|
||||||
role = db.Column(db.String(16), nullable=False) # user, assistant, system, tool
|
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)
|
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)
|
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):
|
class TokenUsage(db.Model):
|
||||||
__tablename__ = "token_usage"
|
__tablename__ = "token_usage"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Message API routes"""
|
"""Message API routes"""
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
|
|
@ -45,11 +46,23 @@ def message_list(conv_id):
|
||||||
|
|
||||||
# POST - create message and get AI response
|
# POST - create message and get AI response
|
||||||
d = request.json or {}
|
d = request.json or {}
|
||||||
content = (d.get("content") or "").strip()
|
text = (d.get("text") or "").strip()
|
||||||
if not content:
|
attachments = d.get("attachments") # [{"name": "a.py", "extension": "py", "content": "..."}]
|
||||||
return err(400, "content is required")
|
|
||||||
|
|
||||||
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.add(user_msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import json
|
||||||
import uuid
|
import uuid
|
||||||
from flask import current_app, Response
|
from flask import current_app, Response
|
||||||
from backend import db
|
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.tools import registry, ToolExecutor
|
||||||
from backend.utils.helpers import (
|
from backend.utils.helpers import (
|
||||||
get_or_create_default_user,
|
get_or_create_default_user,
|
||||||
|
|
@ -61,19 +61,24 @@ class ChatService:
|
||||||
prompt_tokens = usage.get("prompt_tokens", 0)
|
prompt_tokens = usage.get("prompt_tokens", 0)
|
||||||
completion_tokens = usage.get("completion_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
|
# Create message
|
||||||
msg = Message(
|
msg = Message(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
conversation_id=conv.id,
|
conversation_id=conv.id,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=message.get("content", ""),
|
content=json.dumps(content_json, ensure_ascii=False),
|
||||||
token_count=completion_tokens,
|
token_count=completion_tokens,
|
||||||
thinking_content=message.get("reasoning_content", ""),
|
|
||||||
)
|
)
|
||||||
db.session.add(msg)
|
db.session.add(msg)
|
||||||
|
|
||||||
# Create tool call records
|
|
||||||
self._save_tool_calls(msg.id, all_tool_calls, all_tool_results)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
user = get_or_create_default_user()
|
user = get_or_create_default_user()
|
||||||
|
|
@ -86,8 +91,15 @@ class ChatService:
|
||||||
conversation_id=conv.id, role="user"
|
conversation_id=conv.id, role="user"
|
||||||
).order_by(Message.created_at.asc()).first()
|
).order_by(Message.created_at.asc()).first()
|
||||||
if user_msg and user_msg.content:
|
if user_msg and user_msg.content:
|
||||||
suggested_title = user_msg.content.strip()[:30]
|
# Parse content JSON to get text
|
||||||
if not suggested_title:
|
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 = "新对话"
|
suggested_title = "新对话"
|
||||||
conv.title = suggested_title
|
conv.title = suggested_title
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
@ -254,18 +266,23 @@ class ChatService:
|
||||||
|
|
||||||
suggested_title = None
|
suggested_title = None
|
||||||
with app.app_context():
|
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(
|
msg = Message(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
conversation_id=conv_id,
|
conversation_id=conv_id,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=full_content,
|
content=json.dumps(content_json, ensure_ascii=False),
|
||||||
token_count=token_count,
|
token_count=token_count,
|
||||||
thinking_content=full_thinking,
|
|
||||||
)
|
)
|
||||||
db.session.add(msg)
|
db.session.add(msg)
|
||||||
|
|
||||||
# Create tool call records
|
|
||||||
self._save_tool_calls(msg_id, all_tool_calls, all_tool_results)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
user = get_or_create_default_user()
|
user = get_or_create_default_user()
|
||||||
|
|
@ -279,9 +296,15 @@ class ChatService:
|
||||||
conversation_id=conv_id, role="user"
|
conversation_id=conv_id, role="user"
|
||||||
).order_by(Message.created_at.asc()).first()
|
).order_by(Message.created_at.asc()).first()
|
||||||
if user_msg and user_msg.content:
|
if user_msg and user_msg.content:
|
||||||
# Use first 30 chars of user message as title
|
# Parse content JSON to get text
|
||||||
suggested_title = user_msg.content.strip()[:30]
|
try:
|
||||||
if not suggested_title:
|
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 = "新对话"
|
suggested_title = "新对话"
|
||||||
# Refresh conv to avoid stale state
|
# Refresh conv to avoid stale state
|
||||||
db.session.refresh(conv)
|
db.session.refresh(conv)
|
||||||
|
|
@ -306,63 +329,59 @@ class ChatService:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def _save_tool_calls(self, message_id: str, tool_calls: list, tool_results: list) -> None:
|
def _build_tool_calls_json(self, tool_calls: list, tool_results: list) -> list:
|
||||||
"""Save tool calls to database"""
|
"""Build tool calls JSON structure"""
|
||||||
|
result = []
|
||||||
for i, tc in enumerate(tool_calls):
|
for i, tc in enumerate(tool_calls):
|
||||||
result_content = tool_results[i]["content"] if i < len(tool_results) else None
|
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
|
execution_time = 0
|
||||||
if result_content:
|
if result_content:
|
||||||
try:
|
try:
|
||||||
result_data = json.loads(result_content)
|
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)
|
execution_time = result_data.get("execution_time", 0)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
tool_call = ToolCall(
|
result.append({
|
||||||
message_id=message_id,
|
"id": tc.get("id", ""),
|
||||||
call_id=tc.get("id", ""),
|
"name": tc["function"]["name"],
|
||||||
call_index=i,
|
"arguments": tc["function"]["arguments"],
|
||||||
tool_name=tc["function"]["name"],
|
"result": result_content,
|
||||||
arguments=tc["function"]["arguments"],
|
|
||||||
result=result_content,
|
|
||||||
execution_time=execution_time,
|
|
||||||
)
|
|
||||||
db.session.add(tool_call)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
result["tool_calls"].append({
|
|
||||||
"id": tc.call_id,
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tc.tool_name,
|
|
||||||
"arguments": tc.arguments,
|
|
||||||
},
|
|
||||||
"result": tc.result,
|
|
||||||
"success": success,
|
"success": success,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"execution_time": tc.execution_time,
|
"execution_time": execution_time,
|
||||||
})
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _message_to_dict(self, msg: Message) -> dict:
|
||||||
|
"""Convert message to dict, parsing JSON content"""
|
||||||
|
result = to_dict(msg)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
if "text" not in result:
|
||||||
|
result["text"] = ""
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,37 +47,30 @@ def to_dict(inst, **extra):
|
||||||
|
|
||||||
|
|
||||||
def message_to_dict(msg: Message) -> dict:
|
def message_to_dict(msg: Message) -> dict:
|
||||||
"""Convert message to dict with tool calls"""
|
"""Convert message to dict, parsing JSON content"""
|
||||||
result = to_dict(msg, thinking_content=msg.thinking_content or None)
|
result = to_dict(msg)
|
||||||
|
|
||||||
# Add tool calls if any
|
# Parse content JSON
|
||||||
tool_calls = msg.tool_calls.all() if msg.tool_calls else []
|
if msg.content:
|
||||||
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:
|
try:
|
||||||
result_data = json.loads(tc.result)
|
content_data = json.loads(msg.content)
|
||||||
success = result_data.get("success", True)
|
if isinstance(content_data, dict):
|
||||||
skipped = result_data.get("skipped", False)
|
# Extract all fields from JSON
|
||||||
except:
|
result["text"] = content_data.get("text", "")
|
||||||
pass
|
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({
|
if "text" not in result:
|
||||||
"id": tc.call_id,
|
result["text"] = ""
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": tc.tool_name,
|
|
||||||
"arguments": tc.arguments,
|
|
||||||
},
|
|
||||||
"result": tc.result,
|
|
||||||
"success": success,
|
|
||||||
"skipped": skipped,
|
|
||||||
"execution_time": tc.execution_time,
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -113,5 +106,29 @@ def build_glm_messages(conv):
|
||||||
# Query messages directly to avoid detached instance warning
|
# Query messages directly to avoid detached instance warning
|
||||||
messages = Message.query.filter_by(conversation_id=conv.id).order_by(Message.created_at.asc()).all()
|
messages = Message.query.filter_by(conversation_id=conv.id).order_by(Message.created_at.asc()).all()
|
||||||
for m in messages:
|
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
|
return msgs
|
||||||
|
|
|
||||||
|
|
@ -109,20 +109,6 @@ classDiagram
|
||||||
+String role
|
+String role
|
||||||
+LongText content
|
+LongText content
|
||||||
+Integer token_count
|
+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
|
+DateTime created_at
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,10 +125,42 @@ classDiagram
|
||||||
|
|
||||||
User "1" --> "*" Conversation : 拥有
|
User "1" --> "*" Conversation : 拥有
|
||||||
Conversation "1" --> "*" Message : 包含
|
Conversation "1" --> "*" Message : 包含
|
||||||
Message "1" --> "*" ToolCall : 触发
|
|
||||||
User "1" --> "*" TokenUsage : 消耗
|
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
|
```mermaid
|
||||||
|
|
@ -155,10 +173,9 @@ classDiagram
|
||||||
+Integer MAX_ITERATIONS
|
+Integer MAX_ITERATIONS
|
||||||
+sync_response(conv, tools_enabled) Response
|
+sync_response(conv, tools_enabled) Response
|
||||||
+stream_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
|
-_message_to_dict(msg) dict
|
||||||
-_process_tool_calls_delta(delta, list) list
|
-_process_tool_calls_delta(delta, list) list
|
||||||
-_emit_process_step(event, data) void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class GLMClient {
|
class GLMClient {
|
||||||
|
|
@ -351,23 +368,8 @@ iteration 2:
|
||||||
| `id` | String(64) | UUID 主键 |
|
| `id` | String(64) | UUID 主键 |
|
||||||
| `conversation_id` | String(64) | 外键关联 Conversation |
|
| `conversation_id` | String(64) | 外键关联 Conversation |
|
||||||
| `role` | String(16) | user/assistant/system/tool |
|
| `role` | String(16) | user/assistant/system/tool |
|
||||||
| `content` | LongText | 消息内容 |
|
| `content` | LongText | JSON 格式内容(见上方结构说明) |
|
||||||
| `token_count` | Integer | Token 数量 |
|
| `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 | 创建时间 |
|
| `created_at` | DateTime | 创建时间 |
|
||||||
|
|
||||||
### TokenUsage(Token 使用统计)
|
### TokenUsage(Token 使用统计)
|
||||||
|
|
|
||||||
|
|
@ -186,19 +186,21 @@ function loadMoreMessages() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Send message (streaming) --
|
// -- Send message (streaming) --
|
||||||
async function sendMessage(content) {
|
async function sendMessage(data) {
|
||||||
if (!currentConvId.value || streaming.value) return
|
if (!currentConvId.value || streaming.value) return
|
||||||
|
|
||||||
const convId = currentConvId.value // 保存当前对话ID
|
const convId = currentConvId.value // 保存当前对话ID
|
||||||
|
const text = data.text || ''
|
||||||
|
const attachments = data.attachments || null
|
||||||
|
|
||||||
// Add user message optimistically
|
// Add user message optimistically
|
||||||
const userMsg = {
|
const userMsg = {
|
||||||
id: 'temp_' + Date.now(),
|
id: 'temp_' + Date.now(),
|
||||||
conversation_id: convId,
|
conversation_id: convId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content,
|
text,
|
||||||
|
attachments: attachments ? attachments.map(a => ({ name: a.name, extension: a.extension })) : null,
|
||||||
token_count: 0,
|
token_count: 0,
|
||||||
thinking_content: null,
|
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
messages.value.push(userMsg)
|
messages.value.push(userMsg)
|
||||||
|
|
@ -209,7 +211,7 @@ async function sendMessage(content) {
|
||||||
streamToolCalls.value = []
|
streamToolCalls.value = []
|
||||||
streamProcessSteps.value = []
|
streamProcessSteps.value = []
|
||||||
|
|
||||||
currentStreamPromise = messageApi.send(convId, content, {
|
currentStreamPromise = messageApi.send(convId, { text, attachments }, {
|
||||||
stream: true,
|
stream: true,
|
||||||
toolsEnabled: toolsEnabled.value,
|
toolsEnabled: toolsEnabled.value,
|
||||||
onThinkingStart() {
|
onThinkingStart() {
|
||||||
|
|
@ -288,11 +290,11 @@ async function sendMessage(content) {
|
||||||
id: data.message_id,
|
id: data.message_id,
|
||||||
conversation_id: convId,
|
conversation_id: convId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: streamContent.value,
|
text: streamContent.value,
|
||||||
token_count: data.token_count,
|
thinking: streamThinking.value || null,
|
||||||
thinking_content: streamThinking.value || null,
|
|
||||||
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
||||||
process_steps: streamProcessSteps.value.filter(Boolean),
|
process_steps: streamProcessSteps.value.filter(Boolean),
|
||||||
|
token_count: data.token_count,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
streamContent.value = ''
|
streamContent.value = ''
|
||||||
|
|
@ -426,11 +428,11 @@ async function regenerateMessage(msgId) {
|
||||||
id: data.message_id,
|
id: data.message_id,
|
||||||
conversation_id: convId,
|
conversation_id: convId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: streamContent.value,
|
text: streamContent.value,
|
||||||
token_count: data.token_count,
|
thinking: streamThinking.value || null,
|
||||||
thinking_content: streamThinking.value || null,
|
|
||||||
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
|
||||||
process_steps: streamProcessSteps.value.filter(Boolean),
|
process_steps: streamProcessSteps.value.filter(Boolean),
|
||||||
|
token_count: data.token_count,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
streamContent.value = ''
|
streamContent.value = ''
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,11 @@ export const messageApi = {
|
||||||
return request(`/conversations/${convId}/messages?${params}`)
|
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) {
|
if (!stream) {
|
||||||
return request(`/conversations/${convId}/messages`, {
|
return request(`/conversations/${convId}/messages`, {
|
||||||
method: 'POST',
|
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`, {
|
const res = await fetch(`${BASE}/conversations/${convId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,14 @@
|
||||||
v-for="msg in messages"
|
v-for="msg in messages"
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
:role="msg.role"
|
:role="msg.role"
|
||||||
:content="msg.content"
|
:text="msg.text"
|
||||||
:thinking-content="msg.thinking_content"
|
:thinking-content="msg.thinking"
|
||||||
:tool-calls="msg.tool_calls"
|
:tool-calls="msg.tool_calls"
|
||||||
:process-steps="msg.process_steps"
|
:process-steps="msg.process_steps"
|
||||||
:tool-name="msg.name"
|
|
||||||
:token-count="msg.token_count"
|
:token-count="msg.token_count"
|
||||||
:created-at="msg.created_at"
|
:created-at="msg.created_at"
|
||||||
:deletable="msg.role === 'user'"
|
:deletable="msg.role === 'user'"
|
||||||
|
:attachments="msg.attachments"
|
||||||
@delete="$emit('deleteMessage', msg.id)"
|
@delete="$emit('deleteMessage', msg.id)"
|
||||||
@regenerate="$emit('regenerateMessage', msg.id)"
|
@regenerate="$emit('regenerateMessage', msg.id)"
|
||||||
/>
|
/>
|
||||||
|
|
@ -72,7 +72,7 @@
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:disabled="streaming"
|
:disabled="streaming"
|
||||||
:tools-enabled="toolsEnabled"
|
:tools-enabled="toolsEnabled"
|
||||||
@send="$emit('sendMessage', $event)"
|
@send="handleSend"
|
||||||
@toggle-tools="$emit('toggleTools', $event)"
|
@toggle-tools="$emit('toggleTools', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -99,7 +99,7 @@ const props = defineProps({
|
||||||
toolsEnabled: { type: Boolean, default: true },
|
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 scrollContainer = ref(null)
|
||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
|
|
@ -109,6 +109,10 @@ const renderedStreamContent = computed(() => {
|
||||||
return renderMarkdown(props.streamingContent)
|
return renderMarkdown(props.streamingContent)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleSend(data) {
|
||||||
|
emit('sendMessage', data)
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToBottom(smooth = true) {
|
function scrollToBottom(smooth = true) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = scrollContainer.value
|
const el = scrollContainer.value
|
||||||
|
|
@ -255,7 +259,7 @@ defineExpose({ scrollToBottom })
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
flex: 1 1 auto; /* 弹性高度,自动填充 */
|
flex: 1 1 auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -272,6 +276,10 @@ defineExpose({ scrollToBottom })
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.messages-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.load-more-top {
|
.load-more-top {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
|
|
@ -379,15 +387,6 @@ defineExpose({ scrollToBottom })
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(p) {
|
.streaming-content :deep(p) {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
<div v-if="role === 'user'" class="avatar">user</div>
|
<div v-if="role === 'user'" class="avatar">user</div>
|
||||||
<div v-else class="avatar">claw</div>
|
<div v-else class="avatar">claw</div>
|
||||||
<div class="message-container">
|
<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">
|
<div class="message-body">
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
v-if="thinkingContent || (toolCalls && toolCalls.length > 0) || (processSteps && processSteps.length > 0)"
|
v-if="thinkingContent || (toolCalls && toolCalls.length > 0) || (processSteps && processSteps.length > 0)"
|
||||||
|
|
@ -49,7 +56,8 @@ import ProcessBlock from './ProcessBlock.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
role: { type: String, required: true },
|
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: '' },
|
thinkingContent: { type: String, default: '' },
|
||||||
toolCalls: { type: Array, default: () => [] },
|
toolCalls: { type: Array, default: () => [] },
|
||||||
processSteps: { type: Array, default: () => [] },
|
processSteps: { type: Array, default: () => [] },
|
||||||
|
|
@ -57,13 +65,16 @@ const props = defineProps({
|
||||||
tokenCount: { type: Number, default: 0 },
|
tokenCount: { type: Number, default: 0 },
|
||||||
createdAt: { type: String, default: '' },
|
createdAt: { type: String, default: '' },
|
||||||
deletable: { type: Boolean, default: false },
|
deletable: { type: Boolean, default: false },
|
||||||
|
attachments: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['delete', 'regenerate'])
|
defineEmits(['delete', 'regenerate'])
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
if (!props.content) return ''
|
// Use 'text' field (new format), fallback to 'content' (old format/assistant messages)
|
||||||
return renderMarkdown(props.content)
|
const displayContent = props.text || props.content || ''
|
||||||
|
if (!displayContent) return ''
|
||||||
|
return renderMarkdown(displayContent)
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatTime(iso) {
|
function formatTime(iso) {
|
||||||
|
|
@ -108,6 +119,40 @@ function copyContent() {
|
||||||
min-width: 0;
|
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 {
|
.message-bubble.assistant .message-body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="message-input">
|
<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
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="text"
|
v-model="text"
|
||||||
|
|
@ -10,6 +24,14 @@
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<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">
|
<div class="input-actions">
|
||||||
<button
|
<button
|
||||||
class="btn-tool"
|
class="btn-tool"
|
||||||
|
|
@ -22,10 +44,20 @@
|
||||||
<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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="btn-send"
|
class="btn-send"
|
||||||
:class="{ active: text.trim() && !disabled }"
|
:class="{ active: canSend }"
|
||||||
:disabled="!text.trim() || disabled"
|
:disabled="!canSend || disabled"
|
||||||
@click="send"
|
@click="send"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
|
@ -35,12 +67,13 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="input-hint">AI 助手回复内容仅供参考</div>
|
<div class="input-hint">AI 助手回复内容仅供参考</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, nextTick } from 'vue'
|
import { ref, computed, nextTick } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
|
|
@ -50,6 +83,10 @@ const props = defineProps({
|
||||||
const emit = defineEmits(['send', 'toggleTools'])
|
const emit = defineEmits(['send', 'toggleTools'])
|
||||||
const text = ref('')
|
const text = ref('')
|
||||||
const textareaRef = ref(null)
|
const textareaRef = ref(null)
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
const uploadedFiles = ref([])
|
||||||
|
|
||||||
|
const canSend = computed(() => text.value.trim() || uploadedFiles.value.length > 0)
|
||||||
|
|
||||||
function autoResize() {
|
function autoResize() {
|
||||||
const el = textareaRef.value
|
const el = textareaRef.value
|
||||||
|
|
@ -66,10 +103,23 @@ function onKeydown(e) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function send() {
|
function send() {
|
||||||
const content = text.value.trim()
|
if (props.disabled || !canSend.value) return
|
||||||
if (!content || props.disabled) return
|
|
||||||
emit('send', content)
|
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 = ''
|
text.value = ''
|
||||||
|
uploadedFiles.value = []
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
autoResize()
|
autoResize()
|
||||||
})
|
})
|
||||||
|
|
@ -79,6 +129,43 @@ function toggleTools() {
|
||||||
emit('toggleTools', !props.toolsEnabled)
|
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() {
|
function focus() {
|
||||||
textareaRef.value?.focus()
|
textareaRef.value?.focus()
|
||||||
}
|
}
|
||||||
|
|
@ -94,31 +181,93 @@ defineExpose({ focus })
|
||||||
transition: background 0.2s, border-color 0.2s;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
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);
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--border-input);
|
border: 1px solid var(--border-input);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 8px 12px;
|
padding: 12px;
|
||||||
transition: border-color 0.2s, background 0.2s;
|
transition: border-color 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper:focus-within {
|
.input-container:focus-within {
|
||||||
border-color: var(--accent-primary);
|
border-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
resize: none;
|
resize: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
min-height: 36px;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
|
|
@ -129,14 +278,21 @@ textarea:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.input-actions {
|
.input-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-left: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-tool {
|
.btn-tool,
|
||||||
|
.btn-send {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
@ -147,12 +303,27 @@ textarea:disabled {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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);
|
background: var(--bg-hover);
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-tool.active {
|
.btn-tool.active {
|
||||||
|
|
@ -160,23 +331,30 @@ textarea:disabled {
|
||||||
color: var(--success-color);
|
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;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-send {
|
.btn-send {
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: var(--bg-code);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-send.active {
|
.btn-send.active {
|
||||||
|
|
@ -187,6 +365,13 @@ textarea:disabled {
|
||||||
|
|
||||||
.btn-send.active:hover {
|
.btn-send.active:hover {
|
||||||
background: var(--accent-primary-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 {
|
.input-hint {
|
||||||
|
|
|
||||||
|
|
@ -280,10 +280,33 @@ onMounted(loadModels)
|
||||||
|
|
||||||
.form-group select {
|
.form-group select {
|
||||||
cursor: pointer;
|
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 {
|
.form-group select option {
|
||||||
background: var(--bg-primary);
|
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 {
|
.form-row {
|
||||||
|
|
@ -295,27 +318,6 @@ onMounted(loadModels)
|
||||||
flex: 1;
|
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 {
|
.range-labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import './styles/global.css'
|
||||||
import './styles/highlight.css'
|
import './styles/highlight.css'
|
||||||
import 'katex/dist/katex.min.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