Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ASK_USER_QUESTION_TIMEOUT_MS,
type AskUserQuestionAnswer,
type AskUserQuestionItem,
INTERACTIVE_TIMEOUT_MAX_MINUTES,
} from "../../lib/chat/askUserQuestion";
import { cn } from "../../lib/shared/utils";
import { Check, Sparkles } from "../icons";
Expand All @@ -25,22 +26,21 @@ function formatCountdown(remainingMs: number) {

/**
* 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
* 切到只读态。
* 网关参数上的 deadline 盖章),两端与桌面计时同源。应答窗口可由设置调到很长
* (≈永不超时),因此上界按「可配置的最大窗口」判定而不是默认窗口;截止时间
* 缺失/已过期/明显不可比时回退为挂载时刻近似,避免把可作答的卡片锁死。
* 倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态。
*
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,
* 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当
* 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信,
* 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的
* 提交仍由桌面挂起表权威拒绝。
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个
* 仍在挂起的提问会在挂载瞬间显示过期(或显示远超任何合法窗口的剩余时间)。
* 因此仅采信落在“挂载时刻(不含)~挂载时刻 + 最大可配置窗口(含)”内的截止
* 时间,否则视为时钟不可比;真正过期的提交仍由桌面挂起表权威拒绝。
*/
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
const [mountedAt] = useState(() => Date.now());
const maxTrustedDeadline = mountedAt + INTERACTIVE_TIMEOUT_MAX_MINUTES * 60_000;
const deadline =
deadlineAt !== undefined &&
deadlineAt > mountedAt &&
deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS
deadlineAt !== undefined && deadlineAt > mountedAt && deadlineAt <= maxTrustedDeadline
? deadlineAt
: mountedAt + ASK_USER_QUESTION_TIMEOUT_MS;
const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now());
Expand Down
8 changes: 6 additions & 2 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,8 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.systemTools": "系统工具",
"settings.systemToolsDesc":
"查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。",
"settings.interactiveTimeout.title": "交互式应答超时",
"settings.interactiveTimeout.unit": "分钟",
"settings.builtinToolCategory.fs": "文件系统",
"settings.builtinToolCategory.process": "终端与进程",
"settings.builtinToolCategory.intelligence": "智能与记忆",
Expand Down Expand Up @@ -1316,7 +1318,7 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.builtinTool.ask_user_question.name": "用户提问",
"settings.builtinTool.ask_user_question.desc": "以选项卡片向你提问并等待选择",
"settings.builtinTool.ask_user_question.detail":
"模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,3 分钟内未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。",
"模型在需要你决策时发起选择题(一次最多 4 个问题,每题 2-6 个选项且各题数量一致,推荐项排在首位)。卡片暂停执行等待作答,超时时间可用下方滑块调整,超时后未作答自动按推荐项继续执行;桌面端与 WebUI 均可作答,点击停止可跳过。仅在对话场景注册。",
"settings.builtinTool.cron_task_manager.name": "定时任务",
"settings.builtinTool.cron_task_manager.desc": "创建与管理定时自动任务",
"settings.builtinTool.cron_task_manager.detail":
Expand Down Expand Up @@ -3466,6 +3468,8 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.systemTools": "System Tools",
"settings.systemToolsDesc":
"View the built-in tools that LiveAgent registers automatically in Agent mode, and set an approval policy per tool (allow / ask before running / deny).",
"settings.interactiveTimeout.title": "Interactive answer timeout",
"settings.interactiveTimeout.unit": "min",
"settings.builtinToolCategory.fs": "File System",
"settings.builtinToolCategory.process": "Terminal & Processes",
"settings.builtinToolCategory.intelligence": "Intelligence & Memory",
Expand Down Expand Up @@ -3540,7 +3544,7 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.builtinTool.ask_user_question.desc":
"Ask you multiple-choice questions in a card and wait for your selections",
"settings.builtinTool.ask_user_question.detail":
"Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; after 3 minutes without an answer the recommended options are auto-selected, and pressing Stop skips the question. Chat sessions only.",
"Lets the model ask you multiple-choice questions when a decision is yours to make (up to 4 questions per call, 2-6 options each with the same count across questions, recommended option shown first). Execution pauses on an interactive card until you answer — from the desktop or the WebUI; the answer window is adjustable with the slider below, and when it elapses without an answer the recommended options are auto-selected. Pressing Stop skips the question. Chat sessions only.",
"settings.builtinTool.cron_task_manager.name": "Scheduled Tasks",
"settings.builtinTool.cron_task_manager.desc": "Create and manage scheduled automations",
"settings.builtinTool.cron_task_manager.detail":
Expand Down
44 changes: 43 additions & 1 deletion crates/agent-gateway/web/src/lib/chat/askUserQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,50 @@ export const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion";
export const ASK_USER_QUESTION_MAX_QUESTIONS = 4;
export const ASK_USER_QUESTION_MIN_OPTIONS = 2;
export const ASK_USER_QUESTION_MAX_OPTIONS = 6;
/** 每轮提问的应答窗口:超时后按推荐项(缺省第一项)自动落定继续执行。 */
/** 每轮提问的应答窗口默认值(毫秒):超时后按推荐项(缺省第一项)自动落定继续执行。
* 运行时实际窗口由设置 system.interactiveTimeoutMinutes 驱动(正数分钟;超长≈永不超时),
* 此常量仅作未注入时的兜底默认,保持历史行为与测试注入口径一致。 */
export const ASK_USER_QUESTION_TIMEOUT_MS = 3 * 60 * 1000;
/** 交互式应答超时(分钟)的默认值与上限,与桌面端 Rust 归一化同口径。
* 上限存在的意义不是规避 setTimeout 溢出(那由分段续期解决),
* 而是避免手改配置写入荒谬值。 */
export const INTERACTIVE_TIMEOUT_MIN_MINUTES = 1;
export const INTERACTIVE_TIMEOUT_MAX_MINUTES = 99_999;
/** setTimeout 的延迟形参是 IDL long(32 位有符号),超过 2^31-1 会被 ToInt32
* 回绕:99999 分钟被截成约 19.7 天,而 35792~200000 分钟之间的值回绕成负数后
* 被钳为 0,导致「永不超时」瞬间立刻超时。故长延迟必须分段续期。 */
export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647;

