refactor: 优化SSE 格式等

This commit is contained in:
ViperEkura 2026-03-27 11:12:07 +08:00
parent f57e813f76
commit ea425cf9a6
10 changed files with 466 additions and 211 deletions

View File

@ -21,3 +21,6 @@ for _m in MODELS:
}
DEFAULT_MODEL = _cfg.get("default_model", "glm-5")
# Max agentic loop iterations (tool call rounds)
MAX_ITERATIONS = _cfg.get("max_iterations", 5)

View File

@ -2,6 +2,7 @@
import os
import uuid
import shutil
from datetime import datetime, timezone
from flask import Blueprint, request, g
from backend import db
@ -18,26 +19,46 @@ from backend.utils.workspace import (
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"])
def list_projects():
"""List all projects for 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)
if cursor:
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,
"name": p.name,
"path": p.path,
"description": p.description,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
"conversation_count": p.conversations.count(),
}
for p in rows[:limit]
]
return ok({
"projects": [
{
"id": p.id,
"name": p.name,
"path": p.path,
"description": p.description,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
"conversation_count": p.conversations.count()
}
for p in projects
],
"total": len(projects)
"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,8 +112,9 @@ def create_project():
@bp.route("/api/projects/<project_id>", methods=["GET"])
def get_project(project_id):
"""Get project details"""
project = Project.query.get(project_id)
user = g.current_user
project = _get_project(project_id)
if not project:
return err(404, "Project not found")
@ -124,7 +146,8 @@ def get_project(project_id):
@bp.route("/api/projects/<project_id>", methods=["PUT"])
def update_project(project_id):
"""Update project details"""
project = Project.query.get(project_id)
user = g.current_user
project = _get_project(project_id)
if not project:
return err(404, "Project not found")
@ -169,10 +192,9 @@ def update_project(project_id):
@bp.route("/api/projects/<project_id>", methods=["DELETE"])
def delete_project(project_id):
"""Delete a project"""
user = g.current_user
project = Project.query.get(project_id)
if not project or project.user_id != user.id:
project = _get_project(project_id)
if not project:
return err(404, "Project not found")
# Delete project directory
@ -247,8 +269,8 @@ def upload_project_folder():
@bp.route("/api/projects/<project_id>/files", methods=["GET"])
def list_project_files(project_id):
"""List files in a project directory"""
project = Project.query.get(project_id)
project = _get_project(project_id)
if not project:
return err(404, "Project not found")
@ -326,7 +348,7 @@ TEXT_EXTENSIONS = {
def _resolve_file_path(project_id, filepath):
"""Resolve and validate a file path within a project directory."""
project = Project.query.get(project_id)
project = _get_project(project_id)
if not project:
return None, None, err(404, "Project not found")
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"])
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)
if error:
return error
@ -411,16 +467,22 @@ def delete_project_file(project_id, 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):
"""Create a directory in the project."""
project = _get_project(project_id)
if not project:
return err(404, "Project not found")
data = request.get_json()
if not data or "path" not in data:
return err(400, "Missing 'path' in request body")
project_dir, target, error = _resolve_file_path(project_id, data["path"])
if error:
return error
project_dir = get_project_path(project.id, project.path)
try:
target = validate_path_in_project(data["path"], project_dir)
except ValueError:
return err(403, "Invalid path: outside project directory")
try:
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)
case_sensitive = data.get("case_sensitive", False)
project = Project.query.get(project_id)
project = _get_project(project_id)
if not project:
return err(404, "Project not found")

View File

@ -10,13 +10,12 @@ from backend.utils.helpers import (
build_messages,
)
from backend.services.glm_client import GLMClient
from backend.config import MAX_ITERATIONS
class ChatService:
"""Chat completion service with tool support"""
MAX_ITERATIONS = 5
def __init__(self, glm_client: GLMClient):
self.glm_client = glm_client
self.executor = ToolExecutor(registry=registry)
@ -60,15 +59,19 @@ class ChatService:
# (each iteration re-sends the full context, so earlier
# prompts are strict subsets of the final one)
for iteration in range(self.MAX_ITERATIONS):
for iteration in range(MAX_ITERATIONS):
full_content = ""
full_thinking = ""
token_count = 0
msg_id = str(uuid.uuid4())
tool_calls_list = []
# Clear state for new iteration
# (frontend resets via onProcessStep when first step arrives)
# Streaming step tracking — step ID is assigned on first chunk arrival.
# 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:
with app.app_context():
@ -115,13 +118,19 @@ class ChatService:
reasoning = delta.get("reasoning_content", "")
if 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
text = delta.get("content", "")
if 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
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"
return
# --- Finalize thinking/text steps for this iteration (common to both paths) ---
if full_thinking:
step_data = {
'id': f'step-{step_index}',
'index': step_index,
'type': 'thinking',
'content': full_thinking,
}
all_steps.append(step_data)
yield f"event: process_step\ndata: {json.dumps(step_data, ensure_ascii=False)}\n\n"
# --- Finalize: save thinking/text steps to all_steps for DB storage ---
# No need to yield to frontend — incremental process_step events already sent.
if thinking_step_id is not None:
all_steps.append({
'id': thinking_step_id, 'index': thinking_step_idx,
'type': 'thinking', 'content': full_thinking,
})
step_index += 1
if full_content:
step_data = {
'id': f'step-{step_index}',
'index': step_index,
'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"
if text_step_id is not None:
all_steps.append({
'id': text_step_id, 'index': text_step_idx,
'type': 'text', 'content': full_content,
})
step_index += 1
# --- Branch: tool calls vs final ---

View File

@ -252,7 +252,6 @@ classDiagram
class ChatService {
-GLMClient glm_client
-ToolExecutor executor
+Integer MAX_ITERATIONS
+stream_response(conv, tools_enabled, project_id) Response
-_build_tool_calls_json(calls, results) 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` | 创建项目 |
| `GET` | `/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/:filepath` | 读取文件内容(文本文件,最大 5 MB |
| `PUT` | `/api/projects/:id/files/:filepath` | 创建或覆盖文件Body: `{"content": "..."}`) |
| `PATCH` | `/api/projects/:id/files/:filepath` | 重命名或移动文件/目录Body: `{"new_path": "..."}`) |
| `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}`) |
### 其他
@ -472,48 +472,24 @@ def process_tool_calls(self, tool_calls, context=None):
| 事件 | 说明 |
| -------------- | ------------------------------------------------------------------------- |
| `thinking` | 思考过程的增量片段(实时流式输出) |
| `message` | 回复内容的增量片段(实时流式输出) |
| `process_step` | 有序处理步骤thinking/text/tool_call/tool_result支持穿插显示。携带 `id`、`index` 确保渲染顺序 |
| `process_step` | 有序处理步骤thinking/text/tool_call/tool_result支持穿插显示和实时流式更新。携带 `id`、`index` 确保渲染顺序 |
| `error` | 错误信息 |
| `done` | 回复结束,携带 message_id、token_count 和 suggested_title |
> **注意**`thinking` 和 `message` 事件提供实时流式体验,每条 chunk 立即推送到前端。`process_step` 事件在每次迭代结束后发送完整内容,用于确定渲染顺序和 DB 存储。
### thinking / message 事件格式
实时流式事件,每条携带一个增量片段:
```json
// 思考增量片段
{"content": "正在分析用户需求..."}
// 文本增量片段
{"content": "根据分析结果"}
```
字段说明:
| 字段 | 说明 |
| --------- | ---------------------------- |
| `content` | 增量文本片段(前端累积拼接为完整内容) |
> **注意**`process_step` 是唯一的内容传输事件。thinking/text 步骤在每个 LLM chunk 到达时**增量发送**(前端按 `id` 原地更新tool_call/tool_result 步骤在工具执行时**追加发送**。所有步骤在迭代结束时存入 DB。
### process_step 事件格式
每个 `process_step` 事件携带一个带 `id`、`index` 和 `type` 的步骤对象。步骤按 `index` 顺序排列,确保前端可以正确渲染穿插的思考、文本和工具调用。
```json
// 思考过程
{"id": "step-0", "index": 0, "type": "thinking", "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_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}
```
@ -555,6 +531,36 @@ def process_tool_calls(self, tool_calls, context=None):
| `token_count` | 总输出 token 数(跨所有迭代累积) |
| `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
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5
max_iterations: 5
# 工作区根目录
workspace_root: ./workspaces

View File

@ -43,8 +43,6 @@
:conversation="currentConv"
:messages="messages"
:streaming="streaming"
:streaming-content="streamContent"
:streaming-thinking="streamThinkingContent"
:streaming-process-steps="streamProcessSteps"
:has-more-messages="hasMoreMessages"
:loading-more="loadingMessages"
@ -135,13 +133,11 @@ const loadingMessages = ref(false)
const nextMsgCursor = ref(null)
// -- Streaming state --
// These refs hold the real-time streaming data for the current conversation.
// When switching conversations, the current state is saved to streamStates Map
// and restored when switching back. On stream completion (onDone), the finalized
// processSteps are stored in the message object and later persisted to DB.
// processSteps is the single source of truth for all streaming content.
// thinking/text steps are sent incrementally via process_step events and
// updated in-place by id. tool_call/tool_result steps are appended on arrival.
// On stream completion (onDone), the finalized steps are stored in the message object.
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
//
@ -149,8 +145,6 @@ const streamStates = new Map()
function setStreamState(isActive) {
streaming.value = isActive
streamContent.value = ''
streamThinkingContent.value = ''
streamProcessSteps.value = []
}
@ -251,8 +245,6 @@ async function selectConversation(id) {
if (currentConvId.value && streaming.value) {
streamStates.set(currentConvId.value, {
streaming: true,
streamContent: streamContent.value,
streamThinkingContent: streamThinkingContent.value,
streamProcessSteps: [...streamProcessSteps.value],
messages: [...messages.value],
})
@ -266,8 +258,6 @@ async function selectConversation(id) {
const savedState = streamStates.get(id)
if (savedState && savedState.streaming) {
streaming.value = true
streamContent.value = savedState.streamContent
streamThinkingContent.value = savedState.streamThinkingContent || ''
streamProcessSteps.value = savedState.streamProcessSteps
messages.value = savedState.messages || []
} else {
@ -307,30 +297,16 @@ function loadMoreMessages() {
// -- Helpers: create stream callbacks for a conversation --
function createStreamCallbacks(convId, { updateConvList = true } = {}) {
return {
onMessage(text) {
updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text)
},
onThinking(text) {
updateStreamField(convId, 'streamThinkingContent', streamThinkingContent, prev => (prev || '') + text)
},
onProcessStep(step) {
// Insert step at its index position to preserve ordering.
// Uses sparse array strategy: fills gaps with null.
// Each step carries { id, index, type, content, ... }
// these are the same steps that get stored to DB as the 'steps' array.
// Update or insert step by index position.
// thinking/text steps are sent incrementally with the same id each update
// replaces the previous content at that index. tool_call/tool_result are appended.
updateStreamField(convId, 'streamProcessSteps', streamProcessSteps, prev => {
const steps = prev ? [...prev] : []
while (steps.length <= step.index) steps.push(null)
steps[step.index] = step
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) {
streamStates.delete(convId)
@ -341,11 +317,9 @@ function createStreamCallbacks(convId, { updateConvList = true } = {}) {
// Build the final message object.
// 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.
// 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 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)
const toolCallSteps = steps.filter(s => s && s.type === 'tool_call')
@ -539,7 +513,7 @@ async function createProject() {
async function loadProjects() {
try {
const res = await projectApi.list()
projects.value = res.data.projects || []
projects.value = res.data.items || []
} catch (e) {
console.error('Failed to load projects:', e)
}

View File

@ -29,10 +29,10 @@ async function request(url, options = {}) {
* 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: { onMessage, onThinking, onProcessStep, onDone, onError }
* @param {object} callbacks - Event handlers: { onProcessStep, onDone, onError }
* @returns {{ abort: () => void }}
*/
function createSSEStream(url, body, { onMessage, onThinking, onProcessStep, onDone, onError }) {
function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
const controller = new AbortController()
const promise = (async () => {
@ -67,11 +67,7 @@ function createSSEStream(url, body, { onMessage, onThinking, onProcessStep, onDo
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6))
if (currentEvent === 'thinking' && onThinking) {
onThinking(data.content)
} else if (currentEvent === 'message' && onMessage) {
onMessage(data.content)
} else if (currentEvent === 'process_step' && onProcessStep) {
if (currentEvent === 'process_step' && onProcessStep) {
onProcessStep(data)
} else if (currentEvent === 'done' && onDone) {
onDone(data)
@ -183,8 +179,8 @@ export const messageApi = {
}
export const projectApi = {
list() {
return request('/projects')
list(cursor, limit = 20) {
return request(`/projects${buildQueryParams({ cursor, limit })}`)
},
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) {
return request(`/projects/${projectId}/files/${filepath}`, { method: 'DELETE' })
},
mkdir(projectId, dirPath) {
return request(`/projects/${projectId}/files/mkdir`, {
return request(`/projects/${projectId}/directories`, {
method: 'POST',
body: { path: dirPath },
})

View File

@ -48,8 +48,6 @@
<div class="message-body">
<ProcessBlock
:process-steps="streamingProcessSteps"
:streaming-content="streamingContent"
:streaming-thinking="streamingThinking"
:streaming="streaming"
/>
</div>
@ -87,8 +85,6 @@ const props = defineProps({
conversation: { type: Object, default: null },
messages: { type: Array, required: true },
streaming: { type: Boolean, default: false },
streamingContent: { type: String, default: '' },
streamingThinking: { type: String, default: '' },
streamingProcessSteps: { type: Array, default: () => [] },
hasMoreMessages: { type: Boolean, default: false },
loadingMore: { type: Boolean, default: false },
@ -163,7 +159,7 @@ function scrollToMessage(msgId) {
}
// 使 instant smooth
watch([() => props.messages.length, () => props.streamingContent, () => props.streamingThinking], () => {
watch([() => props.messages.length, () => props.streamingProcessSteps], () => {
nextTick(() => {
const el = scrollContainer.value
if (!el) return

View File

@ -60,19 +60,6 @@
</span>
</div>
<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="关闭">
<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"/>

View File

@ -1,28 +1,77 @@
<template>
<div class="tree-item" :class="{ active: isActive }" @click="onClick">
<span class="tree-indent" :style="{ width: depth * 16 + 'px' }"></span>
<div class="tree-node" @mouseenter="hovered = true" @mouseleave="onMouseLeave">
<div class="tree-item" :class="{ active: isActive }" @click="onClick">
<span class="tree-indent" :style="{ width: depth * 16 + 'px' }"></span>
<span v-if="item.type === 'dir'" class="tree-arrow" :class="{ open: expanded }">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</span>
<span v-else class="tree-arrow-placeholder"></span>
<span v-if="item.type === 'dir'" class="tree-arrow" :class="{ open: expanded }">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</span>
<span v-else class="tree-arrow-placeholder"></span>
<!-- File icon -->
<span v-if="item.type === 'file'" class="tree-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" :stroke="iconColor" 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>
</span>
<span v-else class="tree-icon folder-icon">
<svg width="14" height="14" 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>
</span>
<!-- File icon -->
<span v-if="item.type === 'file'" class="tree-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" :stroke="iconColor" 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>
</span>
<span v-else class="tree-icon folder-icon">
<svg width="14" height="14" 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>
</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>
<!-- Children -->
@ -46,7 +95,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { projectApi } from '../api'
import { normalizeFileTree } from '../utils/fileTree'
@ -61,6 +110,11 @@ const emit = defineEmits(['select', 'refresh'])
const expanded = 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)
@ -75,6 +129,19 @@ const iconColor = computed(() => {
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() {
if (props.item.type === 'dir') {
expanded.value = !expanded.value
@ -93,9 +160,88 @@ async function onClick() {
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>
<style scoped>
.tree-node {
position: relative;
}
.tree-item {
display: flex;
align-items: center;
@ -159,6 +305,114 @@ async function onClick() {
.tree-name {
overflow: hidden;
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 {

View File

@ -71,8 +71,6 @@ import { useCodeEnhancement } from '../composables/useCodeEnhancement'
const props = defineProps({
toolCalls: { type: Array, default: () => [] },
processSteps: { type: Array, default: () => [] },
streamingContent: { type: String, default: '' },
streamingThinking: { type: String, default: '' },
streaming: { type: Boolean, default: false }
})
@ -102,26 +100,14 @@ function getResultSummary(result) {
}
// Build ordered process items from all available data (thinking, tool calls, text).
// During streaming, processSteps accumulate completed iterations while streamingContent
// represents the text being generated in the current (latest) iteration.
// processSteps is the single source of truth updated incrementally during streaming
// (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;
// during streaming they use 'id'. Both fields are normalized here.
const processItems = computed(() => {
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) {
for (const step of props.processSteps) {
if (!step) continue
@ -161,7 +147,7 @@ const processItems = computed(() => {
items.push({
type: 'text',
content: step.content,
rendered: renderMarkdown(step.content),
rendered: renderMarkdown(step.content) || '<span class="placeholder">...</span>',
key: step.id || `text-${step.index}`,
})
}
@ -174,17 +160,6 @@ const processItems = computed(() => {
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 {
// Fallback: legacy mode for old messages without processSteps stored in DB
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
@ -224,7 +189,7 @@ const processItems = computed(() => {
const { debouncedEnhance } = useCodeEnhancement(processRef, processItems, { deep: true })
// Throttle code enhancement during streaming to reduce DOM operations
watch(() => props.streamingContent?.length, () => {
watch(() => props.processSteps?.length, () => {
if (props.streaming) debouncedEnhance()
})
</script>