diff --git a/backend/models.py b/backend/models.py index c909200..87842f7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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" diff --git a/backend/routes/messages.py b/backend/routes/messages.py index a87d30e..e9fbf24 100644 --- a/backend/routes/messages.py +++ b/backend/routes/messages.py @@ -1,4 +1,5 @@ """Message API routes""" +import json import uuid from datetime import datetime from flask import Blueprint, request @@ -45,14 +46,26 @@ 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") - - user_msg = Message(id=str(uuid.uuid4()), conversation_id=conv_id, role="user", content=content) + text = (d.get("text") or "").strip() + attachments = d.get("attachments") # [{"name": "a.py", "extension": "py", "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() - + tools_enabled = d.get("tools_enabled", True) if d.get("stream", False): diff --git a/backend/services/chat.py b/backend/services/chat.py index ba245f9..872b34d 100644 --- a/backend/services/chat.py +++ b/backend/services/chat.py @@ -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,64 +329,60 @@ 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) - - # 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, - "skipped": skipped, - "execution_time": tc.execution_time, - }) - + """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 def _process_tool_calls_delta(self, delta: dict, tool_calls_list: list) -> list: diff --git a/backend/utils/helpers.py b/backend/utils/helpers.py index 50cd2db..53e8ec4 100644 --- a/backend/utils/helpers.py +++ b/backend/utils/helpers.py @@ -47,38 +47,31 @@ 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) - - # 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, - "skipped": skipped, - "execution_time": tc.execution_time, - }) - + """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): + # 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 + + 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 diff --git a/docs/Design.md b/docs/Design.md index 9d387be..22c53b3 100644 --- a/docs/Design.md +++ b/docs/Design.md @@ -109,20 +109,6 @@ classDiagram +String role +LongText content +Integer token_count - +LongText thinking_content - +DateTime created_at - +relationship tool_calls - } - - class ToolCall { - +Integer id - +String message_id - +String call_id - +Integer call_index - +String tool_name - +LongText arguments - +LongText result - +Float execution_time +DateTime created_at } @@ -139,10 +125,42 @@ classDiagram User "1" --> "*" Conversation : 拥有 Conversation "1" --> "*" Message : 包含 - Message "1" --> "*" ToolCall : 触发 User "1" --> "*" TokenUsage : 消耗 ``` +### Message Content JSON 结构 + +`content` 字段统一使用 JSON 格式存储: + +**User 消息:** +```json +{ + "text": "用户输入的文本内容", + "attachments": [ + {"name": "utils.py", "extension": "py", "content": "def hello()..."} + ] +} +``` + +**Assistant 消息:** +```json +{ + "text": "AI 回复的文本内容", + "thinking": "思考过程(可选)", + "tool_calls": [ + { + "id": "call_xxx", + "name": "read_file", + "arguments": "{\"path\": \"...\"}", + "result": "{\"content\": \"...\"}", + "success": true, + "skipped": false, + "execution_time": 0.5 + } + ] +} +``` + ### 服务层 ```mermaid @@ -155,10 +173,9 @@ classDiagram +Integer MAX_ITERATIONS +sync_response(conv, tools_enabled) Response +stream_response(conv, tools_enabled) Response - -_save_tool_calls(msg_id, calls, results) void + -_build_tool_calls_json(calls, results) list -_message_to_dict(msg) dict -_process_tool_calls_delta(delta, list) list - -_emit_process_step(event, data) void } class GLMClient { @@ -351,23 +368,8 @@ iteration 2: | `id` | String(64) | UUID 主键 | | `conversation_id` | String(64) | 外键关联 Conversation | | `role` | String(16) | user/assistant/system/tool | -| `content` | LongText | 消息内容 | +| `content` | LongText | JSON 格式内容(见上方结构说明) | | `token_count` | Integer | Token 数量 | -| `thinking_content` | LongText | 思维链内容 | -| `created_at` | DateTime | 创建时间 | - -### ToolCall(工具调用) - -| 字段 | 类型 | 说明 | -|------|------|------| -| `id` | Integer | 自增主键 | -| `message_id` | String(64) | 外键关联 Message | -| `call_id` | String(64) | LLM 返回的工具调用 ID | -| `call_index` | Integer | 消息内的调用顺序 | -| `tool_name` | String(64) | 工具名称 | -| `arguments` | LongText | JSON 参数 | -| `result` | LongText | JSON 结果 | -| `execution_time` | Float | 执行时间(秒) | | `created_at` | DateTime | 创建时间 | ### TokenUsage(Token 使用统计) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e830a8d..4e1e6d9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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 = '' diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 2223e99..7c0fad5 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -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, }) diff --git a/frontend/src/components/ChatView.vue b/frontend/src/components/ChatView.vue index f3e36b2..56e226a 100644 --- a/frontend/src/components/ChatView.vue +++ b/frontend/src/components/ChatView.vue @@ -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)" /> @@ -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; } diff --git a/frontend/src/components/MessageBubble.vue b/frontend/src/components/MessageBubble.vue index 4743376..78bec0d 100644 --- a/frontend/src/components/MessageBubble.vue +++ b/frontend/src/components/MessageBubble.vue @@ -3,6 +3,13 @@