/**
* 分段续期的长延迟定时器:单次 setTimeout 只排最多 MAX_SAFE_TIMEOUT_DELAY_MS,
* 到点后按剩余时间继续排,直到真正跨过 deadline 才触发 onDeadline。返回取消函数。
*
* 与裸 setTimeout 一致,回调恒异步触发(deadline 已过也先让出一轮),
* 这样调用方仍可在同一同步块里先拿到取消函数再在回调中引用它。
*/
export function scheduleAtDeadline(deadlineAt: number, onDeadline: () => void): () => void {
let timerId: ReturnType<typeof setTimeout> | undefined;
let cancelled = false;
const arm = () => {
if (cancelled) return;
const remainingMs = deadlineAt - Date.now();
if (remainingMs <= 0) {
onDeadline();
return;
}
timerId = setTimeout(arm, Math.min(remainingMs, MAX_SAFE_TIMEOUT_DELAY_MS));
};
// 首拍也走 setTimeout,保证回调不会在本函数返回前同步触发。
timerId = setTimeout(
arm,
Math.min(Math.max(0, deadlineAt - Date.now()), MAX_SAFE_TIMEOUT_DELAY_MS),
);
return () => {
cancelled = true;
if (timerId !== undefined) clearTimeout(timerId);
};
}

/** UI 合成"其他(自行输入)"应答的最大长度;超出部分截断。 */
export const ASK_USER_QUESTION_CUSTOM_MAX_LENGTH = 2000;
/**
Expand Down
16 changes: 16 additions & 0 deletions crates/agent-gateway/web/src/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ export type SystemProxyConfig = {
/** 工具审批策略:allow 直接执行、ask 执行前请求用户批准、deny 直接拒绝。 */
export type ToolPolicy = "allow" | "ask" | "deny";

/** 交互式应答(AskUserQuestion 提问卡 + 工具审批栏)的等待窗口,单位分钟。
* 正数 = 超时窗口,超时后按各交互既定姿态落定(提问自动选推荐项并继续、
* 审批按拒绝)。两处交互共用同一窗口。填很大的数(如 99999)≈ 永不超时。 */
export const INTERACTIVE_TIMEOUT_DEFAULT_MINUTES = 3;

