chore: 精简代码并更新文档
This commit is contained in:
parent
8325100c90
commit
31cfcd3ed2
14
README.md
14
README.md
|
|
@ -17,6 +17,8 @@
|
||||||
### 1. 安装依赖
|
### 1. 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
conda create -n claw python=3.12
|
||||||
|
conda activate claw
|
||||||
pip install -e .
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -50,19 +52,13 @@ db_type: sqlite
|
||||||
db_sqlite_file: nano_claw.db
|
db_sqlite_file: nano_claw.db
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 数据库迁移(首次运行或升级)
|
### 3. 启动后端
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m backend.migrations.add_project_support
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 启动后端
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m backend.run
|
python -m backend.run
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 启动前端
|
### 4. 启动前端
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|
@ -124,7 +120,7 @@ frontend/
|
||||||
| `POST` | `/api/conversations` | 创建会话 |
|
| `POST` | `/api/conversations` | 创建会话 |
|
||||||
| `GET` | `/api/conversations` | 会话列表 |
|
| `GET` | `/api/conversations` | 会话列表 |
|
||||||
| `GET` | `/api/conversations/:id/messages` | 消息列表 |
|
| `GET` | `/api/conversations/:id/messages` | 消息列表 |
|
||||||
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE) |
|
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) |
|
||||||
| `GET` | `/api/projects` | 项目列表 |
|
| `GET` | `/api/projects` | 项目列表 |
|
||||||
| `POST` | `/api/projects` | 创建项目 |
|
| `POST` | `/api/projects` | 创建项目 |
|
||||||
| `POST` | `/api/projects/upload` | 上传文件夹 |
|
| `POST` | `/api/projects/upload` | 上传文件夹 |
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,10 @@
|
||||||
from backend import db
|
from backend import db
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from flask import current_app
|
|
||||||
from sqlalchemy import Text
|
from sqlalchemy import Text
|
||||||
from sqlalchemy.dialects.mysql import LONGTEXT as MYSQL_LONGTEXT
|
from sqlalchemy.dialects.mysql import LONGTEXT as MYSQL_LONGTEXT
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_longtext_type():
|
|
||||||
"""Get appropriate text type for long content based on database dialect."""
|
|
||||||
db_uri = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
|
|
||||||
if db_uri.startswith("mysql"):
|
|
||||||
return MYSQL_LONGTEXT
|
|
||||||
return Text # SQLite and PostgreSQL use Text
|
|
||||||
|
|
||||||
|
|
||||||
# For model definitions, we'll use a callable that returns the right type
|
|
||||||
class LongText(db.TypeDecorator):
|
class LongText(db.TypeDecorator):
|
||||||
"""Cross-database LONGTEXT type that works with MySQL, SQLite, and PostgreSQL."""
|
"""Cross-database LONGTEXT type that works with MySQL, SQLite, and PostgreSQL."""
|
||||||
impl = Text
|
impl = Text
|
||||||
|
|
|
||||||
|
|
@ -68,11 +68,9 @@ def message_list(conv_id):
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
tools_enabled = d.get("tools_enabled", True)
|
tools_enabled = d.get("tools_enabled", True)
|
||||||
|
project_id = d.get("project_id")
|
||||||
|
|
||||||
if d.get("stream", False):
|
return _chat_service.stream_response(conv, tools_enabled, project_id)
|
||||||
return _chat_service.stream_response(conv, tools_enabled, project_id)
|
|
||||||
|
|
||||||
return _chat_service.sync_response(conv, tools_enabled, project_id)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
|
@bp.route("/api/conversations/<conv_id>/messages/<msg_id>", methods=["DELETE"])
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ from backend.utils.helpers import (
|
||||||
get_or_create_default_user,
|
get_or_create_default_user,
|
||||||
record_token_usage,
|
record_token_usage,
|
||||||
build_messages,
|
build_messages,
|
||||||
ok,
|
|
||||||
err,
|
|
||||||
to_dict,
|
|
||||||
)
|
)
|
||||||
from backend.services.glm_client import GLMClient
|
from backend.services.glm_client import GLMClient
|
||||||
|
|
||||||
|
|
@ -26,114 +23,6 @@ class ChatService:
|
||||||
self.executor = ToolExecutor(registry=registry)
|
self.executor = ToolExecutor(registry=registry)
|
||||||
|
|
||||||
|
|
||||||
def sync_response(self, conv: Conversation, tools_enabled: bool = True, project_id: str = None):
|
|
||||||
"""Sync response with tool call support
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conv: Conversation object
|
|
||||||
tools_enabled: Whether to enable tools
|
|
||||||
project_id: Project ID for workspace isolation
|
|
||||||
"""
|
|
||||||
tools = registry.list_all() if tools_enabled else None
|
|
||||||
messages = build_messages(conv, project_id)
|
|
||||||
|
|
||||||
# Clear tool call history for new request
|
|
||||||
self.executor.clear_history()
|
|
||||||
|
|
||||||
# Build context for tool execution
|
|
||||||
context = {"project_id": project_id} if project_id else None
|
|
||||||
|
|
||||||
all_tool_calls = []
|
|
||||||
all_tool_results = []
|
|
||||||
|
|
||||||
for _ in range(self.MAX_ITERATIONS):
|
|
||||||
try:
|
|
||||||
resp = self.glm_client.call(
|
|
||||||
model=conv.model,
|
|
||||||
messages=messages,
|
|
||||||
max_tokens=conv.max_tokens,
|
|
||||||
temperature=conv.temperature,
|
|
||||||
thinking_enabled=conv.thinking_enabled,
|
|
||||||
tools=tools,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
result = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
return err(500, f"upstream error: {e}")
|
|
||||||
|
|
||||||
choice = result["choices"][0]
|
|
||||||
message = choice["message"]
|
|
||||||
|
|
||||||
# No tool calls - return final result
|
|
||||||
if not message.get("tool_calls"):
|
|
||||||
usage = result.get("usage", {})
|
|
||||||
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=json.dumps(content_json, ensure_ascii=False),
|
|
||||||
token_count=completion_tokens,
|
|
||||||
)
|
|
||||||
db.session.add(msg)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
user = get_or_create_default_user()
|
|
||||||
record_token_usage(user.id, conv.model, prompt_tokens, completion_tokens)
|
|
||||||
|
|
||||||
# Set title if needed (first message)
|
|
||||||
suggested_title = None
|
|
||||||
if not conv.title or conv.title == "新对话":
|
|
||||||
user_msg = Message.query.filter_by(
|
|
||||||
conversation_id=conv.id, role="user"
|
|
||||||
).order_by(Message.created_at.asc()).first()
|
|
||||||
if user_msg and user_msg.content:
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
return ok({
|
|
||||||
"message": self._message_to_dict(msg),
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": prompt_tokens,
|
|
||||||
"completion_tokens": completion_tokens,
|
|
||||||
"total_tokens": usage.get("total_tokens", 0)
|
|
||||||
},
|
|
||||||
"suggested_title": suggested_title,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Process tool calls
|
|
||||||
tool_calls = message["tool_calls"]
|
|
||||||
all_tool_calls.extend(tool_calls)
|
|
||||||
messages.append(message)
|
|
||||||
|
|
||||||
tool_results = self.executor.process_tool_calls(tool_calls, context)
|
|
||||||
all_tool_results.extend(tool_results)
|
|
||||||
messages.extend(tool_results)
|
|
||||||
|
|
||||||
return err(500, "exceeded maximum tool call iterations")
|
|
||||||
|
|
||||||
def stream_response(self, conv: Conversation, tools_enabled: bool = True, project_id: str = None):
|
def stream_response(self, conv: Conversation, tools_enabled: bool = True, project_id: str = None):
|
||||||
"""Stream response with tool call support
|
"""Stream response with tool call support
|
||||||
|
|
||||||
|
|
@ -379,32 +268,7 @@ class ChatService:
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _message_to_dict(self, msg: Message) -> dict:
|
|
||||||
"""Convert message to dict, parsing JSON content"""
|
|
||||||
result = to_dict(msg)
|
|
||||||
|
|
||||||
# Parse content JSON
|
|
||||||
if msg.content:
|
|
||||||
try:
|
|
||||||
content_data = json.loads(msg.content)
|
|
||||||
if isinstance(content_data, dict):
|
|
||||||
result["text"] = content_data.get("text", "")
|
|
||||||
if content_data.get("attachments"):
|
|
||||||
result["attachments"] = content_data["attachments"]
|
|
||||||
if content_data.get("thinking"):
|
|
||||||
result["thinking"] = content_data["thinking"]
|
|
||||||
if content_data.get("tool_calls"):
|
|
||||||
result["tool_calls"] = content_data["tool_calls"]
|
|
||||||
else:
|
|
||||||
result["text"] = msg.content
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
result["text"] = msg.content
|
|
||||||
|
|
||||||
if "text" not in result:
|
|
||||||
result["text"] = ""
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _process_tool_calls_delta(self, delta: dict, tool_calls_list: list) -> list:
|
def _process_tool_calls_delta(self, delta: dict, tool_calls_list: list) -> list:
|
||||||
"""Process tool calls from streaming delta"""
|
"""Process tool calls from streaming delta"""
|
||||||
tool_calls_delta = delta.get("tool_calls", [])
|
tool_calls_delta = delta.get("tool_calls", [])
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,12 @@ class ToolExecutor:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
registry: Optional[ToolRegistry] = None,
|
registry: Optional[ToolRegistry] = None,
|
||||||
api_url: Optional[str] = None,
|
|
||||||
api_key: Optional[str] = None,
|
|
||||||
enable_cache: bool = True,
|
enable_cache: bool = True,
|
||||||
cache_ttl: int = 300, # 5 minutes
|
cache_ttl: int = 300, # 5 minutes
|
||||||
max_retries: int = 2, # Max retries per tool
|
|
||||||
):
|
):
|
||||||
self.registry = registry or ToolRegistry()
|
self.registry = registry or ToolRegistry()
|
||||||
self.api_url = api_url
|
|
||||||
self.api_key = api_key
|
|
||||||
self.enable_cache = enable_cache
|
self.enable_cache = enable_cache
|
||||||
self.cache_ttl = cache_ttl
|
self.cache_ttl = cache_ttl
|
||||||
self.max_retries = max_retries
|
|
||||||
self._cache: Dict[str, tuple] = {} # key -> (result, timestamp)
|
self._cache: Dict[str, tuple] = {} # key -> (result, timestamp)
|
||||||
self._call_history: List[dict] = [] # Track calls in current session
|
self._call_history: List[dict] = [] # Track calls in current session
|
||||||
|
|
||||||
|
|
@ -124,7 +118,7 @@ class ToolExecutor:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Execute tool with retry
|
# Execute tool with retry
|
||||||
result = self._execute_with_retry(name, args)
|
result = self._execute_tool(name, args)
|
||||||
|
|
||||||
# Cache the result (only cache successful results)
|
# Cache the result (only cache successful results)
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
|
|
@ -141,23 +135,13 @@ class ToolExecutor:
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def _execute_with_retry(
|
def _execute_tool(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
arguments: dict,
|
arguments: dict,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""Execute a tool and return the result."""
|
||||||
Execute tool without automatic retry.
|
return self.registry.execute(name, arguments)
|
||||||
|
|
||||||
If the tool fails, return the error to let the model decide
|
|
||||||
whether to retry with the same tool or try a different approach.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Result dict with success status. Failed tool returns:
|
|
||||||
{"success": False, "error": "..."}
|
|
||||||
"""
|
|
||||||
result = self.registry.execute(name, arguments)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _create_tool_result(
|
def _create_tool_result(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -194,10 +194,8 @@ classDiagram
|
||||||
-GLMClient glm_client
|
-GLMClient glm_client
|
||||||
-ToolExecutor executor
|
-ToolExecutor executor
|
||||||
+Integer MAX_ITERATIONS
|
+Integer MAX_ITERATIONS
|
||||||
+sync_response(conv, tools_enabled, project_id) Response
|
|
||||||
+stream_response(conv, tools_enabled, project_id) Response
|
+stream_response(conv, tools_enabled, project_id) Response
|
||||||
-_build_tool_calls_json(calls, results) list
|
-_build_tool_calls_json(calls, results) list
|
||||||
-_message_to_dict(msg) dict
|
|
||||||
-_process_tool_calls_delta(delta, list) list
|
-_process_tool_calls_delta(delta, list) list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -369,7 +367,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) |
|
| `GET` | `/api/conversations/:id/messages` | 获取消息列表(游标分页) |
|
||||||
| `POST` | `/api/conversations/:id/messages` | 发送消息(支持 SSE 流式) |
|
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) |
|
||||||
| `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 |
|
| `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 |
|
||||||
| `POST` | `/api/conversations/:id/regenerate/:mid` | 重新生成消息 |
|
| `POST` | `/api/conversations/:id/regenerate/:mid` | 重新生成消息 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1188,6 +1188,7 @@
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -1300,6 +1301,7 @@
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
@ -1374,6 +1376,7 @@
|
||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.30.tgz",
|
||||||
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.30",
|
"@vue/compiler-dom": "3.5.30",
|
||||||
"@vue/compiler-sfc": "3.5.30",
|
"@vue/compiler-sfc": "3.5.30",
|
||||||
|
|
|
||||||
|
|
@ -188,35 +188,9 @@ function loadMoreMessages() {
|
||||||
if (hasMoreMessages.value) loadMessages(false)
|
if (hasMoreMessages.value) loadMessages(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Send message (streaming) --
|
// -- Helpers: create stream callbacks for a conversation --
|
||||||
async function sendMessage(data) {
|
function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
if (!currentConvId.value || streaming.value) return
|
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',
|
|
||||||
text,
|
|
||||||
attachments: attachments ? attachments.map(a => ({ name: a.name, extension: a.extension })) : null,
|
|
||||||
token_count: 0,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
messages.value.push(userMsg)
|
|
||||||
|
|
||||||
streaming.value = true
|
|
||||||
streamContent.value = ''
|
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
|
||||||
streamProcessSteps.value = []
|
|
||||||
|
|
||||||
currentStreamPromise = messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
|
|
||||||
stream: true,
|
|
||||||
toolsEnabled: toolsEnabled.value,
|
|
||||||
onThinkingStart() {
|
onThinkingStart() {
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
streamThinking.value = ''
|
streamThinking.value = ''
|
||||||
|
|
@ -242,7 +216,6 @@ async function sendMessage(data) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToolCalls(calls) {
|
onToolCalls(calls) {
|
||||||
console.log('🔧 Tool calls received:', calls)
|
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null })))
|
streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null })))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -252,7 +225,6 @@ async function sendMessage(data) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onToolResult(result) {
|
onToolResult(result) {
|
||||||
console.log('✅ Tool result received:', result)
|
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
const call = streamToolCalls.value.find(c => c.id === result.id)
|
const call = streamToolCalls.value.find(c => c.id === result.id)
|
||||||
if (call) call.result = result.content
|
if (call) call.result = result.content
|
||||||
|
|
@ -266,11 +238,8 @@ async function sendMessage(data) {
|
||||||
onProcessStep(step) {
|
onProcessStep(step) {
|
||||||
const idx = step.index
|
const idx = step.index
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
// 创建新数组确保响应式更新
|
|
||||||
const newSteps = [...streamProcessSteps.value]
|
const newSteps = [...streamProcessSteps.value]
|
||||||
while (newSteps.length <= idx) {
|
while (newSteps.length <= idx) newSteps.push(null)
|
||||||
newSteps.push(null)
|
|
||||||
}
|
|
||||||
newSteps[idx] = step
|
newSteps[idx] = step
|
||||||
streamProcessSteps.value = newSteps
|
streamProcessSteps.value = newSteps
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -282,13 +251,11 @@ async function sendMessage(data) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async onDone(data) {
|
async onDone(data) {
|
||||||
// 清除保存的状态
|
|
||||||
streamStates.delete(convId)
|
streamStates.delete(convId)
|
||||||
|
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
currentStreamPromise = null
|
currentStreamPromise = null
|
||||||
// 添加助手消息(保留临时用户消息)
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: data.message_id,
|
id: data.message_id,
|
||||||
conversation_id: convId,
|
conversation_id: convId,
|
||||||
|
|
@ -305,35 +272,27 @@ async function sendMessage(data) {
|
||||||
streamToolCalls.value = []
|
streamToolCalls.value = []
|
||||||
streamProcessSteps.value = []
|
streamProcessSteps.value = []
|
||||||
|
|
||||||
// Update conversation in list (move to top)
|
if (updateConvList) {
|
||||||
const idx = conversations.value.findIndex(c => c.id === convId)
|
const idx = conversations.value.findIndex(c => c.id === convId)
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
const [conv] = conversations.value.splice(idx, 1)
|
const [conv] = conversations.value.splice(idx, 1)
|
||||||
conv.message_count = (conv.message_count || 0) + 2
|
conv.message_count = (conv.message_count || 0) + 2
|
||||||
if (data.suggested_title) {
|
if (data.suggested_title) conv.title = data.suggested_title
|
||||||
conv.title = data.suggested_title
|
conversations.value.unshift(conv)
|
||||||
}
|
} else if (idx === 0) {
|
||||||
conversations.value.unshift(conv)
|
conversations.value[0].message_count = (conversations.value[0].message_count || 0) + 2
|
||||||
} else if (idx === 0) {
|
if (data.suggested_title) conversations.value[0].title = data.suggested_title
|
||||||
conversations.value[0].message_count = (conversations.value[0].message_count || 0) + 2
|
|
||||||
if (data.suggested_title) {
|
|
||||||
conversations.value[0].title = data.suggested_title
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 后台完成,重新加载该对话的消息
|
|
||||||
try {
|
try {
|
||||||
const res = await messageApi.list(convId, null, 50)
|
const res = await messageApi.list(convId, null, 50)
|
||||||
// 更新对话列表中的消息计数和标题
|
|
||||||
const idx = conversations.value.findIndex(c => c.id === convId)
|
const idx = conversations.value.findIndex(c => c.id === convId)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
conversations.value[idx].message_count = res.data.items.length
|
conversations.value[idx].message_count = res.data.items.length
|
||||||
// 从服务器获取最新标题
|
|
||||||
if (res.data.items.length > 0) {
|
if (res.data.items.length > 0) {
|
||||||
const convRes = await conversationApi.get(convId)
|
const convRes = await conversationApi.get(convId)
|
||||||
if (convRes.data.title) {
|
if (convRes.data.title) conversations.value[idx].title = convRes.data.title
|
||||||
conversations.value[idx].title = convRes.data.title
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -351,6 +310,37 @@ async function sendMessage(data) {
|
||||||
console.error('Stream error:', msg)
|
console.error('Stream error:', msg)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Send message (streaming) --
|
||||||
|
async function sendMessage(data) {
|
||||||
|
if (!currentConvId.value || streaming.value) return
|
||||||
|
|
||||||
|
const convId = currentConvId.value
|
||||||
|
const text = data.text || ''
|
||||||
|
const attachments = data.attachments || null
|
||||||
|
|
||||||
|
const userMsg = {
|
||||||
|
id: 'temp_' + Date.now(),
|
||||||
|
conversation_id: convId,
|
||||||
|
role: 'user',
|
||||||
|
text,
|
||||||
|
attachments: attachments ? attachments.map(a => ({ name: a.name, extension: a.extension })) : null,
|
||||||
|
token_count: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
messages.value.push(userMsg)
|
||||||
|
|
||||||
|
streaming.value = true
|
||||||
|
streamContent.value = ''
|
||||||
|
streamThinking.value = ''
|
||||||
|
streamToolCalls.value = []
|
||||||
|
streamProcessSteps.value = []
|
||||||
|
|
||||||
|
currentStreamPromise = messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
|
||||||
|
toolsEnabled: toolsEnabled.value,
|
||||||
|
...createStreamCallbacks(convId, { updateConvList: true }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,12 +360,9 @@ async function regenerateMessage(msgId) {
|
||||||
if (!currentConvId.value || streaming.value) return
|
if (!currentConvId.value || streaming.value) return
|
||||||
|
|
||||||
const convId = currentConvId.value
|
const convId = currentConvId.value
|
||||||
|
|
||||||
// 找到要重新生成的消息索引
|
|
||||||
const msgIndex = messages.value.findIndex(m => m.id === msgId)
|
const msgIndex = messages.value.findIndex(m => m.id === msgId)
|
||||||
if (msgIndex === -1) return
|
if (msgIndex === -1) return
|
||||||
|
|
||||||
// 移除该消息及其后面的所有消息
|
|
||||||
messages.value = messages.value.slice(0, msgIndex)
|
messages.value = messages.value.slice(0, msgIndex)
|
||||||
|
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
|
|
@ -387,75 +374,7 @@ async function regenerateMessage(msgId) {
|
||||||
currentStreamPromise = messageApi.regenerate(convId, msgId, {
|
currentStreamPromise = messageApi.regenerate(convId, msgId, {
|
||||||
toolsEnabled: toolsEnabled.value,
|
toolsEnabled: toolsEnabled.value,
|
||||||
projectId: currentProject.value?.id,
|
projectId: currentProject.value?.id,
|
||||||
onThinkingStart() {
|
...createStreamCallbacks(convId, { updateConvList: false }),
|
||||||
if (currentConvId.value === convId) {
|
|
||||||
streamThinking.value = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onThinking(text) {
|
|
||||||
if (currentConvId.value === convId) {
|
|
||||||
streamThinking.value += text
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMessage(text) {
|
|
||||||
if (currentConvId.value === convId) {
|
|
||||||
streamContent.value += text
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onToolCalls(calls) {
|
|
||||||
if (currentConvId.value === convId) {
|
|
||||||
streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null })))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onToolResult(result) {
|
|
||||||
if (currentConvId.value === convId) {
|
|
||||||
const call = streamToolCalls.value.find(c => c.id === result.id)
|
|
||||||
if (call) call.result = result.content
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onProcessStep(step) {
|
|
||||||
const idx = step.index
|
|
||||||
if (currentConvId.value === convId) {
|
|
||||||
const newSteps = [...streamProcessSteps.value]
|
|
||||||
while (newSteps.length <= idx) {
|
|
||||||
newSteps.push(null)
|
|
||||||
}
|
|
||||||
newSteps[idx] = step
|
|
||||||
streamProcessSteps.value = newSteps
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async onDone(data) {
|
|
||||||
if (currentConvId.value === convId) {
|
|
||||||
streaming.value = false
|
|
||||||
currentStreamPromise = null
|
|
||||||
messages.value.push({
|
|
||||||
id: data.message_id,
|
|
||||||
conversation_id: convId,
|
|
||||||
role: 'assistant',
|
|
||||||
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 = ''
|
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
|
||||||
streamProcessSteps.value = []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError(msg) {
|
|
||||||
if (currentConvId.value === convId) {
|
|
||||||
streaming.value = false
|
|
||||||
currentStreamPromise = null
|
|
||||||
streamContent.value = ''
|
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
|
||||||
streamProcessSteps.value = []
|
|
||||||
console.error('Regenerate error:', msg)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,79 @@ async function request(url, options = {}) {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared SSE stream processor - parses SSE events and dispatches to callbacks
|
||||||
|
* @param {string} url - API URL (without BASE prefix)
|
||||||
|
* @param {object} body - Request body
|
||||||
|
* @param {object} callbacks - Event handlers: { onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError }
|
||||||
|
* @returns {{ abort: () => void }}
|
||||||
|
*/
|
||||||
|
function createSSEStream(url, body, { onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError }) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}${url}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(err.message || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
|
||||||
|
let currentEvent = ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event: ')) {
|
||||||
|
currentEvent = line.slice(7).trim()
|
||||||
|
} else if (line.startsWith('data: ')) {
|
||||||
|
const data = JSON.parse(line.slice(6))
|
||||||
|
if (currentEvent === 'thinking_start' && onThinkingStart) {
|
||||||
|
onThinkingStart()
|
||||||
|
} else if (currentEvent === 'thinking' && onThinking) {
|
||||||
|
onThinking(data.content)
|
||||||
|
} else if (currentEvent === 'message' && onMessage) {
|
||||||
|
onMessage(data.content)
|
||||||
|
} else if (currentEvent === 'tool_calls' && onToolCalls) {
|
||||||
|
onToolCalls(data.calls)
|
||||||
|
} else if (currentEvent === 'tool_result' && onToolResult) {
|
||||||
|
onToolResult(data)
|
||||||
|
} else if (currentEvent === 'process_step' && onProcessStep) {
|
||||||
|
onProcessStep(data)
|
||||||
|
} else if (currentEvent === 'done' && onDone) {
|
||||||
|
onDone(data)
|
||||||
|
} else if (currentEvent === 'error' && onError) {
|
||||||
|
onError(data.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError' && onError) {
|
||||||
|
onError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
promise.abort = () => controller.abort()
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
export const modelApi = {
|
export const modelApi = {
|
||||||
list() {
|
list() {
|
||||||
return request('/models')
|
return request('/models')
|
||||||
|
|
@ -95,149 +168,25 @@ export const messageApi = {
|
||||||
return request(`/conversations/${convId}/messages?${params}`)
|
return request(`/conversations/${convId}/messages?${params}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
send(convId, data, { stream = true, toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) {
|
send(convId, data, callbacks) {
|
||||||
if (!stream) {
|
return createSSEStream(`/conversations/${convId}/messages`, {
|
||||||
return request(`/conversations/${convId}/messages`, {
|
text: data.text,
|
||||||
method: 'POST',
|
attachments: data.attachments,
|
||||||
body: { text: data.text, attachments: data.attachments, stream: false, tools_enabled: toolsEnabled, project_id: data.projectId },
|
stream: true,
|
||||||
})
|
tools_enabled: callbacks.toolsEnabled !== false,
|
||||||
}
|
project_id: data.projectId,
|
||||||
|
}, callbacks)
|
||||||
const controller = new AbortController()
|
|
||||||
|
|
||||||
const promise = (async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${BASE}/conversations/${convId}/messages`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ text: data.text, attachments: data.attachments, stream: true, tools_enabled: toolsEnabled, project_id: data.projectId }),
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}))
|
|
||||||
throw new Error(err.message || `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
|
|
||||||
let currentEvent = ''
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('event: ')) {
|
|
||||||
currentEvent = line.slice(7).trim()
|
|
||||||
} else if (line.startsWith('data: ')) {
|
|
||||||
const data = JSON.parse(line.slice(6))
|
|
||||||
if (currentEvent === 'thinking_start' && onThinkingStart) {
|
|
||||||
onThinkingStart()
|
|
||||||
} else if (currentEvent === 'thinking' && onThinking) {
|
|
||||||
onThinking(data.content)
|
|
||||||
} else if (currentEvent === 'message' && onMessage) {
|
|
||||||
onMessage(data.content)
|
|
||||||
} else if (currentEvent === 'tool_calls' && onToolCalls) {
|
|
||||||
onToolCalls(data.calls)
|
|
||||||
} else if (currentEvent === 'tool_result' && onToolResult) {
|
|
||||||
onToolResult(data)
|
|
||||||
} else if (currentEvent === 'process_step' && onProcessStep) {
|
|
||||||
onProcessStep(data)
|
|
||||||
} else if (currentEvent === 'done' && onDone) {
|
|
||||||
onDone(data)
|
|
||||||
} else if (currentEvent === 'error' && onError) {
|
|
||||||
onError(data.content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name !== 'AbortError' && onError) {
|
|
||||||
onError(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
promise.abort = () => controller.abort()
|
|
||||||
|
|
||||||
return promise
|
|
||||||
},
|
},
|
||||||
|
|
||||||
delete(convId, msgId) {
|
delete(convId, msgId) {
|
||||||
return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' })
|
return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' })
|
||||||
},
|
},
|
||||||
|
|
||||||
regenerate(convId, msgId, { toolsEnabled = true, projectId, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) {
|
regenerate(convId, msgId, callbacks) {
|
||||||
const controller = new AbortController()
|
return createSSEStream(`/conversations/${convId}/regenerate/${msgId}`, {
|
||||||
|
tools_enabled: callbacks.toolsEnabled !== false,
|
||||||
const promise = (async () => {
|
project_id: callbacks.projectId,
|
||||||
try {
|
}, callbacks)
|
||||||
const res = await fetch(`${BASE}/conversations/${convId}/regenerate/${msgId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ tools_enabled: toolsEnabled, project_id: projectId }),
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}))
|
|
||||||
throw new Error(err.message || `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
|
|
||||||
let currentEvent = ''
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('event: ')) {
|
|
||||||
currentEvent = line.slice(7).trim()
|
|
||||||
} else if (line.startsWith('data: ')) {
|
|
||||||
const data = JSON.parse(line.slice(6))
|
|
||||||
if (currentEvent === 'thinking_start' && onThinkingStart) {
|
|
||||||
onThinkingStart()
|
|
||||||
} else if (currentEvent === 'thinking' && onThinking) {
|
|
||||||
onThinking(data.content)
|
|
||||||
} else if (currentEvent === 'message' && onMessage) {
|
|
||||||
onMessage(data.content)
|
|
||||||
} else if (currentEvent === 'tool_calls' && onToolCalls) {
|
|
||||||
onToolCalls(data.calls)
|
|
||||||
} else if (currentEvent === 'tool_result' && onToolResult) {
|
|
||||||
onToolResult(data)
|
|
||||||
} else if (currentEvent === 'process_step' && onProcessStep) {
|
|
||||||
onProcessStep(data)
|
|
||||||
} else if (currentEvent === 'done' && onDone) {
|
|
||||||
onDone(data)
|
|
||||||
} else if (currentEvent === 'error' && onError) {
|
|
||||||
onError(data.content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name !== 'AbortError' && onError) {
|
|
||||||
onError(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
promise.abort = () => controller.abort()
|
|
||||||
|
|
||||||
return promise
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@
|
||||||
:process-steps="streamingProcessSteps"
|
:process-steps="streamingProcessSteps"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
/>
|
/>
|
||||||
<div class="message-content streaming-content" v-html="renderedStreamContent || '<span class=\'placeholder\'>...</span>'"></div>
|
<div class="md-content streaming-content" v-html="renderedStreamContent || '<span class=\'placeholder\'>...</span>'"></div>
|
||||||
<div class="streaming-indicator">
|
<div class="streaming-indicator">
|
||||||
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
|
@ -370,10 +370,6 @@ defineExpose({ scrollToBottom })
|
||||||
}
|
}
|
||||||
|
|
||||||
.streaming-content {
|
.streaming-content {
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--text-primary);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.streaming-indicator {
|
.streaming-indicator {
|
||||||
|
|
@ -387,85 +383,8 @@ defineExpose({ scrollToBottom })
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.streaming-content :deep(p) {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(p:last-child) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(pre) {
|
|
||||||
background: var(--bg-code);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 8px 0;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(pre code) {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(code) {
|
|
||||||
background: var(--accent-primary-light);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(pre code) {
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(ul),
|
|
||||||
.streaming-content :deep(ol) {
|
|
||||||
padding-left: 20px;
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(blockquote) {
|
|
||||||
border-left: 3px solid rgba(59, 130, 246, 0.4);
|
|
||||||
padding-left: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(table) {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 8px 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(th),
|
|
||||||
.streaming-content :deep(td) {
|
|
||||||
border: 1px solid var(--border-medium);
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(th) {
|
|
||||||
background: var(--bg-code);
|
|
||||||
}
|
|
||||||
|
|
||||||
.streaming-content :deep(.placeholder) {
|
.streaming-content :deep(.placeholder) {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.streaming-content :deep(.math-block),
|
|
||||||
.message-content :deep(.math-block) {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
margin: 8px 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
<div class="tool-badge">工具返回结果: {{ toolName }}</div>
|
<div class="tool-badge">工具返回结果: {{ toolName }}</div>
|
||||||
<pre>{{ content }}</pre>
|
<pre>{{ content }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="message-content" v-html="renderedContent"></div>
|
<div v-else class="md-content message-content" v-html="renderedContent"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-footer">
|
<div class="message-footer">
|
||||||
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||||
|
|
@ -196,10 +196,6 @@ function copyContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--text-primary);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-result-content {
|
.tool-result-content {
|
||||||
|
|
@ -232,75 +228,6 @@ function copyContent() {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content :deep(p) {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(p:last-child) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(pre) {
|
|
||||||
background: var(--bg-code);
|
|
||||||
border: 1px solid var(--border-light);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
overflow-x: auto;
|
|
||||||
margin: 8px 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(pre code) {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(code) {
|
|
||||||
background: var(--accent-primary-light);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(pre code) {
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(ul),
|
|
||||||
.message-content :deep(ol) {
|
|
||||||
padding-left: 20px;
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(blockquote) {
|
|
||||||
border-left: 3px solid rgba(59, 130, 246, 0.4);
|
|
||||||
padding-left: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(table) {
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 8px 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(th),
|
|
||||||
.message-content :deep(td) {
|
|
||||||
border: 1px solid var(--border-medium);
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content :deep(th) {
|
|
||||||
background: var(--bg-code);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-footer {
|
.message-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -343,12 +270,4 @@ function copyContent() {
|
||||||
color: var(--danger-color);
|
color: var(--danger-color);
|
||||||
background: var(--danger-bg);
|
background: var(--danger-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content :deep(.math-block) {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
margin: 8px 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,10 @@
|
||||||
<textarea
|
<textarea
|
||||||
ref="textareaRef"
|
ref="textareaRef"
|
||||||
v-model="text"
|
v-model="text"
|
||||||
placeholder="输入消息... (Shift+Enter 换行)"
|
:placeholder="disabled ? 'AI 正在回复中...' : '输入消息... (Shift+Enter 换行)'"
|
||||||
rows="1"
|
rows="1"
|
||||||
@input="autoResize"
|
@input="autoResize"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
:disabled="disabled"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="input-footer">
|
<div class="input-footer">
|
||||||
<input
|
<input
|
||||||
|
|
@ -274,10 +273,6 @@ textarea::placeholder {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-footer {
|
.input-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,89 @@
|
||||||
|
/* Markdown content shared styles */
|
||||||
|
.md-content {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(p) {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(pre) {
|
||||||
|
background: var(--bg-code);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 8px 0;
|
||||||
|
max-width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(pre code) {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(code) {
|
||||||
|
background: var(--accent-primary-light);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(pre code) {
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(ul),
|
||||||
|
.md-content :deep(ol) {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(blockquote) {
|
||||||
|
border-left: 3px solid rgba(59, 130, 246, 0.4);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 8px 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(th),
|
||||||
|
.md-content :deep(td) {
|
||||||
|
border: 1px solid var(--border-medium);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(th) {
|
||||||
|
background: var(--bg-code);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content :deep(.math-block) {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin: 8px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* 共享滚动条样式 */
|
/* 共享滚动条样式 */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue