refactor: 优化SSE 格式等
This commit is contained in:
parent
f57e813f76
commit
ea425cf9a6
|
|
@ -21,3 +21,6 @@ for _m in MODELS:
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_MODEL = _cfg.get("default_model", "glm-5")
|
DEFAULT_MODEL = _cfg.get("default_model", "glm-5")
|
||||||
|
|
||||||
|
# Max agentic loop iterations (tool call rounds)
|
||||||
|
MAX_ITERATIONS = _cfg.get("max_iterations", 5)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import shutil
|
import shutil
|
||||||
|
from datetime import datetime, timezone
|
||||||
from flask import Blueprint, request, g
|
from flask import Blueprint, request, g
|
||||||
|
|
||||||
from backend import db
|
from backend import db
|
||||||
|
|
@ -18,14 +19,30 @@ from backend.utils.workspace import (
|
||||||
bp = Blueprint("projects", __name__)
|
bp = Blueprint("projects", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project(project_id, check_owner=True):
|
||||||
|
"""Get project with optional ownership check."""
|
||||||
|
project = db.session.get(Project, project_id)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
if check_owner and project.user_id != g.current_user.id:
|
||||||
|
return None
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/projects", methods=["GET"])
|
@bp.route("/api/projects", methods=["GET"])
|
||||||
def list_projects():
|
def list_projects():
|
||||||
"""List all projects for current user"""
|
"""List all projects for current user"""
|
||||||
user = g.current_user
|
user = g.current_user
|
||||||
projects = Project.query.filter_by(user_id=user.id).order_by(Project.updated_at.desc()).all()
|
cursor = request.args.get("cursor")
|
||||||
|
limit = min(int(request.args.get("limit", 20)), 100)
|
||||||
|
q = Project.query.filter_by(user_id=user.id)
|
||||||
|
|
||||||
return ok({
|
if cursor:
|
||||||
"projects": [
|
q = q.filter(Project.updated_at < (
|
||||||
|
db.session.query(Project.updated_at).filter_by(id=cursor).scalar() or datetime.now(timezone.utc)))
|
||||||
|
rows = q.order_by(Project.updated_at.desc()).limit(limit + 1).all()
|
||||||
|
|
||||||
|
items = [
|
||||||
{
|
{
|
||||||
"id": p.id,
|
"id": p.id,
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
|
|
@ -33,11 +50,15 @@ def list_projects():
|
||||||
"description": p.description,
|
"description": p.description,
|
||||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||||
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
||||||
"conversation_count": p.conversations.count()
|
"conversation_count": p.conversations.count(),
|
||||||
}
|
}
|
||||||
for p in projects
|
for p in rows[:limit]
|
||||||
],
|
]
|
||||||
"total": len(projects)
|
return ok({
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": items[-1]["id"] if len(rows) > limit else None,
|
||||||
|
"has_more": len(rows) > limit,
|
||||||
|
"total": Project.query.filter_by(user_id=user.id).count(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,7 +112,8 @@ def create_project():
|
||||||
@bp.route("/api/projects/<project_id>", methods=["GET"])
|
@bp.route("/api/projects/<project_id>", methods=["GET"])
|
||||||
def get_project(project_id):
|
def get_project(project_id):
|
||||||
"""Get project details"""
|
"""Get project details"""
|
||||||
project = Project.query.get(project_id)
|
user = g.current_user
|
||||||
|
project = _get_project(project_id)
|
||||||
|
|
||||||
if not project:
|
if not project:
|
||||||
return err(404, "Project not found")
|
return err(404, "Project not found")
|
||||||
|
|
@ -124,7 +146,8 @@ def get_project(project_id):
|
||||||
@bp.route("/api/projects/<project_id>", methods=["PUT"])
|
@bp.route("/api/projects/<project_id>", methods=["PUT"])
|
||||||
def update_project(project_id):
|
def update_project(project_id):
|
||||||
"""Update project details"""
|
"""Update project details"""
|
||||||
project = Project.query.get(project_id)
|
user = g.current_user
|
||||||
|
project = _get_project(project_id)
|
||||||
|
|
||||||
if not project:
|
if not project:
|
||||||
return err(404, "Project not found")
|
return err(404, "Project not found")
|
||||||
|
|
@ -169,10 +192,9 @@ def update_project(project_id):
|
||||||
@bp.route("/api/projects/<project_id>", methods=["DELETE"])
|
@bp.route("/api/projects/<project_id>", methods=["DELETE"])
|
||||||
def delete_project(project_id):
|
def delete_project(project_id):
|
||||||
"""Delete a project"""
|
"""Delete a project"""
|
||||||
user = g.current_user
|
project = _get_project(project_id)
|
||||||
project = Project.query.get(project_id)
|
|
||||||
|
|
||||||
if not project or project.user_id != user.id:
|
if not project:
|
||||||
return err(404, "Project not found")
|
return err(404, "Project not found")
|
||||||
|
|
||||||
# Delete project directory
|
# Delete project directory
|
||||||
|
|
@ -247,7 +269,7 @@ def upload_project_folder():
|
||||||
@bp.route("/api/projects/<project_id>/files", methods=["GET"])
|
@bp.route("/api/projects/<project_id>/files", methods=["GET"])
|
||||||
def list_project_files(project_id):
|
def list_project_files(project_id):
|
||||||
"""List files in a project directory"""
|
"""List files in a project directory"""
|
||||||
project = Project.query.get(project_id)
|
project = _get_project(project_id)
|
||||||
|
|
||||||
if not project:
|
if not project:
|
||||||
return err(404, "Project not found")
|
return err(404, "Project not found")
|
||||||
|
|
@ -326,7 +348,7 @@ TEXT_EXTENSIONS = {
|
||||||
|
|
||||||
def _resolve_file_path(project_id, filepath):
|
def _resolve_file_path(project_id, filepath):
|
||||||
"""Resolve and validate a file path within a project directory."""
|
"""Resolve and validate a file path within a project directory."""
|
||||||
project = Project.query.get(project_id)
|
project = _get_project(project_id)
|
||||||
if not project:
|
if not project:
|
||||||
return None, None, err(404, "Project not found")
|
return None, None, err(404, "Project not found")
|
||||||
project_dir = get_project_path(project.id, project.path)
|
project_dir = get_project_path(project.id, project.path)
|
||||||
|
|
@ -390,9 +412,43 @@ def write_project_file(project_id, filepath):
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/projects/<project_id>/files/<path:filepath>", methods=["PATCH"])
|
||||||
|
def rename_project_file(project_id, filepath):
|
||||||
|
"""Rename or move a file/directory."""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or "new_path" not in data:
|
||||||
|
return err(400, "Missing 'new_path' in request body")
|
||||||
|
|
||||||
|
project_dir, target, error = _resolve_file_path(project_id, filepath)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
|
||||||
|
if not target.exists():
|
||||||
|
return err(404, "File not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_target = validate_path_in_project(data["new_path"], project_dir)
|
||||||
|
except ValueError:
|
||||||
|
return err(403, "Invalid path: outside project directory")
|
||||||
|
|
||||||
|
if new_target.exists():
|
||||||
|
return err(400, "Destination already exists")
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.rename(new_target)
|
||||||
|
except Exception as e:
|
||||||
|
return err(500, f"Failed to rename: {str(e)}")
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"name": new_target.name,
|
||||||
|
"path": str(new_target.relative_to(project_dir)),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/projects/<project_id>/files/<path:filepath>", methods=["DELETE"])
|
@bp.route("/api/projects/<project_id>/files/<path:filepath>", methods=["DELETE"])
|
||||||
def delete_project_file(project_id, filepath):
|
def delete_project_file(project_id, filepath):
|
||||||
"""Delete a file or empty directory."""
|
"""Delete a file or directory."""
|
||||||
project_dir, target, error = _resolve_file_path(project_id, filepath)
|
project_dir, target, error = _resolve_file_path(project_id, filepath)
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
|
|
@ -411,16 +467,22 @@ def delete_project_file(project_id, filepath):
|
||||||
return ok({"message": f"Deleted '{filepath}'"})
|
return ok({"message": f"Deleted '{filepath}'"})
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/projects/<project_id>/files/mkdir", methods=["POST"])
|
@bp.route("/api/projects/<project_id>/directories", methods=["POST"])
|
||||||
def create_project_directory_endpoint(project_id):
|
def create_project_directory_endpoint(project_id):
|
||||||
"""Create a directory in the project."""
|
"""Create a directory in the project."""
|
||||||
|
project = _get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
return err(404, "Project not found")
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data or "path" not in data:
|
if not data or "path" not in data:
|
||||||
return err(400, "Missing 'path' in request body")
|
return err(400, "Missing 'path' in request body")
|
||||||
|
|
||||||
project_dir, target, error = _resolve_file_path(project_id, data["path"])
|
project_dir = get_project_path(project.id, project.path)
|
||||||
if error:
|
try:
|
||||||
return error
|
target = validate_path_in_project(data["path"], project_dir)
|
||||||
|
except ValueError:
|
||||||
|
return err(403, "Invalid path: outside project directory")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -446,7 +508,7 @@ def search_project_files(project_id):
|
||||||
max_results = min(data.get("max_results", 50), 200)
|
max_results = min(data.get("max_results", 50), 200)
|
||||||
case_sensitive = data.get("case_sensitive", False)
|
case_sensitive = data.get("case_sensitive", False)
|
||||||
|
|
||||||
project = Project.query.get(project_id)
|
project = _get_project(project_id)
|
||||||
if not project:
|
if not project:
|
||||||
return err(404, "Project not found")
|
return err(404, "Project not found")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,12 @@ from backend.utils.helpers import (
|
||||||
build_messages,
|
build_messages,
|
||||||
)
|
)
|
||||||
from backend.services.glm_client import GLMClient
|
from backend.services.glm_client import GLMClient
|
||||||
|
from backend.config import MAX_ITERATIONS
|
||||||
|
|
||||||
|
|
||||||
class ChatService:
|
class ChatService:
|
||||||
"""Chat completion service with tool support"""
|
"""Chat completion service with tool support"""
|
||||||
|
|
||||||
MAX_ITERATIONS = 5
|
|
||||||
|
|
||||||
def __init__(self, glm_client: GLMClient):
|
def __init__(self, glm_client: GLMClient):
|
||||||
self.glm_client = glm_client
|
self.glm_client = glm_client
|
||||||
self.executor = ToolExecutor(registry=registry)
|
self.executor = ToolExecutor(registry=registry)
|
||||||
|
|
@ -60,15 +59,19 @@ class ChatService:
|
||||||
# (each iteration re-sends the full context, so earlier
|
# (each iteration re-sends the full context, so earlier
|
||||||
# prompts are strict subsets of the final one)
|
# prompts are strict subsets of the final one)
|
||||||
|
|
||||||
for iteration in range(self.MAX_ITERATIONS):
|
for iteration in range(MAX_ITERATIONS):
|
||||||
full_content = ""
|
full_content = ""
|
||||||
full_thinking = ""
|
full_thinking = ""
|
||||||
token_count = 0
|
token_count = 0
|
||||||
msg_id = str(uuid.uuid4())
|
msg_id = str(uuid.uuid4())
|
||||||
tool_calls_list = []
|
tool_calls_list = []
|
||||||
|
|
||||||
# Clear state for new iteration
|
# Streaming step tracking — step ID is assigned on first chunk arrival.
|
||||||
# (frontend resets via onProcessStep when first step arrives)
|
# thinking always precedes text in GLM's streaming order, so text gets step_index+1.
|
||||||
|
thinking_step_id = None
|
||||||
|
thinking_step_idx = None
|
||||||
|
text_step_id = None
|
||||||
|
text_step_idx = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
|
@ -115,13 +118,19 @@ class ChatService:
|
||||||
reasoning = delta.get("reasoning_content", "")
|
reasoning = delta.get("reasoning_content", "")
|
||||||
if reasoning:
|
if reasoning:
|
||||||
full_thinking += reasoning
|
full_thinking += reasoning
|
||||||
yield f"event: thinking\ndata: {json.dumps({'content': reasoning}, ensure_ascii=False)}\n\n"
|
if thinking_step_id is None:
|
||||||
|
thinking_step_id = f'step-{step_index}'
|
||||||
|
thinking_step_idx = step_index
|
||||||
|
yield f"event: process_step\ndata: {json.dumps({'id': thinking_step_id, 'index': thinking_step_idx, 'type': 'thinking', 'content': full_thinking}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# Accumulate text content for this iteration
|
# Accumulate text content for this iteration
|
||||||
text = delta.get("content", "")
|
text = delta.get("content", "")
|
||||||
if text:
|
if text:
|
||||||
full_content += text
|
full_content += text
|
||||||
yield f"event: message\ndata: {json.dumps({'content': text}, ensure_ascii=False)}\n\n"
|
if text_step_id is None:
|
||||||
|
text_step_idx = step_index + (1 if thinking_step_id is not None else 0)
|
||||||
|
text_step_id = f'step-{text_step_idx}'
|
||||||
|
yield f"event: process_step\ndata: {json.dumps({'id': text_step_id, 'index': text_step_idx, 'type': 'text', 'content': full_content}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# Accumulate tool calls from streaming deltas
|
# Accumulate tool calls from streaming deltas
|
||||||
tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list)
|
tool_calls_list = self._process_tool_calls_delta(delta, tool_calls_list)
|
||||||
|
|
@ -130,27 +139,20 @@ class ChatService:
|
||||||
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
|
yield f"event: error\ndata: {json.dumps({'content': str(e)}, ensure_ascii=False)}\n\n"
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- Finalize thinking/text steps for this iteration (common to both paths) ---
|
# --- Finalize: save thinking/text steps to all_steps for DB storage ---
|
||||||
if full_thinking:
|
# No need to yield to frontend — incremental process_step events already sent.
|
||||||
step_data = {
|
if thinking_step_id is not None:
|
||||||
'id': f'step-{step_index}',
|
all_steps.append({
|
||||||
'index': step_index,
|
'id': thinking_step_id, 'index': thinking_step_idx,
|
||||||
'type': 'thinking',
|
'type': 'thinking', 'content': full_thinking,
|
||||||
'content': full_thinking,
|
})
|
||||||
}
|
|
||||||
all_steps.append(step_data)
|
|
||||||
yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n"
|
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
if full_content:
|
if text_step_id is not None:
|
||||||
step_data = {
|
all_steps.append({
|
||||||
'id': f'step-{step_index}',
|
'id': text_step_id, 'index': text_step_idx,
|
||||||
'index': step_index,
|
'type': 'text', 'content': full_content,
|
||||||
'type': 'text',
|
})
|
||||||
'content': full_content,
|
|
||||||
}
|
|
||||||
all_steps.append(step_data)
|
|
||||||
yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n"
|
|
||||||
step_index += 1
|
step_index += 1
|
||||||
|
|
||||||
# --- Branch: tool calls vs final ---
|
# --- Branch: tool calls vs final ---
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,6 @@ classDiagram
|
||||||
class ChatService {
|
class ChatService {
|
||||||
-GLMClient glm_client
|
-GLMClient glm_client
|
||||||
-ToolExecutor executor
|
-ToolExecutor executor
|
||||||
+Integer MAX_ITERATIONS
|
|
||||||
+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
|
||||||
-_process_tool_calls_delta(delta, list) list
|
-_process_tool_calls_delta(delta, list) list
|
||||||
|
|
@ -445,7 +444,7 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| -------- | ----------------------------------- | ---------------------------------------------------------------------------------------- |
|
| -------- | ----------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `GET` | `/api/projects` | 获取项目列表 |
|
| `GET` | `/api/projects` | 获取项目列表(支持 `?cursor=&limit=` 分页) |
|
||||||
| `POST` | `/api/projects` | 创建项目 |
|
| `POST` | `/api/projects` | 创建项目 |
|
||||||
| `GET` | `/api/projects/:id` | 获取项目详情 |
|
| `GET` | `/api/projects/:id` | 获取项目详情 |
|
||||||
| `PUT` | `/api/projects/:id` | 更新项目 |
|
| `PUT` | `/api/projects/:id` | 更新项目 |
|
||||||
|
|
@ -454,8 +453,9 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
| `GET` | `/api/projects/:id/files` | 列出项目文件(支持 `?path=subdir` 子目录) |
|
| `GET` | `/api/projects/:id/files` | 列出项目文件(支持 `?path=subdir` 子目录) |
|
||||||
| `GET` | `/api/projects/:id/files/:filepath` | 读取文件内容(文本文件,最大 5 MB) |
|
| `GET` | `/api/projects/:id/files/:filepath` | 读取文件内容(文本文件,最大 5 MB) |
|
||||||
| `PUT` | `/api/projects/:id/files/:filepath` | 创建或覆盖文件(Body: `{"content": "..."}`) |
|
| `PUT` | `/api/projects/:id/files/:filepath` | 创建或覆盖文件(Body: `{"content": "..."}`) |
|
||||||
|
| `PATCH` | `/api/projects/:id/files/:filepath` | 重命名或移动文件/目录(Body: `{"new_path": "..."}`) |
|
||||||
| `DELETE` | `/api/projects/:id/files/:filepath` | 删除文件或目录 |
|
| `DELETE` | `/api/projects/:id/files/:filepath` | 删除文件或目录 |
|
||||||
| `POST` | `/api/projects/:id/files/mkdir` | 创建目录(Body: `{"path": "src/utils"}`) |
|
| `POST` | `/api/projects/:id/directories` | 创建目录(Body: `{"path": "src/utils"}`) |
|
||||||
| `POST` | `/api/projects/:id/search` | 搜索文件内容(Body: `{"query": "...", "path": "", "max_results": 50, "case_sensitive": false}`) |
|
| `POST` | `/api/projects/:id/search` | 搜索文件内容(Body: `{"query": "...", "path": "", "max_results": 50, "case_sensitive": false}`) |
|
||||||
|
|
||||||
### 其他
|
### 其他
|
||||||
|
|
@ -472,48 +472,24 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
|
|
||||||
| 事件 | 说明 |
|
| 事件 | 说明 |
|
||||||
| -------------- | ------------------------------------------------------------------------- |
|
| -------------- | ------------------------------------------------------------------------- |
|
||||||
| `thinking` | 思考过程的增量片段(实时流式输出) |
|
| `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示和实时流式更新。携带 `id`、`index` 确保渲染顺序 |
|
||||||
| `message` | 回复内容的增量片段(实时流式输出) |
|
|
||||||
| `process_step` | 有序处理步骤(thinking/text/tool_call/tool_result),支持穿插显示。携带 `id`、`index` 确保渲染顺序 |
|
|
||||||
| `error` | 错误信息 |
|
| `error` | 错误信息 |
|
||||||
| `done` | 回复结束,携带 message_id、token_count 和 suggested_title |
|
| `done` | 回复结束,携带 message_id、token_count 和 suggested_title |
|
||||||
|
|
||||||
> **注意**:`thinking` 和 `message` 事件提供实时流式体验,每条 chunk 立即推送到前端。`process_step` 事件在每次迭代结束后发送完整内容,用于确定渲染顺序和 DB 存储。
|
> **注意**:`process_step` 是唯一的内容传输事件。thinking/text 步骤在每个 LLM chunk 到达时**增量发送**(前端按 `id` 原地更新),tool_call/tool_result 步骤在工具执行时**追加发送**。所有步骤在迭代结束时存入 DB。
|
||||||
|
|
||||||
### thinking / message 事件格式
|
|
||||||
|
|
||||||
实时流式事件,每条携带一个增量片段:
|
|
||||||
|
|
||||||
```json
|
|
||||||
// 思考增量片段
|
|
||||||
{"content": "正在分析用户需求..."}
|
|
||||||
|
|
||||||
// 文本增量片段
|
|
||||||
{"content": "根据分析结果"}
|
|
||||||
```
|
|
||||||
|
|
||||||
字段说明:
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
| --------- | ---------------------------- |
|
|
||||||
| `content` | 增量文本片段(前端累积拼接为完整内容) |
|
|
||||||
|
|
||||||
### process_step 事件格式
|
### process_step 事件格式
|
||||||
|
|
||||||
每个 `process_step` 事件携带一个带 `id`、`index` 和 `type` 的步骤对象。步骤按 `index` 顺序排列,确保前端可以正确渲染穿插的思考、文本和工具调用。
|
每个 `process_step` 事件携带一个带 `id`、`index` 和 `type` 的步骤对象。步骤按 `index` 顺序排列,确保前端可以正确渲染穿插的思考、文本和工具调用。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
// 思考过程
|
|
||||||
{"id": "step-0", "index": 0, "type": "thinking", "content": "完整思考内容..."}
|
{"id": "step-0", "index": 0, "type": "thinking", "content": "完整思考内容..."}
|
||||||
|
|
||||||
|
|
||||||
// 回复文本(可穿插在任意步骤之间)
|
|
||||||
{"id": "step-1", "index": 1, "type": "text", "content": "回复文本内容..."}
|
{"id": "step-1", "index": 1, "type": "text", "content": "回复文本内容..."}
|
||||||
|
|
||||||
// 工具调用(id_ref 存储工具调用 ID,用于与 tool_result 匹配)
|
|
||||||
{"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"}
|
{"id": "step-2", "index": 2, "type": "tool_call", "id_ref": "call_abc123", "name": "web_search", "arguments": "{\"query\": \"...\"}"}
|
||||||
|
|
||||||
// 工具返回(id_ref 与 tool_call 的 id_ref 匹配)
|
|
||||||
{"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false}
|
{"id": "step-3", "index": 3, "type": "tool_result", "id_ref": "call_abc123", "name": "web_search", "content": "{\"success\": true, ...}", "skipped": false}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -555,6 +531,36 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
| `token_count` | 总输出 token 数(跨所有迭代累积) |
|
| `token_count` | 总输出 token 数(跨所有迭代累积) |
|
||||||
| `suggested_title` | 建议会话标题(从首条用户消息提取,无标题时为 `"新对话"`,已有标题时为 `null`) |
|
| `suggested_title` | 建议会话标题(从首条用户消息提取,无标题时为 `"新对话"`,已有标题时为 `null`) |
|
||||||
|
|
||||||
|
### error 事件格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"content": "exceeded maximum tool call iterations"}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --------- | --------------------- |
|
||||||
|
| `content` | 错误信息字符串,前端展示给用户或打印到控制台 |
|
||||||
|
|
||||||
|
### 前端 SSE 解析机制
|
||||||
|
|
||||||
|
前端不使用浏览器原生 `EventSource`(仅支持 GET),而是通过 `fetch` + `ReadableStream` 实现 POST 请求的 SSE 解析(`frontend/src/api/index.js`):
|
||||||
|
|
||||||
|
1. **读取**:通过 `response.body.getReader()` 获取可读流,循环 `reader.read()` 读取二进制 chunk
|
||||||
|
2. **解码拼接**:`TextDecoder` 将二进制解码为 UTF-8 字符串,追加到 `buffer`(处理跨 chunk 的不完整行)
|
||||||
|
3. **切行**:按 `\n` 分割,最后一段保留在 `buffer` 中(可能是不完整的 SSE 行)
|
||||||
|
4. **解析分发**:逐行匹配 `event: xxx` 设置事件类型,`data: {...}` 解析 JSON 后分发到对应回调(`onThinking` / `onMessage` / `onProcessStep` / `onDone` / `onError`)
|
||||||
|
|
||||||
|
```
|
||||||
|
后端 yield: event: thinking\ndata: {"content":"..."}\n\n
|
||||||
|
↓ TCP(可能跨多个网络包)
|
||||||
|
reader.read(): [二进制片段1] → [二进制片段2] → ...
|
||||||
|
↓
|
||||||
|
buffer 拼接: "event: thinking\ndata: {\"content\":\"...\"}\n\n"
|
||||||
|
↓ split('\n')
|
||||||
|
逐行解析: event: → "thinking"
|
||||||
|
data: → JSON.parse → onThinking(data.content)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 数据模型
|
## 数据模型
|
||||||
|
|
@ -935,6 +941,9 @@ models:
|
||||||
# 默认模型
|
# 默认模型
|
||||||
default_model: glm-5
|
default_model: glm-5
|
||||||
|
|
||||||
|
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5)
|
||||||
|
max_iterations: 5
|
||||||
|
|
||||||
# 工作区根目录
|
# 工作区根目录
|
||||||
workspace_root: ./workspaces
|
workspace_root: ./workspaces
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@
|
||||||
:conversation="currentConv"
|
:conversation="currentConv"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
:streaming-content="streamContent"
|
|
||||||
:streaming-thinking="streamThinkingContent"
|
|
||||||
:streaming-process-steps="streamProcessSteps"
|
:streaming-process-steps="streamProcessSteps"
|
||||||
:has-more-messages="hasMoreMessages"
|
:has-more-messages="hasMoreMessages"
|
||||||
:loading-more="loadingMessages"
|
:loading-more="loadingMessages"
|
||||||
|
|
@ -135,13 +133,11 @@ const loadingMessages = ref(false)
|
||||||
const nextMsgCursor = ref(null)
|
const nextMsgCursor = ref(null)
|
||||||
|
|
||||||
// -- Streaming state --
|
// -- Streaming state --
|
||||||
// These refs hold the real-time streaming data for the current conversation.
|
// processSteps is the single source of truth for all streaming content.
|
||||||
// When switching conversations, the current state is saved to streamStates Map
|
// thinking/text steps are sent incrementally via process_step events and
|
||||||
// and restored when switching back. On stream completion (onDone), the finalized
|
// updated in-place by id. tool_call/tool_result steps are appended on arrival.
|
||||||
// processSteps are stored in the message object and later persisted to DB.
|
// On stream completion (onDone), the finalized steps are stored in the message object.
|
||||||
const streaming = ref(false)
|
const streaming = ref(false)
|
||||||
const streamContent = ref('') // Accumulated text content during current iteration
|
|
||||||
const streamThinkingContent = ref('') // Accumulated thinking content during current iteration
|
|
||||||
const streamProcessSteps = shallowRef([]) // Ordered steps: thinking/text/tool_call/tool_result
|
const streamProcessSteps = shallowRef([]) // Ordered steps: thinking/text/tool_call/tool_result
|
||||||
|
|
||||||
// 保存每个对话的流式状态
|
// 保存每个对话的流式状态
|
||||||
|
|
@ -149,8 +145,6 @@ const streamStates = new Map()
|
||||||
|
|
||||||
function setStreamState(isActive) {
|
function setStreamState(isActive) {
|
||||||
streaming.value = isActive
|
streaming.value = isActive
|
||||||
streamContent.value = ''
|
|
||||||
streamThinkingContent.value = ''
|
|
||||||
streamProcessSteps.value = []
|
streamProcessSteps.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,8 +245,6 @@ async function selectConversation(id) {
|
||||||
if (currentConvId.value && streaming.value) {
|
if (currentConvId.value && streaming.value) {
|
||||||
streamStates.set(currentConvId.value, {
|
streamStates.set(currentConvId.value, {
|
||||||
streaming: true,
|
streaming: true,
|
||||||
streamContent: streamContent.value,
|
|
||||||
streamThinkingContent: streamThinkingContent.value,
|
|
||||||
streamProcessSteps: [...streamProcessSteps.value],
|
streamProcessSteps: [...streamProcessSteps.value],
|
||||||
messages: [...messages.value],
|
messages: [...messages.value],
|
||||||
})
|
})
|
||||||
|
|
@ -266,8 +258,6 @@ async function selectConversation(id) {
|
||||||
const savedState = streamStates.get(id)
|
const savedState = streamStates.get(id)
|
||||||
if (savedState && savedState.streaming) {
|
if (savedState && savedState.streaming) {
|
||||||
streaming.value = true
|
streaming.value = true
|
||||||
streamContent.value = savedState.streamContent
|
|
||||||
streamThinkingContent.value = savedState.streamThinkingContent || ''
|
|
||||||
streamProcessSteps.value = savedState.streamProcessSteps
|
streamProcessSteps.value = savedState.streamProcessSteps
|
||||||
messages.value = savedState.messages || []
|
messages.value = savedState.messages || []
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -307,30 +297,16 @@ function loadMoreMessages() {
|
||||||
// -- Helpers: create stream callbacks for a conversation --
|
// -- Helpers: create stream callbacks for a conversation --
|
||||||
function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
return {
|
return {
|
||||||
onMessage(text) {
|
|
||||||
updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text)
|
|
||||||
},
|
|
||||||
onThinking(text) {
|
|
||||||
updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, prev => (prev || '') + text)
|
|
||||||
},
|
|
||||||
onProcessStep(step) {
|
onProcessStep(step) {
|
||||||
// Insert step at its index position to preserve ordering.
|
// Update or insert step by index position.
|
||||||
// Uses sparse array strategy: fills gaps with null.
|
// thinking/text steps are sent incrementally with the same id — each update
|
||||||
// Each step carries { id, index, type, content, ... } —
|
// replaces the previous content at that index. tool_call/tool_result are appended.
|
||||||
// these are the same steps that get stored to DB as the 'steps' array.
|
|
||||||
updateStreamField(convId, 'streamProcessSteps', streamProcessSteps, prev => {
|
updateStreamField(convId, 'streamProcessSteps', streamProcessSteps, prev => {
|
||||||
const steps = prev ? [...prev] : []
|
const steps = prev ? [...prev] : []
|
||||||
while (steps.length <= step.index) steps.push(null)
|
while (steps.length <= step.index) steps.push(null)
|
||||||
steps[step.index] = step
|
steps[step.index] = step
|
||||||
return steps
|
return steps
|
||||||
})
|
})
|
||||||
// When a step is finalized, reset the corresponding streaming content
|
|
||||||
// to prevent duplication (the content is now rendered via processSteps).
|
|
||||||
if (step.type === 'text') {
|
|
||||||
updateStreamField(convId, 'streamContent', streamContent, () => '')
|
|
||||||
} else if (step.type === 'thinking') {
|
|
||||||
updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, () => '')
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async onDone(data) {
|
async onDone(data) {
|
||||||
streamStates.delete(convId)
|
streamStates.delete(convId)
|
||||||
|
|
@ -341,11 +317,9 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
// Build the final message object.
|
// Build the final message object.
|
||||||
// process_steps is the primary ordered data for rendering (thinking/text/tool_call/tool_result).
|
// process_steps is the primary ordered data for rendering (thinking/text/tool_call/tool_result).
|
||||||
// When page reloads, these steps are loaded from DB via the 'steps' field in content JSON.
|
// When page reloads, these steps are loaded from DB via the 'steps' field in content JSON.
|
||||||
// NOTE: streamContent is already '' at this point (reset by process_step text event),
|
|
||||||
// so extract text from the last text step in processSteps.
|
|
||||||
const steps = streamProcessSteps.value.filter(Boolean)
|
const steps = streamProcessSteps.value.filter(Boolean)
|
||||||
const textSteps = steps.filter(s => s.type === 'text')
|
const textSteps = steps.filter(s => s.type === 'text')
|
||||||
const lastText = textSteps.length > 0 ? textSteps[textSteps.length - 1].content : streamContent.value
|
const lastText = textSteps.length > 0 ? textSteps[textSteps.length - 1].content : ''
|
||||||
|
|
||||||
// Derive legacy tool_calls from processSteps (backward compat for DB and MessageBubble fallback)
|
// Derive legacy tool_calls from processSteps (backward compat for DB and MessageBubble fallback)
|
||||||
const toolCallSteps = steps.filter(s => s && s.type === 'tool_call')
|
const toolCallSteps = steps.filter(s => s && s.type === 'tool_call')
|
||||||
|
|
@ -539,7 +513,7 @@ async function createProject() {
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
try {
|
try {
|
||||||
const res = await projectApi.list()
|
const res = await projectApi.list()
|
||||||
projects.value = res.data.projects || []
|
projects.value = res.data.items || []
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load projects:', e)
|
console.error('Failed to load projects:', e)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ async function request(url, options = {}) {
|
||||||
* Shared SSE stream processor - parses SSE events and dispatches to callbacks
|
* Shared SSE stream processor - parses SSE events and dispatches to callbacks
|
||||||
* @param {string} url - API URL (without BASE prefix)
|
* @param {string} url - API URL (without BASE prefix)
|
||||||
* @param {object} body - Request body
|
* @param {object} body - Request body
|
||||||
* @param {object} callbacks - Event handlers: { onMessage, onThinking, onProcessStep, onDone, onError }
|
* @param {object} callbacks - Event handlers: { onProcessStep, onDone, onError }
|
||||||
* @returns {{ abort: () => void }}
|
* @returns {{ abort: () => void }}
|
||||||
*/
|
*/
|
||||||
function createSSEStream(url, body, { onMessage, onThinking, onProcessStep, onDone, onError }) {
|
function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
|
|
@ -67,11 +67,7 @@ function createSSEStream(url, body, { onMessage, onThinking, onProcessStep, onDo
|
||||||
currentEvent = line.slice(7).trim()
|
currentEvent = line.slice(7).trim()
|
||||||
} else if (line.startsWith('data: ')) {
|
} else if (line.startsWith('data: ')) {
|
||||||
const data = JSON.parse(line.slice(6))
|
const data = JSON.parse(line.slice(6))
|
||||||
if (currentEvent === 'thinking' && onThinking) {
|
if (currentEvent === 'process_step' && onProcessStep) {
|
||||||
onThinking(data.content)
|
|
||||||
} else if (currentEvent === 'message' && onMessage) {
|
|
||||||
onMessage(data.content)
|
|
||||||
} else if (currentEvent === 'process_step' && onProcessStep) {
|
|
||||||
onProcessStep(data)
|
onProcessStep(data)
|
||||||
} else if (currentEvent === 'done' && onDone) {
|
} else if (currentEvent === 'done' && onDone) {
|
||||||
onDone(data)
|
onDone(data)
|
||||||
|
|
@ -183,8 +179,8 @@ export const messageApi = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const projectApi = {
|
export const projectApi = {
|
||||||
list() {
|
list(cursor, limit = 20) {
|
||||||
return request('/projects')
|
return request(`/projects${buildQueryParams({ cursor, limit })}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
create(data) {
|
create(data) {
|
||||||
|
|
@ -220,12 +216,19 @@ export const projectApi = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
renameFile(projectId, filepath, newPath) {
|
||||||
|
return request(`/projects/${projectId}/files/${filepath}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { new_path: newPath },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
deleteFile(projectId, filepath) {
|
deleteFile(projectId, filepath) {
|
||||||
return request(`/projects/${projectId}/files/${filepath}`, { method: 'DELETE' })
|
return request(`/projects/${projectId}/files/${filepath}`, { method: 'DELETE' })
|
||||||
},
|
},
|
||||||
|
|
||||||
mkdir(projectId, dirPath) {
|
mkdir(projectId, dirPath) {
|
||||||
return request(`/projects/${projectId}/files/mkdir`, {
|
return request(`/projects/${projectId}/directories`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { path: dirPath },
|
body: { path: dirPath },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,6 @@
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
:process-steps="streamingProcessSteps"
|
:process-steps="streamingProcessSteps"
|
||||||
:streaming-content="streamingContent"
|
|
||||||
:streaming-thinking="streamingThinking"
|
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,8 +85,6 @@ const props = defineProps({
|
||||||
conversation: { type: Object, default: null },
|
conversation: { type: Object, default: null },
|
||||||
messages: { type: Array, required: true },
|
messages: { type: Array, required: true },
|
||||||
streaming: { type: Boolean, default: false },
|
streaming: { type: Boolean, default: false },
|
||||||
streamingContent: { type: String, default: '' },
|
|
||||||
streamingThinking: { type: String, default: '' },
|
|
||||||
streamingProcessSteps: { type: Array, default: () => [] },
|
streamingProcessSteps: { type: Array, default: () => [] },
|
||||||
hasMoreMessages: { type: Boolean, default: false },
|
hasMoreMessages: { type: Boolean, default: false },
|
||||||
loadingMore: { type: Boolean, default: false },
|
loadingMore: { type: Boolean, default: false },
|
||||||
|
|
@ -163,7 +159,7 @@ function scrollToMessage(msgId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流式时使用 instant 滚动,避免 smooth 动画与内容增长互相打架造成抖动
|
// 流式时使用 instant 滚动,避免 smooth 动画与内容增长互相打架造成抖动
|
||||||
watch([() => props.messages.length, () => props.streamingContent, () => props.streamingThinking], () => {
|
watch([() => props.messages.length, () => props.streamingProcessSteps], () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = scrollContainer.value
|
const el = scrollContainer.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
|
||||||
|
|
@ -60,19 +60,6 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-actions">
|
<div class="viewer-actions">
|
||||||
<button class="btn-icon-sm save" @click="saveFile" title="保存 (Ctrl+S)" :disabled="saving">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
|
||||||
<polyline points="17 21 17 13 7 13 7 21"/>
|
|
||||||
<polyline points="7 3 7 8 15 8"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="btn-icon-sm danger" @click="deleteFile" title="删除文件">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<polyline points="3 6 5 6 21 6"/>
|
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button class="btn-icon-sm" @click="activeFile = null" title="关闭">
|
<button class="btn-icon-sm" @click="activeFile = null" title="关闭">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<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 x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div class="tree-node" @mouseenter="hovered = true" @mouseleave="onMouseLeave">
|
||||||
<div class="tree-item" :class="{ active: isActive }" @click="onClick">
|
<div class="tree-item" :class="{ active: isActive }" @click="onClick">
|
||||||
<span class="tree-indent" :style="{ width: depth * 16 + 'px' }"></span>
|
<span class="tree-indent" :style="{ width: depth * 16 + 'px' }"></span>
|
||||||
|
|
||||||
|
|
@ -22,7 +23,55 @@
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="tree-name" :title="item.name">{{ item.name }}</span>
|
<!-- Rename input or name -->
|
||||||
|
<input
|
||||||
|
v-if="renaming"
|
||||||
|
ref="renameInput"
|
||||||
|
v-model="renameName"
|
||||||
|
class="tree-rename-input"
|
||||||
|
@keydown.enter="confirmRename"
|
||||||
|
@keydown.escape="cancelRename"
|
||||||
|
@blur="confirmRename"
|
||||||
|
/>
|
||||||
|
<span v-else class="tree-name" :title="item.path">{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover action buttons -->
|
||||||
|
<div v-show="hovered && !renaming" class="tree-actions">
|
||||||
|
<!-- Folder: new file/folder dropdown -->
|
||||||
|
<div v-if="item.type === 'dir'" class="tree-action-dropdown">
|
||||||
|
<button class="btn-icon-sm" @click.stop="showCreateMenu = !showCreateMenu" title="新建">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Dropdown menu -->
|
||||||
|
<div v-if="showCreateMenu" class="tree-create-menu" @mouseenter="hovered = true" @mouseleave="onMouseLeave">
|
||||||
|
<button @click="createNewFile">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
新建文件
|
||||||
|
</button>
|
||||||
|
<button @click="createNewFolder">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
新建文件夹
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Rename -->
|
||||||
|
<button class="btn-icon-sm" @click.stop="startRename" title="重命名">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Delete -->
|
||||||
|
<button class="btn-icon-sm danger" @click.stop="deleteItem" title="删除">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Children -->
|
<!-- Children -->
|
||||||
|
|
@ -46,7 +95,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import { projectApi } from '../api'
|
import { projectApi } from '../api'
|
||||||
import { normalizeFileTree } from '../utils/fileTree'
|
import { normalizeFileTree } from '../utils/fileTree'
|
||||||
|
|
||||||
|
|
@ -61,6 +110,11 @@ const emit = defineEmits(['select', 'refresh'])
|
||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const hovered = ref(false)
|
||||||
|
const renaming = ref(false)
|
||||||
|
const renameName = ref('')
|
||||||
|
const showCreateMenu = ref(false)
|
||||||
|
const renameInput = ref(null)
|
||||||
|
|
||||||
const isActive = computed(() => props.activePath === props.item.path)
|
const isActive = computed(() => props.activePath === props.item.path)
|
||||||
|
|
||||||
|
|
@ -75,6 +129,19 @@ const iconColor = computed(() => {
|
||||||
return colorMap[ext] || 'var(--text-tertiary)'
|
return colorMap[ext] || 'var(--text-tertiary)'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
if (!showCreateMenu.value) {
|
||||||
|
hovered.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentClick() {
|
||||||
|
if (showCreateMenu.value) showCreateMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||||
|
onUnmounted(() => document.removeEventListener('click', onDocumentClick))
|
||||||
|
|
||||||
async function onClick() {
|
async function onClick() {
|
||||||
if (props.item.type === 'dir') {
|
if (props.item.type === 'dir') {
|
||||||
expanded.value = !expanded.value
|
expanded.value = !expanded.value
|
||||||
|
|
@ -93,9 +160,88 @@ async function onClick() {
|
||||||
emit('select', props.item.path)
|
emit('select', props.item.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Rename --
|
||||||
|
function startRename() {
|
||||||
|
renaming.value = true
|
||||||
|
renameName.value = props.item.name
|
||||||
|
nextTick(() => {
|
||||||
|
renameInput.value?.focus()
|
||||||
|
const dot = renameName.value.lastIndexOf('.')
|
||||||
|
if (dot > 0) renameInput.value?.setSelectionRange(0, dot)
|
||||||
|
else renameInput.value?.select()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRename() {
|
||||||
|
if (!renaming.value) return
|
||||||
|
renaming.value = false
|
||||||
|
const newName = renameName.value.trim()
|
||||||
|
if (!newName || newName === props.item.name) return
|
||||||
|
|
||||||
|
const parentDir = props.item.path.includes('/')
|
||||||
|
? props.item.path.substring(0, props.item.path.lastIndexOf('/'))
|
||||||
|
: ''
|
||||||
|
const newPath = parentDir ? `${parentDir}/${newName}` : newName
|
||||||
|
|
||||||
|
try {
|
||||||
|
await projectApi.renameFile(props.projectId, props.item.path, newPath)
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert('重命名失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
renaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Delete --
|
||||||
|
async function deleteItem() {
|
||||||
|
const type = props.item.type === 'dir' ? '文件夹' : '文件'
|
||||||
|
if (!confirm(`确定要删除${type}「${props.item.name}」吗?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await projectApi.deleteFile(props.projectId, props.item.path)
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert('删除失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Create (in folder) --
|
||||||
|
async function createNewFile() {
|
||||||
|
showCreateMenu.value = false
|
||||||
|
const name = prompt('文件名(例如 utils.py)')
|
||||||
|
if (!name?.trim()) return
|
||||||
|
const path = props.item.path ? `${props.item.path}/${name.trim()}` : name.trim()
|
||||||
|
try {
|
||||||
|
await projectApi.writeFile(props.projectId, path, '')
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert('创建失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewFolder() {
|
||||||
|
showCreateMenu.value = false
|
||||||
|
const name = prompt('文件夹名称')
|
||||||
|
if (!name?.trim()) return
|
||||||
|
const path = props.item.path ? `${props.item.path}/${name.trim()}` : name.trim()
|
||||||
|
try {
|
||||||
|
await projectApi.mkdir(props.projectId, path)
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
alert('创建失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.tree-node {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-item {
|
.tree-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -159,6 +305,114 @@ async function onClick() {
|
||||||
.tree-name {
|
.tree-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Inline rename input -- */
|
||||||
|
.tree-rename-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--accent-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Hover action buttons -- */
|
||||||
|
.tree-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
top: 0;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
z-index: 10;
|
||||||
|
background: linear-gradient(to right, transparent 4px, var(--bg-secondary) 12px);
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-sm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-sm:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-sm.danger:hover {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Create dropdown -- */
|
||||||
|
.tree-action-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-create-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-create-menu button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-create-menu button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-create-menu button svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Children & loading -- */
|
||||||
|
.tree-children {
|
||||||
|
/* no additional styles needed */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-loading {
|
.tree-loading {
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,6 @@ import { useCodeEnhancement } from '../composables/useCodeEnhancement'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
toolCalls: { type: Array, default: () => [] },
|
toolCalls: { type: Array, default: () => [] },
|
||||||
processSteps: { type: Array, default: () => [] },
|
processSteps: { type: Array, default: () => [] },
|
||||||
streamingContent: { type: String, default: '' },
|
|
||||||
streamingThinking: { type: String, default: '' },
|
|
||||||
streaming: { type: Boolean, default: false }
|
streaming: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -102,26 +100,14 @@ function getResultSummary(result) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build ordered process items from all available data (thinking, tool calls, text).
|
// Build ordered process items from all available data (thinking, tool calls, text).
|
||||||
// During streaming, processSteps accumulate completed iterations while streamingContent
|
// processSteps is the single source of truth — updated incrementally during streaming
|
||||||
// represents the text being generated in the current (latest) iteration.
|
// (thinking/text steps have their content replaced in-place by id/index),
|
||||||
|
// and loaded as finalized data from DB on page reload.
|
||||||
// When loaded from DB, steps use 'id_ref' for tool_call/tool_result matching;
|
// When loaded from DB, steps use 'id_ref' for tool_call/tool_result matching;
|
||||||
// during streaming they use 'id'. Both fields are normalized here.
|
// during streaming they use 'id'. Both fields are normalized here.
|
||||||
const processItems = computed(() => {
|
const processItems = computed(() => {
|
||||||
const items = []
|
const items = []
|
||||||
|
|
||||||
// Prepend live streaming thinking content as the first item (before finalized steps).
|
|
||||||
// This appears while thinking chunks are streaming and before the finalized thinking step arrives.
|
|
||||||
if (props.streaming && props.streamingThinking) {
|
|
||||||
items.push({
|
|
||||||
type: 'thinking',
|
|
||||||
content: props.streamingThinking,
|
|
||||||
summary: truncate(props.streamingThinking),
|
|
||||||
key: 'thinking-streaming',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build items from processSteps — finalized steps sent by backend or loaded from DB.
|
|
||||||
// Steps are ordered: each iteration produces thinking → text → tool_call → tool_result.
|
|
||||||
if (props.processSteps && props.processSteps.length > 0) {
|
if (props.processSteps && props.processSteps.length > 0) {
|
||||||
for (const step of props.processSteps) {
|
for (const step of props.processSteps) {
|
||||||
if (!step) continue
|
if (!step) continue
|
||||||
|
|
@ -161,7 +147,7 @@ const processItems = computed(() => {
|
||||||
items.push({
|
items.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: step.content,
|
content: step.content,
|
||||||
rendered: renderMarkdown(step.content),
|
rendered: renderMarkdown(step.content) || '<span class="placeholder">...</span>',
|
||||||
key: step.id || `text-${step.index}`,
|
key: step.id || `text-${step.index}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -174,17 +160,6 @@ const processItems = computed(() => {
|
||||||
last.loading = true
|
last.loading = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the currently streaming text as a live text item.
|
|
||||||
// This text belongs to the latest LLM iteration that hasn't finished yet.
|
|
||||||
if (props.streaming && props.streamingContent) {
|
|
||||||
items.push({
|
|
||||||
type: 'text',
|
|
||||||
content: props.streamingContent,
|
|
||||||
rendered: renderMarkdown(props.streamingContent) || '<span class="placeholder">...</span>',
|
|
||||||
key: 'text-streaming',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: legacy mode for old messages without processSteps stored in DB
|
// Fallback: legacy mode for old messages without processSteps stored in DB
|
||||||
if (props.toolCalls && props.toolCalls.length > 0) {
|
if (props.toolCalls && props.toolCalls.length > 0) {
|
||||||
|
|
@ -205,16 +180,6 @@ const processItems = computed(() => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append streaming text in legacy mode
|
|
||||||
if (props.streaming && props.streamingContent) {
|
|
||||||
items.push({
|
|
||||||
type: 'text',
|
|
||||||
content: props.streamingContent,
|
|
||||||
rendered: renderMarkdown(props.streamingContent) || '<span class="placeholder">...</span>',
|
|
||||||
key: 'text-streaming',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
@ -224,7 +189,7 @@ const processItems = computed(() => {
|
||||||
const { debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
|
const { debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
|
||||||
|
|
||||||
// Throttle code enhancement during streaming to reduce DOM operations
|
// Throttle code enhancement during streaming to reduce DOM operations
|
||||||
watch(() => props.streamingContent?.length, () => {
|
watch(() => props.processSteps?.length, () => {
|
||||||
if (props.streaming) debouncedEnhance()
|
if (props.streaming) debouncedEnhance()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue