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"),
nullable=False, index=True)
role = db.Column(db.String(16), nullable=False) # user, assistant, system, tool
content = db.Column(LongText, default="") # LongText for long conversations
# Unified JSON structure:
# User: {"text": "...", "attachments": [{"name": "a.py", "extension": "py", "content": "..."}]}
# Assistant: {"text": "...", "thinking": "...", "tool_calls": [{"id": "...", "name": "...", "arguments": "...", "result": "..."}]}
content = db.Column(LongText, default="")
token_count = db.Column(db.Integer, default=0)
thinking_content = db.Column(LongText, default="")
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), index=True)
# Tool call support - relation to ToolCall table
tool_calls = db.relationship("ToolCall", backref="message", lazy="dynamic",
cascade="all, delete-orphan",
order_by="ToolCall.call_index.asc()")
class ToolCall(db.Model):
"""Tool call record - separate table, follows database normalization"""
__tablename__ = "tool_calls"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
message_id = db.Column(db.String(64), db.ForeignKey("messages.id"),
nullable=False, index=True)
call_id = db.Column(db.String(64), nullable=False) # Tool call ID
call_index = db.Column(db.Integer, nullable=False, default=0) # Call order
tool_name = db.Column(db.String(64), nullable=False) # Tool name
arguments = db.Column(LongText, nullable=False) # Call arguments JSON
result = db.Column(LongText) # Execution result JSON
execution_time = db.Column(db.Float, default=0) # Execution time (seconds)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
__table_args__ = (
db.Index("ix_tool_calls_message_call", "message_id", "call_index"),
)
class TokenUsage(db.Model):
__tablename__ = "token_usage"

View File

@ -1,4 +1,5 @@
"""Message API routes"""
import json
import uuid
from datetime import datetime
from flask import Blueprint, request
@ -45,11 +46,23 @@ def message_list(conv_id):
# POST - create message and get AI response
d = request.json or {}
content = (d.get("content") or "").strip()
if not content:
return err(400, "content is required")
text = (d.get("text") or "").strip()
attachments = d.get("attachments") # [{"name": "a.py", "extension": "py", "content": "..."}]
user_msg = Message(id=str(uuid.uuid4()), conversation_id=conv_id, role="user", content=content)
if not text and not attachments:
return err(400, "text or attachments is required")
# Build content JSON structure
content_json = {"text": text}
if attachments:
content_json["attachments"] = attachments
user_msg = Message(
id=str(uuid.uuid4()),
conversation_id=conv_id,
role="user",
content=json.dumps(content_json, ensure_ascii=False),
)
db.session.add(user_msg)
db.session.commit()

View File