export type SystemSettings = {
executionMode: ExecutionMode;
workdir: string;
Expand All @@ -188,6 +193,8 @@ export type SystemSettings = {
* 回写会丢掉桌面端设置的策略。策略的裁决在桌面端 resolveToolPolicy。
*/
toolPolicies?: Record<string, ToolPolicy>;
/** 交互式应答超时(分钟):正数=窗口,超长≈永不。与桌面端对齐原样透传。 */
interactiveTimeoutMinutes: number;
workspaceProjects: WorkspaceProject[];
activeWorkspaceProjectId?: string;
hiddenWorkspaceProjectPaths: string[];
Expand Down Expand Up @@ -1678,12 +1685,20 @@ export function normalizeSystemProxyConfig(input: unknown): SystemProxyConfig {
};
}

/** 归一化交互式应答超时(分钟):正数=窗口(超长≈永不),缺省/非法/非正回 3。 */
export function normalizeInteractiveTimeoutMinutes(input: unknown): number {
return typeof input === "number" && Number.isFinite(input) && input > 0
? input
: INTERACTIVE_TIMEOUT_DEFAULT_MINUTES;
}

export function normalizeSystemSettings(input: unknown): SystemSettings {
const obj = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
return {
executionMode: normalizeExecutionMode(obj.executionMode),
workdir: normalizeWorkdir(obj.workdir),
toolPolicies: normalizeToolPolicies(obj.toolPolicies),
interactiveTimeoutMinutes: normalizeInteractiveTimeoutMinutes(obj.interactiveTimeoutMinutes),
workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects),
activeWorkspaceProjectId:
typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim()
Expand Down Expand Up @@ -2235,6 +2250,7 @@ export function getDefaultSettings(): AppSettings {
missingWorkspaceProjectPaths: [],
archivedWorkspaceProjectPaths: [],
systemProxy: getDefaultSystemProxyConfig(),
interactiveTimeoutMinutes: INTERACTIVE_TIMEOUT_DEFAULT_MINUTES,
},
customProviders,
mcp: {
Expand Down
39 changes: 37 additions & 2 deletions crates/agent-gateway/web/src/pages/settings/SystemToolsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { type ToolPolicy, updateSystem } from "../../lib/settings";
import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog";
import type { SettingsSectionProps } from "./types";

// 档位表:60 分钟内细调,超过 1 小时直跳最大档 99999。
const TIMEOUT_STOPS = [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60, 99999];

export function SystemToolsSection(props: SettingsSectionProps) {
const { settings, setSettings } = props;
const { t } = useLocale();
Expand Down Expand Up @@ -51,6 +54,19 @@ export function SystemToolsSection(props: SettingsSectionProps) {

const overriddenCount = Object.keys(policies).length;

const timeoutMinutes = settings.system.interactiveTimeoutMinutes;
const timeoutStopIndex = TIMEOUT_STOPS.reduce(
(best, stop, i) =>
Math.abs(stop - timeoutMinutes) < Math.abs(TIMEOUT_STOPS[best] - timeoutMinutes) ? i : best,
0,
);
const onTimeoutStopChange = (index: number) => {
const next = TIMEOUT_STOPS[index];
if (next !== timeoutMinutes) {
setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: next }));
}
};

return (
<div className="space-y-4">
<div className="flex items-start gap-3">
Expand Down Expand Up @@ -80,7 +96,7 @@ export function SystemToolsSection(props: SettingsSectionProps) {
{entries.map((entry) => {
const policy = effectivePolicy(entry.toolName, entry.isReadOnly);
return (
<div key={entry.id} className="flex items-center gap-3 px-3 py-2.5">
<div key={entry.id} className="flex items-start gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="text-sm font-medium">
Expand All @@ -99,7 +115,26 @@ export function SystemToolsSection(props: SettingsSectionProps) {
{t(`settings.builtinTool.${entry.id}.desc`)}
</div>
</div>
{entry.isReadOnly ? (
{entry.isReadOnly && entry.id === "ask_user_question" ? (
<div className="w-52 shrink-0">
<input
type="range"
min={0}
max={TIMEOUT_STOPS.length - 1}
step={1}
value={timeoutStopIndex}
aria-label={t("settings.interactiveTimeout.title")}
onChange={(event) =>
onTimeoutStopChange(Number(event.currentTarget.value))
}
className="w-full accent-primary"
/>
<div className="mt-1 text-right text-xs text-muted-foreground">
<span className="font-medium tabular-nums">{timeoutMinutes}</span>{" "}
{t("settings.interactiveTimeout.unit")}
</div>
</div>
) : entry.isReadOnly ? (
<span className="shrink-0 text-[11px] text-muted-foreground/60">
{t("settings.toolPolicy.allow")}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ const SYSTEM_HIDDEN_WORKSPACE_PROJECT_PATHS_KEY: &str = "hiddenWorkspaceProjectP
const SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY: &str = "missingWorkspaceProjectPaths";
const SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY: &str = "archivedWorkspaceProjectPaths";
const SYSTEM_SYSTEM_PROXY_KEY: &str = "systemProxy";
// 交互式应答超时(分钟):AskUserQuestion 提问卡与工具审批栏共用的等待窗口。
// 与 toolPolicies 同理必须进保存白名单,否则前端改完滑块重启即回默认值。
const SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY: &str = "interactiveTimeoutMinutes";
/// 交互式应答超时的默认值与上限(分钟)。上限与前端档位表最大档一致:
/// 分钟数换算成毫秒后由前端分段续期计时,不再受 setTimeout 32 位延迟上限影响,
/// 但仍保留上限以免手改配置写入荒谬值(并与前端归一化口径保持一致)。
const DEFAULT_INTERACTIVE_TIMEOUT_MINUTES: f64 = 3.0;
const MAX_INTERACTIVE_TIMEOUT_MINUTES: f64 = 99_999.0;
const DEFAULT_WORKSPACE_PROJECT_ID: &str = "default-project";
const DEFAULT_WORKSPACE_PROJECT_NAME: &str = "Default Project";
pub(crate) const PROVIDER_API_KEY_UPDATES_FIELD: &str = "providerApiKeyUpdates";
Expand Down
21 changes: 21 additions & 0 deletions crates/agent-gui/src-tauri/src/commands/config/settings/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ fn normalize_archived_workspace_project_paths(raw: Option<&Value>) -> Value {
Value::Array(out)
}

/// 归一化交互式应答超时(分钟):正数原样保留、上限 99999(≈永不超时),
/// 缺省/非法/非正回默认 3。与前端 normalizeInteractiveTimeoutMinutes 同口径,
/// 保证桌面端落库值与 GUI/WebUI 归一化结果一致。
fn normalize_interactive_timeout_minutes_value(raw: Option<&Value>) -> Value {
let minutes = raw
.and_then(Value::as_f64)
.filter(|minutes| minutes.is_finite() && *minutes > 0.0)
.map(|minutes| minutes.min(MAX_INTERACTIVE_TIMEOUT_MINUTES))
.unwrap_or(DEFAULT_INTERACTIVE_TIMEOUT_MINUTES);
Number::from_f64(minutes)
.map(Value::Number)
.unwrap_or_else(|| json!(DEFAULT_INTERACTIVE_TIMEOUT_MINUTES))
}

fn normalize_system_proxy_value(raw: Option<&Value>) -> Value {
let obj = match raw {
Some(Value::Object(map)) => map.clone(),
Expand Down Expand Up @@ -340,6 +354,12 @@ fn system_value_with_defaults(raw: Option<Value>, default_workdir: &str) -> Valu
SYSTEM_SYSTEM_PROXY_KEY.to_string(),
normalize_system_proxy_value(system.get(SYSTEM_SYSTEM_PROXY_KEY)),
);
system.insert(
SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY.to_string(),
normalize_interactive_timeout_minutes_value(
system.get(SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY),
),
);

Value::Object(system)
}
Expand Down Expand Up @@ -387,6 +407,7 @@ fn save_system_with_default_workdir(
SYSTEM_MISSING_WORKSPACE_PROJECT_PATHS_KEY,
SYSTEM_ARCHIVED_WORKSPACE_PROJECT_PATHS_KEY,
SYSTEM_SYSTEM_PROXY_KEY,
SYSTEM_INTERACTIVE_TIMEOUT_MINUTES_KEY,
] {
let value = system.get(key).cloned().unwrap_or(Value::Null);
tx.execute(
Expand Down
Loading