From 2fb16c271943d6759917d54bf7385eddbddcf7e8 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:31:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=8A=A0=E3=80=8C?= =?UTF-8?q?=E9=80=9A=E7=94=A8=E3=80=8D=E6=A0=87=E7=AD=BE=E6=94=BE=E5=AF=B9?= =?UTF-8?q?=E5=A4=96=E8=AE=BF=E9=97=AE=E5=9C=B0=E5=9D=80=EF=BC=9B=E7=8E=AF?= =?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E6=8C=87=E5=AE=9A=E6=97=B6=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E3=80=8C=E7=94=B1=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E3=80=8D=E4=B8=8D=E5=8F=AF=E6=94=B9=EF=BC=8C?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E9=87=8C=E7=9A=84=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E8=B7=9F=E7=9D=80=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端已经只认一个对外访问地址(core/public_url.py),界面上这一项以前只在定时任务的 Webhook 一节里改得到,叫「服务 访问地址」,和设置接口、通知里的「对外访问地址」两种说法;环境变量设了时那里照样能改,改了也不生效。 - 新增「设置 → 通用」(pages/settings/GeneralTab.tsx):写明它管哪几件事(通知和 Webhook 的链接、单点登录的回调、来源 核对);管理员能改,保存前先重读 notify 一组、只换这一项,末尾的 / 去掉,带 ? 或 # 的当场标错;改了没保存离开先问; 环境变量指定了的只显示它、写明「由环境变量 AGENTLAB_PUBLIC_URL 指定」,不给输入框;搭建者只看。使用者看不到这一页 - 定时任务 Webhook 一节的快捷入口改叫「对外访问地址」,按 GET /api/settings 的 public_url 一组认:环境变量指定的只显示、 不给修改按钮,设置里存着的旧值不再冒充生效的那个;地址校验和「通用」同一个(lib/publicUrl.ts) - 文案进 lib/terms.ts(GENERAL_SETTING_TEXT,SCHEDULE_TEXT 的 linkBase 一组改说法) 检查:check-manage 加「设置 · 通用:对外访问地址」一节(标签、说明、校验、离开先问、只写 notify 一组、环境变量只读、 搭建者只读);check-schedules 加「对外访问地址由环境变量指定」一节。check-manage、check-schedules 在隔离的一套后端 + vite 上全部通过,tsc、check-copy 通过。 --- frontend/src/lib/publicUrl.ts | 31 +++++++ frontend/src/lib/terms.ts | 42 +++++++-- frontend/src/pages/SettingsPage.tsx | 7 +- .../src/pages/schedules/WebhookFields.tsx | 26 ++++-- frontend/src/pages/schedules/model.ts | 2 +- frontend/src/pages/settings/GeneralTab.tsx | 93 +++++++++++++++++++ scripts/check-manage.mjs | 71 ++++++++++++++ scripts/check-schedules.mjs | 23 ++++- 8 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/publicUrl.ts create mode 100644 frontend/src/pages/settings/GeneralTab.tsx diff --git a/frontend/src/lib/publicUrl.ts b/frontend/src/lib/publicUrl.ts new file mode 100644 index 00000000..781500a9 --- /dev/null +++ b/frontend/src/lib/publicUrl.ts @@ -0,0 +1,31 @@ +/** + * 对外访问地址:别人打开 AgentLab 用的那个地址。服务端全站只认一个(backend/app/core/public_url.py):环境变量 + * AGENTLAB_PUBLIC_URL 优先,其次设置里 notify 一组的 public_base_url(界面上在「设置 → 通用」改,定时任务的 Webhook 一节 + * 有快捷入口)。通知和 Webhook 里的链接、单点登录的回调、来源核对都按它。 + * + * GET /api/settings 另给只读的 public_url 一组 {value, env_locked}:环境变量指定了的话显示它、写明「由环境变量指定」、 + * 不让改(改了服务端回 409)。老服务端没有这一组时按设置里存的那一项、可以改。 + */ + +export interface PublicAddress { + /** 生效的地址;null 是没设置(通知里不附链接) */ + value: string | null + /** 环境变量指定的:设置里的那一项不生效、不能改 */ + env: boolean +} + +export function publicAddressOf(settings: Record | null | undefined): PublicAddress { + const env = settings?.public_url?.env_locked === true + const raw = env ? settings?.public_url?.value : settings?.notify?.public_base_url + return { value: typeof raw === 'string' && raw ? raw : null, env } +} + +/** 填的地址去掉首尾空白和末尾的 /(服务端存的也是这样) */ +export function cleanPublicUrl(text: string): string { + return text.trim().replace(/\/+$/, '') +} + +/** http:// 或 https:// 开头、有主机名、中间没有空白、不带 ? 或 # 之后的部分(同服务端 public_url.url_problem) */ +export function isPublicUrl(text: string): boolean { + return /^https?:\/\/[^\s/?#]+(?:\/[^\s?#]*)?$/i.test(text.trim()) +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 51fb6dba..0f0a6dad 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3889,19 +3889,20 @@ export const SCHEDULE_TEXT = { testResult: (head: string, status: number | null, detail: string) => `${head}${status != null ? `(HTTP ${status})` : ''}${detail ? `:${detail}` : ''}`, - // ---- 通知里的链接:运行、对比页都按这个地址拼(设置 notify 组,所有定时任务共用) + // ---- 通知里的链接:运行、对比页都按全站的对外访问地址拼(GENERAL_SETTING_TEXT,这里是它的快捷入口) linkBase: '通知中的链接', linkBaseValue: (url: string) => `按 ${url} 拼接`, - linkBaseUnset: '未设置服务访问地址,通知中不附带运行和对比页链接', + linkBaseUnset: '未设置对外访问地址,通知中不附带运行和对比页链接', linkBaseEdit: '修改', linkBaseSet: '设置', - linkBaseLabel: '服务访问地址', - linkBaseHint: '收件人打开运行、对比页链接用的地址,例如 https://agentlab.example.com。所有定时任务共用', + linkBaseLabel: '对外访问地址', + linkBaseHint: '收件人打开运行、对比页链接用的地址,例如 https://agentlab.example.com。全站共用一个,也可以在「设置 → 通用」中修改', linkBasePlaceholder: 'https://agentlab.example.com', - linkBaseInvalid: '请输入以 http:// 或 https:// 开头的地址', - linkBaseSaved: '已保存服务访问地址', - linkBaseCleared: '已清除服务访问地址', - linkBaseFailed: '无法读取服务访问地址', + linkBaseInvalid: '请输入以 http:// 或 https:// 开头的完整地址,不带 ? 或 # 之后的部分', + linkBaseSaved: '已保存对外访问地址', + linkBaseCleared: '已清除对外访问地址', + linkBaseFailed: '无法读取对外访问地址', + linkBaseEnv: '由环境变量 AGENTLAB_PUBLIC_URL 指定,不能在这里修改', // ---- 表单:保存 save: '保存', @@ -5825,6 +5826,31 @@ export const APP_SETTING_TEXT = { personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制', } +/** + * 设置 → 通用:全站共用的部署设置。对外访问地址全站只有一个(服务端 core/public_url.py):环境变量 AGENTLAB_PUBLIC_URL + * 优先,其次这里填的;定时任务 Webhook 一节里是它的快捷入口(SCHEDULE_TEXT.linkBase*) + */ +export const GENERAL_SETTING_TEXT = { + tab: '通用', + publicUrl: '对外访问地址', + publicUrlHint: '别人打开 AgentLab 用的地址。通知和定时任务 Webhook 里的链接、单点登录的回调地址都按它拼,提交操作时也按它核对请求来源。留空时通知里不附链接,回调和来源核对按浏览器访问的地址认', + address: '地址', + addressHint: '以 http:// 或 https:// 开头的完整地址,例如 https://agentlab.example.com;放在反向代理的子路径下时带上路径', + placeholder: 'https://agentlab.example.com', + invalid: '请输入以 http:// 或 https:// 开头的完整地址,不带 ? 或 # 之后的部分', + unset: '未设置', + envLocked: '由环境变量 AGENTLAB_PUBLIC_URL 指定', + envLockedHint: '部署时用环境变量定了这个地址,这里不能修改。要改请修改部署配置后重启服务', + readOnly: '全站共用,只有管理员可以修改', + save: '保存', + saved: '已保存对外访问地址', + cleared: '已清除对外访问地址', + leaveTitle: '对外访问地址还没保存', + leaveBody: '离开此页面将丢失这次修改。', + leaveConfirm: '放弃修改', + leaveCancel: '返回保存', +} + /** 自定义工具绑定登记的端点(§3.9):只填路径,地址和认证头由端点提供 */ export const TOOL_ENDPOINT_TEXT = { bind: '端点', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 68314672..4eb66a44 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -25,6 +25,7 @@ import { localActor, setLocalActor } from '../lib/actor' import { AGENT_GUARD_TEXT, APP_SETTING_TEXT, DRAFT_MODEL_SETTING_TEXT, IDENTITY_TEXT, JUDGE_SETTING_TEXT, NOTIFY_TEXT, PROVIDER_REF_TEXT, SETTINGS_ROLE_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText, + GENERAL_SETTING_TEXT, } from '../lib/terms' import { atLeast, loginOn, prefsLocalOnly, usePrincipal } from '../store/auth' import type { ThemePref } from '../lib/theme' @@ -32,6 +33,7 @@ import { ProviderOptions, VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft, } from './VerifySettings' import { DraftModelSection } from './DraftModelSettings' +import { GeneralTab } from './settings/GeneralTab' import { IdentityTab } from './settings/IdentityTab' import { NotifyTab } from './settings/NotifyTab' import { AuditTab } from './settings/AuditTab' @@ -41,6 +43,8 @@ import { AuditTab } from './settings/AuditTab' const TABS = [ { key: 'providers', label: '模型接入' }, { key: 'prefs', label: '偏好设置' }, + // 全站共用的部署设置:对外访问地址(环境变量指定时只读) + { key: 'general', label: GENERAL_SETTING_TEXT.tab }, { key: 'system', label: '运行环境' }, // 应用平台(A7):登录方式和人、通知渠道、管理类操作的留痕 { key: 'identity', label: IDENTITY_TEXT.tab }, @@ -74,12 +78,13 @@ export function SettingsPage() { return (
} title="设置" - subtitle={builder ? '模型接入、偏好、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} /> + subtitle={builder ? '模型接入、偏好、对外访问地址、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} />
{tab === 'providers' && } {tab === 'prefs' && (builder ? : )} + {tab === 'general' && } {tab === 'system' && } {tab === 'identity' && } {tab === 'notify' && } diff --git a/frontend/src/pages/schedules/WebhookFields.tsx b/frontend/src/pages/schedules/WebhookFields.tsx index 2041d5c9..ab27bcb4 100644 --- a/frontend/src/pages/schedules/WebhookFields.tsx +++ b/frontend/src/pages/schedules/WebhookFields.tsx @@ -4,12 +4,13 @@ import clsx from 'clsx' import { api } from '../../api/client' import { Field, Notice, Spinner, isComposing, toast, useRadioGroup } from '../../components/ui' import { errorMessage } from '../../lib/errors' +import { cleanPublicUrl, isPublicUrl, publicAddressOf } from '../../lib/publicUrl' import { SCHEDULE_TEXT as T } from '../../lib/terms' import type { ScheduleEvent, ScheduleWebhook, ScheduleWebhookTest } from '../../types' -import { SCHEDULE_EVENTS, WEBHOOK_FORMATS, isHttpUrl, webhookTestText, type FormField, type WebhookForm } from './model' +import { SCHEDULE_EVENTS, WEBHOOK_FORMATS, webhookTestText, type FormField, type WebhookForm } from './model' /** - * Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行、对比页链接用的服务访问地址。 + * Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行、对比页链接用的对外访问地址(快捷入口)。 * * 地址和密钥保存之后服务端不再返回明文:地址只显示主机和末 4 位(url_hint),可以替换或删除;密钥只说「已设置」, * 可以替换或清除。没点替换时请求里不带它们,改别的字段不会把地址清掉。测试消息用的是已保存的设置, @@ -195,11 +196,12 @@ function SecretField({ value: w, saved, onChange, error }: { } /** - * 通知里运行、对比页链接用的服务访问地址:设置里 notify 一组的 public_base_url,所有定时任务共用。这里给出当前值和修改入口, - * 写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉 + * 通知里运行、对比页链接用的对外访问地址:全站只有一个(lib/publicUrl.ts,「设置 → 通用」),这里给出当前值和修改的快捷入口。 + * 环境变量指定了的只显示、写明由环境变量指定,不给修改。写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉 */ function LinkBase() { const [value, setValue] = useState(undefined) + const [env, setEnv] = useState(false) const [failed, setFailed] = useState(false) const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') @@ -208,13 +210,18 @@ function LinkBase() { useEffect(() => { let alive = true api.settings.get() - .then((s) => { if (alive) setValue(typeof s?.notify?.public_base_url === 'string' ? s.notify.public_base_url : null) }) + .then((s) => { + if (!alive) return + const address = publicAddressOf(s) + setValue(address.value) + setEnv(address.env) + }) .catch(() => { if (alive) setFailed(true) }) return () => { alive = false } }, []) - const cleaned = draft.trim().replace(/\/+$/, '') - const invalid = !!cleaned && !isHttpUrl(cleaned) + const cleaned = cleanPublicUrl(draft) + const invalid = !!cleaned && !isPublicUrl(cleaned) const save = async () => { if (invalid || busy) return setBusy(true) @@ -222,7 +229,7 @@ function LinkBase() { const current = await api.settings.get() const notify = { ...(current?.notify ?? {}), public_base_url: cleaned || null } const out = await api.settings.put({ notify }) - setValue(typeof out?.notify?.public_base_url === 'string' ? out.notify.public_base_url : notify.public_base_url) + setValue(out?.notify ? publicAddressOf(out).value : notify.public_base_url) setFailed(false) setEditing(false) toast.ok(cleaned ? T.linkBaseSaved : T.linkBaseCleared) @@ -240,12 +247,13 @@ function LinkBase() { {failed ? T.linkBaseFailed : value === undefined ? : value ? T.linkBaseValue(value) : T.linkBaseUnset} - {!editing && value !== undefined && ( + {!editing && value !== undefined && !env && ( )}
+ {env &&

{T.linkBaseEnv}

} {editing && (
diff --git a/frontend/src/pages/schedules/model.ts b/frontend/src/pages/schedules/model.ts index 30b3ab7d..b6a68b15 100644 --- a/frontend/src/pages/schedules/model.ts +++ b/frontend/src/pages/schedules/model.ts @@ -559,7 +559,7 @@ export function freqProblem(f: Freq): string | null { return null } -/** 以 http:// 或 https:// 开头、中间没有空白的地址(Webhook、服务访问地址都用它) */ +/** 以 http:// 或 https:// 开头、中间没有空白的地址(Webhook 地址用它;对外访问地址另有 lib/publicUrl 的 isPublicUrl) */ export function isHttpUrl(text: string): boolean { return /^https?:\/\/\S+$/i.test(text.trim()) } diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx new file mode 100644 index 00000000..a29fd693 --- /dev/null +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import { Check, Lock } from 'lucide-react' +import { api } from '../../api/client' +import { ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, isComposing, toast } from '../../components/ui' +import { useLeaveGuard } from '../../lib/leave' +import { cleanPublicUrl, isPublicUrl, publicAddressOf } from '../../lib/publicUrl' +import { GENERAL_SETTING_TEXT as T } from '../../lib/terms' +import { useAtLeast } from '../../store/auth' + +/* + * 设置 → 通用:全站共用的部署设置。现在只有「对外访问地址」一项:服务端全站只认一个对外地址(core/public_url.py), + * 环境变量 AGENTLAB_PUBLIC_URL 优先,其次这里填的(存在 notify 一组)。以前两处各管各的,设置里这一项只在定时任务的 + * Webhook 一节里改得到,那里现在是它的快捷入口。 + * + * 环境变量设了:只显示它,写明「由环境变量指定」,不给输入框(GET /api/settings 的 public_url 一组)。只有管理员能改 + * (整个设置接口的改动都要管理员),搭建者只看。保存时先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉 + */ +export function GeneralTab() { + const admin = useAtLeast('admin') + const [settings, setSettings] = useState | null>(null) + const [loadError, setLoadError] = useState(null) + // null:没在改,输入框显示存着的值 + const [draft, setDraft] = useState(null) + const [busy, setBusy] = useState(false) + + const load = () => api.settings.get().then((s) => { setSettings(s); setLoadError(null) }, setLoadError) + useEffect(() => { void load() }, []) + + const address = publicAddressOf(settings) + const editable = admin && !address.env + const saved = address.value ?? '' + const cleaned = cleanPublicUrl(draft ?? saved) + const invalid = !!cleaned && !isPublicUrl(cleaned) + const dirty = settings != null && editable && cleaned !== saved + useLeaveGuard(dirty, () => confirmDialog({ + title: T.leaveTitle, body: T.leaveBody, confirmLabel: T.leaveConfirm, cancelLabel: T.leaveCancel, danger: true, + })) + + const save = async () => { + if (!dirty || invalid || busy) return + setBusy(true) + try { + const current = await api.settings.get() + const out = await api.settings.put({ notify: { ...(current?.notify ?? {}), public_base_url: cleaned || null } }) + setSettings(out) + setDraft(null) + toast.ok(cleaned ? T.saved : T.cleared) + } catch (e) { + toast.error(e) + // 环境变量这期间被设上了(服务端回 409)这类:按服务端现在的样子重读 + void load() + } finally { + setBusy(false) + } + } + + return ( +
+
+ + {!settings ? ( + loadError ? void load()} /> : + ) : editable ? ( + +
+ setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); void save() } }} + data-public-url-input="" /> + +
+
+ ) : ( +
+
+ {address.value ?? T.unset} +
+

+ + {address.env ? `${T.envLocked}。${T.envLockedHint}` : T.readOnly} +

+
+ )} +
+
+ ) +} diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index d5546dbd..eb02c474 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -731,6 +731,77 @@ await section('设置 · 应用:使用者可以建个人应用(只有管理 await builder.close() }) +await section('设置 · 通用:对外访问地址', async () => { + // 全站只认一个对外访问地址(服务端 core/public_url.py):环境变量优先,其次这里填的(存在 notify 一组)。通知和 Webhook 的 + // 链接、单点登录的回调、来源核对都按它。管理员能改;环境变量指定了的只显示、写明来源;搭建者只看 + let stored = await get('/settings') + stored = { ...stored, notify: { ...(stored.notify ?? {}), public_base_url: null }, public_url: { value: null, env_locked: false } } + const { page, sent, errors, natives, close } = await open('/settings/general', { + handlers: [ + [/^GET \/settings$/, (route) => json(stored)(route)], + [/^PUT \/settings$/, (route, { body }) => { + stored = { ...stored, ...body.values, public_url: { value: body.values.notify?.public_base_url ?? null, env_locked: false } } + return json(stored)(route) + }], + ], + }) + const box = page.locator('[data-general-public-url]') + await box.waitFor() + check('设置页有「通用」标签,里面是对外访问地址', (await page.getByRole('tab', { name: '通用' }).getAttribute('aria-selected')) === 'true' + && (await box.innerText()).includes('对外访问地址')) + const said = await box.innerText() + check('……写明它管哪几件事:通知和 Webhook 的链接、单点登录的回调、来源核对', said.includes('Webhook') && said.includes('单点登录') + && said.includes('来源'), said.replace(/\s+/g, ' ')) + const input = box.locator('[data-public-url-input]') + const save = box.locator('[data-public-url-save]') + check('没改动时保存不可点', await save.isDisabled()) + await input.fill('https://agentlab.example.com/?from=x') + check('带 ? 的地址:当场标错、不让存', await save.isDisabled() && (await box.innerText()).includes('不带 ? 或 #')) + await input.fill('agentlab.example.com') + check('没写协议:同样不让存', await save.isDisabled()) + await input.fill('https://agentlab.example.com/') + await page.getByRole('tab', { name: '运行环境' }).click() + await page.waitForTimeout(300) + check('改了没保存就切标签:先问', (await dialog(page).innerText().catch(() => '')).includes('还没保存')) + await dialog(page).getByRole('button', { name: '返回保存' }).click() + await page.waitForTimeout(200) + check('……选「返回保存」:还在通用页,改动还在', page.url().endsWith('/settings/general') + && (await input.inputValue()) === 'https://agentlab.example.com/') + await save.click() + await until(() => sent.some((x) => x.key === 'PUT /settings')) + const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values + check('保存只 PUT notify 一组、去掉末尾的斜杠,同组别的项原样带回', Object.keys(put ?? {}).join() === 'notify' + && put.notify.public_base_url === 'https://agentlab.example.com', JSON.stringify(put)) + check('……存完按钮回到不可点', await until(() => save.isDisabled())) + check('没有运行时报错、原生对话框', errors.length === 0 && natives.length === 0, [...errors, ...natives][0] ?? '') + await close() + + // 环境变量指定了:只显示它、写明来源,不给输入框 + const env = await open('/settings/general', { + handlers: [[/^GET \/settings$/, json({ ...stored, public_url: { value: 'https://agentlab.example.internal', env_locked: true } })]], + }) + const ro = env.page.locator('[data-public-url-readonly="env"]') + await ro.waitFor() + const roText = await ro.innerText() + check('环境变量指定的:显示它、写明由 AGENTLAB_PUBLIC_URL 指定,不给输入框', roText.includes('https://agentlab.example.internal') + && roText.includes('AGENTLAB_PUBLIC_URL') && await env.page.locator('[data-public-url-input]').count() === 0, roText) + await env.close() + + // 搭建者(开了登录):只看,写明只有管理员可以修改 + const builder = await open('/settings/general', { + handlers: [ + [/^GET \/auth\/me$/, json({ mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true })], + [/^GET \/settings$/, json(stored)], + ], + }) + const ro2 = builder.page.locator('[data-public-url-readonly="role"]') + await ro2.waitFor() + const ro2Text = await ro2.innerText() + check('搭建者:只显示当前值、写明只有管理员可以修改', ro2Text.includes('https://agentlab.example.com') && ro2Text.includes('只有管理员') + && await builder.page.locator('[data-public-url-input]').count() === 0, ro2Text) + await builder.close() +}) + await section('设置 · 点名的接入已停用或不存在', async () => { // 点名停用的接入以前照常调用,现在运行时照实报错(K1):存着的值后来被停用了,那一格下面要说;别的停用接入列着但选不了; // 服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的 diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index 32723b47..b43fd823 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -535,7 +535,9 @@ function applyPatch(s, body) { let realSettings = null async function settingsOf(state) { realSettings ??= await fetch(`${API}/settings`).then((r) => r.json()).catch(() => ({})) - return { ...realSettings, ui: { ...(realSettings.ui ?? {}), theme: state.theme ?? 'dark' }, notify: state.notify } + // 对外访问地址只读的那一组:state.publicUrl 设了就按它(环境变量指定的情形),否则照真服务端 + return { ...realSettings, ui: { ...(realSettings.ui ?? {}), theme: state.theme ?? 'dark' }, notify: state.notify, + public_url: state.publicUrl ?? realSettings.public_url } } /** 一个请求怎么答。返回 {status, json};返回 null 表示放行(GET)或拦下(写请求) */ @@ -1245,6 +1247,25 @@ await section('详情:Webhook 与运行链接地址', async () => { check('没有页面报错、原生对话框', !errors.length, errors.join(' | ')) }) +await section('详情:对外访问地址由环境变量指定', async () => { + // 全站只认一个对外访问地址(服务端 core/public_url.py):环境变量设了就以它为准,这里只显示、写明来源,不给修改; + // notify 里存着的旧值不生效,也不显示 + const env = 'https://agentlab.example.internal' + const { page, sent, errors } = await open('/schedules/s-weekly', { + state: freshState({ notify: { public_base_url: 'https://stale.example.com' }, publicUrl: { value: env, env_locked: true } }), + }) + await page.locator('[data-link-base]').waitFor() + const shown = await until(async () => { + const t = await text(page, '[data-link-base]') + return t.includes(env) ? t : null + }) + check('显示环境变量给的地址,不显示设置里存着的旧值', !!shown && !shown.includes('stale.example.com'), oneLine([shown ?? ''])) + check('……写明由环境变量 AGENTLAB_PUBLIC_URL 指定', (await text(page, '[data-link-base-env]').catch(() => '')).includes('AGENTLAB_PUBLIC_URL')) + check('……没有修改按钮,也没发 PUT', await page.locator('[data-link-base-edit]').count() === 0 + && writes(sent, /^PUT \/settings$/).length === 0) + check('没有页面报错、原生对话框', !errors.length, errors.join(' | ')) +}) + await section('详情:保存冲突与页头操作', async () => { const { page, sent, errors, state } = await open('/schedules/s-weekly') await page.locator('[data-schedule-form]').waitFor()