@ -3,7 +3,7 @@ import json
import uuid
from flask import current_app, Response
from backend import db
from backend.models import Conversation, Message, ToolCall
from backend.models import Conversation, Message
from backend.tools import registry, ToolExecutor
from backend.utils.helpers import (
get_or_create_default_user,
@ -61,19 +61,24 @@ class ChatService:
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
# Build content JSON
content_json = {
"text": message.get("content", ""),
}
if message.get("reasoning_content"):
content_json["thinking"] = message["reasoning_content"]
if all_tool_calls:
content_json["tool_calls"] = self._build_tool_calls_json(all_tool_calls, all_tool_results)
# Create message
msg = Message(
id=str(uuid.uuid4()),
conversation_id=conv.id,
role="assistant",
content=message.get("content", ""),
content=json.dumps(content_json, ensure_ascii=False),
token_count=completion_tokens,
thinking_content=message.get("reasoning_content", ""),
)
db.session.add(msg)
# Create tool call records
self._save_tool_calls(msg.id, all_tool_calls, all_tool_results)
db.session.commit()
user = get_or_create_default_user()
@ -86,8 +91,15 @@ class ChatService:
conversation_id=conv.id, role="user"
).order_by(Message.created_at.asc()).first()
if user_msg and user_msg.content:
suggested_title = user_msg.content.strip()[:30]
if not suggested_title:
# Parse content JSON to get text
try:
content_data = json.loads(user_msg.content)
title_text = content_data.get("text", "")[:30]
except (json.JSONDecodeError, TypeError):
title_text = user_msg.content.strip()[:30]
if title_text:
suggested_title = title_text
else:
suggested_title = "新对话"
conv.title = suggested_title
db.session.commit()
@ -254,18 +266,23 @@ class ChatService:
suggested_title = None
with app.app_context():
# Build content JSON
content_json = {
"text": full_content,
}
if full_thinking:
content_json["thinking"] = full_thinking
if all_tool_calls:
content_json["tool_calls"] = self._build_tool_calls_json(all_tool_calls, all_tool_results)
msg = Message(
id=msg_id,
conversation_id=conv_id,
role="assistant",
content=full_content,
content=json.dumps(content_json, ensure_ascii=False),
token_count=token_count,
thinking_content=full_thinking,
)
db.session.add(msg)
# Create tool call records
self._save_tool_calls(msg_id, all_tool_calls, all_tool_results)
db.session.commit()
user = get_or_create_default_user()
@ -279,16 +296,22 @@ class ChatService:
conversation_id=conv_id, role="user"
).order_by(Message.created_at.asc()).first()
if user_msg and user_msg.content:
# Use first 30 chars of user message as title
suggested_title = user_msg.content.strip()[:30]
if not suggested_title:
# Parse content JSON to get text
try:
content_data = json.loads(user_msg.content)
title_text = content_data.get("text", "")[:30]
except (json.JSONDecodeError, TypeError):
title_text = user_msg.content.strip()[:30]
if title_text:
suggested_title = title_text
else:
suggested_title = "新对话"
# Refresh conv to avoid stale state
db.session.refresh(conv)
conv.title = suggested_title
db.session.commit()
else:
suggested_title = None
else:
suggested_title = None
yield f"event: done\ndata: {json.dumps({'message_id': msg_id, 'token_count': token_count, 'suggested_title': suggested_title}, ensure_ascii=False)}\n\n"
return
@ -306,63 +329,59 @@ class ChatService:
}
)
def _save_tool_calls(self, message_id: str, tool_calls: list, tool_results: list) -> None:
"""Save tool calls to database"""
def _build_tool_calls_json(self, tool_calls: list, tool_results: list) -> list:
"""Build tool calls JSON structure"""
result = []
for i, tc in enumerate(tool_calls):
result_content = tool_results[i]["content"] if i < len(tool_results) else None
# Parse result to extract execution_time if present
# Parse result to extract success/skipped status
success = True
skipped = False
execution_time = 0
if result_content:
try:
result_data = json.loads(result_content)
success = result_data.get("success", True)
skipped = result_data.get("skipped", False)
execution_time = result_data.get("execution_time", 0)
except:
pass
tool_call = ToolCall(
message_id=message_id,
call_id=tc.get("id", ""),
call_index=i,
tool_name=tc["function"]["name"],
arguments=tc["function"]["arguments"],
result=result_content,
execution_time=execution_time,
)
db.session.add(tool_call)
result.append({
"id": tc.get("id", ""),
"name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
"result": result_content,
"success": success,
"skipped": skipped,
"execution_time": execution_time,
})
return result
def _message_to_dict(self, msg: Message) -> dict:
"""Convert message to dict with tool calls"""
result = to_dict(msg, thinking_content=msg.thinking_content or None)
"""Convert message to dict, parsing JSON content"""
result = to_dict(msg)
# Add tool calls if any
tool_calls = msg.tool_calls.all() if msg.tool_calls else []
if tool_calls:
result["tool_calls"] = []
for tc in tool_calls:
# Parse result to extract success/skipped status
success = True
skipped = False
if tc.result:
try:
result_data = json.loads(tc.result)
success = result_data.get("success", True)
skipped = result_data.get("skipped", False)
except:
pass
# Parse content JSON
if msg.content:
try:
content_data = json.loads(msg.content)
if isinstance(content_data, dict):
result["text"] = content_data.get("text", "")
if content_data.get("attachments"):
result["attachments"] = content_data["attachments"]
if content_data.get("thinking"):
result["thinking"] = content_data["thinking"]
if content_data.get("tool_calls"):
result["tool_calls"] = content_data["tool_calls"]
else:
result["text"] = msg.content
except (json.JSONDecodeError, TypeError):
result["text"] = msg.content
result["tool_calls"].append({
"id": tc.call_id,
"type": "function",
"function": {
"name": tc.tool_name,
"arguments": tc.arguments,
},
"result": tc.result,
"success": success,
"skipped": skipped,
"execution_time": tc.execution_time,
})
if "text" not in result:
result["text"] = ""
return result

