diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index c1f0fdaa..bc6f3e25 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -646,7 +646,7 @@ def notify_problem(value: Any) -> str | None: from app.notify.digest import time_ok if not time_ok(item): - return f"「{NOTIFY_SETTING_LABEL[key]}」需要写成 时:分(00:00 到 23:59,例如 09:00),当前为「{item}」" + return f"「{NOTIFY_SETTING_LABEL[key]}」需要是 24 小时制的时间,例如 09:00(从 00:00 到 23:59),当前为「{item}」" elif problem := _base_url_problem(item): return problem return None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f312c5f4..e4f8696f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,7 @@ import { NewAppStart } from './pages/blueprints/NewAppStart' import { BlueprintPage } from './pages/blueprints/BlueprintPage' import { personalAppsOn } from './pages/blueprints/shared' import { LoginPage } from './pages/LoginPage' +import { SharePage } from './pages/apps/SharePage' import { Logo } from './components/Logo' import { CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify, @@ -56,7 +57,17 @@ import { APP_TEXT, AUTH_MODE_LABEL, AUTH_TEXT, ROLE_LABEL, RUN_COMPARE_TEXT, TAS /** 启动时认人(/auth/me)最多等多久再取目录 */ const AUTH_WAIT_MS = 2500 +/** + * 分享页(/s#<分享 key>,04 细案 C2)不在外壳里,比登录页还早一步分开:拿到链接的人没有登录这回事,外壳的认人、取目录、 + * 心跳、读主题一样都不做(开了登录时它们会 401,再被请求层送去登录页)。整页加载时定一次:分享页上没有通往外壳的链接 + */ +const SHARE_PAGE = typeof window !== 'undefined' && window.location.pathname === '/s' + export default function App() { + return SHARE_PAGE ? : +} + +function Shell() { const loaded = useCatalog((s) => s.loaded) const backend = useCatalog((s) => s.backend) const everOk = useCatalog((s) => s.lastOkAt != null) diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index 221d6964..ecda1b52 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -6,10 +6,11 @@ */ import type { AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult, - ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, + ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, ViewShareCreated, ViewShareList, + ViewShareRow, } from '../typesApp' import type { ConversationDetail, EvidenceSegmentDetail } from '../types' -import { del, get, patch, post, put, qs, type RequestOptions } from './http' +import { del, get, patch, post, put, qs, request, type RequestOptions } from './http' const at = (id: string) => `/apps/${encodeURIComponent(id)}` const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURIComponent(name)}` @@ -74,6 +75,15 @@ export const apps = { deleteHook: (id: string, name: string) => del(hookAt(id, name)), /** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */ hookReceipts: (id: string, name: string) => get(`${hookAt(id, name)}/receipts`), + /** + * 分享链接(manage,服务端 api/shares.py):列、建、撤销。建要管理员开了「允许建分享链接」,到期必填(最长 max_days 天); + * key 只在建的这一次交回 + */ + shares: (id: string) => get(`${at(id)}/shares`), + createShare: (id: string, body: { page_id: string; mode: 'latest' | 'fixed'; expires_at: string; embed_allowed: boolean }) => + post(`${at(id)}/shares`, body), + revokeShare: (id: string, shareId: string) => + request(`${at(id)}/shares/${encodeURIComponent(shareId)}`, { method: 'DELETE' }), /** * 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次; * 老服务端只回对话本身时由调用方再按 id 取一次 diff --git a/frontend/src/api/capabilities.ts b/frontend/src/api/capabilities.ts index 08fde911..95d67e4a 100644 --- a/frontend/src/api/capabilities.ts +++ b/frontend/src/api/capabilities.ts @@ -5,7 +5,7 @@ import type { CatalogReviewAction, CatalogSource, CatalogStatus } from '../types' import type { CapabilityEffect, CapabilityReviewOutcome, CapabilityReviewRow, CapabilityRow, EndpointRow, NotifyChannelRow, - NotifyEventRow, + NotifyEventRow, NotifyMailState, } from '../typesApp' import { del, get, patch, post, put, qs, type RequestOptions } from './http' @@ -122,8 +122,10 @@ export const endpoints = { } export const notify = { - channels: () => get<{ items: NotifyChannelRow[] }>('/notify/channels'), - createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url: string; + /** mail:邮件服务启没启用、本人的账号邮箱(老服务端不给) */ + channels: () => get<{ items: NotifyChannelRow[]; mail?: NotifyMailState }>('/notify/channels'), + /** 邮件渠道:个人的不带 url(只发到账号邮箱),全局、应用的 url 是收件地址(逗号或换行分隔,最多 20 个) */ + createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url?: string; secret?: string | null; events: string[] }) => post('/notify/channels', body), updateChannel: (id: string, body: Record) => patch(`/notify/channels/${encodeURIComponent(id)}`, body), removeChannel: (id: string) => del(`/notify/channels/${encodeURIComponent(id)}`), diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0930d931..d37f2e4b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -689,6 +689,8 @@ export const api = { /** 今天(本地日期)结论句裁判花了多少:设置页放在每日上限旁边。只读,计数只有裁判自己记 */ judgeSpend: () => get<{ date?: string; usd?: number; calls?: number; unpriced_calls?: number daily_max_usd?: number | null }>('/settings/judge/spend'), + /** 按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(管理员;关登录、没有邮箱时 422) */ + mailTest: () => post<{ ok: boolean; message: string }>('/settings/mail/test'), }, // ---- 工具 ---- diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index d7903892..3e280d5d 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -104,7 +104,8 @@ export function actorHeader(): Record { function toLogin(path?: string) { if (typeof window === 'undefined' || path === '/auth/login' || path === '/auth/me') return const { pathname, search } = window.location - if (pathname === '/login') return + // 分享页(/s)上没有登录这回事:不送去登录页 + if (pathname === '/login' || pathname === '/s') return const back = `${pathname}${search}` window.location.assign(back && back !== '/' ? `/login?next=${encodeURIComponent(back)}` : '/login') } diff --git a/frontend/src/api/shares.ts b/frontend/src/api/shares.ts new file mode 100644 index 00000000..49390180 --- /dev/null +++ b/frontend/src/api/shares.ts @@ -0,0 +1,42 @@ +/** + * 分享页(/s)的两个接口:GET /api/shares/page、GET /api/shares/report.html(服务端 api/shares.py,04 细案 C2)。 + * + * 分享 key 在链接的 # 后面(/s#),打开页面时不发给服务器;这里读出来放进请求头 X-AgentLab-Share,服务端只认请求头。 + * 不经 http.request:那条路会带署名头、把 401 当成「会话没了」整页跳去登录页——分享页上没有登录这回事,也不该在外壳的连接 + * 状态里报来报去。报错照样翻成 ApiError({detail, code}),页面按 code 写「链接已撤销 / 已到期」。 + * + * 只从 ./http、./bundle 导入(同 reportExport.ts,避免和 client.ts 循环导入)。 + */ +import type { SharePayload } from '../typesApp' +import { ApiError, BASE, NETWORK_MESSAGE, codeOf, describeDetail } from './http' +import { dispositionName } from './bundle' + +export const SHARE_HEADER = 'X-AgentLab-Share' + +async function call(path: string, key: string): Promise { + let res: Response + try { + res = await fetch(BASE + path, { headers: { [SHARE_HEADER]: key }, credentials: 'omit', referrerPolicy: 'no-referrer' }) + } catch (e: any) { + throw new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` }) + } + if (res.ok) return res + const text = await res.text().catch(() => '') + let body: any = null + try { body = JSON.parse(text) } catch { /* 不是 JSON(代理回的页面) */ } + const detail = body?.detail + throw new ApiError(res.status, detail ? describeDetail(detail, path) : `请求失败(HTTP ${res.status})`, + { detail, code: codeOf(body), body, raw: `${res.status} ${text.slice(0, 500)}` }) +} + +export const shares = { + /** 分享页的视图模型、页脚、能下载报告的运行 */ + page: async (key: string): Promise => (await call('/shares/page', key)).json(), + /** 下载报告(附件):只认这条分享解析出来的运行;文件名取服务端给的,没给是 null */ + report: async (key: string, runId: string, reportNode?: string): Promise<{ blob: Blob; name: string | null }> => { + const q = new URLSearchParams({ run: runId }) + if (reportNode) q.set('report', reportNode) + const res = await call(`/shares/report.html?${q.toString()}`, key) + return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) } + }, +} diff --git a/frontend/src/apps/ViewRenderer.tsx b/frontend/src/apps/ViewRenderer.tsx index 26ce0974..5e43dbfc 100644 --- a/frontend/src/apps/ViewRenderer.tsx +++ b/frontend/src/apps/ViewRenderer.tsx @@ -11,6 +11,9 @@ * 隐藏(normal-user-apps N6):作者隐藏的组件,发布版解析时服务端就跳过;按草稿解析时照样下发、带 hidden。这里只看这个 * 标记:应用页上直接改的编辑状态(editing)画成半透明、标「已隐藏」,别处(编辑器的预览)照使用者看到的样子不画。 * 编辑状态另给草稿里隐藏了的组件 id(editing.hidden):服务端没带标记的也认得出来。 + * + * 分享页(pages/apps/SharePage,04 细案 C2)也用它:传 preview(不发起、不刷新)和 onEvidence——点数字不打开在线出处(那要 + * 按运行读证据的身份,分享页上没有),交给分享页说明「出处在下载的报告里」。 */ import { createContext, useCallback, useContext, useMemo, useState } from 'react' import type { ReactNode } from 'react' @@ -35,6 +38,8 @@ export interface ViewRendererProps { onReload?: () => void /** 应用页上直接改的编辑状态:隐藏的组件画成半透明(不传时隐藏的组件不画) */ editing?: ViewEditMarks + /** 点数字看出处时不开证据面板、交给调用方(分享页:出处在下载的报告里) */ + onEvidence?: (evidence: ViewEvidence, label: string, text?: string) => void } /** 编辑状态的标记:草稿里隐藏了的组件 id */ @@ -47,11 +52,12 @@ const EditMarksCtx = createContext(null) /** 服务端按草稿解析时给隐藏的组件带的标记(ViewNode.hidden,发布版里没有这样的组件),加上这一次刚隐藏的 */ const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => node.hidden === true || !!marks?.hidden.has(node.id) -export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing }: ViewRendererProps) { +export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing, onEvidence }: ViewRendererProps) { const [evidence, setEvidence] = useState(null) const openEvidence = useCallback((ev: ViewEvidence, label: string, text?: string) => { - setEvidence({ evidence: ev, label, text }) - }, []) + if (onEvidence) onEvidence(ev, label, text) + else setEvidence({ evidence: ev, label, text }) + }, [onEvidence]) // 视图模型换了(刷新、重新取)就是新的一代:画坏了的组件跟着重试一次 const gen = useMemo(() => String(++generation), [model]) const render = useCallback((nodes: ViewNode[] | undefined): ReactNode => @@ -81,7 +87,7 @@ export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, )} {render(model.tree)} - setEvidence(null)} /> + {!onEvidence && setEvidence(null)} />} ) } diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index e93f53d7..41775fe0 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5611,7 +5611,8 @@ export const NOTIFY_TEXT = { urlHint: '地址加密保存,之后只显示主机名', secret: '签名密钥(可选)', format: '格式', - formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人' } as Record, + // email:邮件(第五种格式,服务端 notify/mail.py):要先在「邮件服务」一节配好并启用 + formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人', email: '邮件' } as Record, scope: '范围', scopes: { global: '全局', app: '应用', user: '个人' } as Record, events: '订阅的事件', @@ -6085,6 +6086,9 @@ export const APP_SETTING_TEXT = { onDemandLimitInvalid: '请填写 0 到 100,000 之间的整数', personalApps: '使用者可以建个人应用', personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制', + // 分享链接(服务端 apps.share_links,04 细案 C2):默认关 + shareLinks: '允许建分享链接', + shareLinksHint: '开启后,管理应用的人可以在应用编辑的「岗位与授权」里给一页建分享链接:拿到链接的人不用登录就能看(只读、只显示定时出数的视图、数据和使用者看到的一样遮过),必须设到期、最长 90 天、随时可以撤销。关掉时已有的分享链接一并打不开', } /** diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index ef185807..43d29ef6 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -802,3 +802,122 @@ export const HOOK_RECEIPT_TEXT = { ? `近 1 小时验签失败 ${n} 次:验签失败的请求不留记录,只计数` : '近 1 小时没有验签失败的请求'), } + +/** + * 邮件(通知渠道的第五种格式,服务端 notify/mail.py,04 细案 B2):设置 → 通知里的「邮件服务」「每天的待办汇总」两节、 + * 渠道里的邮件格式。照实说:成功只写「已交给邮件服务器」,退信不追踪,不说已送达 + */ +export const MAIL_TEXT = { + section: '邮件服务', + hint: '通知渠道选「邮件」时用这台邮件服务器发信。口令加密保存,之后只显示「已设置」', + readOnly: '只有管理员能修改邮件服务', + enabled: '启用邮件', + enabledHint: '关着时通知渠道里没有「邮件」这一格式;已有的邮件渠道发不出去,投递记录里写明原因', + host: 'SMTP 服务器', + hostHint: '主机名或 IP 地址,例如 smtp.example.com,不带协议和端口', + port: '端口', + security: '加密方式', + securityLabel: { starttls: 'STARTTLS(常用端口 587)', ssl: 'SSL/TLS(常用端口 465)', none: '不加密(只在内网中继时用)' } as Record, + username: '用户名', + usernameHint: '服务器不要求登录时留空', + password: '口令', + passwordSet: '已设置;留空表示不修改', + passwordUnset: '未设置', + passwordClear: '清除已保存的口令', + fromAddr: '发件地址', + fromName: '发件人名称', + defaultAssignees: '默认发邮件给待办处理人', + defaultAssigneesHint: '打开后,第一次有待办派给某人时,自动给他建一个个人邮件渠道(订阅待办打开、动作结果未知、审核有结果),发到他的账号邮箱;本人之后可以改订阅或关掉、删掉。缺省关:刚配好邮件服务时不会突然往外发邮件', + save: '保存邮件服务', + saved: '已保存邮件服务设置', + invalidPort: '请填写 1 到 65535 之间的整数', + leaveBody: '邮件服务有没保存的改动,离开将丢失这些改动。', + test: '发测试邮件', + testHint: '按已保存的设置发一封到你自己的账号邮箱', + testDirty: '先保存再发测试邮件', + handoff: '已交给邮件服务器', + handoffHint: '「已交给邮件服务器」不等于已经进了收件箱:退信不追踪,是否送达以邮件服务器为准', + // 每天的待办汇总(服务端 notify/digest.py) + digest: '每天的待办汇总', + digestHint: '每天这个时间(按服务端所在时区)给订阅了「每天的待办汇总」的个人渠道发一条:还有几条待办、最早的时限、最多 5 条和去待办箱的链接。没有待办的人那天不发', + digestAt: '发送时间', + digestSave: '保存发送时间', + digestSaved: (at: string) => `每天 ${at} 发待办汇总`, + digestReadOnly: '只有管理员能修改发送时间', + // 渠道里的邮件格式 + personalTo: (email: string | null) => (email ? `发到账号邮箱 ${email}` : '你的账号没有邮箱地址,发不出去:请联系管理员在「设置 → 身份」里填写'), + personalToHint: '个人邮件渠道只发到你的账号邮箱,不能填别的地址', + recipients: '收件地址', + recipientsHint: '多个地址用逗号或换行分隔,最多 20 个;人多的请填邮件组的地址', + to: (list: string[]) => (list.length + ? `发到 ${list.slice(0, 2).join('、')}${list.length > 2 ? ` 等 ${list.length} 个地址` : ''}` : ''), + off: '邮件服务没有启用,暂时不能选邮件:请管理员在下面的「邮件服务」里配置并启用', +} + +/** + * 分享链接(服务端 api/shares.py,04 细案 C2):应用编辑 → 岗位与授权里的管理。叫「分享链接」,不叫快照的那种说法: + * 「快照」专指查询快照 + */ +export const SHARE_TEXT = { + section: '分享链接', + hint: '拿到链接的人不用登录就能看这一页:只读,只显示定时出数的视图,看到的数据和使用者一样遮过;出处给「下载报告」。必须设到期,随时可以撤销', + off: '管理员还没有打开分享链接:在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」后才能建', + offWithRows: '管理员已关闭分享链接:下面这些暂时打不开,仍然可以撤销', + personal: '个人应用只给你自己用,不能分享', + unpublished: '发布之后才能建分享链接', + create: '新建分享链接', + empty: '还没有分享链接', + page: '分享哪一页', + pageHint: '只显示这一页上定时出数的视图;入口、表单、实例、待办和报告正文在分享页上换成说明', + mode: '数据', + modeLatest: '始终最新:跟着最近一次封存走', + modeFixed: '固定在现在这一期:之后出的数不换上来', + modeShort: { latest: '始终最新', fixed: '固定这一期' } as Record, + expires: '有效期', + expiresDays: (n: number) => `${n} 天`, + expiresHint: (max: number) => `必须设到期,最长 ${max} 天`, + embed: '允许嵌入到别的站点', + embedHint: '勾上之后这条链接能放进门户的 iframe(只限部署配置里允许嵌入分享页的站点);不勾的在别的页面里不显示内容', + embedNoOrigins: '部署没有配置允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS),勾了也只能在 AgentLab 自己的页面里嵌', + submit: '建分享链接', + created: '分享链接已建好', + once: '链接只显示这一次,请现在复制下来;之后只看得到开头几位', + dismiss: '我已复制', + state: { active: '有效', revoked: '已撤销', expired: '已到期' } as Record, + embedded: '可嵌入', + opened: (n: number) => (n ? `打开过 ${n} 次` : '还没打开过'), + by: (who: string) => `由 ${who} 建`, + until: (at: string) => `${at} 到期`, + revoke: '撤销', + revokeTitle: (prefix: string) => `撤销分享链接 ${prefix}…?`, + revokeBody: '撤销之后,拿着这条链接的人打开会看到「链接已撤销」,不能恢复;要再分享请建一条新的。', + revoked: '已撤销分享链接', +} + +/** 分享页(/s,不在外壳里):拿到分享链接的人看到的页面 */ +export const SHARE_PAGE_TEXT = { + loading: '正在打开分享的页面…', + readOnly: '只读', + footer: (who: string, at: string, until: string) => `只读 · 由 ${who} 于 ${at} 分享 · ${until} 到期`, + someone: '管理应用的人', + fixed: '固定在分享时的那一期', + latest: '跟着最近一次封存更新', + stamps: '数据的来历', + stampsHint: '每个视图取的是哪一次运行、截至什么时候、按哪个口径、出具档位', + download: '下载报告', + downloadHint: '下载的报告里每个数都点得开出处(口径、来源、计算方式),离线也能看', + downloading: '正在下载…', + downloaded: '已下载报告', + noReports: '这一页的数据没有可下载的报告', + evidenceTitle: '出处在下载的报告里', + evidence: '分享页不打开在线出处:点下面的「下载报告」,下载的报告里每个数都点得开出处,离线也能看', + close: '知道了', + noKey: '链接不完整:缺少 # 后面的那一段,请向分享的人确认链接', + errorTitle: { + share_revoked: '链接已撤销', share_expired: '链接已到期', share_links_off: '分享链接已关闭', + share_gone: '分享的页面打不开了', share_not_found: '链接无效', share_key_missing: '链接不完整', + } as Record, + failed: '分享的页面打不开', + framed: '这条分享链接没有允许嵌入到别的页面,请直接打开链接查看', + openDirect: '在新窗口打开', +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c51630c6..a058f038 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -682,6 +682,8 @@ function PrefsTab() { // 应用一组(D-j):按需视图每个应用每天最多发起几次;使用者能不能建个人应用(N7,默认关) appsDaily: string appsPersonal: boolean + // 允许建分享链接(服务端 apps.share_links,04 细案 C2,默认关) + appsShare: boolean } | null>(null) const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null) // 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取 @@ -735,6 +737,7 @@ function PrefsTab() { // 服务端没给这一项时按默认的 200 显示(同服务端的缺省值) appsDaily: String(values?.apps?.on_demand_daily_limit ?? APPS_DAILY_DEFAULT), appsPersonal: values?.apps?.user_personal_apps === true, + appsShare: values?.apps?.share_links === true, } const cur = draft ?? saved const stepCap = Number(values?.limits?.max_agent_steps) || 100 @@ -834,7 +837,8 @@ function PrefsTab() { const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null // 应用一组同样整组写:别的键(以后加的)照原样带回去 const apps = appsChanged - ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal } + ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal, + share_links: cur.appsShare } : null if (runChanged || judge || verify || catalogDraft || apps) { const run = { @@ -1201,6 +1205,17 @@ function PrefsTab() { + @@ -1228,6 +1243,7 @@ function PrefsTab() { jClick: '每次点击的金额上限', jDaily: '每日金额上限', draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model, appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps, + appsShare: APP_SETTING_TEXT.shareLinks, })[k]).concat(verifyLabels(verifyKeys)).join('、')} @@ -1261,7 +1277,7 @@ function PrefsTab() { } /** 应用一组在草稿里的几项:保存时整组写 apps */ -const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal']) +const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal', 'appsShare']) /** 按需视图每日上限的缺省值和最大值(同服务端 settings.APPS_DEFAULTS、APPS_DAILY_LIMIT_MAX) */ const APPS_DAILY_DEFAULT = 200 diff --git a/frontend/src/pages/apps/SharePage.tsx b/frontend/src/pages/apps/SharePage.tsx new file mode 100644 index 00000000..10ee5a25 --- /dev/null +++ b/frontend/src/pages/apps/SharePage.tsx @@ -0,0 +1,178 @@ +/** + * 分享页(/s#<分享 key>,不在外壳里,同登录页的写法;04 细案 C2):拿到分享链接的人不用登录就能看应用的一页。 + * + * - key 在 # 后面:打开页面时不发给服务器、不进访问日志和 Referer,这里读出来放进请求头(api/shares.ts); + * - 用同一个 ViewRenderer 渲染服务端按分享解析好的视图模型(不另写渲染器):preview 打开,所有操作禁用;入口、表单、实例、 + * 待办、报告正文服务端已经换成写明原因的说明块; + * - 印章常显:页面上没放印章组件的视图,也在「数据的来历」里逐个列出它的印章(截至、口径、出具档位、来源); + * - 出处给「下载报告」:点数字不开在线证据,说明出处在下载的报告里(报告文件自己离线点得开); + * - 页脚写「只读 · 由 X 于 … 分享 · … 到期」;撤销、到期、关了开关、链接不完整,照服务端的话写明,不给空白页; + * - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」(服务端在 /s 上只能按全站的嵌入白名单 + * 发头,打开页面那一刻它还不知道是哪一条分享)。 + * + * 不认人、不取目录、不连心跳(App 在 /s 上整个跳过外壳的启动),也就不会因为没登录被送去登录页。 + */ +import { useEffect, useMemo, useState } from 'react' +import { Download, ExternalLink, Lock } from 'lucide-react' +import { ApiError } from '../../api/http' +import { shares } from '../../api/shares' +import { ViewRenderer } from '../../apps/ViewRenderer' +import { Stamp } from '../../apps/Stamp' +import { Logo } from '../../components/Logo' +import { Modal, Notice, Spinner, toast } from '../../components/ui' +import { formatDateTime } from '../../lib/format' +import { SHARE_PAGE_TEXT as T } from '../../lib/termsApp' +import type { SharePayload } from '../../typesApp' + +/** 链接里 # 后面的那一段就是分享 key */ +export const shareKeyOf = (hash: string): string => decodeURIComponent(hash.replace(/^#/, '').trim()) + +const framed = (): boolean => { + try { + return window.self !== window.top + } catch { + // 跨源的父页面读 top 会抛:那就是被别的站点嵌着 + return true + } +} + +export function SharePage() { + const key = useMemo(() => shareKeyOf(window.location.hash), []) + const [data, setData] = useState(null) + const [error, setError] = useState<{ code?: string; message: string } | null>(key ? null : { code: 'share_key_missing', message: T.noKey }) + const [note, setNote] = useState(false) + + useEffect(() => { + if (!key) return + shares.page(key).then(setData, (e) => setError({ + code: e instanceof ApiError ? e.code : undefined, + message: e instanceof Error ? e.message : T.failed, + })) + }, [key]) + useEffect(() => { + if (data) document.title = `${data.view.app.name} · ${data.view.page.title || data.view.page.id}` + }, [data]) + + const blocked = !!data && !data.share.embed_allowed && framed() + const state = error ? 'error' : !data ? 'loading' : blocked ? 'framed' : 'ok' + + return ( +
+
+
+ +
+

