refactor: 统一消息存储为 JSON 结构

This commit is contained in:
ViperEkura 2026-03-25 23:02:54 +08:00
parent beb1a03d92
commit 46cacc7fa2
13 changed files with 658 additions and 266 deletions

View File

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

View File

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

View File

@ -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,16 +296,22 @@ 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)
conv.title = suggested_title conv.title = suggested_title
db.session.commit() db.session.commit()
else: else:
suggested_title = None 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" 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 return
@ -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"], "success": success,
result=result_content, "skipped": skipped,
execution_time=execution_time, "execution_time": execution_time,
) })
db.session.add(tool_call) return result
def _message_to_dict(self, msg: Message) -> dict: def _message_to_dict(self, 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: try:
result["tool_calls"] = [] content_data = json.loads(msg.content)
for tc in tool_calls: if isinstance(content_data, dict):
# Parse result to extract success/skipped status result["text"] = content_data.get("text", "")
success = True if content_data.get("attachments"):
skipped = False result["attachments"] = content_data["attachments"]
if tc.result: if content_data.get("thinking"):
try: result["thinking"] = content_data["thinking"]
result_data = json.loads(tc.result) if content_data.get("tool_calls"):
success = result_data.get("success", True) result["tool_calls"] = content_data["tool_calls"]
skipped = result_data.get("skipped", False) else:
except: result["text"] = msg.content
pass 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

View File

@ -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: try:
result["tool_calls"] = [] content_data = json.loads(msg.content)
for tc in tool_calls: if isinstance(content_data, dict):
# Parse result to extract success/skipped status # Extract all fields from JSON
success = True result["text"] = content_data.get("text", "")
skipped = False if content_data.get("attachments"):
if tc.result: result["attachments"] = content_data["attachments"]
try: if content_data.get("thinking"):
result_data = json.loads(tc.result) result["thinking"] = content_data["thinking"]
success = result_data.get("success", True) if content_data.get("tool_calls"):
skipped = result_data.get("skipped", False) result["tool_calls"] = content_data["tool_calls"]
except: else:
pass # 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

View File

@ -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 | 创建时间 |
### TokenUsageToken 使用统计) ### TokenUsageToken 使用统计)

View File

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

View File

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

View File

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

View File

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

View File

@ -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,29 +24,48 @@
@keydown="onKeydown" @keydown="onKeydown"
:disabled="disabled" :disabled="disabled"
></textarea> ></textarea>
<div class="input-actions"> <div class="input-footer">
<button <input
class="btn-tool" ref="fileInputRef"
:class="{ active: toolsEnabled }" type="file"
:disabled="disabled" 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"
@click="toggleTools" @change="handleFileUpload"
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'" style="display: none"
> />
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="input-actions">
<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"/> <button
</svg> class="btn-tool"
</button> :class="{ active: toolsEnabled }"
<button :disabled="disabled"
class="btn-send" @click="toggleTools"
:class="{ active: text.trim() && !disabled }" :title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
:disabled="!text.trim() || disabled" >
@click="send" <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 width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> </svg>
<line x1="22" y1="2" x2="11" y2="13"></line> </button>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon> <button
</svg> class="btn-upload"
</button> :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> </div>
<div class="input-hint">AI 助手回复内容仅供参考</div> <div class="input-hint">AI 助手回复内容仅供参考</div>
@ -40,7 +73,7 @@
</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 {

View File

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

View File

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

View File

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