View File

@ -47,37 +47,30 @@ def to_dict(inst, **extra):
def message_to_dict(msg: Message) -> dict:
"""Convert message to dict with tool calls"""
result = to_dict(msg, thinking_content=msg.thinking_content or None)
"""Convert message to dict, parsing JSON content"""
result = to_dict(msg)
# Add tool calls if any
tool_calls = msg.tool_calls.all() if msg.tool_calls else []
if tool_calls:
result["tool_calls"] = []
for tc in tool_calls:
# Parse result to extract success/skipped status
success = True
skipped = False
if tc.result:
try:
result_data = json.loads(tc.result)
success = result_data.get("success", True)
skipped = result_data.get("skipped", False)
except:
pass
# Parse content JSON
if msg.content:
try:
content_data = json.loads(msg.content)
if isinstance(content_data, dict):
# Extract all fields from JSON
result["text"] = content_data.get("text", "")
if content_data.get("attachments"):
result["attachments"] = content_data["attachments"]
if content_data.get("thinking"):
result["thinking"] = content_data["thinking"]
if content_data.get("tool_calls"):
result["tool_calls"] = content_data["tool_calls"]
else:
# Fallback: plain text
result["text"] = msg.content
except (json.JSONDecodeError, TypeError):
result["text"] = msg.content
result["tool_calls"].append({
"id": tc.call_id,
"type": "function",
"function": {
"name": tc.tool_name,
"arguments": tc.arguments,
},
"result": tc.result,
"success": success,
"skipped": skipped,
"execution_time": tc.execution_time,
})
if "text" not in result:
result["text"] = ""
return result
@ -113,5 +106,29 @@ def build_glm_messages(conv):
# Query messages directly to avoid detached instance warning
messages = Message.query.filter_by(conversation_id=conv.id).order_by(Message.created_at.asc()).all()
for m in messages:
msgs.append({"role": m.role, "content": m.content})
# Build full content from JSON structure
full_content = m.content
try:
content_data = json.loads(m.content)
if isinstance(content_data, dict):
text = content_data.get("text", "")
attachments = content_data.get("attachments", [])
# Build full content with attachments
parts = []
if text:
parts.append(text)
for att in attachments:
filename = att.get("name", "")
file_content = att.get("content", "")
if filename and file_content:
parts.append(f"```{filename}\n{file_content}\n```")
full_content = "\n\n".join(parts) if parts else ""
except (json.JSONDecodeError, TypeError):
# Plain text, use as-is
pass
msgs.append({"role": m.role, "content": full_content})
return msgs

View File

