From 2198e40c497565abb17c1ed33b6e911002f9cc1e Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 14 Sep 2026 13:39:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(review):=20=E5=AF=B9=E8=AF=9D=E5=9B=9E?= =?UTF-8?q?=E9=A1=BE=E6=96=B0=E5=A2=9E=E7=BA=AF=E5=87=80=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=EF=BC=88=E9=BB=98=E8=AE=A4=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20/new=20=E9=A1=B5=E9=9D=A2=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_review_lines 新增 content_mode:dialogue(默认推荐,仅保留有实际内容的 user/assistant)/ full(原完整行为,含工具调用与结果) - conversation_review 工具与 review_preview/review 两个手动 API 均支持 content_mode,落盘 md 文件首行加模式标注 - 回顾弹窗新增「完整记录」切换按钮,预览实时跟随模式刷新 - 修复 /new 页面无法生成回顾:移除活跃对话硬拦截,发送给模型时改走正常发送入口自动创建对话 Co-authored-by: Astrion powered by Kimi-K3 --- .../tools_definition/context_tools.py | 7 +- core/main_terminal_parts/tools_execution.py | 14 +++- modules/i18n_messages/api_conversation.py | 8 +++ modules/i18n_messages/tools_execution.py | 4 ++ server/conversation.py | 22 ++++-- server/utils_common.py | 18 ++++- static/src/App.vue | 2 + static/src/app/methods/ui/dialog.ts | 2 + static/src/app/methods/ui/review.ts | 33 +++++---- static/src/app/state.ts | 2 + .../components/chat/actions/toolRenderers.ts | 7 ++ .../overlay/ConversationReviewDialog.vue | 71 ++++++++++++++++--- static/src/locales/en-US/overlay.ts | 3 + static/src/locales/en-US/toolResults.ts | 3 + static/src/locales/zh-CN/overlay.ts | 3 + static/src/locales/zh-CN/toolResults.ts | 3 + utils/tool_result_formatter/agent_context.py | 10 ++- 17 files changed, 180 insertions(+), 32 deletions(-) diff --git a/core/main_terminal_parts/tools_definition/context_tools.py b/core/main_terminal_parts/tools_definition/context_tools.py index f218932d..743a37eb 100644 --- a/core/main_terminal_parts/tools_definition/context_tools.py +++ b/core/main_terminal_parts/tools_definition/context_tools.py @@ -202,7 +202,7 @@ class ToolsDefinitionContextToolsMixin: "type": "function", "function": { "name": "conversation_review", - "description": "按 id 回顾当前工作区内的历史对话。mode=read 时直接返回回顾内容;若内容超过 50000 字符,将自动保存到 .astrion/review/ 并提示分段或查找阅读。mode=save 时保存 Markdown 文件到 .astrion/review/ 并返回路径。若 id 不属于当前工作区,将返回不存在或不属于当前工作区。", + "description": "按 id 回顾当前工作区内的历史对话。mode=read 时直接返回回顾内容;若内容超过 50000 字符,将自动保存到 .astrion/review/ 并提示分段或查找阅读。mode=save 时保存 Markdown 文件到 .astrion/review/ 并返回路径。若 id 不属于当前工作区,将返回不存在或不属于当前工作区。默认只保留有实际对话内容的 user 与 assistant 消息(不含工具调用/结果与空消息),需要含工具细节时用 content_mode=full。", "parameters": { "type": "object", "properties": self._inject_intent({ @@ -211,6 +211,11 @@ class ToolsDefinitionContextToolsMixin: "type": "string", "enum": ["read", "save"], "description": "必要参数。read=直接返回回顾内容;save=保存为 .astrion/review/ 下的 Markdown 文件并返回路径。" + }, + "content_mode": { + "type": "string", + "enum": ["dialogue", "full"], + "description": "可选参数,默认 dialogue(推荐)。dialogue=纯净对话:只保留有实际内容的 user 与 assistant 消息,不含工具调用、工具结果、system 与空消息;full=完整记录:含工具调用与结果等全部内容。" } }), "required": ["conversation_id", "mode"] diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 51584d80..a42ec2f3 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -2094,10 +2094,13 @@ class MainTerminalToolsExecutionMixin: elif tool_name == "conversation_review": conversation_id = str(arguments.get("conversation_id") or "").strip() review_mode = str(arguments.get("mode") or "").strip().lower() + content_mode = str(arguments.get("content_mode") or "dialogue").strip().lower() if not conversation_id: result = {"success": False, "error": tr("tools_exec.conversation_id_empty")} elif review_mode not in {"read", "save"}: result = {"success": False, "error": tr("tools_exec.review_mode_invalid"), "conversation_id": conversation_id} + elif content_mode not in {"dialogue", "full"}: + result = {"success": False, "error": tr("tools_exec.review_content_mode_invalid"), "conversation_id": conversation_id} else: manager = getattr(self.context_manager, "conversation_manager", None) conversation_data = manager.load_conversation(conversation_id) if manager else None @@ -2111,7 +2114,7 @@ class MainTerminalToolsExecutionMixin: from server.utils_common import build_review_lines, _sanitize_filename_component messages = conversation_data.get("messages", []) - content = "\n".join(build_review_lines(messages)) + "\n" + content = "\n".join(build_review_lines(messages, content_mode=content_mode)) + "\n" title = conversation_data.get("title") or "untitled" char_count = len(content) @@ -2122,13 +2125,19 @@ class MainTerminalToolsExecutionMixin: review_dir.mkdir(parents=True, exist_ok=True) filename = f"review_{safe_title}_{timestamp}.md" target = review_dir / filename - target.write_text(content, encoding="utf-8") + annotation = tr( + "conversation.review_annotation_dialogue" + if content_mode == "dialogue" + else "conversation.review_annotation_full" + ) + target.write_text(f"> {annotation}\n\n" + content, encoding="utf-8") return f"{WORKSPACE_REVIEW_DIRNAME}/{filename}" if review_mode == "read" and char_count <= 50000: result = { "success": True, "mode": "read", + "content_mode": content_mode, "conversation_id": conversation_id, "title": title, "content": content, @@ -2141,6 +2150,7 @@ class MainTerminalToolsExecutionMixin: result = { "success": True, "mode": review_mode, + "content_mode": content_mode, "conversation_id": conversation_id, "title": title, "path": rel_path, diff --git a/modules/i18n_messages/api_conversation.py b/modules/i18n_messages/api_conversation.py index d8474099..d9bae640 100644 --- a/modules/i18n_messages/api_conversation.py +++ b/modules/i18n_messages/api_conversation.py @@ -273,6 +273,14 @@ MESSAGES = { "zh-CN": "生成对话回顾时发生异常", "en-US": "An error occurred while generating the conversation review", }, + "conversation.review_annotation_dialogue": { + "zh-CN": "回顾模式:纯净对话(仅含用户与助手的实际对话内容)", + "en-US": "Review mode: clean dialogue (only actual user/assistant messages)", + }, + "conversation.review_annotation_full": { + "zh-CN": "回顾模式:完整记录(含工具调用与结果)", + "en-US": "Review mode: full record (including tool calls and results)", + }, # ── 统计 / 当前对话 ── "conversation.get_statistics_exception": { diff --git a/modules/i18n_messages/tools_execution.py b/modules/i18n_messages/tools_execution.py index d92034cc..f5fd4439 100644 --- a/modules/i18n_messages/tools_execution.py +++ b/modules/i18n_messages/tools_execution.py @@ -453,6 +453,10 @@ MESSAGES = { "zh-CN": "mode 必须为 read 或 save", "en-US": "mode must be read or save", }, + "tools_exec.review_content_mode_invalid": { + "zh-CN": "content_mode 必须为 dialogue 或 full", + "en-US": "content_mode must be dialogue or full", + }, "tools_exec.review_conversation_missing": { "zh-CN": "对话不存在或不属于当前工作区", "en-US": "Conversation does not exist or does not belong to the current workspace", diff --git a/server/conversation.py b/server/conversation.py index e0e81e89..9e567bac 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -75,6 +75,7 @@ from .gateway_auth import api_login_or_host_token_required from .context import with_terminal, get_gui_manager, get_upload_guard, build_upload_error_response, ensure_conversation_loaded, reset_system_state, get_user_resources, get_or_create_usage_tracker from .utils_common import ( build_review_lines, + REVIEW_CONTENT_MODES, debug_log, _sanitize_filename_component, log_backend_chunk, @@ -2235,7 +2236,10 @@ def review_conversation_preview(conversation_id, terminal: WebTerminal, workspac }), 404 limit = request.args.get('limit', default=20, type=int) or 20 - lines = build_review_lines(conversation_data.get("messages", []), limit=limit) + content_mode = (request.args.get('content_mode') or 'dialogue').strip().lower() + if content_mode not in REVIEW_CONTENT_MODES: + content_mode = 'dialogue' + lines = build_review_lines(conversation_data.get("messages", []), limit=limit, content_mode=content_mode) return jsonify({ "success": True, @@ -2278,7 +2282,11 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW }), 404 messages = conversation_data.get("messages", []) - lines = build_review_lines(messages) + payload = request.get_json(silent=True) or {} + content_mode = str(payload.get("content_mode") or "dialogue").strip().lower() + if content_mode not in REVIEW_CONTENT_MODES: + content_mode = "dialogue" + lines = build_review_lines(messages, content_mode=content_mode) content = "\n".join(lines) + "\n" char_count = len(content) @@ -2291,13 +2299,19 @@ def review_conversation(conversation_id, terminal: WebTerminal, workspace: UserW filename = f"review_{safe_title}_{timestamp}.md" target = review_dir / filename - target.write_text(content, encoding='utf-8') + annotation = tr( + "conversation.review_annotation_dialogue" + if content_mode == "dialogue" + else "conversation.review_annotation_full" + ) + target.write_text(f"> {annotation}\n\n" + content, encoding='utf-8') return jsonify({ "success": True, "data": { "path": f".astrion/review/{filename}", - "char_count": char_count + "char_count": char_count, + "content_mode": content_mode } }) except Exception as e: diff --git a/server/utils_common.py b/server/utils_common.py index 4a17a914..24053086 100644 --- a/server/utils_common.py +++ b/server/utils_common.py @@ -33,10 +33,16 @@ def sanitize_filename_preserve_unicode(filename: str) -> str: return cleaned[:255] -def build_review_lines(messages, limit=None): +# 对话回顾内容模式:dialogue=纯净对话(默认,仅含实际内容的 user/assistant);full=完整记录(含工具调用/结果与 system) +REVIEW_CONTENT_MODES = ("dialogue", "full") + + +def build_review_lines(messages, limit=None, content_mode="dialogue"): """ 将对话消息序列拍平成简化文本。 - 保留 user / assistant / system 以及 assistant 内的 tool 调用与 tool 消息。 + content_mode="dialogue"(默认):只保留提取文本后非空的 user / assistant 消息, + 不含 tool / tool_call / system 行,空内容消息整条跳过。 + content_mode="full":保留 user / assistant / system 以及 assistant 内的 tool 调用与 tool 消息。 limit 为正整数时,最多返回该数量的行(用于预览)。 """ lines: List[str] = [] @@ -71,6 +77,14 @@ def build_review_lines(messages, limit=None): base_content_raw = msg.get("content") if isinstance(msg.get("content"), (str, list, dict)) else msg.get("text") or "" base_content = extract_text(base_content_raw) + if content_mode == "dialogue": + # 纯净模式:仅保留有实际内容的 user / assistant 消息 + if role in ("user", "assistant") and base_content.strip(): + append_line(f"{role}:{base_content}") + if isinstance(limit, int) and limit > 0 and len(lines) >= limit: + return lines[:limit] + continue + if role in ("user", "assistant", "system"): append_line(f"{role}:{base_content}") diff --git a/static/src/App.vue b/static/src/App.vue index 33fc65d3..32768d14 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -522,12 +522,14 @@ :preview-error="reviewPreviewError" :preview-limit="reviewPreviewLimit" :send-to-model="reviewSendToModel" + :content-mode="reviewContentMode" :generated-path="reviewGeneratedPath" :icon-style="iconStyle" @close="reviewDialogOpen = false" @select="handleReviewSelect" @load-more="loadMoreReviewConversations" @toggle-send="reviewSendToModel = $event" + @toggle-content-mode="toggleReviewContentMode" @confirm="handleConfirmReview" /> diff --git a/static/src/app/methods/ui/dialog.ts b/static/src/app/methods/ui/dialog.ts index 2791621a..01241d8b 100644 --- a/static/src/app/methods/ui/dialog.ts +++ b/static/src/app/methods/ui/dialog.ts @@ -178,6 +178,8 @@ export const dialogMethods = { this.reviewPreviewLines = []; this.reviewPreviewError = null; this.reviewGeneratedPath = null; + // 每次打开默认回到纯净对话模式(推荐项) + this.reviewContentMode = 'dialogue'; this.closeQuickMenu(); // 弹窗使用独立列表(仅含有内容的对话),加载完成后自动选中首个可用项 this.loadReviewConversations(); diff --git a/static/src/app/methods/ui/review.ts b/static/src/app/methods/ui/review.ts index 2e682c06..ed90dc62 100644 --- a/static/src/app/methods/ui/review.ts +++ b/static/src/app/methods/ui/review.ts @@ -87,6 +87,14 @@ export const reviewMethods = { this.reviewListLoadingMore = false; } }, + toggleReviewContentMode() { + if (this.reviewSubmitting) return; + this.reviewContentMode = this.reviewContentMode === 'full' ? 'dialogue' : 'full'; + // 预览跟随模式实时刷新 + if (this.reviewSelectedConversationId) { + this.loadReviewPreview(this.reviewSelectedConversationId); + } + }, async handleConfirmReview() { if (this.reviewSubmitting) return; if (!this.reviewSelectedConversationId) { @@ -105,19 +113,12 @@ export const reviewMethods = { }); return; } - if (!this.currentConversationId) { - this.uiPushToast({ - title: t('appUi.cannotSend'), - message: t('appUi.noActiveConversationMessage'), - type: 'warning' - }); - return; - } this.reviewSubmitting = true; try { const { path, char_count } = await this.generateConversationReview( - this.reviewSelectedConversationId + this.reviewSelectedConversationId, + this.reviewContentMode ); if (!path) { throw new Error(t('appUi.reviewPathMissing')); @@ -132,7 +133,11 @@ export const reviewMethods = { count: count || t('appUi.unknown'), suggestion }); - const sent = await this.sendAutoUserMessage(message); + // /new 页面(无活跃对话)时走正常发送入口:自动创建新对话并跳转, + // 与用户手动发送首条消息的体验一致;有活跃对话时保持原自动消息路径。 + const sent = this.currentConversationId + ? await this.sendAutoUserMessage(message) + : await this.sendMessage({ presetText: message }); if (sent) { this.reviewDialogOpen = false; } @@ -160,7 +165,7 @@ export const reviewMethods = { this.reviewPreviewLines = []; try { const resp = await fetch( - `/api/conversations/${conversationId}/review_preview?limit=${this.reviewPreviewLimit}` + `/api/conversations/${conversationId}/review_preview?limit=${this.reviewPreviewLimit}&content_mode=${this.reviewContentMode}` ); const payload = await resp.json().catch(() => ({})); if (!resp.ok || !payload?.success) { @@ -175,9 +180,11 @@ export const reviewMethods = { this.reviewPreviewLoading = false; } }, - async generateConversationReview(conversationId) { + async generateConversationReview(conversationId, contentMode = 'dialogue') { const response = await fetch(`/api/conversations/${conversationId}/review`, { - method: 'POST' + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content_mode: contentMode }) }); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload?.success) { diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 5659a98f..68d94731 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -344,6 +344,8 @@ export function dataState() { reviewPreviewLimit: 20, reviewSendToModel: true, reviewGeneratedPath: null, + // 回顾内容模式:dialogue=纯净对话(默认推荐);full=完整记录(含工具调用与结果) + reviewContentMode: 'dialogue', // 回顾弹窗独立的对话列表(只含有内容的对话,不污染侧边栏) reviewConversations: [], reviewListLoading: false, diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index 71f855c5..3dbc3b38 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -1159,6 +1159,13 @@ function renderConversationReview(result: any, args: any): string { html += `
${escapeHtml(t('toolResults.labels.conversationId'))}${escapeHtml(result?.conversation_id || args?.conversation_id || '')}
`; html += `
${escapeHtml(t('toolResults.labels.status'))}${status}
`; html += `
${escapeHtml(t('toolResults.labels.mode'))}${escapeHtml(result?.mode || args?.mode || '')}
`; + const reviewContentMode = result?.content_mode || args?.content_mode || ''; + if (reviewContentMode) { + const reviewContentModeLabel = reviewContentMode === 'full' + ? t('toolResults.values.reviewContentModeFull') + : t('toolResults.values.reviewContentModeDialogue'); + html += `
${escapeHtml(t('toolResults.labels.contentMode'))}${escapeHtml(reviewContentModeLabel)}
`; + } if (result?.title) { html += `
${escapeHtml(t('toolResults.labels.title'))}${escapeHtml(result.title)}
`; } diff --git a/static/src/components/overlay/ConversationReviewDialog.vue b/static/src/components/overlay/ConversationReviewDialog.vue index f8b49513..1f5e7278 100644 --- a/static/src/components/overlay/ConversationReviewDialog.vue +++ b/static/src/components/overlay/ConversationReviewDialog.vue @@ -109,14 +109,27 @@ {{ $t('overlay.sendToModel') }} - + @@ -147,6 +160,7 @@ const props = defineProps<{ previewError?: string | null; previewLimit?: number; sendToModel: boolean; + contentMode: 'dialogue' | 'full'; generatedPath?: string | null; iconStyle?: (key: string) => Record; }>(); @@ -158,6 +172,7 @@ defineEmits<{ (event: 'select', id: string): void; (event: 'load-more'): void; (event: 'confirm'): void; + (event: 'toggle-content-mode'): void; (event: 'toggle-send', value: boolean): void; }>(); @@ -569,6 +584,46 @@ const formatUpdatedAt = (value: string | number) => { white-space: nowrap; } +/* ===== 底部按钮组 ===== */ +.footer-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 10px; +} + +/* 次操作按钮(完整记录):中性色、固定高度,与主按钮对齐 */ +.secondary-btn { + flex: 0 0 auto; + height: 38px; + border: 1px solid var(--border-default); + border-radius: 10px; + padding: 0 16px; + font-size: 14px; + cursor: pointer; + background: var(--surface-soft); + color: var(--text-primary); + transition: + background 140ms ease, + color 140ms ease; +} + +.secondary-btn:hover:not(:disabled) { + background: var(--hover-bg); +} + +/* 切换按下态(完整记录模式):中性灰阶区分,不用彩色 */ +.secondary-btn.active { + background: var(--surface-muted); + border-color: var(--border-strong); + font-weight: 600; +} + +.secondary-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ===== 主操作按钮:固定高度,无彩色光晕 ===== */ .primary-btn { flex: 0 0 auto; diff --git a/static/src/locales/en-US/overlay.ts b/static/src/locales/en-US/overlay.ts index 8eb8be97..33a9415b 100644 --- a/static/src/locales/en-US/overlay.ts +++ b/static/src/locales/en-US/overlay.ts @@ -33,6 +33,9 @@ export default { previewCount: '{n} lines', sendToModel: 'Send to model', generating: 'Generating...', + fullRecord: 'Full record', + fullRecordHint: 'Switch to full record: preview and output will include tool calls and results', + cleanDialogueHint: 'Switch back to clean dialogue: only the actual conversation', // ── GoalProgressDialog ── goalDoneTitle: 'Goal completed', diff --git a/static/src/locales/en-US/toolResults.ts b/static/src/locales/en-US/toolResults.ts index 6534619d..6f242a9c 100644 --- a/static/src/locales/en-US/toolResults.ts +++ b/static/src/locales/en-US/toolResults.ts @@ -82,6 +82,7 @@ export default { title: 'Title:', reviewFile: 'Review file:', charCount: 'Characters:', + contentMode: 'Content mode:', skill: 'Skill:', workflow: 'Workflow:', workflowCount: 'Workflows:', @@ -252,6 +253,8 @@ export default { colon: ':', optionLabel: 'Option {n}', line: 'Line {line}', + reviewContentModeDialogue: 'Clean dialogue', + reviewContentModeFull: 'Full record', }, // —— Search topic / time range / domain limits —— diff --git a/static/src/locales/zh-CN/overlay.ts b/static/src/locales/zh-CN/overlay.ts index f76f7376..73865d24 100644 --- a/static/src/locales/zh-CN/overlay.ts +++ b/static/src/locales/zh-CN/overlay.ts @@ -34,6 +34,9 @@ export default { previewCount: '{n} 条', sendToModel: '是否发送给模型', generating: '生成中...', + fullRecord: '完整记录', + fullRecordHint: '切换到完整记录:预览与生成将包含工具调用与结果', + cleanDialogueHint: '点击切回纯净对话:仅含双方的实际对话内容', // ── GoalProgressDialog:目标进度 ── goalDoneTitle: '目标已完成', diff --git a/static/src/locales/zh-CN/toolResults.ts b/static/src/locales/zh-CN/toolResults.ts index 600f72cc..77ad65a0 100644 --- a/static/src/locales/zh-CN/toolResults.ts +++ b/static/src/locales/zh-CN/toolResults.ts @@ -89,6 +89,7 @@ export default { title: '标题:', reviewFile: '回顾文件:', charCount: '字符数:', + contentMode: '内容模式:', skill: 'Skill:', workflow: '工作流:', workflowCount: '工作流数量:', @@ -259,6 +260,8 @@ export default { colon: ':', optionLabel: '选项 {n}', line: '行 {line}', + reviewContentModeDialogue: '纯净对话', + reviewContentModeFull: '完整记录', }, // —— 搜索主题 / 时间范围 / 网站限定 —— diff --git a/utils/tool_result_formatter/agent_context.py b/utils/tool_result_formatter/agent_context.py index cba8d938..110fc43f 100644 --- a/utils/tool_result_formatter/agent_context.py +++ b/utils/tool_result_formatter/agent_context.py @@ -50,8 +50,14 @@ def _format_conversation_search(result_data: Dict[str, Any]) -> str: def _format_conversation_review(result_data: Dict[str, Any]) -> str: if not result_data.get("success"): return _format_failure("conversation_review", result_data) + content_mode = result_data.get("content_mode") + mode_note = "" + if content_mode == "full": + mode_note = "(完整记录,含工具调用与结果)" + elif content_mode == "dialogue": + mode_note = "(纯净对话,仅含实际对话内容)" if result_data.get("mode") == "read" and result_data.get("content"): - lines = ["对话回顾内容:"] + lines = [f"对话回顾内容{mode_note}:"] if result_data.get("title"): lines.append(f"标题:{result_data.get('title')}") if result_data.get("char_count") is not None: @@ -70,7 +76,7 @@ def _format_conversation_review(result_data: Dict[str, Any]) -> str: lines.insert(1, f"标题:{result_data.get('title')}") return "\n".join(lines) lines = [ - "已生成对话回顾文件:", + f"已生成对话回顾文件{mode_note}:", str(path), ] if result_data.get("title"):