mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:41:58 +00:00
设置页加「通用」标签放对外访问地址;环境变量指定时显示「由环境变量指定」不可改,定时任务里的入口跟着认
服务端已经只认一个对外访问地址(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 通过。
This commit is contained in:
@@ -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<string, any> | 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())
|
||||
}
|
||||
@@ -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: '端点',
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader icon={<SettingsIcon size={13} />} title="设置"
|
||||
subtitle={builder ? '模型接入、偏好、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} />
|
||||
subtitle={builder ? '模型接入、偏好、对外访问地址、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} />
|
||||
<Tabs tabs={tabs} active={tab} onChange={change} label="设置" idPrefix="settings" />
|
||||
<TabPanel idPrefix="settings" tabKey={tab} className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-4xl p-4">
|
||||
{tab === 'providers' && <ProvidersTab />}
|
||||
{tab === 'prefs' && (builder ? <PrefsTab /> : <PersonalPrefs />)}
|
||||
{tab === 'general' && <GeneralTab />}
|
||||
{tab === 'system' && <SystemTab />}
|
||||
{tab === 'identity' && <IdentityTab />}
|
||||
{tab === 'notify' && <NotifyTab />}
|
||||
|
||||
@@ -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<string | null | undefined>(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() {
|
||||
<span className={clsx('min-w-0 break-all', value ? 'mono text-fg' : 'text-faint')}>
|
||||
{failed ? T.linkBaseFailed : value === undefined ? <Spinner size={10} /> : value ? T.linkBaseValue(value) : T.linkBaseUnset}
|
||||
</span>
|
||||
{!editing && value !== undefined && (
|
||||
{!editing && value !== undefined && !env && (
|
||||
<button type="button" className="btn btn-xs" onClick={() => { setDraft(value ?? ''); setEditing(true) }} data-link-base-edit="">
|
||||
{value ? T.linkBaseEdit : T.linkBaseSet}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{env && <p className="mt-1 text-2xs leading-relaxed text-faint" data-link-base-env="">{T.linkBaseEnv}</p>}
|
||||
{editing && (
|
||||
<div className="mt-2">
|
||||
<Field label={T.linkBaseLabel} htmlFor="schedule-link-base" hint={T.linkBaseHint} error={invalid ? T.linkBaseInvalid : undefined}>
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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<Record<string, any> | null>(null)
|
||||
const [loadError, setLoadError] = useState<unknown>(null)
|
||||
// null:没在改,输入框显示存着的值
|
||||
const [draft, setDraft] = useState<string | null>(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 (
|
||||
<div className="max-w-2xl space-y-6 pb-20">
|
||||
<section data-general-public-url="">
|
||||
<SectionBar title={T.publicUrl} hint={T.publicUrlHint} />
|
||||
{!settings ? (
|
||||
loadError ? <ErrorState error={loadError} onRetry={() => void load()} /> : <Skeleton rows={2} height={14} />
|
||||
) : editable ? (
|
||||
<Field label={T.address} htmlFor="general-public-url" hint={T.addressHint} error={invalid ? T.invalid : undefined}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<input id="general-public-url" type="url" inputMode="url" className="field mono min-w-0 flex-1 basis-60"
|
||||
value={draft ?? saved} placeholder={T.placeholder} autoComplete="off" spellCheck={false}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid ? 'general-public-url-error' : 'general-public-url-hint'}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); void save() } }}
|
||||
data-public-url-input="" />
|
||||
<button type="button" className="btn btn-sm btn-primary" onClick={() => void save()}
|
||||
disabled={!dirty || invalid || busy} data-public-url-save="">
|
||||
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {T.save}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="rounded-md border bg-elev px-2.5 py-2" data-public-url-readonly={address.env ? 'env' : 'role'}>
|
||||
<div className={address.value ? 'mono break-all text-xs text-fg' : 'text-xs text-faint'} data-public-url-value="">
|
||||
{address.value ?? T.unset}
|
||||
</div>
|
||||
<p className="mt-1 flex items-start gap-1 text-2xs leading-relaxed text-faint">
|
||||
<Lock size={11} className="mt-0.5 shrink-0" aria-hidden />
|
||||
<span>{address.env ? `${T.envLocked}。${T.envLockedHint}` : T.readOnly}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user