fix(frontend): 修复多个前端交互问题

- 修复运行时 intent Unicode 转义显示异常(后端 extract_intent_from_partial 解码)
- 修复 ask_user 弹窗问题/说明不换行
- 修复刷新页面后等待回答提示不恢复(添加 fetchPendingUserQuestions 并在初始化路径调用)
- 修复回答问题弹窗长问题撑满窗口(header/context 限高滚动)
- 修复角色编辑器模型选择菜单定位与滚动
- 在 AGENTS.md 中再次强调前端调试日志必须统一筛选词

测试:/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9 -m unittest test.test_server_refactor_smoke; npm run build
This commit is contained in:
JOJO 2026-07-15 00:31:13 +08:00
parent 297a6cc5ec
commit 4e1d08f106
12 changed files with 111 additions and 17 deletions

View File

@ -207,7 +207,9 @@
### 前端调试日志
- **统一筛选词**:所有临时 `console.log` 必须使用**同一个**筛选前缀(例如 `[route-debug]`),方便用户在浏览器控制台用该前缀一次性筛选。
- **统一筛选词(强制)**:所有临时 `console.log` 必须使用**同一个**筛选前缀(例如 `[route-debug]`),方便用户在浏览器控制台用该前缀一次性筛选。
- **同一次调试任务中严禁使用多个前缀**。如果涉及多个模块如状态栏、Git 摘要、用户问题),应共用同一个前缀(如 `[status-bar-debug]`),通过日志对象里的字段区分模块,而不是发明多个前缀增加用户筛选成本。
- 选择前缀时优先与本次排查的「用户可见现象」对齐,而不是与内部模块名对齐。
- **控制数量与时机**
- 禁止一次性输出上百上千条日志刷屏。
- 只在关键路径(入口、分支判断、状态变化、导航动作)打印,避免在循环、高频事件、每帧渲染中输出。

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional
@ -10,7 +11,16 @@ def extract_intent_from_partial(arg_str: str) -> Optional[str]:
return None
match = re.search(r'"intent"\s*:\s*"([^"]{0,128})', arg_str, re.IGNORECASE | re.DOTALL)
if match:
return match.group(1)
raw = match.group(1)
try:
# 某些模型会对非 ASCII 字符输出 JSON Unicode 转义(如 \u4e2d\u6587
# 这里用 json.loads 安全解码,失败时回退原始值。
decoded = json.loads('"{}"'.format(raw))
if isinstance(decoded, str):
return decoded
except Exception:
pass
return raw
return None

View File