@ -109,20 +109,6 @@ classDiagram
+String role
+LongText content
+Integer token_count
+LongText thinking_content
+DateTime created_at
+relationship tool_calls
}
class ToolCall {
+Integer id
+String message_id
+String call_id
+Integer call_index
+String tool_name
+LongText arguments
+LongText result
+Float execution_time
+DateTime created_at
}
@ -139,10 +125,42 @@ classDiagram
User "1" --> "*" Conversation : 拥有
Conversation "1" --> "*" Message : 包含
Message "1" --> "*" ToolCall : 触发
User "1" --> "*" TokenUsage : 消耗
```
### Message Content JSON 结构
`content` 字段统一使用 JSON 格式存储:
**User 消息:**
```json
{
"text": "用户输入的文本内容",
"attachments": [
{"name": "utils.py", "extension": "py", "content": "def hello()..."}
]
}
```
**Assistant 消息:**
```json
{
"text": "AI 回复的文本内容",
"thinking": "思考过程(可选)",
"tool_calls": [
{
"id": "call_xxx",
"name": "read_file",
"arguments": "{\"path\": \"...\"}",
"result": "{\"content\": \"...\"}",
"success": true,
"skipped": false,
"execution_time": 0.5
}
]
}
```
### 服务层
```mermaid
@ -155,10 +173,9 @@ classDiagram
+Integer MAX_ITERATIONS
+sync_response(conv, tools_enabled) Response
+stream_response(conv, tools_enabled) Response
-_save_tool_calls(msg_id, calls, results) void
-_build_tool_calls_json(calls, results) list
-_message_to_dict(msg) dict
-_process_tool_calls_delta(delta, list) list
-_emit_process_step(event, data) void
}
class GLMClient {
@ -351,23 +368,8 @@ iteration 2:
| `id` | String(64) | UUID 主键 |
| `conversation_id` | String(64) | 外键关联 Conversation |
| `role` | String(16) | user/assistant/system/tool |
| `content` | LongText | 消息内容 |
| `content` | LongText | JSON 格式内容(见上方结构说明) |
| `token_count` | Integer | Token 数量 |
| `thinking_content` | LongText | 思维链内容 |
| `created_at` | DateTime | 创建时间 |
### ToolCall工具调用
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | Integer | 自增主键 |
| `message_id` | String(64) | 外键关联 Message |
| `call_id` | String(64) | LLM 返回的工具调用 ID |
| `call_index` | Integer | 消息内的调用顺序 |
| `tool_name` | String(64) | 工具名称 |
| `arguments` | LongText | JSON 参数 |
| `result` | LongText | JSON 结果 |
| `execution_time` | Float | 执行时间(秒) |
| `created_at` | DateTime | 创建时间 |
### TokenUsageToken 使用统计)

View File

@ -186,19 +186,21 @@ function loadMoreMessages() {
}
// -- Send message (streaming) --
async function sendMessage(content) {
async function sendMessage(data) {
if (!currentConvId.value || streaming.value) return
const convId = currentConvId.value // ID
const text = data.text || ''
const attachments = data.attachments || null
// Add user message optimistically
const userMsg = {
id: 'temp_' + Date.now(),
conversation_id: convId,
role: 'user',
content,
text,
attachments: attachments ? attachments.map(a => ({ name: a.name, extension: a.extension })) : null,
token_count: 0,
thinking_content: null,
created_at: new Date().toISOString(),
}
messages.value.push(userMsg)
@ -209,7 +211,7 @@ async function sendMessage(content) {
streamToolCalls.value = []
streamProcessSteps.value = []
currentStreamPromise = messageApi.send(convId, content, {
currentStreamPromise = messageApi.send(convId, { text, attachments }, {
stream: true,
toolsEnabled: toolsEnabled.value,
onThinkingStart() {
@ -288,11 +290,11 @@ async function sendMessage(content) {
id: data.message_id,
conversation_id: convId,
role: 'assistant',
content: streamContent.value,
token_count: data.token_count,
thinking_content: streamThinking.value || null,
text: streamContent.value,
thinking: streamThinking.value || null,
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
process_steps: streamProcessSteps.value.filter(Boolean),
token_count: data.token_count,
created_at: new Date().toISOString(),
})
streamContent.value = ''
@ -426,11 +428,11 @@ async function regenerateMessage(msgId) {
id: data.message_id,
conversation_id: convId,
role: 'assistant',
content: streamContent.value,
token_count: data.token_count,
thinking_content: streamThinking.value || null,
text: streamContent.value,
thinking: streamThinking.value || null,
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
process_steps: streamProcessSteps.value.filter(Boolean),
token_count: data.token_count,
created_at: new Date().toISOString(),
})
streamContent.value = ''

View File

@ -95,11 +95,11 @@ export const messageApi = {
return request(`/conversations/${convId}/messages?${params}`)
},
send(convId, content, { stream = true, toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) {
send(convId, data, { stream = true, toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) {
if (!stream) {
return request(`/conversations/${convId}/messages`, {
method: 'POST',
body: { content, stream: false, tools_enabled: toolsEnabled },
body: { text: data.text, attachments: data.attachments, stream: false, tools_enabled: toolsEnabled },
})
}
@ -110,7 +110,7 @@ export const messageApi = {
const res = await fetch(`${BASE}/conversations/${convId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, stream: true, tools_enabled: toolsEnabled }),
body: JSON.stringify({ text: data.text, attachments: data.attachments, stream: true, tools_enabled: toolsEnabled }),
signal: controller.signal,
})

View File

@ -35,14 +35,14 @@
v-for="msg in messages"
:key="msg.id"
:role="msg.role"
:content="msg.content"
:thinking-content="msg.thinking_content"
:text="msg.text"
:thinking-content="msg.thinking"
:tool-calls="msg.tool_calls"
:process-steps="msg.process_steps"
:tool-name="msg.name"
:token-count="msg.token_count"
:created-at="msg.created_at"
:deletable="msg.role === 'user'"
:attachments="msg.attachments"
@delete="$emit('deleteMessage', msg.id)"
@regenerate="$emit('regenerateMessage', msg.id)"
/>
@ -72,7 +72,7 @@
ref="inputRef"
:disabled="streaming"
:tools-enabled="toolsEnabled"
@send="$emit('sendMessage', $event)"
@send="handleSend"
@toggle-tools="$emit('toggleTools', $event)"
/>
</template>
@ -99,7 +99,7 @@ const props = defineProps({
toolsEnabled: { type: Boolean, default: true },
})
defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
const emit = defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
const scrollContainer = ref(null)
const inputRef = ref(null)
@ -109,6 +109,10 @@ const renderedStreamContent = computed(() => {
return renderMarkdown(props.streamingContent)
})
function handleSend(data) {
emit('sendMessage', data)
}
function scrollToBottom(smooth = true) {
nextTick(() => {
const el = scrollContainer.value
@ -255,7 +259,7 @@ defineExpose({ scrollToBottom })
}
.messages-container {
flex: 1 1 auto; /* 弹性高度,自动填充 */
flex: 1 1 auto;
overflow-y: auto;
padding: 16px 0;
width: 100%;
@ -272,6 +276,10 @@ defineExpose({ scrollToBottom })
border-radius: 3px;
}
.messages-container::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
.load-more-top {
text-align: center;
padding: 12px 0;
@ -379,15 +387,6 @@ defineExpose({ scrollToBottom })
color: var(--text-tertiary);
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.streaming-content :deep(p) {
margin: 0 0 8px;
}

View File

@ -3,6 +3,13 @@
<div v-if="role === 'user'" class="avatar">user</div>
<div v-else class="avatar">claw</div>
<div class="message-container">
<!-- 附件列表 -->
<div v-if="attachments && attachments.length > 0" class="attachments-list">
<div v-for="(file, index) in attachments" :key="index" class="attachment-item">
<span class="attachment-icon">{{ file.extension }}</span>
<span class="attachment-name">{{ file.name }}</span>
</div>
</div>
<div class="message-body">
<ProcessBlock
v-if="thinkingContent || (toolCalls && toolCalls.length > 0) || (processSteps && processSteps.length > 0)"
@ -49,7 +56,8 @@ import ProcessBlock from './ProcessBlock.vue'
const props = defineProps({
role: { type: String, required: true },
content: { type: String, default: '' },
text: { type: String, default: '' },
content: { type: String, default: '' }, // Keep for backward compatibility
thinkingContent: { type: String, default: '' },
toolCalls: { type: Array, default: () => [] },
processSteps: { type: Array, default: () => [] },
@ -57,13 +65,16 @@ const props = defineProps({
tokenCount: { type: Number, default: 0 },
createdAt: { type: String, default: '' },
deletable: { type: Boolean, default: false },
attachments: { type: Array, default: () => [] },
})
defineEmits(['delete', 'regenerate'])
const renderedContent = computed(() => {
if (!props.content) return ''
return renderMarkdown(props.content)
// Use 'text' field (new format), fallback to 'content' (old format/assistant messages)
const displayContent = props.text || props.content || ''
if (!displayContent) return ''
return renderMarkdown(displayContent)
})
function formatTime(iso) {
@ -108,6 +119,40 @@ function copyContent() {
min-width: 0;
}
.attachments-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
width: 100%;
}
.attachment-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--bg-code);
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.attachment-icon {
background: rgba(139, 92, 246, 0.15);
color: #8b5cf6;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.attachment-name {
color: var(--text-primary);
font-weight: 500;
}
.message-bubble.assistant .message-body {
width: 100%;
}

View File

@ -1,6 +1,20 @@
<template>
<div class="message-input">
<div class="input-wrapper">
<!-- 文件列表 -->
<div v-if="uploadedFiles.length > 0" class="file-list">
<div v-for="(file, index) in uploadedFiles" :key="index" class="file-item">
<span class="file-icon">{{ getFileIcon(file.extension) }}</span>
<span class="file-name">{{ file.name }}</span>
<button class="btn-remove-file" @click="removeFile(index)" title="移除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="input-container">
<textarea
ref="textareaRef"
v-model="text"
@ -10,29 +24,48 @@
@keydown="onKeydown"
:disabled="disabled"
></textarea>
<div class="input-actions">
<button
class="btn-tool"
:class="{ active: toolsEnabled }"
:disabled="disabled"
@click="toggleTools"
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</button>
<button
class="btn-send"
:class="{ active: text.trim() && !disabled }"
:disabled="!text.trim() || disabled"
@click="send"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
<div class="input-footer">
<input
ref="fileInputRef"
type="file"
accept=".txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.h,.hpp,.yaml,.yml,.toml,.ini,.csv,.sql,.sh,.bat,.log,.vue,.svelte,.go,.rs,.rb,.php,.swift,.kt,.scala,.lua,.r,.dart,.scala"
@change="handleFileUpload"
style="display: none"
/>
<div class="input-actions">
<button
class="btn-tool"
:class="{ active: toolsEnabled }"
:disabled="disabled"
@click="toggleTools"
:title="toolsEnabled ? '工具调用: 已开启' : '工具调用: 已关闭'"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</button>
<button
class="btn-upload"
:disabled="disabled"
@click="triggerFileUpload"
title="上传文件"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<button
class="btn-send"
:class="{ active: canSend }"
:disabled="!canSend || disabled"
@click="send"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
</div>
</div>
<div class="input-hint">AI 助手回复内容仅供参考</div>
@ -40,7 +73,7 @@
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { ref, computed, nextTick } from 'vue'
const props = defineProps({
disabled: { type: Boolean, default: false },
@ -50,6 +83,10 @@ const props = defineProps({
const emit = defineEmits(['send', 'toggleTools'])
const text = ref('')
const textareaRef = ref(null)
const fileInputRef = ref(null)
const uploadedFiles = ref([])
const canSend = computed(() => text.value.trim() || uploadedFiles.value.length > 0)
function autoResize() {
const el = textareaRef.value
@ -66,10 +103,23 @@ function onKeydown(e) {
}
function send() {
const content = text.value.trim()
if (!content || props.disabled) return
emit('send', content)
if (props.disabled || !canSend.value) return
const messageText = text.value.trim()
//
emit('send', {
text: messageText,
attachments: uploadedFiles.value.length > 0 ? uploadedFiles.value.map(f => ({
name: f.name,
extension: f.extension,
content: f.content,
})) : null,
})
//
text.value = ''
uploadedFiles.value = []
nextTick(() => {
autoResize()
})
@ -79,6 +129,43 @@ function toggleTools() {
emit('toggleTools', !props.toolsEnabled)
}
function triggerFileUpload() {
fileInputRef.value?.click()
}
function handleFileUpload(event) {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
const content = e.target?.result
if (typeof content === 'string') {
const extension = file.name.split('.').pop()?.toLowerCase() || ''
uploadedFiles.value.push({
name: file.name,
content,
extension,
})
}
}
reader.onerror = () => {
console.error('文件读取失败')
}
reader.readAsText(file)
// input 便
event.target.value = ''
}
function removeFile(index) {
uploadedFiles.value.splice(index, 1)
}
function getFileIcon(extension) {
return `.${extension}`
}
function focus() {
textareaRef.value?.focus()
}
@ -94,31 +181,93 @@ defineExpose({ focus })
transition: background 0.2s, border-color 0.2s;
}
.input-wrapper {
.file-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
padding: 0 24px;
}
.file-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px 6px 8px;
background: var(--bg-code);
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
}
.file-item:hover {
border-color: var(--border-medium);
background: var(--bg-hover);
}
.file-icon {
font-size: 14px;
line-height: 1;
}
.file-name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
font-weight: 500;
}
.btn-remove-file {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
padding: 0;
}
.btn-remove-file:hover {
background: var(--danger-bg);
color: var(--danger-color);
}
.input-container {
display: flex;
flex-direction: column;
background: var(--bg-input);
border: 1px solid var(--border-input);
border-radius: 12px;
padding: 8px 12px;
padding: 12px;
transition: border-color 0.2s, background 0.2s;
}
.input-wrapper:focus-within {
.input-container:focus-within {
border-color: var(--accent-primary);
}
textarea {
flex: 1;
width: 100%;
background: none;
border: none;
color: var(--text-primary);
font-size: 15px;
line-height: 1.5;
line-height: 1.6;
resize: none;
outline: none;
font-family: inherit;
min-height: 36px;
max-height: 200px;
padding: 0;
}
textarea::placeholder {
@ -129,14 +278,21 @@ textarea:disabled {
opacity: 0.5;
}
.input-footer {
display: flex;
justify-content: flex-end;
padding-top: 8px;
margin-top: 4px;
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: 8px;
}
.btn-tool {
.btn-tool,
.btn-send {
width: 36px;
height: 36px;
border-radius: 8px;
@ -147,12 +303,27 @@ textarea:disabled {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
transition: all 0.15s ease;
}
.btn-tool:hover {
.btn-upload {
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: rgba(139, 92, 246, 0.12);
color: #8b5cf6;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.btn-tool:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-secondary);
color: var(--text-primary);
transform: translateY(-1px);
}
.btn-tool.active {
@ -160,23 +331,30 @@ textarea:disabled {
color: var(--success-color);
}
.btn-tool:disabled {
.btn-tool.active:hover:not(:disabled) {
background: var(--success-color);
color: white;
}
.btn-upload:hover:not(:disabled) {
background: #8b5cf6;
color: white;
transform: translateY(-1px);
}
.btn-upload:active:not(:disabled) {
background: #7c3aed;
transform: translateY(0);
}
.btn-tool:disabled,
.btn-upload:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-send {
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: var(--bg-code);
color: var(--text-tertiary);
cursor: not-allowed;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-send.active {
@ -187,6 +365,13 @@ textarea:disabled {
.btn-send.active:hover {
background: var(--accent-primary-hover);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
.btn-send.active:active {
transform: translateY(0);
box-shadow: none;
}
.input-hint {

View File

@ -280,10 +280,33 @@ onMounted(loadModels)
.form-group select {
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
padding-right: 40px;
}
.form-group select:hover {
border-color: var(--accent-primary);
}
.form-group select:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.form-group select option {
background: var(--bg-primary);
color: var(--text-primary);
padding: 10px;
}
[data-theme="dark"] .form-group select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a0a0a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
}
.form-row {
@ -295,27 +318,6 @@ onMounted(loadModels)
flex: 1;
}
.form-group input[type="range"] {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--bg-code);
border-radius: 2px;
outline: none;
}
.form-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-primary);
cursor: pointer;
border: 2px solid var(--bg-primary);
}
.range-labels {
display: flex;
justify-content: space-between;

View File

@ -1,5 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles/global.css'
import './styles/highlight.css'
import 'katex/dist/katex.min.css'

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