fix(ui,runtime,search): merge runtime/frontend stabilization into main

This commit is contained in:
JOJO 2026-05-27 16:38:19 +08:00
parent e955ef73e0
commit cd82c6d658
14 changed files with 282 additions and 52 deletions

View File

@ -9,8 +9,16 @@ AGENT_THINKING_API_BASE_URL=
AGENT_THINKING_API_KEY=
AGENT_THINKING_MODEL_ID=
# 对话标题生成模型(可选,留空则回退到基础推理模型)
AGENT_TITLE_API_BASE_URL=
AGENT_TITLE_API_KEY=
AGENT_TITLE_MODEL_ID=
# 第三方搜索(可选)
AGENT_TAVILY_API_KEY=
AGENT_TAVILY_API_KEY_2=
AGENT_TAVILY_API_KEY_BACKUP=
# 使用哪个 Tavily 环境变量请在 config/search.py 里改 TAVILY_API_KEY_ENV_NAME
# 每轮最大响应 token可选
AGENT_DEFAULT_RESPONSE_MAX_TOKENS=32768

View File

@ -35,6 +35,7 @@ def _load_dotenv():
_load_dotenv()
from . import api as _api
from . import search as _search
from . import paths as _paths
from . import limits as _limits
from . import terminal as _terminal
@ -51,6 +52,7 @@ from . import custom_tools as _custom_tools
from . import mcp as _mcp
from .api import *
from .search import *
from .paths import *
from .limits import *
from .terminal import *
@ -67,7 +69,7 @@ from .custom_tools import *
from .mcp import *
__all__ = []
for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp):
for module in (_api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp):
__all__ += getattr(module, "__all__", [])
del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp
del _api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp

View File

@ -19,8 +19,12 @@ THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL)
THINKING_API_KEY = _env("AGENT_THINKING_API_KEY", API_KEY)
THINKING_MODEL_ID = _env("AGENT_THINKING_MODEL_ID", MODEL_ID)
# Tavily 搜索(可选)
TAVILY_API_KEY = _env("AGENT_TAVILY_API_KEY", "")
from .search import TAVILY_API_KEY
# 对话标题生成模型(可选,留空则回退到基础快速模型)
TITLE_API_BASE_URL = _env("AGENT_TITLE_API_BASE_URL", API_BASE_URL)
TITLE_API_KEY = _env("AGENT_TITLE_API_KEY", API_KEY)
TITLE_MODEL_ID = _env("AGENT_TITLE_MODEL_ID", MODEL_ID)
# 默认响应 token 限制
DEFAULT_RESPONSE_MAX_TOKENS = int(os.environ.get("AGENT_DEFAULT_RESPONSE_MAX_TOKENS", "32768"))
@ -29,9 +33,11 @@ __all__ = [
"API_BASE_URL",
"API_KEY",
"MODEL_ID",
"TAVILY_API_KEY",
"DEFAULT_RESPONSE_MAX_TOKENS",
"THINKING_API_BASE_URL",
"THINKING_API_KEY",
"THINKING_MODEL_ID",
"TITLE_API_BASE_URL",
"TITLE_API_KEY",
"TITLE_MODEL_ID",
]

View File