@ -41,7 +41,6 @@ export async function mounted() {
// 立即加载初始数据(并行获取状态,优先同步运行模式)
const initialDataPromise = this.loadInitialData();
this.refreshProjectGitSummary?.();
this.startProjectGitSummaryIdleRefresh?.();
this.startConnectionHeartbeat();
this.fetchTerminalCount();
@ -50,6 +49,8 @@ export async function mounted() {
if (initialDataPromise && typeof initialDataPromise.then === 'function') {
initialDataPromise
.then(() => {
// 初始数据加载完成后再刷新 git 摘要,确保 workspace/project_path 已就绪
this.refreshProjectGitSummary?.();
// 避免在初始加载阶段被覆盖,加载完成后再兜底检查一次
this.checkTutorialPrompt();
})

View File

@ -232,6 +232,8 @@ export const loadMethods = {
this.fetchNetworkPermission();
await this.fetchVersioningStatus(conversationId, { silent: true });
this.fetchPendingToolApprovals();
this.fetchPendingUserQuestions();
this.refreshProjectGitSummary();
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
traceLog('loadConversation:after-history', {

View File

@ -58,6 +58,35 @@ export const dialogMethods = {
this.userQuestionDialogVisible = true;
this.userQuestionMinimized = false;
},
async fetchPendingUserQuestions() {
if (!this.currentConversationId) {
this.pendingUserQuestions = [];
return;
}
try {
const response = await fetch(
`/api/user-questions/pending?conversation_id=${encodeURIComponent(this.currentConversationId)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
return;
}
const items = Array.isArray(payload.items) ? payload.items : [];
this.pendingUserQuestions = items;
if (items.length > 0) {
this.userQuestionActiveIndex = Math.min(
Math.max(0, Number(this.userQuestionActiveIndex || 0)),
Math.max(0, items.length - 1)
);
} else {
this.userQuestionDialogVisible = false;
this.userQuestionMinimized = false;
this.userQuestionActiveIndex = 0;
}
} catch (_error) {
// ignore
}
},
approveToolApproval(approvalId) {
return this.decideToolApproval(approvalId, 'approved');
},

View File

@ -70,7 +70,7 @@ export const gitMethods = {
if (this.gitChangesPanelOpen) {
this.loadGitChangesDiff?.();
}
} catch (_) {
} catch (_error) {
this.projectGitSummary = null;
this.gitChangesDiff = null;
} finally {

View File

@ -151,6 +151,9 @@ export const routeMethods = {
this.initialRouteResolved = true;
}
await this.restoreComposerDraftState('bootstrap-route:conversation');
if (this.currentConversationId) {
this.fetchPendingUserQuestions?.();
}
},
handlePopState(event) {
const state = event.state || {};

View File

@ -372,6 +372,7 @@ export const socketMethods = {
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();
await this.fetchVersioningStatus(this.currentConversationId, { silent: true });
this.fetchPendingUserQuestions();
} catch (e) {
console.warn('获取当前对话标题失败:', e);
this.titleReady = true;

View File

@ -170,7 +170,7 @@
>目标模式就绪</span
>
<button
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
v-if="pendingUserQuestionCount > 0"
type="button"
class="floating-project-status__notice floating-project-status__notice--button"
@click.stop="$emit('restore-user-question')"
@ -828,33 +828,43 @@ const runtimeQueuedMessagesForRender = computed(() => {
});
const projectGitSummaryForRender = computed(() => {
if (personalizationStore?.form?.show_git_status_bar === false) return null;
if (personalizationStore?.form?.show_git_status_bar === false) {
return null;
}
const summary = props.projectGitSummary;
if (!summary?.has_git) return null;
if (!summary?.has_git) {
return null;
}
const projectName = String(summary.project_name || '').trim();
const branch = String(summary.branch || '').trim();
if (!projectName || !branch) return null;
return {
if (!projectName || !branch) {
return null;
}
const result = {
projectName,
projectPath: String(summary.project_path || '').trim(),
branch,
additions: Math.max(0, Number(summary.additions || 0)),
deletions: Math.max(0, Number(summary.deletions || 0))
};
return result;
});
const floatingStatusVisible = computed(() => {
if (skillSlashMenuOpen.value || fileAtOpen.value || props.quickMenuOpen) return false;
// /git /
if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance)
if (skillSlashMenuOpen.value || fileAtOpen.value || props.quickMenuOpen) {
return false;
return (
}
// /git /
if (runtimeQueuedMessagesForRender.value.length > 0 || props.hasPendingRuntimeGuidance) {
return false;
}
const visible =
!!projectGitSummaryForRender.value ||
!!props.goalRunning ||
!!props.goalModeArmed ||
(!!props.userQuestionMinimized && Number(props.pendingUserQuestionCount || 0) > 0) ||
(Number(props.activeSubAgentCount || 0) > 0)
);
(Number(props.pendingUserQuestionCount || 0) > 0) ||
(Number(props.activeSubAgentCount || 0) > 0);
return visible;
});
const showComposerAvatar = computed(() => {

View File

@ -213,6 +213,7 @@ function submit() {
gap: 12px;
align-items: center;
padding: 20px 22px 0;
flex-shrink: 0;
}
.user-question-kicker-row {
@ -240,6 +241,9 @@ function submit() {
line-height: 1.55;
font-weight: 650;
letter-spacing: -0.01em;
white-space: pre-wrap;
max-height: min(180px, 30vh);
overflow-y: auto;
}
.user-question-nav {
@ -297,6 +301,9 @@ function submit() {
color: var(--claude-text-secondary);
line-height: 1.6;
font-size: 13px;
white-space: pre-wrap;
max-height: min(160px, 25vh);
overflow-y: auto;
}
.user-question-options {
@ -335,6 +342,7 @@ function submit() {
color: var(--claude-text-secondary);
font-size: 12px;
line-height: 1.42;
white-space: pre-wrap;
}
.user-question-textarea-wrap {

View File

@ -2289,13 +2289,37 @@ const updateFloatingMenuPosition = async () => {
const button = document.querySelector<HTMLElement>(
'.settings-select-wrap.open .settings-select-button'
);
const menu = document.querySelector<HTMLElement>(
'.settings-select-wrap.open .settings-floating-menu'
);
if (!button) {
return;
}
const rect = button.getBoundingClientRect();
const menuWidth = Math.min(300, Math.max(240, window.innerWidth - 32));
const left = Math.max(16, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 16));
const top = Math.max(16, Math.min(rect.bottom + 10, window.innerHeight - 80));
const padding = 16;
const gap = 10;
const menuHeight = menu?.getBoundingClientRect().height || 0;
let top: number;
if (menuHeight > 0) {
const spaceBelow = window.innerHeight - rect.bottom - padding;
const spaceAbove = rect.top - padding;
if (spaceBelow >= menuHeight) {
top = rect.bottom + gap;
} else if (spaceAbove >= menuHeight) {
top = rect.top - menuHeight - gap;
} else if (spaceBelow >= spaceAbove) {
// max-height
top = Math.max(rect.bottom + gap, window.innerHeight - menuHeight - padding);
} else {
//
top = padding;
}
} else {
top = Math.max(padding, Math.min(rect.bottom + gap, window.innerHeight - 80));
}
floatingMenuStyle.value = {
position: 'fixed',
top: `${Math.round(top)}px`,

View File

@ -40,6 +40,10 @@ class ServerRefactorSmokeTest(unittest.TestCase):
import server.chat_flow_runner_helpers as helpers
self.assertEqual(helpers.extract_intent_from_partial('{"intent": "fix"}'), "fix")
self.assertEqual(
helpers.extract_intent_from_partial('{"intent": "\\u7b49\\u5f85\\u4e0b\\u8f7d\\u5b8c\\u6210"}'),
"等待下载完成",
)
self.assertEqual(helpers.resolve_monitor_path({"file_path": " a.txt "}), "a.txt")
self.assertEqual(helpers.resolve_monitor_memory([1, 2, 3], entry_limit=2), ["1", "2"])