Skip to content
Draft
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 @@ -25,22 +25,18 @@ function formatCountdown(remainingMs: number) {

/**
* 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
* 切到只读态
* 网关参数上的 deadline 盖章),两端与桌面计时同源。超长窗口(≈永不超时)时
* 显示很长倒计时;截止时间缺失/已过期时回退为挂载时刻的默认窗口近似,避免把
* 可作答的卡片锁死。倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态
*
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,
* 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当
* 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信,
* 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的
* 提交仍由桌面挂起表权威拒绝。
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个
* 仍在挂起的提问会在挂载瞬间显示过期。仅采信“挂载时刻之后”的截止时间;真正
* 过期的提交仍由桌面挂起表权威拒绝。
*/
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
const [mountedAt] = useState(() => Date.now());
const deadline =
deadlineAt !== undefined &&
deadlineAt > mountedAt &&
deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS
deadlineAt !== undefined && deadlineAt > mountedAt
? deadlineAt
: mountedAt + ASK_USER_QUESTION_TIMEOUT_MS;
const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now());
Expand Down
11 changes: 11 additions & 0 deletions crates/agent-gateway/web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,11 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.systemTools": "系统工具",
"settings.systemToolsDesc":
"查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。",
"settings.interactiveTimeout.title": "交互式应答超时",
"settings.interactiveTimeout.desc": "AskUserQuestion 提问卡与工具审批栏的等待窗口,二者共用。",
"settings.interactiveTimeout.unit": "分钟",
"settings.interactiveTimeout.hint":
"正数分钟 = 超时窗口;填很大的数(如 99999)≈ 永不超时。超时后按各交互既定姿态落定(提问自动选推荐项并继续、审批按拒绝)。",
"settings.builtinToolCategory.fs": "文件系统",
"settings.builtinToolCategory.process": "终端与进程",
"settings.builtinToolCategory.intelligence": "智能与记忆",
Expand Down Expand Up @@ -3466,6 +3471,12 @@ 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.desc":
"Wait window shared by the AskUserQuestion card and the tool approval bar.",
"settings.interactiveTimeout.unit": "min",
"settings.interactiveTimeout.hint":
"Positive minutes = answer window; use a very large number (e.g. 99999) for effectively no timeout. On timeout, each prompt settles its own way (questions auto-pick the recommended option and continue; approvals are denied).",
"settings.builtinToolCategory.fs": "File System",
"settings.builtinToolCategory.process": "Terminal & Processes",
"settings.builtinToolCategory.intelligence": "Intelligence & Memory",
Expand Down
4 changes: 3 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,7 +7,9 @@ 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;
/** 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
//
// 说明:MCP 工具按 server、插件工具按工具的策略已就地内联到各自 Hub 卡片旁
//(需运行时数据),不在本节;本节聚焦内置工具,补上内置工具此前不可管控的缺口。
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { ToolPolicyToggle } from "../../components/hub/ToolPolicyToggle";
import { Wrench } from "../../components/icons";
import { Input } from "../../components/ui/input";
import { useLocale } from "../../i18n";
import { type ToolPolicy, updateSystem } from "../../lib/settings";
import { BUILTIN_TOOL_CATALOG, BUILTIN_TOOL_CATEGORIES } from "../../lib/tools/builtinToolCatalog";
Expand Down Expand Up @@ -51,6 +52,22 @@ export function SystemToolsSection(props: SettingsSectionProps) {

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

// 交互式应答超时(分钟):正数=超时窗口,超长≈永不超时。草稿 + blur 提交,避免
// 逐字符触发设置同步;空/非法回退不提交,非正由归一化回默认 3。
const interactiveTimeoutMinutes = settings.system.interactiveTimeoutMinutes;
const [timeoutDraft, setTimeoutDraft] = useState<string | null>(null);
const timeoutInputValue = timeoutDraft ?? String(interactiveTimeoutMinutes);
const commitTimeoutDraft = () => {
if (timeoutDraft === null) return;
const parsed = Number.parseInt(timeoutDraft, 10);
if (!Number.isFinite(parsed)) {
setTimeoutDraft(null);
return;
}
setSettings((prev) => updateSystem(prev, { interactiveTimeoutMinutes: parsed }));
setTimeoutDraft(null);
};

return (
<div className="space-y-4">
<div className="flex items-start gap-3">
Expand All @@ -70,6 +87,36 @@ export function SystemToolsSection(props: SettingsSectionProps) {
) : null}
</div>

<div className="rounded-xl border border-border/50 bg-background/60 p-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings.interactiveTimeout.title")}</div>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{t("settings.interactiveTimeout.desc")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Input
type="number"
value={timeoutInputValue}
aria-label={t("settings.interactiveTimeout.title")}
onChange={(event) => setTimeoutDraft(event.currentTarget.value)}
onBlur={commitTimeoutDraft}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
}}
className="w-20"
/>
<span className="text-xs text-muted-foreground">
{t("settings.interactiveTimeout.unit")}
</span>
</div>
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-muted-foreground/70">
{t("settings.interactiveTimeout.hint")}
</p>
</div>

<div className="space-y-4">
{groups.map(({ category, entries }) => (
<div key={category.id} className="space-y-1.5">
Expand Down
12 changes: 12 additions & 0 deletions crates/agent-gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
} from "./lib/settings/sync";
import { applyStoredGlobalShortcuts } from "./lib/shortcuts/globalShortcuts";
import { applyFontFamilies } from "./lib/system/fontFamily";
import { setAskUserQuestionTimeoutMs } from "./lib/tools/askUserQuestionTools";
import { setToolApprovalTimeoutMs } from "./lib/tools/toolApproval";
import { ChatPage } from "./pages/ChatPage";
import { SettingsPage } from "./pages/SettingsPage";
import type { SectionId } from "./pages/settings/types";
Expand Down Expand Up @@ -380,6 +382,16 @@ export default function App() {
const getMcpSettings = useCallback(() => settingsRef.current.mcp, []);
const getToolPolicies = useCallback(() => settingsRef.current.system.toolPolicies, []);

// 把交互式应答超时设置(分钟)注入工具运行时窗口(毫秒):AskUserQuestion 与
// 工具审批共用同一窗口;永不超时用很大的分钟数表达。设置变更后新挂起的提问/
// 审批生效,已挂起的沿用旧窗口。模块级配置由工具侧 ensureAskUserQuestionDeadlineAt /
// requestToolApproval 读取,避免在 6+ 处工具预览调用点逐个传参。
useEffect(() => {
const ms = settings.system.interactiveTimeoutMinutes * 60_000;
setAskUserQuestionTimeoutMs(ms);
setToolApprovalTimeoutMs(ms);
}, [settings.system.interactiveTimeoutMinutes]);

const reloadPersistedSettings = useCallback(async () => {
await saveChainRef.current.catch(() => undefined);
const { settings: loaded, defaultWorkdir } = await loadPersistedSettingsWithDefaults();
Expand Down
18 changes: 7 additions & 11 deletions crates/agent-gui/src/components/chat/AskUserQuestionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,18 @@ function formatCountdown(remainingMs: number) {

/**
* 倒计时提示:优先使用调用方传入的权威截止时间(GUI 读工具挂起表,WebUI 读
* 网关参数上的 deadline 盖章),两端与桌面计时同源;缺失时(历史/降级数据)
* 回退为挂载时刻近似。倒计时归零立即禁止交互,随后 tool_result 把卡片
* 切到只读态
* 网关参数上的 deadline 盖章),两端与桌面计时同源。超长窗口(≈永不超时)时
* 显示很长倒计时;截止时间缺失/已过期时回退为挂载时刻的默认窗口近似,避免把
* 可作答的卡片锁死。倒计时归零立即禁止交互,随后 tool_result 把卡片切到只读态
*
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,
* 一个仍在挂起的提问会在挂载瞬间就显示过期(或远超完整窗口)。因此仅当
* 截止时间落在“挂载时刻(不含)~挂载时刻 + 完整应答窗口(含)”内才采信,
* 否则视为时钟不可比、回退挂载近似,避免把可作答的卡片锁死;真正过期的
* 提交仍由桌面挂起表权威拒绝。
* 盖章用的是桌面时钟,而倒计时读本机时钟:远端浏览器时钟偏移足够大时,一个
* 仍在挂起的提问会在挂载瞬间显示过期。仅采信“挂载时刻之后”的截止时间;真正
* 过期的提交仍由桌面挂起表权威拒绝。
*/
function useAnswerCountdown(active: boolean, deadlineAt?: number) {
const [mountedAt] = useState(() => Date.now());
const deadline =
deadlineAt !== undefined &&
deadlineAt > mountedAt &&
deadlineAt <= mountedAt + ASK_USER_QUESTION_TIMEOUT_MS
deadlineAt !== undefined && deadlineAt > mountedAt
? deadlineAt
: mountedAt + ASK_USER_QUESTION_TIMEOUT_MS;
const [remainingMs, setRemainingMs] = useState(() => deadline - Date.now());
Expand Down
11 changes: 11 additions & 0 deletions crates/agent-gui/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,11 @@ export const translations: Record<Locale, Record<string, string>> = {
"settings.systemTools": "系统工具",
"settings.systemToolsDesc":
"查看 LiveAgent 在 Agent 模式下自动注册的内置工具,并为每个工具设置审批策略(放行 / 执行前询问 / 拒绝)。",
"settings.interactiveTimeout.title": "交互式应答超时",
"settings.interactiveTimeout.desc": "AskUserQuestion 提问卡与工具审批栏的等待窗口,二者共用。",
"settings.interactiveTimeout.unit": "分钟",
"settings.interactiveTimeout.hint":
"正数分钟 = 超时窗口;填很大的数(如 99999)≈ 永不超时。超时后按各交互既定姿态落定(提问自动选推荐项并继续、审批按拒绝)。",
"settings.builtinToolCategory.fs": "文件系统",
"settings.builtinToolCategory.process": "终端与进程",
"settings.builtinToolCategory.intelligence": "智能与记忆",
Expand Down Expand Up @@ -3629,6 +3634,12 @@ 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.desc":
"Wait window shared by the AskUserQuestion card and the tool approval bar.",
"settings.interactiveTimeout.unit": "min",
"settings.interactiveTimeout.hint":
"Positive minutes = answer window; use a very large number (e.g. 99999) for effectively no timeout. On timeout, each prompt settles its own way (questions auto-pick the recommended option and continue; approvals are denied).",
"settings.builtinToolCategory.fs": "File System",
"settings.builtinToolCategory.process": "Terminal & Processes",
"settings.builtinToolCategory.intelligence": "Intelligence & Memory",
Expand Down
4 changes: 3 additions & 1 deletion crates/agent-gui/src/lib/chat/askUserQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ 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;
/** UI 合成"其他(自行输入)"应答的最大长度;超出部分截断。 */
export const ASK_USER_QUESTION_CUSTOM_MAX_LENGTH = 2000;
Expand Down
16 changes: 16 additions & 0 deletions crates/agent-gui/src/lib/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,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 @@ -207,6 +212,8 @@ export type SystemSettings = {
* 可选:旧快照缺失该字段时视为空表(全部走默认),保证零回归。
*/
toolPolicies?: Record<string, ToolPolicy>;
/** 交互式应答超时(分钟):正数=窗口,超长≈永不。缺省 3,保持历史行为。 */
interactiveTimeoutMinutes: number;
workspaceProjects: WorkspaceProject[];
activeWorkspaceProjectId?: string;
hiddenWorkspaceProjectPaths: string[];
Expand Down Expand Up @@ -1642,12 +1649,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 @@ -2209,6 +2224,7 @@ export function getDefaultSettings(): AppSettings {
missingWorkspaceProjectPaths: [],
archivedWorkspaceProjectPaths: [],
systemProxy: getDefaultSystemProxyConfig(),
interactiveTimeoutMinutes: INTERACTIVE_TIMEOUT_DEFAULT_MINUTES,
},
customProviders,
mcp: {
Expand Down
Loading
Loading