@ -12,7 +12,7 @@
"max_output_tokens": 32768,
"thinkmode_status": {
"type": "param_toggle",
"model_id": "kimi-k2.5",
"model_id": "kimi-k2.6",
"fast_extra_parameter": {
"thinking": {
"type": "disabled"

17
config/search.py Normal file
View File

@ -0,0 +1,17 @@
"""搜索相关配置。"""
import os
# 选择 Tavily 使用哪个环境变量中的密钥。
# 默认保持兼容:仍使用 AGENT_TAVILY_API_KEY。
# 你可以改成例如AGENT_TAVILY_API_KEY_2 / AGENT_TAVILY_API_KEY_BACKUP
TAVILY_API_KEY_ENV_NAME = "AGENT_TAVILY_API_KEY_2"
# 实际生效的 Tavily 密钥
TAVILY_API_KEY = os.environ.get(TAVILY_API_KEY_ENV_NAME, "")
__all__ = [
"TAVILY_API_KEY_ENV_NAME",
"TAVILY_API_KEY",
]

View File

@ -8,6 +8,7 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from config import TITLE_API_BASE_URL, TITLE_API_KEY, TITLE_MODEL_ID
from core.web_terminal import WebTerminal
from config import LOGS_DIR
from utils.api_client import DeepSeekClient

View File

@ -236,6 +236,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
# 执行每个工具
pending_runtime_mode_notices: List[str] = []
last_completed_tool_call_id: Optional[str] = None
for tool_call in tool_calls:
# 检查停止标志
client_stop_info = get_stop_flag(client_sid, username)
@ -1137,6 +1138,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
"name": function_name,
"content": tool_message_content
})
last_completed_tool_call_id = tool_call_id
if not pending_runtime_mode_notices:
try:
@ -1150,28 +1152,34 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie
if tool_failed:
mark_force_thinking(web_terminal, reason=f"{function_name}_failed")
# 同一轮全部 tool_call 完成后,再统一 poll 子智能体/后台命令并注入通知,
# 避免在 assistant.tool_calls 与对应 tool 消息序列之间插入 user 消息导致 API 报错。
debug_log("[SubAgent] poll updates after all tool_calls finished")
await process_sub_agent_updates(
messages=messages,
inline=True,
after_tool_call_id=None,
web_terminal=web_terminal,
sender=sender,
debug_log=debug_log,
maybe_mark_failure_from_message=maybe_mark_failure_from_message,
)
debug_log("[BgCmdDebug] poll background command updates after all tool_calls finished")
await process_background_command_updates(
messages=messages,
inline=True,
after_tool_call_id=None,
web_terminal=web_terminal,
sender=sender,
debug_log=debug_log,
maybe_mark_failure_from_message=maybe_mark_failure_from_message,
)
# 子智能体/后台指令完成通知:必须等待同一轮全部 tool_call 都完成后再注入,
# 避免在 assistant.tool_calls 与未完成的 tool 结果之间插入 system 消息导致 API 报错。
if last_completed_tool_call_id:
debug_log(
f"[SubAgent] after all tools finished -> poll updates inline after_tool_call_id={last_completed_tool_call_id}"
)
await process_sub_agent_updates(
messages=messages,
inline=True,
after_tool_call_id=last_completed_tool_call_id,
web_terminal=web_terminal,
sender=sender,
debug_log=debug_log,
maybe_mark_failure_from_message=maybe_mark_failure_from_message,
)
debug_log(
"[BgCmdDebug] after all tools finished -> poll background command updates "
f"inline after_tool_call_id={last_completed_tool_call_id}"
)
await process_background_command_updates(
messages=messages,
inline=True,
after_tool_call_id=last_completed_tool_call_id,
web_terminal=web_terminal,
sender=sender,
debug_log=debug_log,
maybe_mark_failure_from_message=maybe_mark_failure_from_message,
)
# 运行期模式通知:必须等待同一轮全部 tool_call 都完成后再注入,
# 避免在 assistant.tool_calls 与对应 tool 消息之间插入 user 消息导致 API 报错。

View File

@ -160,12 +160,36 @@ export const resourceMethods = {
this.executionModeDirectUntil = status.execution_mode.direct_until ?? null;
this.executionModeEnabled =
typeof status.execution_mode_enabled === 'boolean' ? status.execution_mode_enabled : true;
if (typeof this.scheduleExecutionModeExpirySync === 'function') {
this.scheduleExecutionModeExpirySync(this.executionModeDirectUntil);
}
const currentMode = this.currentExecutionMode;
const parseDirectUntilMs = (value: any): number => {
if (!value) return 0;
if (typeof value === 'number') {
return value > 1e12 ? value : value * 1000;
}
if (typeof value === 'string') {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
};
const prevDirectUntilMs = parseDirectUntilMs(prevDirectUntil);
const fallbackLikelyByExpiry = !!prevDirectUntilMs && Date.now() >= prevDirectUntilMs - 1200;
const directExpiredToSandbox =
prevExecutionMode === 'direct' &&
currentMode === 'sandbox' &&
!!prevDirectUntil;
prevExecutionMode === 'direct' && currentMode === 'sandbox' && fallbackLikelyByExpiry;
if (directExpiredToSandbox) {
if (this.currentPermissionMode === 'unrestricted') {
if (typeof pendingModes.permission_mode === 'string' && pendingModes.permission_mode) {
this.currentPermissionMode = pendingModes.permission_mode;
} else {
this.currentPermissionMode = 'approval';
}
}
if (typeof this.fetchPermissionMode === 'function') {
Promise.resolve(this.fetchPermissionMode()).catch(() => {});
}
try {
if (this.executionModeAutoFallbackToastId) {
this.uiDismissToast(this.executionModeAutoFallbackToastId);
@ -173,7 +197,7 @@ export const resourceMethods = {
} catch (_err) {}
this.executionModeAutoFallbackToastId = this.uiPushToast({
title: '执行环境已自动回退',
message: '完全访问权限已到期,当前已切换为沙箱执行环境。',
message: '完全访问权限已到期,自动切回受限权限并切换为沙箱执行环境。',
type: 'warning',
duration: null
});

View File

@ -84,12 +84,7 @@ function isRuntimeModeNoticePayload(data: any): boolean {
return false;
}
const meta = data.metadata || {};
return !!(
data.runtime_guidance ||
meta.runtime_guidance ||
data.runtime_mode_notice ||
meta.runtime_mode_notice
);
return !!(data.runtime_mode_notice || meta.runtime_mode_notice);
}
function resolveUserMessageSource(data: any): string {
@ -1723,15 +1718,22 @@ export const taskPollingMethods = {
this.streamingMessage = false;
this.stopRequested = false;
} else {
// 运行期/空闲期由系统注入的 user 消息runtime_guidance / sub_agent / background_command / notify 等)
// 现已统一为前缀 [系统通知|...] 的 user 消息,前端按普通 user 渲染。
this.chatAddUserMessage(
message,
incomingImages,
incomingVideos,
incomingMediaRefs,
resolveUserMessageSource(data)
);
const source = resolveUserMessageSource(data);
// 仅在对话运行期间展示 runtime_mode_notice其余自动消息按来源正常显示
// 保持与后端历史重建一致guidance/notify/sub_agent/background_command
if (isRuntimeModeNotice && !(this.taskInProgress || this.streamingMessage)) {
debugLog('[TaskPolling] 空闲态跳过 runtime_mode_notice 用户通知', {
messagePreview: message.slice(0, 80)
});
} else {
this.chatAddUserMessage(
message,
incomingImages,
incomingVideos,
incomingMediaRefs,
source
);
}
}
if (data?.sub_agent_notice) {
if (typeof data?.has_running_sub_agents === 'boolean') {

View File

@ -1212,6 +1212,7 @@ export const uiMethods = {
this.executionModeDirectUntil = state.direct_until ?? null;
this.executionModeTtlSeconds =
typeof state.ttl_seconds === 'number' ? state.ttl_seconds : this.executionModeTtlSeconds;
this.scheduleExecutionModeExpirySync(this.executionModeDirectUntil);
this.uiPushToast({
title: '执行环境已更新',
message: payload?.message || '已立即生效',
@ -1253,6 +1254,8 @@ export const uiMethods = {
async fetchExecutionMode() {
try {
const prevExecutionMode = this.currentExecutionMode;
const prevDirectUntil = this.executionModeDirectUntil;
const response = await fetch('/api/execution-mode');
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.success) {
@ -1268,11 +1271,78 @@ export const uiMethods = {
if (typeof state.ttl_seconds === 'number') {
this.executionModeTtlSeconds = state.ttl_seconds;
}
this.scheduleExecutionModeExpirySync(this.executionModeDirectUntil);
const parseDirectUntilMs = (value) => {
if (!value) return 0;
if (typeof value === 'number') {
return value > 1e12 ? value : value * 1000;
}
if (typeof value === 'string') {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
};
const prevDirectUntilMs = parseDirectUntilMs(prevDirectUntil);
const fallbackLikelyByExpiry = !!prevDirectUntilMs && Date.now() >= prevDirectUntilMs - 1200;
const directExpiredToSandbox =
prevExecutionMode === 'direct' &&
this.currentExecutionMode === 'sandbox' &&
fallbackLikelyByExpiry;
if (directExpiredToSandbox) {
if (this.currentPermissionMode === 'unrestricted') {
this.currentPermissionMode = 'approval';
}
Promise.resolve(this.fetchPermissionMode()).catch(() => {});
try {
if (this.executionModeAutoFallbackToastId) {
this.uiDismissToast(this.executionModeAutoFallbackToastId);
}
} catch (_err) {}
this.executionModeAutoFallbackToastId = this.uiPushToast({
title: '执行环境已自动回退',
message: '完全访问权限已到期,已自动切回受限权限并切换为沙箱执行环境。',
type: 'warning',
duration: null
});
}
} catch (_error) {
// ignore
}
},
clearExecutionModeExpirySyncTimer() {
if (this.executionModeExpirySyncTimer) {
clearTimeout(this.executionModeExpirySyncTimer);
this.executionModeExpirySyncTimer = null;
}
},
scheduleExecutionModeExpirySync(directUntil) {
this.clearExecutionModeExpirySyncTimer();
if (!this.executionModeEnabled || this.currentExecutionMode !== 'direct' || !directUntil) {
return;
}
let untilMs = 0;
if (typeof directUntil === 'number') {
untilMs = directUntil > 1e12 ? directUntil : directUntil * 1000;
} else if (typeof directUntil === 'string') {
const parsed = Date.parse(directUntil);
if (!Number.isNaN(parsed)) {
untilMs = parsed;
}
}
if (!untilMs) {
return;
}
const delayMs = Math.max(250, untilMs - Date.now() + 250);
this.executionModeExpirySyncTimer = window.setTimeout(async () => {
this.executionModeExpirySyncTimer = null;
await this.fetchExecutionMode();
await this.fetchPermissionMode();
}, delayMs);
},
async openPathAuthorizationDialog() {
if (!this.executionModeEnabled) return;
this.pathAuthorizationDialogOpen = true;

View File

@ -104,6 +104,7 @@ export function dataState() {
executionModeDirectUntil: null,
executionModeAutoFallbackToastId: null,
executionModeTtlSeconds: 600,
executionModeExpirySyncTimer: null,
pathAuthorizationDialogOpen: false,
pathAuthorizationMode: 'writable',
pathAuthorizationWritableDraft: '',

View File

@ -338,11 +338,32 @@ function normalizeShowTagsPlugin() {
};
}
function wrapMarkdownTablesPlugin() {
return (tree: any) => {
visit(tree, 'element', (node: any, index: number | undefined, parent: any) => {
if (!node || node.tagName !== 'table' || !parent || typeof index !== 'number') return;
if (parent?.tagName === 'div' && Array.isArray(parent?.properties?.className)) {
const classes = parent.properties.className as string[];
if (classes.includes('md-table-scroll')) {
return;
}
}
parent.children[index] = {
type: 'element',
tagName: 'div',
properties: { className: ['md-table-scroll'], 'data-md-table-scroll': '1' },
children: [node]
};
});
};
}
const sanitizedSchema: Record<string, any> = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames || []), 'show_html', 'show_image', 'show-html', 'show-image'],
attributes: {
...(defaultSchema.attributes || {}),
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
show_html: ['ratio', 'js', 'data-encoded', 'data-partial'],
show_image: ['src', 'alt', 'title', 'width', 'height'],
'show-html': ['ratio', 'js', 'data-encoded', 'data-partial'],
@ -357,6 +378,7 @@ const markdownProcessor = unified()
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(normalizeShowTagsPlugin)
.use(wrapMarkdownTablesPlugin)
.use(rehypeSanitize, sanitizedSchema)
.use(rehypeStringify, { allowDangerousHtml: false });

View File

@ -74,6 +74,7 @@
.messages-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
overflow-anchor: none;
padding: 24px;
padding-top: 30px;
@ -412,6 +413,7 @@
.message-block {
margin-bottom: 24px;
min-width: 0;
}
.message-block.message-block--sub-agent-notice {
@ -1084,6 +1086,9 @@ show-html:not([data-rendered="1"])[ratio="3:4"] { aspect-ratio: 3 / 4; }
.text-output .text-content {
padding: 0 20px 0 15px;
max-width: 100%;
min-width: 0;
overflow-x: hidden;
}
.text-output pre {
@ -1093,7 +1098,8 @@ show-html:not([data-rendered="1"])[ratio="3:4"] { aspect-ratio: 3 / 4; }
line-height: 1.55;
overflow-x: auto;
max-width: 100%;
scrollbar-gutter: stable both-edges;
scrollbar-gutter: stable;
box-sizing: border-box;
}
.text-output pre code {
@ -1106,15 +1112,58 @@ show-html:not([data-rendered="1"])[ratio="3:4"] { aspect-ratio: 3 / 4; }
}
.text-output .text-content table {
width: 100%;
width: max-content;
min-width: 100%;
border-collapse: collapse;
margin: 16px 0;
margin: 0;
background: var(--theme-surface-soft);
}
.text-output .text-content .md-table-scroll,
.text-output .text-content [data-md-table-scroll='1'] {
display: block;
width: 100%;
max-width: 100%;
margin: 16px 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--claude-border);
border-radius: 12px;
overflow: hidden;
background: var(--theme-surface-soft);
box-shadow: var(--theme-shadow-soft);
}
.text-output .text-content .md-table-scroll > table,
.text-output .text-content [data-md-table-scroll='1'] > table {
margin: 0;
}
.text-output .text-content .md-table-scroll::-webkit-scrollbar,
.text-output .text-content [data-md-table-scroll='1']::-webkit-scrollbar {
height: 10px;
}
.text-output .text-content .md-table-scroll::-webkit-scrollbar-track,
.text-output .text-content [data-md-table-scroll='1']::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--theme-surface-muted, #d1d5db) 70%, transparent);
border-radius: 999px;
margin: 0 8px 6px;
}
.text-output .text-content .md-table-scroll::-webkit-scrollbar-thumb,
.text-output .text-content [data-md-table-scroll='1']::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--claude-text-secondary, #6b7280) 55%, transparent);
border-radius: 999px;
}
.text-output .text-content .md-table-scroll,
.text-output .text-content [data-md-table-scroll='1'] {
scrollbar-color: color-mix(in srgb, var(--claude-text-secondary, #6b7280) 55%, transparent)
color-mix(in srgb, var(--theme-surface-muted, #d1d5db) 70%, transparent);
scrollbar-width: thin;
}
.text-output .text-content thead {
background: var(--theme-chip-bg);
}
@ -1251,7 +1300,8 @@ show-html:not([data-rendered="1"])[ratio="3:4"] { aspect-ratio: 3 / 4; }
line-height: 1.55;
overflow-x: auto;
max-width: 100%;
scrollbar-gutter: stable both-edges;
scrollbar-gutter: stable;
box-sizing: border-box;
}
.code-block-wrapper pre code {

View File

@ -396,10 +396,13 @@
}
.todo-panel {
flex: 1 1 auto;
min-height: 0;
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.sub-agent-panel {
@ -412,6 +415,22 @@
overflow-y: auto;
}
/* 左侧四个工作面板统一支持纵向滚动 */
.file-tree,
.todo-panel,
.sub-agent-panel {
-webkit-overflow-scrolling: touch;
-ms-overflow-style: none;
scrollbar-width: none;
}
.file-tree::-webkit-scrollbar,
.todo-panel::-webkit-scrollbar,
.sub-agent-panel::-webkit-scrollbar {
width: 0;
height: 0;
}
.sub-agent-cards {
display: flex;
flex-direction: column;