+ {data ? data.view.app.name : 'AgentLab'} + {data?.view.page.title && · {data.view.page.title}} +

+
+ {data && ( + + {T.readOnly} · {data.share.mode === 'fixed' ? T.fixed : T.latest} + + )} +
+ + {error ? ( +
+

{(error.code && T.errorTitle[error.code]) || T.failed}

+

{error.message}

+
+ ) : !data ? ( +
{T.loading}
+ ) : blocked ? ( +
+

{T.framed}

+ + {T.openDirect} + +
+ ) : ( + <> + setNote(true)} /> + +
+ +

+ {T.footer(data.share.created_by || T.someone, formatDateTime(data.share.created_at), + formatDateTime(data.share.expires_at))} +

+
+ + )} +
+ {data && ( + setNote(false)} title={T.evidenceTitle} width={460} + footer={}> +
+

{T.evidence}

+ +
+
+ )} +
+ ) +} + +/** 印章常显:每个视图来源的印章(取到了运行的),页面上有没有放印章组件都列 */ +function Stamps({ data }: { data: SharePayload }) { + const rows = Object.entries(data.view.sources).filter(([, s]) => s.stamp) + if (!rows.length) return null + return ( +
+
+

{T.stamps}

+

{T.stampsHint}

+
+
+ {rows.map(([vid, s]) => )} +
+
+ ) +} + +/** 下载报告:只给这条分享解析出来、带报告的那几次运行(服务端只认这几次) */ +function Downloads({ shareKey, data }: { shareKey: string; data: SharePayload }) { + const [busy, setBusy] = useState(null) + const items = data.downloads.flatMap((d) => d.reports.map((node) => ({ ...d, node }))) + if (!items.length) return

{T.noReports}

+ const download = async (runId: string, node: string) => { + const id = `${runId}:${node}` + setBusy(id) + try { + const { blob, name } = await shares.report(shareKey, runId, node) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = name ?? `报告-${runId.slice(0, 6)}.html` + document.body.appendChild(a) + a.click() + a.remove() + setTimeout(() => URL.revokeObjectURL(url), 1000) + toast.ok(T.downloaded) + } catch (e) { + toast.error(e) + } finally { + setBusy(null) + } + } + return ( +
+
+ {items.map((d) => { + const id = `${d.run_id}:${d.node}` + return ( + + ) + })} +
+ {T.downloadHint} +
+ ) +} diff --git a/frontend/src/pages/apps/edit/AccessSections.tsx b/frontend/src/pages/apps/edit/AccessSections.tsx index 1427c5f7..bd031221 100644 --- a/frontend/src/pages/apps/edit/AccessSections.tsx +++ b/frontend/src/pages/apps/edit/AccessSections.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from 'react' -import { KeyRound, Plus } from 'lucide-react' +import { KeyRound, Link2, Plus } from 'lucide-react' import { api } from '../../../api/client' -import type { AppHookRow, HookReceipts } from '../../../typesApp' +import type { AppHookRow, HookReceipts, ViewShareCreated, ViewShareList, ViewShareRow } from '../../../typesApp' import { RecentRequests, type RecentRequestRow } from '../../../components/RecentRequests' -import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT } from '../../../lib/termsApp' +import { formatDateTime } from '../../../lib/format' +import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT, SHARE_TEXT as S } from '../../../lib/termsApp' import { CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast, } from '../../../components/ui' @@ -20,6 +21,8 @@ import { useEditor } from './context' * 岗位:图里人工节点写的岗位名(assignee 的岗位)在这里绑定到人或组;绑定不进 spec,存在草稿的 bindings 里,发布时 * 进应用版本——spec 可以导出,绑定是这一个部署自己的。人员变动只改组成员,不发新版本。 * 授权:use / observe / manage,即时生效、写审计,不需要发新版本(A1)。关登录时所有人都是管理员,授权开登录后才生效 + * 分享链接(04 细案 C2):管理员开了「允许建分享链接」后,管理应用的人给发布版的一页建只读、不用登录的链接(必设到期、 + * 最长 90 天、可撤销);链接只在建好时显示一次。关着时不给新建,已有的照列、能撤销 */ const ROLES: GrantRole[] = ['use', 'observe', 'manage'] @@ -73,6 +76,169 @@ export function AccessSection() { + + + ) +} + +/** 有效期的几个选项(天),按服务端的上限截 */ +const SHARE_DAYS = [1, 7, 30, 90] + +function Shares() { + const { appId, detail } = useEditor() + const personal = detail.scope === 'personal' + const pages = detail.published?.pages ?? [] + const [list, setList] = useState(null) + const [error, setError] = useState(null) + const [form, setForm] = useState<{ page: string; mode: 'latest' | 'fixed'; days: number; embed: boolean } | null>(null) + const [busy, setBusy] = useState(false) + const [made, setMade] = useState(null) + const load = useCallback(() => { + if (personal) return + api.apps.shares(appId).then((v) => { setList(v); setError(null) }, setError) + }, [appId, personal]) + useEffect(load, [load]) + + if (personal) { + return ( +
+ +
+ ) + } + const create = async () => { + if (!form || busy) return + setBusy(true) + try { + const out = await api.apps.createShare(appId, { + page_id: form.page, mode: form.mode, embed_allowed: form.embed, + expires_at: new Date(Date.now() + form.days * 86_400_000).toISOString(), + }) + setMade(out) + setForm(null) + load() + } catch (e) { + toast.error(e) + } finally { + setBusy(false) + } + } + const revoke = async (row: ViewShareRow) => { + if (!(await confirmDialog({ title: S.revokeTitle(row.prefix), body: S.revokeBody, danger: true, confirmLabel: S.revoke }))) return + try { + await api.apps.revokeShare(appId, row.id) + toast.ok(S.revoked) + load() + } catch (e) { + toast.error(e) + } + } + const maxDays = list?.max_days ?? 90 + const canCreate = !!list?.enabled && pages.length > 0 && detail.published_version != null + const hint = !list ? S.hint : !list.enabled ? (list.items.length ? S.offWithRows : S.off) + : detail.published_version == null ? S.unpublished : S.hint + return ( +
+ + {canCreate && !form && ( + + )} + + {made && setMade(null)} />} + {form && ( +
+ + {(p) => ( + + )} + +
+ {S.mode} + {(['latest', 'fixed'] as const).map((m) => ( + + ))} +
+ + {(p) => ( + + )} + + +
+ + +
+
+ )} + {error ? : !list ? : !list.items.length ? ( +

{S.empty}

+ ) : ( +
+ {list.items.map((row) => ( +
+ {row.prefix}… + {row.page_title} + {S.modeShort[row.mode] ?? row.mode} + + {S.state[row.state] ?? row.state} + + {row.embed_allowed && {S.embedded}} + + {[row.expires_at ? S.until(formatDateTime(row.expires_at)) : null, S.opened(row.open_count), + row.created_by_name || row.created_by ? S.by(row.created_by_name || row.created_by || '') : null] + .filter(Boolean).join(' · ')} + + {row.state === 'active' && ( + + )} +
+ ))} +
+ )} +
+ ) +} + +/** 刚建好的分享链接:只显示这一次(库里只有哈希),附复制按钮;关掉就没有了 */ +function ShareOnce({ made, onDismiss }: { made: ViewShareCreated; onDismiss: () => void }) { + const link = made.url ?? `${window.location.origin}${made.path}` + return ( +
+
{S.created}
+

{S.once}

+
+ {link} + +
+
+ +
) } diff --git a/frontend/src/pages/settings/NotifyTab.tsx b/frontend/src/pages/settings/NotifyTab.tsx index 297c6955..06ea318a 100644 --- a/frontend/src/pages/settings/NotifyTab.tsx +++ b/frontend/src/pages/settings/NotifyTab.tsx @@ -1,37 +1,55 @@ import { useCallback, useEffect, useState } from 'react' -import { Bell, Plus, Send } from 'lucide-react' +import { Bell, Check, Mail, Plus, Send } from 'lucide-react' import { api } from '../../api/client' import { DeleteButton, EmptyState, ErrorState, Field, Modal, SectionBar, Skeleton, Spinner, confirmDialog, toast, } from '../../components/ui' import { formatDateTime } from '../../lib/format' +import { useLeaveGuard } from '../../lib/leave' import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms' -import { NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp' +import { MAIL_TEXT as M, NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp' import { useAtLeast } from '../../store/auth' -import type { NotifyChannelRow, NotifyEventRow } from '../../typesApp' +import type { NotifyChannelRow, NotifyEventRow, NotifyMailState } from '../../typesApp' /* - * 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式只有 Webhook 的四种(Q6),发送核心和定时任务的 - * 通知共用;消息里只放标题、状态和链接,不放报告里的数字。地址和签名密钥加密保存,之后只显示主机名。 + * 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式是 Webhook 的四种加上邮件(04 细案 B2,改写了 Q6): + * Webhook 的发送核心和定时任务的通知共用;邮件走管理员在「邮件服务」一节配的 SMTP。消息里只放标题、状态和链接,不放报告 + * 里的数字。地址和签名密钥加密保存,之后只显示主机名。 * 全局渠道只给管理员建;个人渠道每个人都能建(§3.12)。开了登录的使用者只看、只建自己的个人渠道(scope=user), - * 投递记录只给搭建者以上 + * 投递记录只给搭建者以上。 + * + * 邮件:邮件服务没启用时不列「邮件」这一格式(建了也发不出去);个人邮件渠道不填地址,只发到本人的账号邮箱(列表里写 + * 「发到账号邮箱 …」,没有邮箱照实写发不出去);全局、应用的填固定收件地址。投递成功写「已交给邮件服务器」,不写已送达: + * 退信不追踪。「邮件服务」「每天的待办汇总」两节给搭建者以上看(设置接口搭建者只读),只有管理员能改。 */ -const FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const +const WEBHOOK_FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const const EVENTS = Object.keys(T.eventLabel) /** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */ const eventLabel = (event: string, scope: string): string => (scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event +/** 一个渠道发到哪:Webhook 只有主机名;邮件写收件人(个人渠道是账号邮箱) */ +function targetOf(row: NotifyChannelRow): string { + if (row.format !== 'email') return row.url_host + if (row.scope === 'user') return M.personalTo(row.mail_to?.[0] ?? null) + return M.to(row.mail_to ?? []) +} + export function NotifyTab() { const builder = useAtLeast('builder') const [rows, setRows] = useState(null) + const [mail, setMail] = useState({ enabled: false, account_email: null }) const [error, setError] = useState(null) const [editing, setEditing] = useState(null) const [testing, setTesting] = useState(null) const load = useCallback(() => { api.notify.channels() - .then(({ items }) => { setRows(builder ? items : items.filter((c) => c.scope === 'user')); setError(null) }) + .then(({ items, mail: state }) => { + setRows(builder ? items : items.filter((c) => c.scope === 'user')) + setMail(state ?? { enabled: false, account_email: null }) + setError(null) + }) .catch(setError) }, [builder]) useEffect(load, [load]) @@ -40,7 +58,8 @@ export function NotifyTab() { setTesting(row.id) try { const res = await api.notify.testChannel(row.id) - if (res.ok) toast.ok(T.testOk) + // 邮件渠道成功时服务端说的是「已交给邮件服务器……」:照原话写,不写「已发送」 + if (res.ok) toast.ok(row.format === 'email' ? res.message : T.testOk) else toast.warn(res.message) } catch (e) { toast.error(e) @@ -79,7 +98,8 @@ export function NotifyTab() { ) : (
{rows.map((row) => ( -
+
{row.name} @@ -89,7 +109,10 @@ export function NotifyTab() { {!row.enabled && 已停用}
- {row.url_host} + + {targetOf(row)} + {row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`}
@@ -108,7 +131,9 @@ export function NotifyTab() { )} {builder && } - {editing && setEditing(null)} + {builder && } + {builder && } + {editing && setEditing(null)} onSaved={(name) => { setEditing(null); toast.ok(T.saved(name)); load() }} />}
) @@ -123,11 +148,13 @@ function Deliveries() { {!rows ? : !rows.length ?

{T.deliveriesEmpty}

: (
{rows.map((d) => ( -
+
{T.deliveryEventLabel[d.event] ?? T.eventLabel[d.event] ?? d.event} {d.error ?? (d.http_status ? `HTTP ${d.http_status}` : '')} - - {d.status === 'ok' ? '已送达' : d.status === 'failed' ? '失败' : d.status} + {/* 邮件只知道交给了邮件服务器,进没进收件箱不知道(退信不追踪):不写「已送达」 */} + + {d.status === 'ok' ? (d.format === 'email' ? M.handoff : '已送达') : d.status === 'failed' ? '失败' : d.status}
@@ -138,14 +165,16 @@ function Deliveries() { ) } -function ChannelEditor({ row, onClose, onSaved }: { - row: NotifyChannelRow | null; onClose: () => void; onSaved: (name: string) => void +function ChannelEditor({ row, mail, onClose, onSaved }: { + row: NotifyChannelRow | null; mail: NotifyMailState; onClose: () => void; onSaved: (name: string) => void }) { const admin = useAtLeast('admin') const builder = useAtLeast('builder') const [form, setForm] = useState({ name: row?.name ?? '', scope: row?.scope ?? (admin ? 'global' : 'user'), format: row?.format ?? 'generic', url: '', secret: '', events: row?.events ?? ['task.opened', 'action.unknown'], + // 全局、应用的邮件渠道:收件地址整份重填(服务端照原样给,编辑时带出来) + recipients: row?.format === 'email' && row.scope !== 'user' ? (row.mail_to ?? []).join('\n') : '', }) const [busy, setBusy] = useState(false) // 列哪些事件由服务端说(notify/service.EVENTS 的 scopes):这种范围的渠道收得到的才列,登记了还没推送的不列(勾了收不到)。 @@ -155,18 +184,28 @@ function ChannelEditor({ row, onClose, onSaved }: { useEffect(() => { api.notify.events().then(setRegistry).catch(() => setRegistryFailed(true)) }, []) const offered = EVENTS.filter((ev) => form.events.includes(ev) || row?.events.includes(ev) || registry?.some((r) => r.event === ev && r.scopes.includes(form.scope))) + const email = form.format === 'email' + // 邮件服务没启用:不列「邮件」(建了也发不出去);原来就是邮件渠道的照列,能改名、改订阅、换回 Webhook + const formats = [...WEBHOOK_FORMATS, ...(mail.enabled || row?.format === 'email' ? ['email' as const] : [])] + const personalMail = email && form.scope === 'user' + const recipientsChanged = form.recipients.trim() !== (row?.format === 'email' ? (row.mail_to ?? []).join('\n') : '') + const missingTarget = email ? !personalMail && !form.recipients.trim() + : !row || row.format === 'email' ? !form.url.trim() : false const save = async () => { setBusy(true) try { + const target = email + ? (personalMail ? {} : recipientsChanged || row?.format !== 'email' ? { url: form.recipients.trim() } : {}) + : form.url.trim() ? { url: form.url.trim() } : {} if (row) { await api.notify.updateChannel(row.id, { name: form.name.trim(), scope: form.scope, format: form.format, events: form.events, // 地址、密钥只回主机名:不填就是保持原值 - ...(form.url.trim() ? { url: form.url.trim() } : {}), ...(form.secret ? { secret: form.secret } : {}), + ...target, ...(!email && form.secret ? { secret: form.secret } : {}), }) } else { - await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, url: form.url.trim(), - secret: form.secret || null, events: form.events }) + await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, ...target, + secret: !email && form.secret ? form.secret : null, events: form.events }) } onSaved(form.name.trim()) } catch (e) { @@ -179,7 +218,7 @@ function ChannelEditor({ row, onClose, onSaved }: { - @@ -191,9 +230,9 @@ function ChannelEditor({ row, onClose, onSaved }: { {(p) => ( - )} @@ -207,14 +246,32 @@ function ChannelEditor({ row, onClose, onSaved }: { )}
- - {(p) => setForm({ ...form, url: e.target.value })} />} - - - {(p) => setForm({ ...form, secret: e.target.value })} />} - + {!mail.enabled && !email &&

{M.off}

} + {personalMail ? ( +
+
+ {M.personalTo(mail.account_email)} +
+

{M.personalToHint}

+
+ ) : email ? ( + + {(p) =>