From 1dd4f935795e61dbf7a30b341f0929c9521c27e7 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Wed, 14 Oct 2026 21:55:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=B9=B3=E5=8F=B0=E9=9B=86?= =?UTF-8?q?=E6=88=90=EF=BC=88=E5=89=8D=E7=AB=AF=EF=BC=89=EF=BC=9A=E9=92=A9?= =?UTF-8?q?=E5=AD=90=E6=8C=89=E5=90=8D=E5=AD=97=E7=AE=A1=E7=90=86=E5=B9=B6?= =?UTF-8?q?=E5=8F=AA=E6=98=BE=E7=A4=BA=E4=B8=80=E6=AC=A1=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E3=80=81=E6=8C=89=E6=96=B0=E5=8F=A3=E5=BE=84=E8=A1=A5=E7=AE=97?= =?UTF-8?q?=E3=80=81=E5=BA=94=E7=94=A8=E5=8A=A9=E6=89=8B=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E3=80=81=E8=AE=BE=E7=BD=AE=E9=A1=B5=E6=8C=89?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E6=94=B6=E8=B5=B7=E4=B8=8E=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E8=A1=A5=E7=AE=A1=E7=90=86=E5=91=98=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=90=8D=E3=80=81=E6=8C=89=E9=9C=80=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E4=B8=8A=E9=99=90=E3=80=81=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E5=81=8F=E5=A5=BD=E6=8C=89=E8=A7=92=E8=89=B2=E5=8F=AA=E5=AD=98?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E3=80=81=E4=BD=BF=E7=94=A8=E8=80=85=E9=97=AE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=94=A8=E6=95=B0=E6=8D=AE=E6=BA=90=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E5=B9=B6=E5=9C=A8=E8=BF=90=E8=A1=8C=E6=B5=81=E8=BF=9E?= =?UTF-8?q?=E4=B8=8D=E4=B8=8A=E6=97=B6=E6=94=B9=E8=BD=AE=E8=AF=A2=E3=80=81?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=AD=97=E6=AE=B5=E5=BE=85=E5=AE=A1=E9=98=85?= =?UTF-8?q?=E3=80=81=E8=AE=B0=E5=BD=95=E9=A1=B5=E6=8C=89=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E4=B8=8E=E5=BE=85=E5=8A=9E=20id=E3=80=81?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E7=9A=84=E5=85=A5=E5=8F=A3=20/=20?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=20/=20=E5=BE=85=E5=8A=9E=E5=A4=96=E8=A7=82?= =?UTF-8?q?=20JSON=20=E5=88=86=E5=8C=BA=E3=80=81=E7=94=BB=E5=B8=83=20=3Fpu?= =?UTF-8?q?blish=3D1=E3=80=81=E5=B7=A5=E5=85=B7=E9=A1=B5=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=E4=B8=8E=20MCP=20=E8=AE=A4=E8=AF=81=E5=A4=B4?= =?UTF-8?q?=E6=8E=A9=E7=A0=81=E7=BC=96=E8=BE=91=E3=80=81=E6=B4=BE=E7=BB=99?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E6=96=B0=E5=BE=85=E5=8A=9E=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=80=9A=E7=9F=A5=E3=80=81=E6=A0=B7=E4=BE=8B=E4=B8=8E?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC=E6=94=B9=E8=B5=B0=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E6=8E=A5=E5=8F=A3=EF=BC=88=E5=90=AB=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E5=90=8E=E7=9A=84=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 66 ++++- frontend/src/api/apps.ts | 121 +++++++++- frontend/src/api/capabilities.ts | 101 +++++++- frontend/src/api/client.ts | 75 +++++- frontend/src/apps/components/display.tsx | 2 +- frontend/src/components/CommandPalette.tsx | 17 +- frontend/src/dev/fixtures/challenge.tsx | 3 +- frontend/src/dev/fixtures/dual_check.tsx | 3 +- frontend/src/dev/fixtures/inquiry.tsx | 13 +- frontend/src/dev/preview.tsx | 11 +- frontend/src/dev/taskOf.ts | 43 ++++ frontend/src/lib/terms.ts | 188 ++++++++++++++- frontend/src/pages/ChatPage.tsx | 13 +- frontend/src/pages/SettingsPage.tsx | 89 ++++++- frontend/src/pages/StudioPage.tsx | 16 ++ frontend/src/pages/ToolsPage.tsx | 182 ++++++++++++-- frontend/src/pages/apps/AppPage.tsx | 3 + frontend/src/pages/apps/AppsHome.tsx | 2 +- frontend/src/pages/apps/backfill.tsx | 176 ++++++++++++++ .../src/pages/apps/edit/AccessSections.tsx | 217 +++++++++++++++-- frontend/src/pages/apps/edit/AppAssistant.tsx | 94 +++++++- frontend/src/pages/apps/edit/AppEditor.tsx | 32 ++- frontend/src/pages/apps/edit/FormsSection.tsx | 208 ++++++++++++++++ frontend/src/pages/apps/edit/PagesSection.tsx | 18 +- .../src/pages/apps/edit/ReviewSections.tsx | 6 +- frontend/src/pages/apps/edit/appOps.ts | 124 +++++++++- frontend/src/pages/apps/edit/context.ts | 15 +- frontend/src/pages/apps/shared.tsx | 3 +- frontend/src/pages/catalog/ReviewQueue.tsx | 160 ++++++++----- frontend/src/pages/runs/useRunsData.ts | 45 +++- frontend/src/pages/settings/IdentityTab.tsx | 16 +- frontend/src/pages/settings/NotifyTab.tsx | 19 +- frontend/src/pages/tools/CapabilitiesTab.tsx | 226 ++++++++++++++---- frontend/src/run/RunPanel.tsx | 16 +- frontend/src/run/TaskCard.tsx | 11 +- frontend/src/store/auth.ts | 24 ++ frontend/src/store/catalog.ts | 14 +- frontend/src/types.ts | 17 ++ scripts/check-app-editor.mjs | 206 +++++++++++++++- scripts/check-auth.mjs | 43 +++- scripts/check-capabilities.mjs | 139 ++++++++++- scripts/check-challenge.mjs | 30 ++- scripts/check-inquiry.mjs | 31 ++- scripts/check-runs.mjs | 36 +++ scripts/check-shell.mjs | 39 ++- scripts/check-stream.mjs | 7 +- scripts/check-studio.mjs | 7 + scripts/check-verify.mjs | 32 ++- 48 files changed, 2651 insertions(+), 308 deletions(-) create mode 100644 frontend/src/dev/taskOf.ts create mode 100644 frontend/src/pages/apps/backfill.tsx create mode 100644 frontend/src/pages/apps/edit/FormsSection.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 57ec1858..e1488645 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,7 +33,7 @@ import { CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify, toggleTheme, usePages, useTheme, visiblePages, waitedFor, } from './components/CommandPalette' -import { atLeast, loginOn, loginUrl, needsLogin, principalName, useAuth } from './store/auth' +import { atLeast, authSettled, loginOn, loginUrl, needsLogin, prefsLocalOnly, principalName, useAuth } from './store/auth' import { ErrorBoundary, Kbd, OfflineBanner, Spinner, StatusBadge, isComposing, toast, useTicker } from './components/ui' import { applyTheme, normalizeTheme } from './lib/theme' import { ariaShortcut, isTypingTarget, matchShortcut } from './lib/keys' @@ -46,7 +46,7 @@ import { import type { Attention, FaviconDot } from './lib/notify' import { useLeaveBlocker } from './lib/leave' import { useLocalActor } from './lib/actor' -import { APP_TEXT, AUTH_MODE_LABEL, AUTH_TEXT, ROLE_LABEL, RUN_COMPARE_TEXT, runName } from './lib/terms' +import { APP_TEXT, AUTH_MODE_LABEL, AUTH_TEXT, ROLE_LABEL, RUN_COMPARE_TEXT, TASK_NOTIFY_TEXT, runName } from './lib/terms' /** 启动时认人(/auth/me)最多等多久再取目录 */ const AUTH_WAIT_MS = 2500 @@ -89,14 +89,19 @@ export default function App() { // 主题的真源在后端设置里。启动时没连上就等恢复再读一次,不然这次会话一直用缓存。 // 读到过、或者人在这台设备上换过(导航、⌘K 都派发 agentlab:theme),就不再读, // 路上的那一次读回来也不应用:断线时换的主题不能在重连时被服务端的旧值翻回去 + // 开了登录、又不是管理员(D-s):主题只存本机,不读服务端那份全站共用的(使用者也读不到),先等认出人再决定 const themeSettled = useRef(false) const loadTheme = () => { if (themeSettled.current) return - api.settings.get().then((s) => { + void authSettled().then((me) => { if (themeSettled.current) return - themeSettled.current = true - applyTheme(normalizeTheme(s?.ui?.theme)) - }).catch(() => {}) + if (prefsLocalOnly(me)) { themeSettled.current = true; return } + api.settings.get().then((s) => { + if (themeSettled.current) return + themeSettled.current = true + applyTheme(normalizeTheme(s?.ui?.theme)) + }).catch(() => {}) + }) } useEffect(loadTheme, []) useEffect(() => { @@ -440,7 +445,7 @@ function NotifyToggle() { if (!notifySupported()) return null const Icon = notify ? Bell : BellOff const tip = notify - ? '后台提醒:已开启 · 运行结束、失败或有新的待审批时发送系统通知' + ? '后台提醒:已开启 · 运行结束、失败或有新的待审批、待办时发送系统通知' : permission === 'denied' ? '后台提醒:浏览器已拒绝通知权限,请在站点设置中允许' : '后台提醒:已关闭 · 开启时浏览器将请求通知权限' @@ -1148,6 +1153,51 @@ function watchApprovals(go: Go): () => void { }) } +/** + * 派给自己的新待办:规则同待审批——拿到过一次真列表才记底,之后新来的、标签页在后台时发系统通知。不每拍都取列表: + * 心跳取的「派给我的」待办数(taskCounts.mine)变了才取一次派给我的待处理待办。搭建者以上已经按待审批发过通知的 + * (待办带着那条审批)不再发第二条;核实、等外部系统这类没有审批的待办只在这里通知 + */ +function watchTasks(go: Go): () => void { + let seen: Set | null = null + let inflight = false + let again = false + const pull = async () => { + if (inflight) { again = true; return } + inflight = true + try { + const { items } = await api.tasks.list({ assigned: 'me', status: 'pending', limit: 100 }) + const pending = items.filter((t) => t.status === 'pending') + if (!seen) { seen = new Set(pending.map((t) => t.id)); return } + const fresh = pending.filter((t) => !seen!.has(t.id)) + fresh.forEach((t) => seen!.add(t.id)) + const builder = atLeast(useAuth.getState().me, 'builder') + const approvals = useCatalog.getState().approvals + for (const t of fresh) { + if (builder && t.approval_id && approvals.some((a) => a.id === t.approval_id)) continue + const owner = t.app_name ?? (t.workflow_name || t.workflow_id ? runName(t) : null) + notifyInBackground({ + title: `${TASK_NOTIFY_TEXT.title}${t.title || TASK_NOTIFY_TEXT.untitled}`, + body: [owner, t.node_label].filter(Boolean).join(' · ') || undefined, + tag: `task:${t.id}`, + onClick: () => go(t.app_id ? `/apps/inbox/${encodeURIComponent(t.id)}` : `/runs/${t.run_id}`), + }) + } + } catch { + // 下次待办数变了再取;没记底之前不发通知 + } finally { + inflight = false + if (again) { again = false; void pull() } + } + } + return useCatalog.subscribe((s, p) => { + if (s.taskCounts === p.taskCounts || !s.taskCounts) return + if (seen && s.taskCounts.mine === p.taskCounts?.mine) return + if (needsLogin(useAuth.getState().me)) return + void pull() + }) +} + function ShellSignals() { const { pathname, search } = useLocation() const navigate = useNavigate() @@ -1185,7 +1235,7 @@ function ShellSignals() { navRef.current = navigate useEffect(() => { const go: Go = (to) => { if (to) navRef.current(to) } - const stops = [watchStudio(go), watchChat(go), watchApprovals(go)] + const stops = [watchStudio(go), watchChat(go), watchApprovals(go), watchTasks(go)] return () => stops.forEach((stop) => stop()) }, []) return null diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index 39e7aeca..24f7bdc3 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -5,25 +5,94 @@ * 入口提交校验失败回 422,ApiError 的 body 里带 {errors: FormError[]}。 */ import type { - AppBindings, AppDetail, AppGrant, AppSpec, AppSummary, AppVersionRow, ComponentCatalog, Disposition, InstanceRow, - LintIssue, PublishResult, ViewModel, + AppBindings, AppDetail, AppGrant, AppHookRow, AppSpec, AppSummary, AppVersionRow, ComponentCatalog, Disposition, + InstanceRow, LintIssue, PublishResult, ViewModel, } from '../typesApp' -import type { EvidenceSegmentDetail } from '../types' -import { get, patch, post, put, qs, type RequestOptions } from './http' +import type { ConversationDetail, EvidenceSegmentDetail } from '../types' +import { del, get, patch, post, put, qs, type RequestOptions } from './http' const at = (id: string) => `/apps/${encodeURIComponent(id)}` +const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURIComponent(name)}` + +/** + * 钩子管理里的一行(D-g):草稿里声明的 ∪ 已经生成过密钥的,按名字合成一行。 + * - declared:草稿的 spec.hooks 里有这个名字;published:当前发布版里有(入站请求按发布版认种类和入口,没有就拒收); + * - has_secret:已经生成过密钥(app_hooks 里有这一行)。密钥本身只在生成、轮换时交回一次(AppHookRow.secret)。 + * 服务端几种写法都认(hookStateOf),缺的项按「没有」算 + */ +export interface AppHookState { + name: string + kind: 'start' | 'signal' | null + entry_id: string | null + declared: boolean + published: boolean + has_secret: boolean + enabled: boolean + /** app_hooks 那一行的 id:入站地址是 /api/hooks/{id}。还没生成密钥时为 null */ + id: string | null + /** 收请求的地址(服务端给的路径);没给时按 id 拼 */ + url_path: string | null + created_at: string | null +} + +function hookStateOf(raw: Record): AppHookState { + const id = typeof raw.id === 'string' && raw.id ? raw.id : null + const flag = (...keys: string[]) => keys.some((k) => raw[k] === true) + return { + name: String(raw.name ?? ''), + kind: raw.kind === 'start' || raw.kind === 'signal' ? raw.kind : null, + entry_id: raw.entry_id ?? raw.entry ?? null, + declared: flag('declared', 'in_draft'), + published: flag('published', 'in_published'), + has_secret: raw.has_secret != null || raw.secret_set != null ? flag('has_secret', 'secret_set') : !!id, + enabled: raw.enabled !== false, + id, + url_path: typeof raw.url_path === 'string' && raw.url_path ? raw.url_path : id ? `/api/hooks/${encodeURIComponent(id)}` : null, + created_at: raw.created_at ?? null, + } +} + +/** 按新口径补算的结果(D-f):发起了几次、跳过了几次(同一输入已经补算过) */ +export interface BackfillResult { + started: number + skipped: number + run_ids?: string[] + /** 发起到一半被拒(比如版本刚被退役):已经发起的照算,原因照实交回 */ + refused?: string | null +} + +/** 补算会发起几次(服务端 apps/backfill.Plan.out,不发起运行):这个视图、哪个固定版本从几升到几、趋势画几期 */ +export interface BackfillPlan { + view: string + alias: string + source: string + from_version: number + to_version: number + periods: number + will_launch: number + will_skip: number +} + +/** + * 应用详情。manage 才有草稿和岗位绑定(D-m);另带能按新口径补算的视图(backfill,只在取详情时算)。use / observe + * 只有概况和当前发布版的页面、入口目录(published) + */ +export type AppDetailOut = AppDetail & { + backfill?: BackfillPlan[] + published?: { version: number; pages: { id: string; title: string }[]; entries: { id: string; label: string }[] } | null +} export const apps = { catalog: (opts?: RequestOptions) => get('/apps/catalog', opts), list: (opts?: RequestOptions) => get<{ items: AppSummary[] }>('/apps', opts), - get: (id: string, opts?: RequestOptions) => get(at(id), opts), + get: (id: string, opts?: RequestOptions) => get(at(id), opts), create: (body: { name: string; description?: string; spec?: AppSpec }) => post('/apps', body), /** 从工作流版本确定性派生一份应用草稿(不落库),编辑器拿去预览 / 新建 */ derive: (body: { workflow_id: string; version: number; name?: string }) => post<{ spec: AppSpec }>('/apps/derive', body), /** 改草稿:spec、岗位绑定、名称等,只带改了的字段 */ update: (id: string, body: { name?: string; description?: string; icon?: string; draft?: AppSpec; - draft_bindings?: AppBindings }) => patch(at(id), body), + draft_bindings?: AppBindings }) => patch(at(id), body), archive: (id: string) => post(`${at(id)}/archive`), lint: (id: string) => post<{ issues: LintIssue[]; level: string | null }>(`${at(id)}/lint`), publish: (id: string, body: { note?: string; dispositions?: Record }) => @@ -56,8 +125,48 @@ export const apps = { /** 按需视图:发起一次运行(或命中缓存) */ refreshView: (id: string, view: string, params?: Record) => post<{ run_id: string; cached: boolean }>(`${at(id)}/views/${encodeURIComponent(view)}/refresh`, { params: params ?? {} }), + /** + * 按新口径补算(D-f,manage):这个视图的固定版本相对上一个应用版本升了版、处置为按新口径重算时,取旧版本在同一来源上 + * 最近几次封存的正式运行的输入,用新版本逐个发起正式运行(来源记为按新口径补算),同一输入补算过的跳过 + */ + backfill: async (id: string, view: string): Promise => { + const raw = await post>(`${at(id)}/views/${encodeURIComponent(view)}/backfill`) + const n = (...keys: string[]) => Number(keys.map((k) => raw?.[k]).find((v) => typeof v === 'number') ?? 0) + return { started: n('launched', 'started'), skipped: n('skipped'), run_ids: Array.isArray(raw?.run_ids) ? raw.run_ids : undefined, + refused: typeof raw?.refused === 'string' ? raw.refused : null } + }, + /** 补算会发起几次、跳过几次(不发起运行):确认框按它写明次数 */ + backfillPlan: (id: string, view: string) => get(`${at(id)}/views/${encodeURIComponent(view)}/backfill`), + /** 钩子(D-g):草稿声明 ∪ 已有的行,按名字 */ + hooks: async (id: string): Promise => { + const raw = await get<{ items: Record[] } | Record[]>(`${at(id)}/hooks`) + return (Array.isArray(raw) ? raw : raw?.items ?? []).map(hookStateOf).filter((h) => h.name) + }, + /** 生成或轮换签名密钥:secret 只在这一次交回;url_path 是收请求的地址 */ + rotateHook: (id: string, name: string) => post(`${hookAt(id, name)}/rotate`), + setHookEnabled: (id: string, name: string, enabled: boolean) => patch(hookAt(id, name), { enabled }), + /** 删掉这个名字的密钥行:外部系统再用旧密钥签的请求一律拒收 */ + deleteHook: (id: string, name: string) => del(hookAt(id, name)), + /** + * 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次; + * 老服务端只回对话本身时由调用方再按 id 取一次 + */ + assistantConversation: (id: string): Promise => { + // 同一个应用正在取的合并成一次:开发模式下 effect 执行两次,两个请求同时到服务端各建一段对话,历史就拆开了 + const pending = assistantInflight.get(id) + if (pending) return pending + const request = get(`${at(id)}/assistant-conversation`) + .finally(() => assistantInflight.delete(id)) + assistantInflight.set(id, request) + return request + }, } +type AssistantConversation = + ConversationDetail | (Omit & { turns?: ConversationDetail['turns'] }) +/** 正在取的助手对话,按应用 id(见 apps.assistantConversation) */ +const assistantInflight = new Map>() + export const ledger = { /** 一个工作流版本被哪些应用钉住、生效记录数、正式运行数 */ version: (workflowId: string, version: number) => diff --git a/frontend/src/api/capabilities.ts b/frontend/src/api/capabilities.ts index b0a9e676..810afd39 100644 --- a/frontend/src/api/capabilities.ts +++ b/frontend/src/api/capabilities.ts @@ -2,9 +2,84 @@ * 能力登记、端点、通知渠道的接口(/api/capabilities、/api/endpoints、/api/notify),挂在 api.capabilities / * api.endpoints / api.notify 上。形状见 typesApp.ts。端点和渠道的密钥只回掩码;提交掩码值表示保持原值。 */ -import type { CapabilityRow, EndpointRow, NotifyChannelRow } from '../typesApp' +import type { CatalogReviewAction, CatalogSource, CatalogStatus } from '../types' +import type { CapabilityEffect, CapabilityRow, EndpointRow, NotifyChannelRow } from '../typesApp' import { del, get, patch, post, put, qs, type RequestOptions } from './http' +/** + * 能力字段语义的审阅(D-i):沿用数据目录「项」的状态(推断 / 已核实 / 已确认 / 已驳回,带理由),按被多少运行用过、 + * 是不是写入能力、来源排好序(服务端 data/capability_review.py,同 data/catalog_review.py 的思路)。一项是某个能力 + * 返回里某个位置(JSON 指针)的某个字段(中文名、含义、单位、度量类型、码值) + */ +export interface CapabilityReviewItem { + /** 能力名(同图里工具的名字) */ + name: string + title: string | null + /** 审阅队列里这一项的键:字段@位置(同一个能力里不重复)。成批审阅时拆回位置和字段 */ + path: string + /** 返回里的位置(JSON 指针) */ + pointer: string + field: string + /** 给人看的位置(服务端写好的「返回字段 /amount 的单位」) */ + where: string + value: unknown + display: string + source: CatalogSource + status: CatalogStatus + note: string | null + priority: number + reasons: string[] + /** 用到这个能力的运行次数 */ + usage: number + effect: CapabilityEffect | null + /** 能力的治理版本:成批审阅时逐项带上(乐观锁) */ + version: number +} + +export interface CapabilityReviewResult { + name: string + path: string + ok: boolean + version?: number | null + error: string | null + code?: string | null +} + +const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null) +const num = (...vs: unknown[]): number => Number(vs.find((v) => typeof v === 'number') ?? 0) + +/** 队列一项的键:字段@位置。字段只有几个固定的英文名,不含 @,拆的时候按第一个 @ 拆 */ +export const reviewPathOf = (field: string, pointer: string) => `${field}@${pointer}` +export function splitReviewPath(path: string): { field: string; pointer: string } { + const at = path.indexOf('@') + return at < 0 ? { field: '', pointer: path } : { field: path.slice(0, at), pointer: path.slice(at + 1) } +} + +function reviewItemOf(raw: Record): CapabilityReviewItem { + const pointer = String(raw.pointer ?? '') + const field = String(raw.field ?? '') + const short = String(raw.field_text ?? raw.field ?? '') + return { + name: String(raw.capability ?? raw.name ?? ''), + title: str(raw.title), + path: reviewPathOf(field, pointer), + pointer, + field, + // 服务端写好的位置说法;老写法只给了字段名时自己拼 + where: str(raw.field_label) ?? (pointer ? `${pointer} 的${short}` : short), + value: raw.value, + display: typeof raw.display === 'string' ? raw.display : raw.value == null ? '' : String(raw.value), + source: raw.source, + status: raw.status ?? 'proposed', + note: str(raw.note), + priority: num(raw.priority), + reasons: Array.isArray(raw.reasons) ? raw.reasons.map(String) : [], + usage: num(raw.runs, raw.usage, raw.run_count), + effect: raw.effect ?? null, + version: num(raw.version), + } +} + export const capabilities = { list: (params?: { system?: string; effect?: string; q?: string }, opts?: RequestOptions) => get<{ items: CapabilityRow[] }>(`/capabilities${qs(params)}`, opts), @@ -14,6 +89,30 @@ export const capabilities = { govern: (name: string, body: Partial & { version: number }) => put(`/capabilities/${encodeURIComponent(name)}/governance`, body), sync: () => post<{ changed: number }>('/capabilities/sync'), + /** 待审阅的能力字段(D-i):顺序服务端排好;capability 只看这一个能力 */ + reviewQueue: async (params: { limit?: number; offset?: number; capability?: string | null }, opts?: RequestOptions) => { + const raw = await get<{ total?: number; items?: Record[] }>(`/capabilities/review-queue${qs({ + limit: params.limit, offset: params.offset, capability: params.capability || undefined, + })}`, opts) + const items = (raw?.items ?? []).map(reviewItemOf).filter((i) => i.name && i.field) + return { total: typeof raw?.total === 'number' ? raw.total : items.length, items } + }, + /** + * 成批审阅(只给管理员):逐个能力按治理版本做乐观锁,逐条交回结果(一个能力冲突只记在那几条上,code 为 + * capability_conflict);写审计。path 是队列一项的键(字段@位置),这里拆回位置和字段 + */ + reviewBatch: async (items: { name: string; path: string; action: CatalogReviewAction; if_version: number }[]) => { + const raw = await post<{ results?: Record[] }>('/capabilities/review/batch', { + items: items.map((i) => ({ capability: i.name, ...splitReviewPath(i.path), action: i.action, if_version: i.if_version })), + }) + return { + results: (raw?.results ?? []).map((r): CapabilityReviewResult => ({ + name: String(r.capability ?? r.name ?? ''), path: reviewPathOf(String(r.field ?? ''), String(r.pointer ?? '')), + ok: r.ok === true, version: typeof r.version === 'number' ? r.version : null, + error: str(r.error) ?? str(r.message), code: str(r.code), + })), + } + }, } export const endpoints = { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1db565fd..14558002 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,7 +8,7 @@ import type { RevokeAcceptanceBody, RevokeAcceptanceOut, SnapshotOut, Staging, } from '../types' import type { - Approval, AutofixResult, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph, + Approval, AutofixResult, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, DataSourceName, EvidenceGraph, EvidenceAudit, EvidenceJudgeResult, EvidenceProvenance, EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run, RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, UpgradeResult, UploadDecision, UploadMixedColumn, UploadResult, UploadShapeReason, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion, @@ -261,6 +261,14 @@ export const api = { // ---- 运行 ---- datasources: { list: (opts?: RequestOptions) => get('/datasources', opts), + /** + * 只有名字和说明(D-c):开了登录、管理员允许使用者问数据时,使用者的问数据页按它列数据源(完整列表只给搭建者)。 + * 形状按 {items} 或数组两种都认 + */ + names: async (opts?: RequestOptions): Promise => { + const raw = await get('/datasources/names', opts) + return Array.isArray(raw) ? raw : raw?.items ?? [] + }, kinds: () => get('/datasources/kinds'), create: (body: any) => post('/datasources', body), update: (id: string, body: any) => patch(`/datasources/${id}`, body), @@ -1025,7 +1033,16 @@ export function streamCatalogAssistant( * 审批恢复时会重新 attachRun,不传 after 就是 after=0,后端按 `seq > after` * 把整条历史再推一遍,而消费方多半是无条件追加——事件就这么重复累积了。 * 已经有事件在手的场景(重新接上一条运行)应当传当前最大 seq。 + * + * 连不上 WebSocket 时退回轮询(应用平台 D-c):握手连续 WS_GIVE_UP 次都没打开(服务端按运行判权限,拒绝时握手 + * 直接断开;代理不转发 WebSocket 也是这样),改为每 POLL_MS 取一次 /runs/{id}/events?after= 和运行状态,运行不在 + * 进行中时照 stream.end 的样子收尾。第一批是补的历史,同 WebSocket 的回放一样标 replay */ +const WS_GIVE_UP = 2 +const POLL_MS = 1500 +/** 不在进行中的状态:同服务端 api/runs._SETTLED,另加服务重启挂起的 suspended(轮询时没有后续事件会来) */ +const SETTLED_STATUS = new Set(['succeeded', 'failed', 'cancelled', 'interrupted', 'suspended']) + export function streamRun( runId: string, onEvent: (event: RunEvent) => void, @@ -1038,19 +1055,62 @@ export function streamRun( let closed = false let lastSeq = after let retry = 0 + /** 握手连续失败(一次都没打开过就断了)几次 */ + let refused = 0 + let pollTimer: ReturnType | undefined + + const finish = (status?: string) => { + if (closed) return + closed = true + onClose?.({ status }) + } + + const poll = async (first: boolean) => { + if (closed) return + let delay = POLL_MS + try { + const events = await api.runs.events(runId, lastSeq) + if (closed) return + for (const event of events) { + if (event.seq) lastSeq = Math.max(lastSeq, event.seq) + onEvent(first ? { ...event, replay: true } : event) + } + // 这一批没有新事件时才看状态:运行刚结束时最后几条事件和状态可能前后脚落库,先把事件收完 + if (!events.length) { + const run = await api.runs.get(runId) + if (closed) return + if (SETTLED_STATUS.has(String(run.status))) { + // 状态落了、最后几条事件可能刚落库:再补一次再收尾 + const rest = await api.runs.events(runId, lastSeq).catch(() => [] as RunEvent[]) + for (const event of rest) { + if (event.seq) lastSeq = Math.max(lastSeq, event.seq) + onEvent(event) + } + finish(run.status) + return + } + } + retry = 0 + } catch { + // 断线、服务重启:退避重试,最多等 10 秒 + retry += 1 + delay = Math.min(POLL_MS * 2 ** retry, 10_000) + } + if (!closed) pollTimer = setTimeout(() => void poll(false), delay) + } const connect = () => { if (closed) return const proto = location.protocol === 'https:' ? 'wss' : 'ws' + let opened = false socket = new WebSocket(`${proto}://${location.host}/api/runs/${runId}/stream?after=${lastSeq}`) socket.onmessage = (ev) => { const event = parseBody(ev.data) as RunEvent & { type: string; status?: string } if (event.type === 'stream.end') { - closed = true socket?.close() // 新后端放在 data.status,老后端放在顶层 status - onClose?.({ status: event.data?.status ?? event.status }) + finish(event.data?.status ?? event.status) return } if (event.seq) lastSeq = Math.max(lastSeq, event.seq) @@ -1058,11 +1118,19 @@ export function streamRun( } socket.onclose = () => { if (closed) return + if (!opened) refused += 1 + if (refused >= WS_GIVE_UP) { + socket = null + void poll(true) + return + } // 指数退避重连,最多等 5 秒 retry += 1 setTimeout(connect, Math.min(300 * 2 ** retry, 5000)) } socket.onopen = () => { + opened = true + refused = 0 retry = 0 report(true) } @@ -1071,6 +1139,7 @@ export function streamRun( return () => { closed = true + clearTimeout(pollTimer) socket?.close() } } diff --git a/frontend/src/apps/components/display.tsx b/frontend/src/apps/components/display.tsx index 817ff6e7..16dfcf2e 100644 --- a/frontend/src/apps/components/display.tsx +++ b/frontend/src/apps/components/display.tsx @@ -68,7 +68,7 @@ export function StampBlock({ node }: NodeProps) { const src = sourceOf(model, node) const stamp = node.data?.stamp ?? src?.stamp const state = blockState(node) - const onDemand = (src as { kind?: string } | null)?.kind === 'on_demand' + const onDemand = src?.kind === 'on_demand' const refresh = async () => { setBusy(true) try { diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index bd2c23e1..b9114dbd 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -10,7 +10,7 @@ import { } from 'lucide-react' import { ApiError, api } from '../api/client' import { hasPendingApproval, useCatalog } from '../store/catalog' -import { atLeast, loginOn, useAuth } from '../store/auth' +import { atLeast, loginOn, prefsLocalOnly, useAuth } from '../store/auth' import { useConversations } from '../store/conversations' import { createWorkflow } from '../canvas/WorkflowPicker' import { Kbd, Modal, StatusBadge, isComposing, toast } from './ui' @@ -125,6 +125,9 @@ let unsavedTheme: ThemePref | null = null /** * 换主题并存进设置。主题的真源是后端 settings 的 ui.theme(App 启动时按它 * 应用),只写 localStorage 的话下次打开又被改回去。 + * + * 开了登录、又不是管理员(D-s):服务端的 ui 一组全站共用,改它要管理员,这里只存本机(applyTheme 已经写了 + * localStorage),不发请求、不提示失败 */ export async function setThemePref(pref: ThemePref): Promise { applyTheme(pref) @@ -132,6 +135,10 @@ export async function setThemePref(pref: ThemePref): Promise { // 设置页的下拉若开着,听这个事件同步,免得它手上的旧值被再存回去;App 听它 // 记下「人在这台设备上换过」,重连时就不再拿服务端的旧值翻回去 window.dispatchEvent(new CustomEvent('agentlab:theme', { detail: pref })) + if (prefsLocalOnly(useAuth.getState().me)) { + unsavedTheme = null + return + } try { await saveTheme(pref) } catch (e) { @@ -163,6 +170,10 @@ async function saveTheme(pref: ThemePref): Promise { */ export function flushThemePref(): boolean { if (!unsavedTheme) return false + if (prefsLocalOnly(useAuth.getState().me)) { + unsavedTheme = null + return true + } void saveTheme(unsavedTheme).catch(() => {}) return true } @@ -177,7 +188,7 @@ export async function toggleNotify(): Promise { return } const permission = await enableNotify() - if (permission === 'granted') toast.ok('后台提醒已开启:标签页在后台时,运行结束、失败或有新的待审批将发送系统通知') + if (permission === 'granted') toast.ok('后台提醒已开启:标签页在后台时,运行结束、失败或有新的待审批、待办时发送系统通知') else if (permission === 'denied') { toast.warn('浏览器已拒绝通知权限', { detail: '请在地址栏左侧的站点设置中将「通知」改为允许,然后重新打开此开关。', @@ -422,7 +433,7 @@ function PaletteView() { if (notifySupported()) { list.push({ id: 'act:notify', group: 'actions', label: notify ? '关闭后台提醒' : '开启后台提醒', - hint: '标签页在后台时,运行结束、失败或有新的待审批将发送系统通知', + hint: '标签页在后台时,运行结束、失败或有新的待审批、待办时发送系统通知', keywords: 'notification 通知 提醒 后台', icon: notify ? : , run: () => void toggleNotify(), }) diff --git a/frontend/src/dev/fixtures/challenge.tsx b/frontend/src/dev/fixtures/challenge.tsx index b2947d90..b2c7528b 100644 --- a/frontend/src/dev/fixtures/challenge.tsx +++ b/frontend/src/dev/fixtures/challenge.tsx @@ -3,6 +3,7 @@ import { ReactFlow, ReactFlowProvider } from '@xyflow/react' import { Inspector } from '../../canvas/Inspector' import { NodeCard } from '../../canvas/NodeCard' import { ApprovalCard } from '../../run/RunPanel' +import { withTask } from '../taskOf' import { ChallengeSummary } from '../../run/ChallengeSummary' import { useCatalog } from '../../store/catalog' import { useStudio } from '../../store/studio' @@ -66,7 +67,7 @@ function Card({ approval, name, dense = false }: { approval: Approval; name: str return (
- undefined} /> + undefined} />
) diff --git a/frontend/src/dev/fixtures/dual_check.tsx b/frontend/src/dev/fixtures/dual_check.tsx index 17088d86..33383a10 100644 --- a/frontend/src/dev/fixtures/dual_check.tsx +++ b/frontend/src/dev/fixtures/dual_check.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import type { JSX } from 'react' import { ReactFlow, ReactFlowProvider } from '@xyflow/react' import { ApprovalCard } from '../../run/RunPanel' +import { withTask } from '../taskOf' import { Inspector } from '../../canvas/Inspector' import { NodeCard } from '../../canvas/NodeCard' import { EvidencePane } from '../../pages/runs/EvidencePane' @@ -73,7 +74,7 @@ function approvalOf(search: string): Approval { function Card({ value }: { value: Approval }) { return (
- { (window as any).__dualResolved = id }} /> + { (window as any).__dualResolved = id }} />
) } diff --git a/frontend/src/dev/fixtures/inquiry.tsx b/frontend/src/dev/fixtures/inquiry.tsx index 1b2979f3..850a243d 100644 --- a/frontend/src/dev/fixtures/inquiry.tsx +++ b/frontend/src/dev/fixtures/inquiry.tsx @@ -4,6 +4,7 @@ import { parseServerTime } from '../../lib/format' import { ArtifactsPane, useRunArtifacts } from '../../pages/runs/ArtifactsPane' import { EvidencePane } from '../../pages/runs/EvidencePane' import { ApprovalCard } from '../../run/RunPanel' +import { withTask } from '../taskOf' import { emptyTrace } from '../../run/trace' import type { Approval, ApprovalReport, Run } from '../../types' import fx from './inquiry.json' @@ -54,7 +55,7 @@ function Card({ approval, name, dense = false }: { approval: Approval; name: str return (
- undefined} /> + undefined} />
) @@ -68,7 +69,7 @@ function TwoPlaces({ approval }: { approval: Approval }) { {pop && createPortal(
- +
, document.body, )} @@ -78,7 +79,7 @@ function TwoPlaces({ approval }: { approval: Approval }) { ) @@ -97,8 +98,8 @@ function Steps({ name, keyed }: { name: string; keyed: boolean }) {
{keyed - ? undefined} /> - : undefined} />} + ? undefined} /> + : undefined} />}
) @@ -133,7 +134,7 @@ function Waiting({ approval, run }: { approval: Approval; run: Run }) {
- undefined} showWorkflow={false} /> + undefined} showWorkflow={false} />
diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx index ad260fce..c4d7ac4c 100644 --- a/frontend/src/dev/preview.tsx +++ b/frontend/src/dev/preview.tsx @@ -6,6 +6,7 @@ import { Database, MessageSquare, RotateCw } from 'lucide-react' import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream' import { Markdown } from '../run/Markdown' import { ApprovalCard } from '../run/RunPanel' +import { withTask } from './taskOf' import { decodeCopilot, decodeRun } from '../run/decode' import { catalogPatchesOf } from '../run/catalogPatch' import { ToastHost } from '../components/ui' @@ -341,11 +342,13 @@ function synthetic(name: string): StreamTurn[] { return [{ id: 'cancelled', question: '把大表全量拉一遍', phase: 'done', statusCode: 'cancelled', steps: decodeRun(cancelledRun()), runId: 'syn-cancel' }] case 'chat': { - // 问数据的一轮:先建图、再执行。执行开始后「规划」收成一行 + // 问数据的一轮:先建图(按需查看了两张表:look 事件)、再执行。执行开始后「规划」收成一行 const plan = decodeCopilot([ + { op: 'heartbeat', phase: 'looking', elapsed_ms: 1800 }, + { op: 'look', id: 'L1', by: 'model', need: '设备 KPI 和设备维度', status: 'ok', elapsed_ms: 2100, objects: [ + { kind: 'table', source: 'warehouse', source_id: 'src-warehouse', named: [], picked: ['v_device_kpi', 'dim_device'], + joined: [], missing: [], total: 53 }] }, { op: 'heartbeat', phase: 'planning', elapsed_ms: 6400 }, - { op: 'context', elapsed_ms: 2100, sources: [ - { source: 'warehouse', tables: ['v_device_kpi', 'dim_device'], selected_by: 'model', total: 53 }] }, { op: 'thinking', delta: '要先查表结构,再按设备汇总 KPI。' }, { op: 'plan', summary: '取数 → 汇总 → 出结论' }, { op: 'add_node', node: { id: 'q', type: 'tool', label: '查询设备 KPI' } }, @@ -570,7 +573,7 @@ function Preview() { return (
- undefined} /> + undefined} />
) diff --git a/frontend/src/dev/taskOf.ts b/frontend/src/dev/taskOf.ts new file mode 100644 index 00000000..55c7be2e --- /dev/null +++ b/frontend/src/dev/taskOf.ts @@ -0,0 +1,43 @@ +/** + * 预览里合成的审批带上它对应的待办(应用平台 X4:一次中断对应一条 Approval 和一条 Task,审批卡内容在 run/TaskCard, + * 回复走 /api/tasks/{id}/respond)。审批卡(RunPanel 的 ApprovalCard)看到审批上带着 task 就直接用,不再按运行去取待办 + * ——预览页没有服务端,按运行取只会落到「未能读取这条审批对应的待办」。 + * + * 种类同服务端 process/tasks.kind_of:人工节点按 mode,工具审批、代码审批、双盲复算、动作确认按中断内容的 kind。 + * 检查脚本按 task-<审批 id> 伪造 /api/tasks/{id}/respond + */ +import type { Approval } from '../types' +import type { Task, TaskKind } from '../typesApp' + +const KIND_OF: Record = { + tool_approval: 'tool', code_approval: 'code', dual_check: 'dual_check', action_confirm: 'action_confirm', +} + +export const taskIdOf = (approvalId: string) => `task-${approvalId}` + +export function taskOf(a: Approval, patch: Partial = {}): Task { + const p: Record = a.payload ?? {} + const mode = String(p.mode ?? a.mode ?? 'approve') + const kind: TaskKind = p.kind === 'human_node' + ? (mode === 'input' || mode === 'edit' ? mode : 'approve') + : KIND_OF[String(p.kind)] ?? 'approve' + const pending = a.status === 'pending' + return { + id: taskIdOf(a.id), approval_id: a.id, run_id: a.run_id, node_id: a.node_id, node_label: a.node_label ?? null, + run_class: a.run_class ?? null, workflow_id: a.workflow_id ?? null, workflow_name: a.workflow_name ?? null, + app_id: null, app_version: null, kind, title: a.title, form: null, policy: { kind: 'any' }, + status: pending ? 'pending' : 'resolved', due_at: null, escalation_level: 0, payload: p, + assignments: [{ + id: `as-${a.id}`, principal_kind: 'anyone', principal: '', source: 'anyone', status: pending ? 'pending' : 'responded', + responded_by: a.resolved_by ?? null, responded_at: a.resolved_at ?? null, verified: false, + }], + can_respond: pending, outcome: pending ? null : (a.response ?? {}), resolved_by: a.resolved_by ?? null, + resolved_at: a.resolved_at ?? null, created_at: a.created_at ?? new Date().toISOString(), ...patch, + } +} + +/** 审批带上待办和待办 id(同服务端审批列表上的 task_id) */ +export function withTask(a: A): A & { task: Task; task_id: string } { + const task = taskOf(a) + return { ...a, task_id: task.id, task } +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index e9ce3106..9a6b1546 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3122,6 +3122,8 @@ export const CATALOG_ASSISTANT_TEXT = { export const CATALOG_REVIEW_TEXT = { viewLabel: '数据目录的视图', + /** 只能查看、不能成批审阅时(ReviewQueue 的 readOnly)列表头上的说明 */ + readOnlyNote: '你只能查看待审阅的推断项,确认、驳回需要相应的权限', view: { list: '表清单', review: '审阅队列' } as Record<'list' | 'review', string>, viewHint: { list: '逐表查看和编辑数据目录', @@ -5077,10 +5079,12 @@ export const IDENTITY_TEXT = { oidcClientSecret: '客户端密钥', oidcGroupsClaim: '组声明', oidcAdminGroups: '映射为管理员的组', + oidcAdminUsers: '映射为管理员的用户', + oidcListHint: '每行一个', oidcBuilderGroups: '映射为搭建者的组', - oidcAdminRequired: '切换到单点登录时,至少要配置一个映射为管理员的组', + oidcAdminRequired: '切换到单点登录时,至少要配置一个映射为管理员的用户或组', userCanChat: '使用者可以问数据', - userCanChatHint: '问数据可以查询任何已接入的数据源,目前没有按数据源的权限控制;查询结果中设了遮罩的列对使用者显示为已遮罩。使用者之间的问数据对话记录暂未隔离,彼此可以看到。默认关闭', + userCanChatHint: '问数据可以查询任何已接入的数据源,目前没有按数据源的权限控制;查询结果中设了遮罩的列对使用者显示为已遮罩。使用者只能发起探索运行,只用数据源和只读的内置工具;对话按人隔离,各自只看得到自己的。默认关闭', saved: '已保存身份设置', users: '用户', groups: '组', @@ -5266,7 +5270,7 @@ export const MAKE_APP_TEXT = { button: '做成应用', hint: '从这次回答提取工作流(只保留实际执行的步骤),在画布上发布后做成应用', busy: '正在提取工作流…', - done: '已提取工作流。发布后,在发布成功的提示中点「做成应用」', + done: '已提取工作流,请在打开的发布窗口中发布;发布成功后点「做成应用」', noRun: '这一轮没有运行记录,不能做成应用', } @@ -5282,8 +5286,10 @@ export const APP_EDITOR_TEXT = { title: '应用编辑', newTitle: '新建应用', loadFailed: '无法加载应用', + noManage: '需要这个应用的管理权限', + noManageBody: '编辑应用需要管理权限,可以请应用的创建者或管理员授予。你仍然可以打开这个应用使用它。', sections: { - pins: '固定版本', pages: '页面', preview: '预览', check: '检查', publish: '发布', access: '岗位与授权', + pins: '固定版本', pages: '页面', forms: '入口与表单', preview: '预览', check: '检查', publish: '发布', access: '岗位与授权', hooks: '钩子', notify: '通知', } as Record, save: '保存草稿', @@ -5375,6 +5381,27 @@ export const APP_EDITOR_TEXT = { viewFrom: '取自', viewTtl: '缓存时效(秒)', viewAuto: '打开页面时自动发起(仅在没有缓存时)', + // ---- 按新口径补算(D-f) + backfill: '按新口径补算历史', + backfillHint: (alias: string, from: number, to: number) => + `「${alias}」从 v${from} 升到 v${to},处置为按新口径重算:可以取旧版本最近几次封存运行的输入,用新版本逐个重新发起正式运行`, + backfillTitle: (view: string) => `按新口径补算视图「${view}」的历史?`, + backfillConsequences: (n: number, alias: string, from: number, to: number) => [ + `最多发起 ${n} 次正式运行:按「${alias}」v${from} 最近 ${n} 次封存运行的输入,逐个用 v${to} 发起`, + '同一输入已经补算过的跳过', + '会产生运行费用;补算的运行来源记为「按新口径补算」,趋势按它对应的输入期排序', + ], + backfillConfirm: (n: number) => `发起最多 ${n} 次运行`, + backfillExact: (launch: number, skip: number, alias: string, from: number, to: number) => [ + `将发起 ${launch} 次正式运行:按「${alias}」v${from} 最近几次封存运行的输入,逐个用 v${to} 发起`, + skip ? `${skip} 次的输入已经补算过,跳过` : '', + '会产生运行费用;补算的运行来源记为「按新口径补算」,趋势按它对应的输入期排序', + ].filter(Boolean), + backfillConfirmExact: (n: number) => `发起 ${n} 次运行`, + backfillNothing: (skip: number) => (skip ? `没有需要补算的历史:最近 ${skip} 次的输入都已按新口径补算过` : '没有需要补算的历史'), + backfillDone: (started: number, skipped: number) => + started || skipped ? `已发起 ${started} 次补算${skipped ? `,跳过 ${skipped} 次(同一输入已补算过)` : ''}` : '没有需要补算的历史', + backfillPublishedOnly: '按已发布的应用版本补算:草稿里改过的视图要先发布', // ---- 预览 previewHint: '按草稿解析布局;不发起运行,视图取固定版本已有的封存运行', previewSaveFirst: '预览的是已保存的草稿,有未保存的修改时先保存', @@ -5394,7 +5421,7 @@ export const APP_EDITOR_TEXT = { publishNote: '版本说明(可选)', publish: '发布', publishing: '正在发布…', - published: (v: number) => `已发布应用版本 v${v}`, + published: (v: number, level?: string | null) => `已发布应用版本 v${v}${level === 'governed' ? '(受管级别)' : ''}`, publishBlocked: '发布未通过检查,请先处理下面的问题', dispositionTitle: '固定版本升了版,请为每一项选处置', dispositionIntro: '口径升版是必须处置的事件。趋势类组件按这里的选择来画,发布后记进应用版本', @@ -5434,8 +5461,63 @@ export const APP_EDITOR_TEXT = { hookKind: '种类', hookKinds: { start: '发起实例', signal: '回调' } as Record, hookEntry: '入口', - hookRemove: (name: string) => `删除钩子「${name}」`, + hookRemove: (name: string) => `删除钩子「${name}」的声明`, hooksEmpty: '还没有钩子', + hookStateLoadFailed: '无法读取钩子的密钥状态', + hookSecretNone: '未生成密钥', + hookSecretSet: '已生成密钥', + hookGenerate: '生成密钥', + hookRotate: '轮换密钥', + hookRotateTitle: (name: string) => `轮换钩子「${name}」的密钥?`, + hookRotateBody: '轮换后旧密钥立即失效,外部系统须换用新密钥签名。', + hookRotateConfirm: '轮换密钥', + hookRotated: (name: string) => `已生成钩子「${name}」的密钥`, + hookSaveFirst: '草稿有未保存的修改,生成密钥前先保存', + hookSecretTitle: (name: string) => `钩子「${name}」的签名密钥`, + hookSecretOnce: '密钥只显示这一次:请复制并保存到外部系统。离开这一页或刷新后不再显示,丢失只能轮换。', + hookSecretDismiss: '我已保存,关闭', + hookUrl: '入站地址', + hookSigning: '外部系统对「时间戳 + 请求体」做 HMAC-SHA256 签名,时间窗 5 分钟,按幂等键去重', + hookPublished: '发布版里有', + hookNotPublished: '发布版里还没有:外部请求会被拒收,发布后生效', + hookNotDeclared: '草稿里没有声明:发布新版本后,这个名字的外部请求会被拒收', + hookEnabled: '启用', + hookEnabledHint: '停用后外部请求一律拒收,密钥保留', + hookDeleteKey: '删除密钥', + hookDeleteTitle: (name: string) => `删除钩子「${name}」的密钥?`, + hookDeleteBody: '外部系统再用这个密钥签名的请求一律拒收。草稿里的声明保留,之后可以重新生成。', + hookDeleted: (name: string) => `已删除钩子「${name}」的密钥`, + hookDuplicate: (name: string) => `钩子名称「${name}」重复`, + // ---- 入口与表单(JSON 编辑) + formsHint: '入口、表单和待办外观按 JSON 编辑,改完点「应用到草稿」,保存后生效。发布检查以服务端为准,点问题定位到出处', + jsonEntries: '入口', + jsonEntriesHint: '每个入口写编号、名称、固定版本的别名、表单(内联,或引用下面「表单」里的一份)和字段映射', + jsonForms: '表单', + jsonFormsHint: 'agentlab.form/1 表单,按名称登记,入口和待办按名称引用', + jsonTaskUi: '待办外观', + jsonTaskUiHint: '按人工节点覆盖待办的标题、字段顺序和控件,不能改数据约束', + jsonApply: '应用到草稿', + jsonReset: '还原', + jsonDirty: '有未应用的修改', + jsonApplied: (what: string) => `已把「${what}」应用到草稿,保存后生效`, + jsonSyntax: (msg: string) => `JSON 格式有误:${msg}`, + jsonAt: (line: number, col: number) => `第 ${line} 行第 ${col} 列`, + jsonCheck: '检查这一节', + jsonChecked: (n: number) => (n ? `发布检查发现这一节有 ${n} 处问题` : '发布检查在这一节没有发现问题'), + jsonLocated: (path: string) => `已定位到 ${path}`, + shapeArray: '须为数组', + shapeObject: '须为对象(名称对应内容)', + entryNotObject: (i: number) => `第 ${i} 个入口须为对象`, + entryId: (i: number) => `第 ${i} 个入口缺少编号`, + entryDup: (id: string) => `入口编号「${id}」重复`, + entryPin: (id: string, pin: string) => `入口「${id}」引用的固定版本「${pin}」不存在`, + entryPinMissing: (id: string) => `入口「${id}」没有写固定版本`, + entryForm: (id: string, ref: string) => `入口「${id}」引用的表单「${ref}」不存在`, + entryMap: (id: string) => `入口「${id}」的字段映射须为对象,值写成文字`, + formShape: (name: string) => `表单「${name}」须为对象类型并列出字段`, + formFieldType: (name: string, field: string) => `表单「${name}」的字段「${field}」类型不在支持范围内`, + formPattern: (name: string, field: string) => `表单「${name}」的字段「${field}」用了正则校验:表单不支持(浏览器和服务端的正则写法不完全一致)`, + taskUiShape: (node: string) => `待办外观「${node}」须为对象`, // ---- 通知 notifyHint: '订阅的事件推送到通知渠道。渠道在「设置 → 通知」中管理', notifyAdd: '新增订阅', @@ -5449,6 +5531,100 @@ export const APP_EDITOR_TEXT = { assistantEmpty: '让助手调整这个应用的页面:先查看固定的版本,再改布局和文案', assistantApplied: '助手的修改已落到草稿上,保存后生效', assistantStop: '停止', + assistantHistoryFailed: '无法读取之前的对话:这一轮照常可用,但接不上前面聊过的内容', +} + +/** 派给自己的新待办:标签页在后台时的系统通知(同新的待审批) */ +export const TASK_NOTIFY_TEXT = { + title: '新的待办:', + untitled: '有事项等待处理', +} + +/** 设置页按角色收起的部分(§3.12):使用者只剩个人的主题和通知;运行默认值、模型接入只有管理员能改 */ +export const SETTINGS_ROLE_TEXT = { + subtitleUser: '个人的主题和通知', + themeLocal: '主题只保存在当前浏览器中,更换设备后需重新选择', + prefsReadOnly: '运行默认值、证据裁判和核查的设置全站共用,只有管理员可以修改', + notifyPersonal: '个人渠道只推送派给你的待办和你发起的实例的事件', +} + +/** 设置 → 偏好设置里的应用一组(D-j) */ +export const APP_SETTING_TEXT = { + title: '应用', + hint: '保存在服务端,所有应用共用', + onDemandLimit: '按需视图每日上限', + onDemandLimitHint: '每个应用每天最多按需发起几次运行,命中缓存的不计;填 0 表示不再发起按需运行。到上限后,视图提示次日再试', + onDemandLimitInvalid: '请填写 0 到 100,000 之间的整数', +} + +/** 自定义工具绑定登记的端点(§3.9):只填路径,地址和认证头由端点提供 */ +export const TOOL_ENDPOINT_TEXT = { + bind: '端点', + none: '不绑定(填写完整地址)', + hint: '绑定登记的端点后只填路径,参数逐段编码代入;地址和认证头由端点按环境提供,模型拿不到任意地址', + missing: (ref: string) => `端点「${ref}」已不存在`, + path: '路径', + pathHint: '以 / 开头,可用 {{ 参数名 }} 代入,参数逐段编码', + query: '查询参数(JSON,可插值)', + body: '请求体(JSON,可插值)', + noEndpoints: '还没有登记的端点,在「工具 → 端点」中登记', +} + +/** MCP 服务的认证头、端点绑定和本地子进程的权限(A6 第 6 条) */ +export const MCP_AUTH_TEXT = { + headers: '认证头', + headersHint: '值加密保存,界面上只显示掩码;不改的保持掩码即可', + addHeader: '添加认证头', + headerName: '名称', + headerValue: '值', + endpoint: '端点', + endpointNone: '不绑定(填写地址)', + endpointHint: '绑定登记的端点时,地址和认证头取端点的生产环境;不绑定的地址须通过外网安全检查', + stdioAdmin: '本地子进程会在服务器上执行命令,只有管理员可以新建和修改', + stdioReadOnly: '这是本地子进程服务,只有管理员可以修改', +} + +/** + * 能力字段语义的审阅(D-i):沿用数据目录审阅队列的组件和说法,只换掉说的是「表」的那几句。 + * 状态(推断 / 已确认 / 已驳回)、成批确认和驳回、撤销审阅、冲突的说法都和数据目录一致 + */ +export const CAPABILITY_REVIEW_TEXT: typeof CATALOG_REVIEW_TEXT = { + ...CATALOG_REVIEW_TEXT, + viewLabel: '能力的视图', + view: { list: '全部能力', review: '待审阅' }, + viewHint: { + list: '逐个查看和治理能力', + review: '列出待确认的字段语义推断,按被运行用过的次数、是否写入能力和来源排好,可以成批确认、驳回', + }, + queueLabel: '待审阅的能力字段', + tableLabel: '按能力筛选', + tableAll: '全部能力', + tableOption: (label: string | null, name: string, n: number) => `${label ? `${label}(${name})` : name} · ${formatNumber(n)} 项`, + empty: '没有待审阅的能力字段', + emptyBody: '能力字段的推断都审阅过了。同步出新的字段语义后会出现在这里。', + emptyFilteredBody: '请换一个能力。', + loadFailed: '无法读取待审阅的能力字段', + usage: (n: number) => `被 ${formatNumber(n)} 次运行用过`, + usageHint: '这个能力被运行调用过的次数', + open: (where: string, name: string) => `打开能力「${name}」,查看${where}`, + select: (where: string, name: string) => `选择:${name} ${where}`, + confirmConsequences: ['确认后作为字段语义提供给助手,并用于应用视图的列名和单位', '来源保持不变,状态改为已确认', '之后仍可撤销审阅'], + rejectConsequences: ['驳回后不提供给助手、不用于应用视图', '之后同步也不会再提出', '之后仍可撤销审阅'], + conflict: '未生效:这个能力刚被修改过,请刷新队列后再审阅', + summary: (action: 'confirm' | 'reject' | 'reset', ok: number, conflicts: number, failed: number) => { + const head = `${{ confirm: '已确认', reject: '已驳回', reset: '已撤销审阅' }[action]} ${formatNumber(ok)} 项` + const bad = [ + conflicts ? `${formatNumber(conflicts)} 项未生效(所在能力刚被修改过)` : '', + failed ? `${formatNumber(failed)} 项未生效` : '', + ].filter(Boolean) + return bad.length ? `${head};${bad.join('、')},请刷新队列后再审阅` : head + }, + overviewTitle: '从左侧队列挑一项开始审阅', + overviewBody: '队列按能力被运行用过的次数、是否写入外部系统和来源排序:常用的写入能力里的推断排在最前。点开一项,右侧是这个能力的治理设置。', + readOnlyNote: '确认、驳回能力的字段语义需要管理员角色,这里只能查看', + focusMark: '从待审阅打开', + openQueue: '打开待审阅', + openQueueHint: '列出待确认的字段语义推断,可以成批确认、驳回', } // 应用平台使用面的文案(FE-5 维护)。调用方照旧从 lib/terms 导入 diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index c5d948e6..7c26bf50 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -19,6 +19,7 @@ import { canLeave, leavePass } from '../lib/leave' import { formatNumber, formatTime } from '../lib/format' import { MAKE_APP_TEXT } from '../lib/terms' import { useCatalog, useCatalogListState, useDatasources, useOnReconnect, type CatalogListState } from '../store/catalog' +import { useAtLeast } from '../store/auth' import { agentSteps, busyTurn, isBusy, stepsVisible, useChat, type Attempt, type ChatTurn, type Failure, type ScopeSource, } from '../store/chat' @@ -487,7 +488,8 @@ function MakeAppButton({ runId, conversationId, turnId }: { runId: string; conve try { const res = await api.copilot.fromRun(runId, undefined, { conversation_id: conversationId, turn_id: turnId }) void refreshCatalog() - navigate(`/studio/${res.workflow_id}`, leavePass()) + // 带 publish=1:画布载入这张图后直接打开发布弹窗,发布成功的提示里点「做成应用」 + navigate(`/studio/${res.workflow_id}?publish=1`, leavePass()) toast.ok(MAKE_APP_TEXT.done) } catch (e) { toast.error(e) @@ -510,13 +512,15 @@ function TurnExtras({ turnId, readOnly }: { const approvals = useCatalog((s) => s.approvals) const { reattach, runNow, toggleSteps } = useChat.getState() const busy = useChat((s) => (conversationId ? isBusy(s.byConversation[conversationId]) : false)) + // 做成应用要去画布发布:只给搭建者以上(开了登录的使用者问数据时没有画布) + const builder = useAtLeast('builder') if (!turn || !conversationId) return null const runId = turn.run?.id const pending = runId ? approvals.filter((a) => a.run_id === runId && a.status === 'pending') : [] const canSteps = !!turn.restored && !!runId && !BUSY.has(turn.phase) const scoped = !!turn.scope?.length // 回答成功、有运行的轮次可以做成应用(A8):先从这次运行提取工作流,在画布上发布后再做成应用 - const canApp = !!runId && turn.phase === 'done' && !readOnly + const canApp = !!runId && turn.phase === 'done' && !readOnly && builder if (!pending.length && !turn.pendingRun && !canSteps && !scoped && !canApp) return null return ( @@ -1233,7 +1237,10 @@ function useDataSources(): SourcesState { // 不用整页刷新就能出现在范围、例句里 const { list, state: listState } = useDatasources() const [tables, setTables] = useState>({}) + // 开了登录的使用者(D-c)只拿得到数据源的名字和说明,表结构接口只给搭建者:例句按名字和说明造,不去取表名 + const builder = useAtLeast('builder') useEffect(() => { + if (!builder) return const todo = list.filter((r) => !(r.id in tables)) if (!todo.length) return let alive = true @@ -1244,7 +1251,7 @@ function useDataSources(): SourcesState { })).then((pairs) => { if (alive) setTables((t) => ({ ...t, ...Object.fromEntries(pairs) })) }) return () => { alive = false } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [list]) + }, [list, builder]) return useMemo(() => ({ // 取失败和「没有数据源」是两回事:前者不能劝人去新建。取回来过一次就按手上的列表算 state: listState === 'ok' ? 'ready' : listState === 'loading' ? 'loading' : list.length ? 'ready' : 'error', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 79a31053..0d001237 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -23,9 +23,10 @@ import { normalizeTheme } from '../lib/theme' import { useLeaveGuard } from '../lib/leave' import { localActor, setLocalActor } from '../lib/actor' import { - AGENT_GUARD_TEXT, DRAFT_MODEL_SETTING_TEXT, IDENTITY_TEXT, JUDGE_SETTING_TEXT, NOTIFY_TEXT, PROVIDER_REF_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, - judgeUnlimitedText, + 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, } from '../lib/terms' +import { atLeast, loginOn, prefsLocalOnly, usePrincipal } from '../store/auth' import type { ThemePref } from '../lib/theme' import { ProviderOptions, VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft, @@ -47,10 +48,20 @@ const TABS = [ { key: 'audit', label: IDENTITY_TEXT.audit }, ] +/** 只给管理员的页签(§3.12):登录方式和人、审计 */ +const ADMIN_TABS = new Set(['identity', 'audit']) +/** 开了登录的使用者只剩个人的:主题、通知渠道(个人) */ +const USER_TABS = new Set(['prefs', 'notify']) + export function SettingsPage() { const { tab: raw } = useParams() + const me = usePrincipal() + const builder = atLeast(me, 'builder') + const admin = atLeast(me, 'admin') + // 按角色收起:关登录时所有人等同管理员,全部页签照旧 + const tabs = TABS.filter((t) => (builder ? admin || !ADMIN_TABS.has(t.key) : USER_TABS.has(t.key))) // datasources 也算认得:否则 useTabRoute 会先把地址纠正回 providers,和下面的跳转打架 - const [tab, setTab] = useTabRoute([...TABS.map((t) => t.key), 'datasources'], 'providers') + const [tab, setTab] = useTabRoute([...tabs.map((t) => t.key), ...(builder ? ['datasources'] : [])], builder ? 'providers' : 'prefs') if (raw === 'datasources') return @@ -62,12 +73,13 @@ export function SettingsPage() { return (
- } title="设置" subtitle="模型接入、偏好、运行环境、身份和通知" /> - + } title="设置" + subtitle={builder ? '模型接入、偏好、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} /> +
{tab === 'providers' && } - {tab === 'prefs' && } + {tab === 'prefs' && (builder ? : )} {tab === 'system' && } {tab === 'identity' && } {tab === 'notify' && } @@ -625,6 +637,10 @@ function ProviderEditor({ provider, catalog, onClose, onSaved }: { * 所以不做自动保存。署名以前每敲一个字就写 localStorage,现在跟着一起保存。 */ function PrefsTab() { + const me = usePrincipal() + // 开了登录:署名就是登录的人,不再有自报署名;运行默认值这些全站共用的设置只有管理员能改(搭建者只读) + const signedIn = loginOn(me) + const admin = atLeast(me, 'admin') const collections = useCatalog((s) => s.collections) const providers = useCatalog((s) => s.providers) const [values, setValues] = useState | null>(null) @@ -640,6 +656,8 @@ function PrefsTab() { jClaims: string | null; jCost: string | null; jTimeout: string | null; jClick: string | null; jDaily: string | null // 起草模型(数据目录的用模型起草、按配方导入的 AI 起草):空串 = 跟随助手的模型(存 null) draftProvider: string; draftModel: string + // 应用一组(D-j):按需视图每个应用每天最多发起几次 + appsDaily: string } | null>(null) const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null) // 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取 @@ -690,6 +708,8 @@ function PrefsTab() { // 起草模型(服务端 catalog_draft 一组;老服务端没有这一组时两项都是空,这一节也不显示) draftProvider: typeof values?.catalog_draft?.provider === 'string' ? values.catalog_draft.provider : '', draftModel: typeof values?.catalog_draft?.model === 'string' ? values.catalog_draft.model : '', + // 服务端没给这一项时按默认的 200 显示(同服务端的缺省值) + appsDaily: String(values?.apps?.on_demand_daily_limit ?? APPS_DAILY_DEFAULT), } const cur = draft ?? saved const stepCap = Number(values?.limits?.max_agent_steps) || 100 @@ -707,6 +727,10 @@ function PrefsTab() { ? undefined : '请填写大于 0 的金额,或留空表示不限', } const guardInvalid = Object.values(guardErrors).some(Boolean) + // 0 到 100,000(同服务端 settings.APPS_DAILY_LIMIT_MAX):0 是不再发起按需运行 + const appsError = cur.appsDaily === saved.appsDaily + || (/^\d+$/.test(cur.appsDaily.trim()) && Number(cur.appsDaily) <= APPS_DAILY_MAX) + ? undefined : APP_SETTING_TEXT.onDemandLimitInvalid // 裁判的上限同样只校验改过的;null(不限)总是对的 const judgeError = (key: 'jClaims' | 'jCost' | 'jTimeout' | 'jClick' | 'jDaily') => { const v = cur[key] @@ -726,7 +750,7 @@ function PrefsTab() { const verifyKeys = verifyChanged(verifyCur, verifySaved) const verifyErrs = verifyErrors(verifyCur, verifySaved) const verifyInvalid = Object.values(verifyErrs).some(Boolean) - const invalid = guardInvalid || judgeInvalid || verifyInvalid + const invalid = guardInvalid || judgeInvalid || verifyInvalid || !!appsError const changed = (Object.keys(saved) as (keyof typeof saved)[]).filter((k) => cur[k] !== saved[k]) const dirty = values ? changed.length + verifyKeys.length : 0 // 外壳唯一的 blocker 在地址(pathname)变化时问:切标签、导航、⌘K、⌥ 数字、后退都算 @@ -766,7 +790,8 @@ function PrefsTab() { } const judgeChanged = changed.some((k) => JUDGE_DRAFT.has(k)) const draftChanged = changed.some((k) => DRAFT_MODEL_DRAFT.has(k)) - const runChanged = changed.some((k) => k !== 'actor' && !JUDGE_DRAFT.has(k) && !DRAFT_MODEL_DRAFT.has(k)) + const appsChanged = changed.includes('appsDaily') + const runChanged = changed.some((k) => k !== 'actor' && k !== 'appsDaily' && !JUDGE_DRAFT.has(k) && !DRAFT_MODEL_DRAFT.has(k)) try { // 裁判一组整组写:后端对没给的键回落到默认值,只发改过的那一项会把别的项冲回默认 const judge = judgeChanged ? { @@ -782,7 +807,9 @@ function PrefsTab() { const verify = verifyKeys.length ? verifyPayload(verifyCur) : null // 起草模型一组:两项一起写,空着的存 null(跟随助手的模型) const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null - if (runChanged || judge || verify || catalogDraft) { + // 应用一组同样整组写:别的键(以后加的)照原样带回去 + const apps = appsChanged ? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()) } : null + if (runChanged || judge || verify || catalogDraft || apps) { const run = { ...(values.run ?? {}), default_memory_scope: cur.scope, @@ -796,7 +823,7 @@ function PrefsTab() { } const out = await api.settings.put({ ...(runChanged ? { run } : {}), ...(judge ? { judge } : {}), ...(verify ? { verify } : {}), - ...(catalogDraft ? { catalog_draft: catalogDraft } : {}), + ...(catalogDraft ? { catalog_draft: catalogDraft } : {}), ...(apps ? { apps } : {}), }) setValues((v) => ({ ...(v ?? {}), @@ -804,6 +831,7 @@ function PrefsTab() { ...(judge ? { judge: out?.judge ?? judge } : {}), ...(verify ? { verify: out?.verify ?? verify } : {}), ...(catalogDraft ? { catalog_draft: out?.catalog_draft ?? catalogDraft } : {}), + ...(apps ? { apps: out?.apps ?? apps } : {}), })) // 每日上限改了:旁边那句「今天已花」的分母跟着变,顺手再取一次 if (judge) void loadSpend() @@ -902,6 +930,10 @@ function PrefsTab() { : ) : ( <> + {!admin && ( +

{SETTINGS_ROLE_TEXT.prefsReadOnly}

+ )} + {!signedIn && (
edit({ actor: e.target.value })} />
+ )} +
@@ -1119,6 +1153,18 @@ function PrefsTab() { )} + +
+ + + {(p) => ( + edit({ appsDaily: e.target.value })} data-apps-daily="" /> + )} + +
+
)} @@ -1143,6 +1189,7 @@ function PrefsTab() { jClaims: '每份报告最多裁判句数', jCost: '每份报告的金额上限', jTimeout: '每份报告的时长上限', jClick: '每次点击的金额上限', jDaily: '每日金额上限', draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model, + appsDaily: APP_SETTING_TEXT.onDemandLimit, })[k]).concat(verifyLabels(verifyKeys)).join('、')} @@ -1158,6 +1205,7 @@ function PrefsTab() { +
+ ) +} function McpServers() { const refresh = useCatalog((s) => s.refresh) @@ -957,7 +1069,10 @@ function McpServers() { } } - const create = () => setEditing({ transport: 'stdio', args: [], env: {}, enabled: true }) + // 本地子进程会在服务器上执行命令,只有管理员能建(A6 第 6 条):别的角色新建时默认 HTTP + const admin = useAtLeast('admin') + const create = () => setEditing(admin ? { transport: 'stdio', args: [], env: {}, enabled: true } + : { transport: 'http', args: [], env: {}, headers: {}, enabled: true }) return (
@@ -1074,17 +1189,22 @@ function McpEditor({ row, onClose, onSaved }: { /** reconnected:连接配置改过了,之前的探测结果不再代表它 */ onSaved: (name: string, reconnected: boolean) => void }) { + const admin = useAtLeast('admin') + const endpoints = useEndpoints() const [initial] = useState(() => ({ name: row.name ?? '', transport: row.transport ?? 'stdio', command: row.command ?? '', args: (row.args ?? []).join(' '), env: row.env ?? {}, url: row.url ?? '', enabled: row.enabled ?? true, + headers: row.headers ?? {}, endpoint_id: row.endpoint_id ?? null, })) const [form, setForm] = useState(initial) const [busy, setBusy] = useState(false) const dirty = JSON.stringify(form) !== JSON.stringify(initial) + // 本地子进程只有管理员能建、能改:已有的本地子进程服务对别的角色只读 + const locked = !admin && initial.transport === 'stdio' && !!row.id const missing = [ !form.name.trim() && '名称', form.transport === 'stdio' && !form.command.trim() && '命令', - form.transport !== 'stdio' && !form.url.trim() && 'URL', + form.transport !== 'stdio' && !form.endpoint_id && !form.url.trim() && 'URL', ].filter(Boolean) as string[] const submit = async () => { @@ -1106,12 +1226,15 @@ function McpEditor({ row, onClose, onSaved }: { - + {!locked && ( + + )} }> -
+
+ {locked &&

{MCP_AUTH_TEXT.stdioReadOnly}

}
{(p) => ( @@ -1123,12 +1246,13 @@ function McpEditor({ row, onClose, onSaved }: { {(p) => ( )}
+ {!admin && !locked &&

{MCP_AUTH_TEXT.stdioAdmin}

} {form.transport === 'stdio' ? ( <> @@ -1149,18 +1273,36 @@ function McpEditor({ row, onClose, onSaved }: { ) : ( - - {(p) => ( - setForm({ ...form, url: e.target.value })} /> - )} - + <> + + {(p) => ( + + )} + + + {(p) => ( + setForm({ ...form, url: e.target.value })} /> + )} + + + {() => setForm((f: any) => ({ ...f, headers: v }))} />} + + )} -
+
) } diff --git a/frontend/src/pages/apps/AppPage.tsx b/frontend/src/pages/apps/AppPage.tsx index 8dab471d..00b1bfca 100644 --- a/frontend/src/pages/apps/AppPage.tsx +++ b/frontend/src/pages/apps/AppPage.tsx @@ -14,6 +14,7 @@ import { useAtLeast } from '../../store/auth' import { usePageTitle } from '../../lib/notify' import { APP_TEXT } from '../../lib/termsApp' import { AppMeta, BackLink, LoadError, appPath, loadHome, useLoad, usePoll } from './shared' +import { BackfillPanel } from './backfill' const POLL_MS = 5000 @@ -68,6 +69,8 @@ export function AppPage() { {APP_TEXT.needsUpgrade}:{APP_TEXT.needsUpgradeHint} )} + {/* 按新口径补算(D-f):只有 manage 看得到,固定版本升了版、处置为按新口径重算时才有 */} + )} diff --git a/frontend/src/pages/apps/AppsHome.tsx b/frontend/src/pages/apps/AppsHome.tsx index b57703f6..99d751e0 100644 --- a/frontend/src/pages/apps/AppsHome.tsx +++ b/frontend/src/pages/apps/AppsHome.tsx @@ -103,7 +103,7 @@ function InboxRow({ task }: { task: Task }) { const voters = task.assignments.filter((a) => a.status !== 'withdrawn') const agreed = voters.filter((a) => a.status === 'responded' && a.decision !== 'reject').length const pending = task.status === 'pending' - const where = (task as Task & { node_label?: string | null }).node_label + const where = task.node_label return (
  • ({ + view: p.view, alias: p.alias, from: p.from_version, to: p.to_version, max: p.periods, + exact: { launch: p.will_launch, skip: p.will_skip }, +}) + +/** 视图里趋势组件最多取几次;没有趋势组件为 0 */ +export function seriesDepth(spec: AppSpec, view: string): number { + let n = 0 + const walk = (list: AppComponent[]) => list.forEach((c) => { + const b = c.bind + if (b && b.view === view && typeof b.series === 'string') { + const last = typeof b.last === 'number' && b.last > 0 ? Math.floor(b.last) : SERIES_DEFAULT + n = Math.max(n, Math.min(last, SERIES_MAX)) + } + if (c.children?.length) walk(c.children) + }) + spec.pages.forEach((p) => walk(p.body)) + return n +} + +/** 哪些视图可以按新口径补算(见文件开头)。previous 是上一个应用版本,没有就不算升版 */ +export function backfillTargets(spec: AppSpec, current: AppVersionRow, previous: AppVersionRow | undefined): BackfillTarget[] { + if (!previous) return [] + const out: BackfillTarget[] = [] + for (const [view, v] of Object.entries(spec.views ?? {})) { + const alias = v?.source?.pin + if (!alias || v.source.kind === 'instance' || current.dispositions?.[alias] !== 'recompute') continue + const now = current.pins.find((p) => p.alias === alias) + const before = previous.pins.find((p) => p.alias === alias) + if (!now || !before || before.version === now.version) continue + const max = seriesDepth(spec, view) + if (max > 0) out.push({ view, alias, from: before.version, to: now.version, max }) + } + return out +} + +/** + * 这个应用当前发布版里能补算的视图。服务端在应用详情里给了(plans)就用它;没给(老服务端,或者保存草稿后的回包不带) + * 按版本列表自己推。publishedVersion 为空(还没发布)时不取。manage 才能补算:调用方先判断角色,接口拒绝(403)时当作没有 + */ +export function useBackfillTargets(appId: string, publishedVersion: number | null | undefined, enabled = true, + plans?: BackfillPlan[] | null): BackfillTarget[] { + const [targets, setTargets] = useState([]) + const planKey = plans ? JSON.stringify(plans) : null + useEffect(() => { + if (!enabled || publishedVersion == null) { setTargets([]); return } + if (plans) { setTargets(plans.filter((p) => p.periods > 0).map(fromPlan)); return } + let alive = true + void (async () => { + try { + const { items } = await api.apps.versions(appId) + const current = items.find((v) => v.version === publishedVersion) + const previous = items.filter((v) => v.version < publishedVersion).sort((a, b) => b.version - a.version)[0] + if (!current || !previous || !Object.values(current.dispositions ?? {}).includes('recompute')) { + if (alive) setTargets([]) + return + } + const detail = await api.apps.version(appId, publishedVersion) + if (alive) setTargets(backfillTargets(detail.spec, current, previous)) + } catch { + if (alive) setTargets([]) + } + })() + return () => { alive = false } + }, [appId, publishedVersion, enabled, planKey]) // eslint-disable-line react-hooks/exhaustive-deps + return targets +} + +export function BackfillButton({ appId, target, onDone }: { appId: string; target: BackfillTarget; onDone?: () => void }) { + const [busy, setBusy] = useState(false) + const run = async () => { + setBusy(true) + // 先取一次最新的计划(不发起运行):确认框写的次数要和点下去之后发起的一致。取不到(老服务端)按上限写 + const plan = await api.apps.backfillPlan(appId, target.view).then(fromPlan, () => null) + setBusy(false) + const t = plan ?? target + if (t.exact && t.exact.launch === 0) { + toast.info(T.backfillNothing(t.exact.skip)) + return + } + const ok = await confirmDialog({ + title: T.backfillTitle(t.view), + consequences: t.exact ? T.backfillExact(t.exact.launch, t.exact.skip, t.alias, t.from, t.to) + : T.backfillConsequences(t.max, t.alias, t.from, t.to), + confirmLabel: t.exact ? T.backfillConfirmExact(t.exact.launch) : T.backfillConfirm(t.max), + }) + if (!ok) return + setBusy(true) + try { + const res = await api.apps.backfill(appId, target.view) + toast.ok(T.backfillDone(res.started, res.skipped)) + if (res.refused) toast.warn(res.refused) + onDone?.() + } catch (e) { + toast.error(e) + } finally { + setBusy(false) + } + } + return ( + + ) +} + +/** + * 应用页上的补算入口(manage):每个能补算的视图一行,写明从哪一版升到哪一版。只给搭建者以上取(使用者没有 manage), + * 取应用详情看自己是不是 manage + */ +export function BackfillPanel({ appId, publishedVersion, onDone }: { + appId: string + publishedVersion: number | null | undefined + onDone?: () => void +}) { + const builder = useAtLeast('builder') + const [manage, setManage] = useState(false) + const [plans, setPlans] = useState(null) + useEffect(() => { + if (!builder) return + let alive = true + api.apps.get(appId).then((a) => { + if (!alive) return + setManage(a.my_role === 'manage') + setPlans(Array.isArray(a.backfill) ? a.backfill : null) + }, () => undefined) + return () => { alive = false } + }, [appId, builder]) + const targets = useBackfillTargets(appId, publishedVersion, builder && manage, plans) + if (!targets.length) return null + return ( +
    + {targets.map((t) => ( +
    + {T.backfillHint(t.alias, t.from, t.to)} + +
    + ))} +
    + ) +} diff --git a/frontend/src/pages/apps/edit/AccessSections.tsx b/frontend/src/pages/apps/edit/AccessSections.tsx index 474a14ad..d2bdc034 100644 --- a/frontend/src/pages/apps/edit/AccessSections.tsx +++ b/frontend/src/pages/apps/edit/AccessSections.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useState } from 'react' -import { Plus } from 'lucide-react' +import { KeyRound, Plus } from 'lucide-react' import { api } from '../../../api/client' -import { DeleteButton, EmptyState, Field, SectionBar, Skeleton, Spinner, toast } from '../../../components/ui' +import type { AppHookState } from '../../../api/apps' +import { + CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast, +} from '../../../components/ui' import { PrincipalPicker, type Principal as Picked } from '../../../components/PrincipalPicker' import { APP_EDITOR_TEXT as T, GRANT_ROLE_LABEL, NOTIFY_TEXT } from '../../../lib/terms' import { useLoginOn } from '../../../store/auth' @@ -122,45 +125,131 @@ function Grants() { ) } +/* + * 钩子(D-g):按名字关联。spec.hooks 声明 {name, kind, entry}(进草稿、随应用版本发布);密钥按 (应用, 名字) 存在服务端, + * 只在「生成 / 轮换」时交回一次,这里显示一次、附复制按钮,离开这一节就不再有。入站请求到来时服务端按当前发布版里这个 + * 名字的种类和入口处理:发布版里没声明、停用了的一律拒收——所以每一行照实标出「发布版里有没有」「生成过密钥没有」。 + * 改了名字就是另一个钩子:旧名字的密钥行留在下面「草稿里没有声明」那一组,可以删掉 + */ export function HooksSection() { - const { draft, setDraft } = useEditor() + const { appId, draft, setDraft, dirty, save } = useEditor() + const [rows, setRows] = useState(null) + const [loadError, setLoadError] = useState(null) + const [busy, setBusy] = useState(null) + const [shown, setShown] = useState<{ name: string; secret: string; path: string | null } | null>(null) + const load = useCallback(() => { + api.apps.hooks(appId).then((list) => { setRows(list); setLoadError(null) }).catch(setLoadError) + }, [appId]) + useEffect(load, [load]) + const set = (hooks: typeof draft.hooks) => setDraft((s) => ({ ...s, hooks })) + const stateOf = (name: string) => rows?.find((r) => r.name === name) + const undeclared = (rows ?? []).filter((r) => r.has_secret && !draft.hooks.some((h) => h.name === r.name)) + const names = draft.hooks.map((h) => h.name.trim()) + const dup = (name: string) => names.filter((n) => n === name.trim()).length > 1 + + /** 生成或轮换:服务端按保存过的草稿认这个名字,有未保存的修改先存 */ + const rotate = async (name: string) => { + const cur = stateOf(name) + if (cur?.has_secret && !(await confirmDialog({ + title: T.hookRotateTitle(name), body: T.hookRotateBody, confirmLabel: T.hookRotateConfirm, danger: true, + }))) return + setBusy(name) + try { + if (dirty && !(await save())) return + const row = await api.apps.rotateHook(appId, name) + const id = row.id ?? cur?.id ?? null + if (row.secret) setShown({ name, secret: row.secret, path: row.url_path ?? (id ? `/api/hooks/${encodeURIComponent(id)}` : null) }) + toast.ok(T.hookRotated(name)) + load() + } catch (e) { + toast.error(e) + } finally { + setBusy(null) + } + } + const toggle = async (name: string, enabled: boolean) => { + setBusy(name) + try { + await api.apps.setHookEnabled(appId, name, enabled) + load() + } catch (e) { + toast.error(e) + } finally { + setBusy(null) + } + } + const removeKey = async (name: string) => { + if (!(await confirmDialog({ title: T.hookDeleteTitle(name), body: T.hookDeleteBody, confirmLabel: T.hookDeleteKey, danger: true }))) return + setBusy(name) + try { + await api.apps.deleteHook(appId, name) + if (shown?.name === name) setShown(null) + toast.ok(T.hookDeleted(name)) + load() + } catch (e) { + toast.error(e) + } finally { + setBusy(null) + } + } + + const status = (name: string, declared: boolean) => ( + void rotate(name)} onToggle={(v) => void toggle(name, v)} onRemove={() => void removeKey(name)} /> + ) + return ( -
    +
    - {!draft.hooks.length ? : ( + {shown && setShown(null)} />} + {loadError ? : null} + {!draft.hooks.length && !undeclared.length ? : (
    {draft.hooks.map((h, i) => ( -
    - - {(p) => set(draft.hooks.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />} - - - {(p) => ( - - )} - - {h.kind === 'start' ? ( - +
    +
    + + {(p) => set(draft.hooks.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />} + + {(p) => ( - set(draft.hooks.map((x, j) => (j === i ? { ...x, kind: e.target.value as 'start' | 'signal' } : x)))}> + {Object.entries(T.hookKinds).map(([k, label]) => )} )} - ) :
    } - set(draft.hooks.filter((_, j) => j !== i))} /> + {h.kind === 'start' ? ( + + {(p) => ( + + )} + + ) :
    } + set(draft.hooks.filter((_, j) => j !== i))} /> +
    + {h.name.trim() && !dup(h.name) && status(h.name.trim(), true)} +
    + ))} + {undeclared.map((r) => ( +
    +
    + {r.name} + {r.kind && {T.hookKinds[r.kind]}} +
    + {status(r.name, false)}
    ))}
    @@ -169,6 +258,82 @@ export function HooksSection() { ) } +/** 一行钩子的服务端状态:发布版里有没有、生成过密钥没有、启用;生成 / 轮换、删除密钥 */ +function HookStatus({ name, state, declared, busy, loaded, onRotate, onToggle, onRemove }: { + name: string + state: AppHookState | undefined + declared: boolean + busy: boolean + loaded: boolean + onRotate: () => void + onToggle: (enabled: boolean) => void + onRemove: () => void +}) { + if (!loaded) return null + const has = !!state?.has_secret + return ( +
    + + {has ? T.hookSecretSet : T.hookSecretNone} + + {!declared + ? {T.hookNotDeclared} + : state?.published ? {T.hookPublished} + : {T.hookNotPublished}} + + {has && ( + + )} + {declared && ( + + )} + {has && ( + + )} +
    + ) +} + +/** 刚生成、轮换的密钥:只显示这一次,附复制按钮和入站地址;关掉、离开这一节就没有了 */ +function SecretOnce({ shown, onDismiss }: { shown: { name: string; secret: string; path: string | null }; onDismiss: () => void }) { + const url = shown.path ? `${window.location.origin}${shown.path}` : null + return ( +
    +
    + {T.hookSecretTitle(shown.name)} +
    +

    {T.hookSecretOnce}

    +
    + + {shown.secret} + + +
    + {url && ( +
    + {T.hookUrl} + {url} + +
    + )} +

    {T.hookSigning}

    +
    + +
    +
    + ) +} + export function NotifySection() { const { draft, setDraft } = useEditor() const [channels, setChannels] = useState(null) diff --git a/frontend/src/pages/apps/edit/AppAssistant.tsx b/frontend/src/pages/apps/edit/AppAssistant.tsx index 3fb278d4..7ee14859 100644 --- a/frontend/src/pages/apps/edit/AppAssistant.tsx +++ b/frontend/src/pages/apps/edit/AppAssistant.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef, useState } from 'react' +import { api } from '../../../api/client' import { streamAppCopilot } from '../../../api/appCopilot' import { toast } from '../../../components/ui' import { AssistantStream, type StreamTurn } from '../../../run/AssistantStream' import { PromptBox } from '../../../run/Composer' -import { COPILOT_PHASE_TEXT, copilotLook, decodeCopilot, type CopilotOp } from '../../../run/decode' +import { COPILOT_PHASE_TEXT, copilotLook, decodeCopilot, looksOf, type CopilotOp } from '../../../run/decode' import { APP_EDITOR_TEXT as T, LOOK_TEXT } from '../../../lib/terms' +import type { ConversationTurn } from '../../../types' import type { AppSpec } from '../../../typesApp' import { applyAppOp } from './appOps' import { useEditor } from './context' @@ -14,7 +16,12 @@ import { useEditor } from './context' * * 走 /api/copilot/app-stream:系统先代为查看固定的版本(look,by=system),模型可以再查看;应用操作(app_add 等) * 到一条落一条到草稿上(applyAppOp,和人在页面树上的编辑同一种动作),流结束时的 final 带服务端落地后的整份 spec, - * 以它为准。助手问回来一句(ask)时这一轮到此结束,回答就是下一轮。过程照 decode.ts 的说法显示(查看、自查、操作) + * 以它为准。助手问回来一句(ask)时这一轮到此结束,回答就是下一轮。过程照 decode.ts 的说法显示(查看、自查、操作)。 + * + * 对话(D-e):每个应用一段(kind=app、归属这个应用),进编辑器先取或建(GET /api/apps/{id}/assistant-conversation), + * 请求带 conversation_id,服务端据此把前几轮(问题、说明、查看过什么)放进这一轮,同问数据;每轮结束落一条轮次 + * (问题、说明、助手问回来的话、meta.looks)。之前的轮次显示在上面,只有问题、查看记录和说明——当时的操作已经落进草稿, + * 不重放。取不到对话时这一轮照常可用,只是接不上前面聊过的 */ interface Turn { @@ -26,9 +33,25 @@ interface Turn { error?: string } +/** 落过库的一轮 → 显示用的一轮:查看记录照 decode 的说法,说明挂在「应用草稿已调整完成」那一行,问回来的话照 ask 显示 */ +function pastTurn(t: ConversationTurn): StreamTurn { + const looks: CopilotOp[] = Array.isArray(t.meta?.looks) ? t.meta!.looks : [] + const ops: CopilotOp[] = [...looks] + if (t.status !== 'error') ops.push({ op: 'done', explanation: t.explanation || '' }) + if (t.answer) ops.push({ op: 'ask', question: t.answer }) + return { + id: `past-${t.id}`, question: t.question, phase: t.status === 'error' ? 'error' : 'done', status: '', + steps: decodeCopilot(ops, { context: 'app' }), + ...(t.status === 'error' && t.error ? { error: t.error } : {}), + } +} + export function AppAssistant() { const { appId, draft, setDraft, bindings } = useEditor() const [turns, setTurns] = useState([]) + const [past, setPast] = useState([]) + const [conversationId, setConversationId] = useState(null) + const [historyFailed, setHistoryFailed] = useState(false) const [text, setText] = useState('') const cancel = useRef<(() => void) | null>(null) const latest = useRef(draft) @@ -36,9 +59,45 @@ export function AppAssistant() { useEffect(() => () => cancel.current?.(), []) const busy = turns.some((t) => t.phase === 'running') + // 取或建这个应用的助手对话,带着之前的轮次。老服务端只回对话本身时按 id 再取一次 + useEffect(() => { + let alive = true + void (async () => { + try { + const conv = await api.apps.assistantConversation(appId) + const detail = Array.isArray(conv.turns) ? conv : await api.conversations.get(conv.id) + if (!alive) return + setConversationId(detail.id) + setPast((detail.turns ?? []).map(pastTurn)) + } catch { + if (alive) setHistoryFailed(true) + } + })() + return () => { alive = false } + }, [appId]) + const patch = (id: string, fn: (t: Turn) => Partial) => setTurns((ts) => ts.map((t) => (t.id === id ? { ...t, ...fn(t) } : t))) + /** 这一轮落库:问题、说明、助手问回来的话、查看记录。失败不打扰(这一轮的改动已经在草稿上) */ + const record = (question: string, ops: CopilotOp[], error?: string) => { + if (!conversationId) return + const final = [...ops].reverse().find((o) => o.op === 'final') + const done = [...ops].reverse().find((o) => o.op === 'done') + const ask = [...ops].reverse().find((o) => o.op === 'ask') + const looks = looksOf(ops) + const body: Partial = { + explanation: String(final?.explanation ?? done?.explanation ?? ''), + answer: ask ? String(ask.question ?? '') : '', + status: error ? 'error' : 'done', + error: error ?? '', + ...(looks.length ? { meta: { looks } } : {}), + } + void api.conversations.startTurn(conversationId, question) + .then((turn) => api.conversations.patchTurn(conversationId, turn.id, body)) + .catch(() => undefined) + } + const send = () => { const instruction = text.trim() if (!instruction || busy) return @@ -46,9 +105,12 @@ export function AppAssistant() { setText('') setTurns((ts) => [...ts, { id, question: instruction, ops: [], phase: 'running', status: COPILOT_PHASE_TEXT.connecting }]) let changed = false + let failed: string | undefined + const got: CopilotOp[] = [] cancel.current = streamAppCopilot( - { app_id: appId, instruction, spec: latest.current, bindings }, + { app_id: appId, instruction, spec: latest.current, bindings, conversation_id: conversationId }, (op: CopilotOp) => { + got.push(op) patch(id, (t) => { const last = t.ops[t.ops.length - 1] if (op.op === 'thinking' && last?.op === 'thinking') { @@ -75,7 +137,8 @@ export function AppAssistant() { if (op.spec && typeof op.spec === 'object') { changed = true; setDraft(() => op.spec as AppSpec) } break case 'error': - patch(id, () => ({ phase: 'error', error: String(op.message ?? '生成失败') })) + failed = String(op.message ?? '生成失败') + patch(id, () => ({ phase: 'error', error: failed })) break } }, @@ -83,18 +146,29 @@ export function AppAssistant() { cancel.current = null patch(id, (t) => (t.phase === 'error' ? {} : error ? { phase: 'error', error } : { phase: 'done', status: '' })) if (!error && changed) toast.ok(T.assistantApplied) + record(instruction, got, failed ?? error) }, ) } - const stream: StreamTurn[] = turns.map((t) => ({ - id: t.id, question: t.question, phase: t.phase, status: t.status, - steps: decodeCopilot(t.ops, { context: 'app' }), - ...(t.error ? { error: t.error } : {}), - })) + const stream: StreamTurn[] = [ + ...past, + ...turns.map((t) => ({ + id: t.id, question: t.question, phase: t.phase, status: t.status, + steps: decodeCopilot(t.ops, { context: 'app' }), + ...(t.error ? { error: t.error } : {}), + })), + ] return ( -
    +
    + {historyFailed && ( +

    + {T.assistantHistoryFailed} +

    + )}
    {T.assistantEmpty}

    } /> diff --git a/frontend/src/pages/apps/edit/AppEditor.tsx b/frontend/src/pages/apps/edit/AppEditor.tsx index 4a43dca9..5aa507ce 100644 --- a/frontend/src/pages/apps/edit/AppEditor.tsx +++ b/frontend/src/pages/apps/edit/AppEditor.tsx @@ -1,7 +1,8 @@ /** * 应用编辑(搭建面,docs/design/app-platform-and-look.md 第四部分、A1、A2、A8):/apps/:appId/edit[/:section]。 * - * 分节:固定版本、页面(页面树 + 属性面板)、预览、检查、发布、岗位与授权、钩子、通知;右侧是助手(应用润色)。 + * 分节:固定版本、页面(页面树 + 属性面板)、入口与表单(JSON 编辑)、预览、检查、发布、岗位与授权、钩子、通知; + * 右侧是助手(应用润色)。 * 草稿存在应用上(PATCH /api/apps/{id}),只在发布时生成不可变的应用版本——草稿不进版本表(F3 的教训)。 * 预览按草稿解析布局、不发起运行;检查和发布同一套规则,都读服务端那份草稿,所以先保存再查。 * @@ -13,21 +14,23 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { Bot, ExternalLink, LayoutGrid, Save } from 'lucide-react' import clsx from 'clsx' import { api } from '../../../api/client' -import { ErrorState, Field, PageHeader, Skeleton, Spinner, confirmDialog, isComposing, toast } from '../../../components/ui' +import { EmptyState, ErrorState, Field, PageHeader, Skeleton, Spinner, confirmDialog, isComposing, toast } from '../../../components/ui' import { formatTime } from '../../../lib/format' import { matchShortcut } from '../../../lib/keys' import { useLeaveGuard } from '../../../lib/leave' import { APP_EDITOR_TEXT as T } from '../../../lib/terms' import { useCatalog } from '../../../store/catalog' -import type { AppBindings, AppDetail, AppSpec, ComponentCatalog } from '../../../typesApp' +import type { AppDetailOut } from '../../../api/apps' +import type { AppBindings, AppSpec, ComponentCatalog } from '../../../typesApp' import { EditorContext, type EditorState } from './context' import { PinsSection, PublishedVersionPicker } from './PinsSection' import { PagesSection } from './PagesSection' import { CheckSection, PreviewSection, PublishSection } from './ReviewSections' import { AccessSection, HooksSection, NotifySection } from './AccessSections' import { AppAssistant } from './AppAssistant' +import { FormsSection } from './FormsSection' -const SECTIONS = ['pins', 'pages', 'preview', 'check', 'publish', 'access', 'hooks', 'notify'] as const +const SECTIONS = ['pins', 'pages', 'forms', 'preview', 'check', 'publish', 'access', 'hooks', 'notify'] as const type Section = typeof SECTIONS[number] /** 组件目录整站一份,进一次编辑器取一次就够 */ @@ -114,7 +117,7 @@ function EditApp({ appId }: { appId: string }) { const { section: raw } = useParams() const navigate = useNavigate() const section: Section = (SECTIONS as readonly string[]).includes(raw ?? '') ? raw as Section : 'pages' - const [detail, setDetail] = useState(null) + const [detail, setDetail] = useState(null) const [error, setError] = useState(null) const [draft, setDraftState] = useState(null) const [bindings, setBindingsState] = useState({ slots: {} }) @@ -123,6 +126,7 @@ function EditApp({ appId }: { appId: string }) { const [savedAt, setSavedAt] = useState(null) const [catalog, setCatalog] = useState(null) const [selected, setSelected] = useState(null) + const [focusPath, setFocusPath] = useState(null) const [assistant, setAssistant] = useState(true) // 保存用的是最新的草稿:助手的操作和人的编辑挨着到时,闭包里那份可能已经旧了 const latest = useRef<{ draft: AppSpec | null; bindings: AppBindings }>({ draft: null, bindings: { slots: {} } }) @@ -155,7 +159,8 @@ function EditApp({ appId }: { appId: string }) { try { const next = await api.apps.update(appId, { name: cur.draft.name, description: cur.draft.description, draft: cur.draft, draft_bindings: cur.bindings }) - setDetail(next) + // 保存草稿的回包不带补算计划(只看发布版,草稿改了也不变):留着手上那份 + setDetail((cur) => ({ ...next, backfill: next.backfill ?? cur?.backfill })) // 保存期间又改了的,那些改动还算没保存 if (latest.current.draft === cur.draft && latest.current.bindings === cur.bindings) setDirty(false) setSavedAt(Date.now()) @@ -191,13 +196,23 @@ function EditApp({ appId }: { appId: string }) { setBindings: (fn) => { setBindingsState((cur) => fn(cur)); setDirty(true) }, bindings, save, select: setSelected, - goto: (s, componentId) => { + goto: (s, componentId, path) => { if (componentId !== undefined) setSelected(componentId) + if (path !== undefined) setFocusPath(path) navigate(`/apps/${encodeURIComponent(appId)}/edit/${s}`) }, - } : null), [appId, detail, draft, catalog, dirty, selected, bindings, save, navigate]) + focusPath, + clearFocus: () => setFocusPath(null), + } : null), [appId, detail, draft, catalog, dirty, selected, bindings, save, navigate, focusPath]) if (error) return void load()} className="m-6" /> + // 没有 manage 的人取不到草稿(D-m):手动打开编辑地址时说清楚,别一直停在骨架屏 + if (detail && (detail.my_role !== 'manage' || !detail.draft)) { + return ( + } title={T.noManage} body={T.noManageBody} offline={false} + className="m-6" action={{T.open}} /> + ) + } if (!ctx) return
    return ( @@ -234,6 +249,7 @@ function EditApp({ appId }: { appId: string }) {
    {section === 'pins' && } {section === 'pages' && (ctx.catalog ? :
    )} + {section === 'forms' && } {section === 'preview' && } {section === 'check' && } {section === 'publish' && void load()} />} diff --git a/frontend/src/pages/apps/edit/FormsSection.tsx b/frontend/src/pages/apps/edit/FormsSection.tsx new file mode 100644 index 00000000..30ad3d88 --- /dev/null +++ b/frontend/src/pages/apps/edit/FormsSection.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AlertTriangle, Check, Crosshair, RotateCcw, RotateCw, XCircle } from 'lucide-react' +import { api } from '../../../api/client' +import { SectionBar, Spinner, toast } from '../../../components/ui' +import { APP_EDITOR_TEXT as T } from '../../../lib/terms' +import type { AppSpec, LintIssue } from '../../../typesApp' +import { jsonErrorAt, lineOfPath, pathParts, shapeIssues, type ShapeIssue } from './appOps' +import { useEditor } from './context' + +/* + * 入口与表单(A2、§3.2、§3.3):入口、表单、待办外观三块按 JSON 编辑。 + * + * 这三块是数据约束(入口的字段映射、表单的必填和取值范围),助手只能改外观(D-h),所以要给人一处能直接改的地方; + * 组件化的编辑器做不全 agentlab.form/1,JSON 是唯一不丢信息的写法。改完点「应用到草稿」才落到草稿上(和页面树、助手的 + * 修改同一份草稿),格式不对的不让应用;形状按目录的子集先查一遍(shapeIssues),规则以服务端的发布检查为准—— + * 「检查这一节」跑一次发布检查,只列落在这三块上的问题,点一条在 JSON 里定位到那一行;检查一节、发布被拦时点「定位」 + * 也跳到这里(focusPath) + */ + +const BLOCKS = ['entries', 'forms', 'task_ui'] as const +type Block = typeof BLOCKS[number] + +const TITLE: Record = { entries: T.jsonEntries, forms: T.jsonForms, task_ui: T.jsonTaskUi } +const HINT: Record = { entries: T.jsonEntriesHint, forms: T.jsonFormsHint, task_ui: T.jsonTaskUiHint } + +interface Focus { block: Block; keys: string[]; seq: number } + +export function FormsSection() { + const { appId, draft, dirty, save, focusPath, clearFocus } = useEditor() + const [issues, setIssues] = useState(null) + const [checking, setChecking] = useState(false) + const [focus, setFocus] = useState(null) + const seq = useRef(0) + + // 从检查、发布那边带着 path 过来:拆成哪一块、块里的键,交给那一块去定位 + useEffect(() => { + if (!focusPath) return + const parts = pathParts(draft, focusPath) + const block = parts[0] as Block + if ((BLOCKS as readonly string[]).includes(block)) setFocus({ block, keys: parts.slice(1), seq: ++seq.current }) + clearFocus() + }, [focusPath]) // eslint-disable-line react-hooks/exhaustive-deps + + const check = async () => { + setChecking(true) + try { + if (dirty && !(await save())) return + const res = await api.apps.lint(appId) + setIssues(res.issues.filter((i) => i.path && (BLOCKS as readonly string[]).includes(pathParts(draft, i.path)[0]))) + } catch (e) { + toast.error(e) + } finally { + setChecking(false) + } + } + + return ( +
    + + + + {issues && ( +

    + {T.jsonChecked(issues.length)} +

    + )} + {BLOCKS.map((block) => ( + pathParts(draft, i.path ?? '')[0] === block)} + focus={focus?.block === block ? focus : null} + onLocate={(keys) => setFocus({ block, keys, seq: ++seq.current })} /> + ))} +
    + ) +} + +type Parsed = { ok: true; value: unknown } | { ok: false; message: string; at: { line: number; col: number } | null } + +function parse(text: string): Parsed { + try { + return { ok: true, value: JSON.parse(text) } + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + return { ok: false, message, at: jsonErrorAt(text, message) } + } +} + +function JsonBlock({ block, issues, focus, onLocate }: { + block: Block + /** 发布检查落在这一块上的问题 */ + issues: LintIssue[] + focus: Focus | null + onLocate: (keys: string[]) => void +}) { + const { draft, setDraft } = useEditor() + const value = draft[block] + const pretty = useMemo(() => JSON.stringify(value ?? (block === 'entries' ? [] : {}), null, 2), [value, block]) + const [text, setText] = useState(pretty) + // 上次和草稿对齐时的那份:text 和它不同就是有未应用的修改 + const [base, setBase] = useState(pretty) + const [located, setLocated] = useState(null) + const ref = useRef(null) + const local = text !== base + // 草稿在别处改了(助手、页面树):这里没有未应用的修改就跟上,有的话留着人手上的(应用时以人的为准) + useEffect(() => { + if (text === base) setText(pretty) + setBase(pretty) + }, [pretty]) // eslint-disable-line react-hooks/exhaustive-deps + + const parsed = useMemo(() => parse(text), [text]) + // 入口引用的表单按这一节的表单查:表单一块有未应用的修改时仍按草稿里的,应用之后再对 + const shape: ShapeIssue[] = parsed.ok ? shapeIssues(block, parsed.value, draft, T) : [] + + const apply = () => { + if (!parsed.ok) return + const next = parsed.value + setDraft((s: AppSpec) => ({ ...s, [block]: next })) + const fresh = JSON.stringify(next, null, 2) + setText(fresh) + setBase(fresh) + toast.ok(T.jsonApplied(TITLE[block])) + } + + /** 选中 keys 指的那一行并滚到可见处。人手上的修改是合法 JSON 时先排好版(内容不变),不合法的按草稿里的找 */ + const locate = useCallback((keys: string[]) => { + const now = parse(text) + let shown = text + let target: unknown = value + if (now.ok) { + shown = JSON.stringify(now.value, null, 2) + target = now.value + if (shown !== text) setText(shown) + } else if (local) { + return + } + const line = keys.length ? lineOfPath(target, keys) : 0 + if (line < 0) return + setLocated(line) + requestAnimationFrame(() => { + const ta = ref.current + if (!ta) return + const lines = shown.split('\n') + const start = lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0) + const end = start + (lines[line]?.length ?? 0) + ta.focus({ preventScroll: true }) + ta.setSelectionRange(start, end) + const lh = Number.parseFloat(getComputedStyle(ta).lineHeight) || 16 + ta.scrollTop = Math.max(0, line * lh - ta.clientHeight / 3) + ta.closest('[data-app-json]')?.scrollIntoView({ block: 'nearest' }) + }) + }, [text, value, local]) + + // 外面要定位到这一块(检查、发布那边点「定位」,或者这一节里点问题) + useEffect(() => { + if (focus) locate(focus.keys) + }, [focus?.seq]) // eslint-disable-line react-hooks/exhaustive-deps + + const problems: { message: string; keys: string[]; level: 'error' | 'warning'; code?: string }[] = [ + ...shape.map((x) => ({ ...x, level: 'error' as const })), + ...issues.map((i) => ({ message: i.message, keys: pathParts(draft, i.path ?? '').slice(1), level: i.level, code: i.code })), + ] + const rows = Math.min(24, Math.max(6, text.split('\n').length + 1)) + + return ( +
    + + {local && {T.jsonDirty}} + + + +