diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index baeeab4fb..3482baba9 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -692,7 +692,12 @@ keep、replace 或 delete;全部完成后宿主才原子提交新配置与状 宿主注入只读的 `window.codexProxyPlugin`。`request({ method, path, query?, contentType?, body? })` 只能访问当前版本 已注册的管理路由,返回 `{ status, contentType, body: ArrayBuffer }`;`callbackTicket({ path, ttlSeconds })` 为已注册 回调签发 1–600 秒的一次性地址;`resourceUrl(path)` 只解析同一制品已注册的非 HTML 资源。`theme` 为当前 -`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。页面不能指定其他实例、制品、版本、URL 或请求 header +`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。管理请求不能指定其他实例、制品、版本、URL 或请求 header + +需要跳转到外部页面时,在插件页面的用户点击处理器中调用 `openExternal(url): Promise`。 +地址必须是绝对 HTTPS URL,不能包含用户名或密码。宿主展示目标主机名和非默认端口,管理员可打开新标签页、复制链接或取消。 +Promise 成功只表示宿主已接受确认请求,不代表链接已打开;无用户操作、地址无效或已有链接待确认时,请求会被拒绝。 +切换页面会关闭确认框。兼容较早的 v2 宿主时,先检查 `typeof window.codexProxyPlugin.openExternal === 'function'` 普通模型请求使用标准 `Response` 接口: diff --git a/docs/api.md b/docs/api.md index f95f6faae..631395206 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3232,6 +3232,7 @@ GET/HEAD 不接受正文。响应状态码为 200–599,内容类型元数据 `If-None-Match` 匹配时返回无正文的 `304`。停用、版本或授权变更后,旧目标仍被拒绝,不返回缓存命中。 两类响应默认设置 nosniff、no-referrer 与限制性 CSP,动态处理器可显式覆盖默认响应头。管理端页面使用隔离 iframe, 仅允许脚本,不具有父页面同源权限;联网、弹窗、表单、嵌套页面和顶层导航均不开放。 +插件页面可通过宿主桥请求打开 HTTPS 外链,管理员在宿主确认后才打开新标签页。 页面访问已声明管理路由须经过固定目标的宿主桥,不能指定其他实例或版本。插件页面作者合同见 [SDK 管理 API 与页面](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) diff --git a/docs/plugins.md b/docs/plugins.md index 29e76fced..661df8500 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -78,7 +78,7 @@ flowchart LR | 功能 | 使用方式 | | --- | --- | -| 管理页面 | 从插件详情或左侧插件导航打开;页面在隔离 iframe 中运行 | +| 管理页面 | 从插件详情或左侧插件导航打开;插件请求打开外链时,核对目标域名后可打开、复制或取消 | | 自动维护 | 启用、重启及账号等配置变化后自动对账;失败重试,周期补漏 | | 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE | | 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 | diff --git a/frontend/src/i18n/locales/en/plugins.json b/frontend/src/i18n/locales/en/plugins.json index 91d7058a1..4949cd5bb 100644 --- a/frontend/src/i18n/locales/en/plugins.json +++ b/frontend/src/i18n/locales/en/plugins.json @@ -330,6 +330,12 @@ "modelResponseReadFailed": "Model response read failed", "duplicateManagementRequest": "Duplicate plugin management request ID", "managementConcurrencyLimit": "Plugin management concurrency limit reached", + "openExternalLink": "Open external link", + "openLink": "Open", + "copyLink": "Copy link", + "externalLinkPending": "An external link is already awaiting confirmation", + "externalLinkRequiresClick": "Click a link or button on the plugin page", + "invalidExternalLink": "Use an absolute HTTPS URL without a username or password", "invalidEntryPoint": "Plugin page entry point is not a registered HTML resource", "unregisteredAsset": "Plugin page references an unregistered resource: {path}", "htmlSubresource": "Plugin HTML resources cannot be loaded as subresources", diff --git a/frontend/src/i18n/locales/zh-Hans/plugins.json b/frontend/src/i18n/locales/zh-Hans/plugins.json index 0d8cf95cf..d441678eb 100644 --- a/frontend/src/i18n/locales/zh-Hans/plugins.json +++ b/frontend/src/i18n/locales/zh-Hans/plugins.json @@ -330,6 +330,12 @@ "modelResponseReadFailed": "模型响应读取失败", "duplicateManagementRequest": "插件管理请求序号重复", "managementConcurrencyLimit": "插件管理请求已达并发上限", + "openExternalLink": "打开外部链接", + "openLink": "打开", + "copyLink": "复制链接", + "externalLinkPending": "请先处理当前外部链接", + "externalLinkRequiresClick": "请在插件页面中点击链接或按钮", + "invalidExternalLink": "请输入不含用户名和密码的 HTTPS 绝对地址", "invalidEntryPoint": "插件页面入口不是已注册的 HTML 资源", "unregisteredAsset": "插件页面引用了未注册资源:{path}", "htmlSubresource": "插件 HTML 资源不能作为子资源加载", diff --git a/frontend/src/i18n/locales/zh-Hant/plugins.json b/frontend/src/i18n/locales/zh-Hant/plugins.json index 063c2a748..e388ea659 100644 --- a/frontend/src/i18n/locales/zh-Hant/plugins.json +++ b/frontend/src/i18n/locales/zh-Hant/plugins.json @@ -330,6 +330,12 @@ "modelResponseReadFailed": "模型響應讀取失敗", "duplicateManagementRequest": "插件管理請求序號重複", "managementConcurrencyLimit": "插件管理請求已達併發上限", + "openExternalLink": "開啟外部連結", + "openLink": "開啟", + "copyLink": "複製連結", + "externalLinkPending": "請先處理目前的外部連結", + "externalLinkRequiresClick": "請在插件頁面中點選連結或按鈕", + "invalidExternalLink": "請輸入不含使用者名稱和密碼的 HTTPS 絕對網址", "invalidEntryPoint": "插件頁面入口不是已註冊的 HTML 資源", "unregisteredAsset": "插件頁面引用了未註冊資源:{path}", "htmlSubresource": "插件 HTML 資源不能作爲子資源加載", diff --git a/frontend/src/views/plugins/components/PluginExternalLinkDialog.vue b/frontend/src/views/plugins/components/PluginExternalLinkDialog.vue new file mode 100644 index 000000000..66feb3445 --- /dev/null +++ b/frontend/src/views/plugins/components/PluginExternalLinkDialog.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/src/views/plugins/components/PluginManagementFrame.vue b/frontend/src/views/plugins/components/PluginManagementFrame.vue index 69c904132..e29610ab5 100644 --- a/frontend/src/views/plugins/components/PluginManagementFrame.vue +++ b/frontend/src/views/plugins/components/PluginManagementFrame.vue @@ -5,6 +5,7 @@ import { useEventListener, useResizeObserver } from '@vueuse/core' import { computed, onMounted, shallowRef, useTemplateRef, watch } from 'vue' import { useThemeStore } from '@/stores/modules/theme' import { usePluginFrameSession } from '../composables/usePluginFrameSession' +import PluginExternalLinkDialog from './PluginExternalLinkDialog.vue' import PluginPageLoading from './PluginPageLoading.vue' const props = defineProps<{ @@ -20,7 +21,7 @@ const themeStore = useThemeStore() const containerRef = useTemplateRef('container') const iframeRef = useTemplateRef('iframe') const viewportHeight = shallowRef(0) -const { srcdoc, loading, loadError, contentHeight, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({ +const { srcdoc, loading, loadError, contentHeight, externalLink, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({ view: () => props.view, page: () => props.page, iframe: iframeRef, @@ -73,5 +74,6 @@ onMounted(pushLayout) referrerpolicy="no-referrer" @load="handleFrameLoad" /> + diff --git a/frontend/src/views/plugins/composables/usePluginFrameSession.ts b/frontend/src/views/plugins/composables/usePluginFrameSession.ts index 64474d80c..db24130aa 100644 --- a/frontend/src/views/plugins/composables/usePluginFrameSession.ts +++ b/frontend/src/views/plugins/composables/usePluginFrameSession.ts @@ -53,6 +53,7 @@ export function usePluginFrameSession(options: { ZNotification.error({ message, deduplicate: true }) }) const contentHeight = shallowRef(0) + const externalLink = shallowRef() const session = shallowRef() let loadController: AbortController | undefined let previousViewportHeight = -1 @@ -62,6 +63,7 @@ export function usePluginFrameSession(options: { loadController = undefined const current = session.value session.value = undefined + externalLink.value = undefined srcdoc.value = '' contentHeight.value = 0 previousViewportHeight = -1 @@ -290,6 +292,20 @@ export function usePluginFrameSession(options: { }, '*', transfer) } + function requestExternalLink(value: unknown) { + if (externalLink.value) + throw new Error(t('plugins.frame.externalLinkPending')) + // 短暂激活只能用于限制提示,打开地址仍须管理员在宿主内确认 + if (!navigator.userActivation?.isActive || document.activeElement !== options.iframe.value) + throw new Error(t('plugins.frame.externalLinkRequiresClick')) + const url = typeof value === 'string' + ? URL.parse(value) + : null + if (!url || url.protocol !== 'https:' || url.username || url.password) + throw new Error(t('plugins.frame.invalidExternalLink')) + externalLink.value = url + } + async function handleBridgeMessage(event: MessageEvent) { const current = session.value const source = event.source @@ -324,7 +340,7 @@ export function usePluginFrameSession(options: { current.models.cancel(frame, event.data.id) return } - if (!['request', 'callback-ticket'].includes(event.data.type)) + if (!['request', 'callback-ticket', 'open-external'].includes(event.data.type)) return if (current.requestIds.has(event.data.id) || current.models.has(event.data.id)) { postResult(current, frame, event.data.id, false, undefined, t('plugins.frame.duplicateManagementRequest')) @@ -336,9 +352,16 @@ export function usePluginFrameSession(options: { } current.requestIds.add(event.data.id) try { - if (event.data.type === 'request') + if (event.data.type === 'request') { await handleRouteRequest(current, frame, event.data) - else await handleCallbackTicket(current, frame, event.data) + } + else if (event.data.type === 'callback-ticket') { + await handleCallbackTicket(current, frame, event.data) + } + else { + requestExternalLink(event.data.payload) + postResult(current, frame, event.data.id, true) + } } catch (error) { if (session.value !== current || current.controller.signal.aborted) @@ -412,6 +435,7 @@ export function usePluginFrameSession(options: { loading, loadError, contentHeight, + externalLink, frameKey, handleFrameLoad, pushLayout, diff --git a/frontend/src/views/plugins/management/bridge-contract.ts b/frontend/src/views/plugins/management/bridge-contract.ts index 6467cad69..ee5c1f87d 100644 --- a/frontend/src/views/plugins/management/bridge-contract.ts +++ b/frontend/src/views/plugins/management/bridge-contract.ts @@ -9,7 +9,7 @@ export const MAXIMUM_MODEL_IN_FLIGHT = 4 export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000 export const MODEL_RESPONSE_IDLE_MS = 30 * 1000 -export type PluginPageMessageType = 'request' | 'callback-ticket' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel' +export type PluginPageMessageType = 'request' | 'callback-ticket' | 'open-external' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel' export type PluginModelMessageType = 'models-response' | 'models-chunk' | 'models-complete' | 'models-error' export interface PluginBridgeMessage { @@ -63,6 +63,7 @@ export interface PluginHostBridge { readonly theme: 'light' | 'dark' request: (input: PluginManagementRequest) => Promise callbackTicket: (input: { path: string, ttlSeconds: number }) => Promise + openExternal: (url: string) => Promise resourceUrl: (path: string) => string readonly models: Readonly<{ responses: (input: PluginModelRequest) => Promise }> } diff --git a/frontend/src/views/plugins/management/bridge-runtime.ts b/frontend/src/views/plugins/management/bridge-runtime.ts index 9bf3f612d..5642a07f7 100644 --- a/frontend/src/views/plugins/management/bridge-runtime.ts +++ b/frontend/src/views/plugins/management/bridge-runtime.ts @@ -80,7 +80,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { isDisposed: () => disposed, }) - function invoke(kind: 'request' | 'callback-ticket', payload: unknown, transfer: Transferable[] = []): Promise { + function invoke(kind: 'request' | 'callback-ticket' | 'open-external', payload: unknown, transfer: Transferable[] = []): Promise { if (disposed) return Promise.reject(new Error('Plugin page is closed')) if (pending.size >= maximumInFlight) @@ -254,6 +254,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) { get theme() { return currentTheme }, request, callbackTicket, + openExternal: url => invoke('open-external', url), models: Object.freeze({ responses: modelBridge.responses }), resourceUrl(path: string) { const key = String(path)