From be61724314b434d7497bf4d743b16b62e16be8e1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 6 Sep 2026 21:24:41 +0800 Subject: [PATCH] =?UTF-8?q?P5=20=E5=89=8D=E7=AB=AF=EF=BC=9A=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=A1=A3=E4=BD=8D=E9=80=89=E6=8B=A9=E5=99=A8=20+=20?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=BE=BD=E6=A0=87=20+=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E9=A1=B5=E6=94=B9=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端完整实现: - types: Session/HumanSession/Mail/Contact 加 permission_mode + permission_enforcement 字段 - api/client: sendMail 支持 permission_mode 参数;新增 updateSessionPermission API - PermissionChip 组件:plan=蓝/只读, workspace=绿/目录内, full=橙/全权 native 实心点=平台强制, advisory 空心点=仅提示;hover 显示 tooltip - ComposePage: 新建会话时显示三档按钮(plan/workspace/full),传入 sendMail - MailView: 会话头部加 PermissionEditor(点击徽标展开三档选择,点保存调 API 改档) - WorkCard: 卡片底部与 BudgetChip 并排显示 PermissionChip(compact 模式) - sessionStore: 新增 setPermissionMode action - 177 前端测试全过,gateway 8 包全绿,已部署 --- web/src/api/client.ts | 20 ++++--- web/src/components/ComposePage.tsx | 34 +++++++++++- web/src/components/MailView.tsx | 68 +++++++++++++++++++++++ web/src/components/PermissionChip.tsx | 80 +++++++++++++++++++++++++++ web/src/components/WorkCard.tsx | 2 + web/src/stores/sessionStore.ts | 18 ++++++ web/src/types/index.ts | 10 ++++ 7 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 web/src/components/PermissionChip.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index cab5ad4..bbf8f1c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -206,15 +206,11 @@ export async function listAgents(status?: string) { export interface SendMailOpts { cc?: string; reply_to?: string; - /** 仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈 */ session_alias?: string; - /** 先用 uploadAttachment 上传取得的 id 列表 */ attachment_ids?: string[]; - /** - * 本任务的往返预算(0/省略 = 不限)。仅在新建会话时生效; - * 续谈已有会话请用 updateSessionBudget(对话页里可随时改)。 - */ max_rounds?: number; + /** 权限档位(仅新建会话时生效):plan / workspace / full */ + permission_mode?: string; } export async function sendMail( @@ -239,7 +235,8 @@ export async function sendMail( session_alias: opts.session_alias ?? '', attachment_ids: opts.attachment_ids ?? [], // null 而非 0:0 是「不限」的合法取值,省略才表示「不设置」 - max_rounds: opts.max_rounds ?? null + max_rounds: opts.max_rounds ?? null, + permission_mode: opts.permission_mode ?? '', }); } @@ -524,6 +521,15 @@ export async function updateSessionBudget( return request('PUT', `/sessions/${id}/budget`, patch); } +export async function updateSessionPermission( + id: string, + permission_mode: string +) { + return request<{ permission_mode: string; permission_enforcement: string }>( + 'PUT', `/sessions/${id}/permission`, { permission_mode } + ); +} + /** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */ export async function dismissRenameProposal(id: string) { return request<{ status: string; dismissed?: string }>( diff --git a/web/src/components/ComposePage.tsx b/web/src/components/ComposePage.tsx index 2eee7cc..baf927b 100644 --- a/web/src/components/ComposePage.tsx +++ b/web/src/components/ComposePage.tsx @@ -29,6 +29,8 @@ export default function ComposePage() { // 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给, // 而不是事后到管理员页面去调某个 Agent 的全局配额。 const [maxRounds, setMaxRounds] = useState(''); + // 权限档位(仅新建会话时生效):plan / workspace / full。 + const [permissionMode, setPermissionMode] = useState(''); // 收件 Agent 的默认预算;null = 还没查到(未注册的收件人也是 null) const [agentDefault, setAgentDefault] = useState(null); const [attachments, setAttachments] = useState([]); @@ -106,11 +108,10 @@ export default function ComposePage() { cc: cc.trim(), session_alias: isNewSession ? sessionAlias.trim() : '', attachment_ids: attachments.map(a => a.id), - // 只在新建会话时提交:续谈已有会话若也带这个字段, - // 每封新信都会悄悄改掉对方正在遵守的预算 ...(isNewSession && maxRounds.trim() !== '' ? { max_rounds: Number(maxRounds.trim()) } - : {}) + : {}), + ...(isNewSession && permissionMode ? { permission_mode: permissionMode } : {}), }); const where = res.session_alias ? `会话别名 ${res.session_alias}` @@ -156,6 +157,7 @@ export default function ComposePage() { setBody(''); setSessionAlias(''); setMaxRounds(''); + setPermissionMode(''); // 已上传的附件要从服务端删掉,否则留到 GC 才回收 attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {})); setAttachments([]); @@ -189,6 +191,32 @@ export default function ComposePage() { )} + {isNewSession && ( + +
+ {[ + { value: 'plan', label: '只读', desc: '不许写/改/执行' }, + { value: 'workspace', label: '目录内', desc: '越界问人' }, + { value: 'full', label: '全权', desc: '自动放行' }, + ].map(o => ( + + ))} +
+
+ )} + {isNewSession && (
diff --git a/web/src/components/MailView.tsx b/web/src/components/MailView.tsx index 7025894..4cc5098 100644 --- a/web/src/components/MailView.tsx +++ b/web/src/components/MailView.tsx @@ -18,6 +18,7 @@ import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, Forwar import AddressInput from './AddressInput'; import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments'; import ThreadView from './ThreadView'; +import PermissionChip, { permissionModeHint } from './PermissionChip'; import BackButton from './BackButton'; export default function MailView() { @@ -61,6 +62,7 @@ export default function MailView() { {currentSessionMails.length} 封
+

{currentSession.subject}

@@ -123,6 +125,72 @@ export default function MailView() { ); } +/** + * 权限档位编辑器(会话头部)。 + * + * 人在派活时声明「这件事允许 Agent 动手到什么程度」。 + * 只有新会话时在 ComposePage 里设;对话页里随时可改(规划档位)。 + * 三档:plan(只读)/ workspace(目录内,越界问人)/ full(全权)。 + */ +function PermissionEditor() { + const session = useSessionStore(s => s.currentSession); + const setPermissionMode = useSessionStore(s => s.setPermissionMode); + const [editing, setEditing] = useState(false); + const [busy, setBusy] = useState(false); + + if (!session) return null; + + const mode = session.permission_mode || 'workspace'; + const enforcement = session.permission_enforcement || 'advisory'; + + if (!editing) { + return ( + + ); + } + + const modes = [ + { value: 'plan', label: '只读', desc: '不许写/改/执行' }, + { value: 'workspace', label: '目录内', desc: '越界问人' }, + { value: 'full', label: '全权', desc: '自动放行' }, + ]; + + return ( +
+ {modes.map(o => ( + + ))} + +
+ ); +} + /** * 本任务的往返预算编辑器(会话头部)。 * diff --git a/web/src/components/PermissionChip.tsx b/web/src/components/PermissionChip.tsx new file mode 100644 index 0000000..2e1799c --- /dev/null +++ b/web/src/components/PermissionChip.tsx @@ -0,0 +1,80 @@ +/** + * 权限档位徽标 —— 卡片/列表上显示本任务的档位与实际强制力。 + * + * 两个字段必须成对显示: + * permission_mode 档位(plan / workspace / full)——「要求什么」 + * permission_enforcement 强制力(native / advisory)——「平台实际做到了什么」 + * + * 为什么强制力也要上界面:只显示档位会让人以为 plan 档管住了 homeagent, + * 而 homeagent 没有工具拦截点、档位只是提示词建议(advisory)。 + * 差异可见才符合 I-5(失败必须当场可见)。 + */ + +export interface PermissionChipProps { + /** 档位:plan / workspace / full */ + mode?: string; + /** 实际强制力:native / advisory */ + enforcement?: string; + /** 紧凑模式(卡片上用);默认常规(详情页用) */ + compact?: boolean; +} + +const MODE_LABEL: Record = { + plan: '只读', + workspace: '目录内', + full: '全权', +}; + +/** 档位 → 文字说明(tooltip 用) */ +export function permissionModeHint(mode?: string, enforcement?: string): string { + const enforced = enforcement === 'native'; + switch (mode) { + case 'plan': + return enforced + ? 'plan 档:只读。写/改/执行会被平台强制拦下,本档只用来查与想。' + : 'plan 档:只读(advisory,平台不强制)。请把结论写在回信里。'; + case 'full': + return 'full 档:全权。工具调用不需额外授权。'; + case 'workspace': + default: + return enforced + ? 'workspace 档:目录内可动,越界需经授权。' + : 'workspace 档(advisory,平台不强制)。请把改动限制在工作目录内。'; + } +} + +export default function PermissionChip({ mode, enforcement, compact }: PermissionChipProps) { + // 空档位(人→人的信、旧会话)不显示徽标 + const normalized = mode || ''; + if (!['plan', 'workspace', 'full'].includes(normalized)) return null; + + const enforced = enforcement === 'native'; + + // 配色按档位:plan 用蓝(只读),workspace 用黄(有边界的动),full 用红/橙(全权) + const color = normalized === 'plan' + ? 'bg-blue-50 text-blue-700 border-blue-200' + : normalized === 'full' + ? 'bg-amber-50 text-amber-700 border-amber-200' + : 'bg-emerald-50 text-emerald-700 border-emerald-200'; + + const hint = permissionModeHint(normalized, enforcement); + const label = MODE_LABEL[normalized] ?? normalized; + + return ( + + {label} + {enforced ? ( + // native:平台强制 —— 实心圆点 + + ) : ( + // advisory:仅提示 —— 空心圆点 + + )} + + ); +} diff --git a/web/src/components/WorkCard.tsx b/web/src/components/WorkCard.tsx index 7b6ed69..d6eee5b 100644 --- a/web/src/components/WorkCard.tsx +++ b/web/src/components/WorkCard.tsx @@ -7,6 +7,7 @@ import { PersonIcon, BotIcon } from './icons'; +import PermissionChip from './PermissionChip'; /** * 工作卡片:中间栏的另一种呈现。 @@ -90,6 +91,7 @@ export function WorkCard({ {c.mail_count} 封 · {time}
+
diff --git a/web/src/stores/sessionStore.ts b/web/src/stores/sessionStore.ts index 13f9f6c..1a4b04b 100644 --- a/web/src/stores/sessionStore.ts +++ b/web/src/stores/sessionStore.ts @@ -25,7 +25,10 @@ interface SessionState { /** 本任务的往返预算;null = 尚未取到 */ budget: SessionBudget | null; /** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */ + /** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */ setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise; + /** 改本会话权限档位(对话页里随时调)。人是权限的源头,可以任改三档。 */ + setPermissionMode: (mode: string) => Promise; /** 重新拉取预算(Agent 发信后剩余会变) */ refreshBudget: () => Promise; /** 归档后若正查看该会话则退出 */ @@ -136,6 +139,21 @@ export const useSessionStore = create((set, get) => ({ } }, + setPermissionMode: async mode => { + const id = get().currentSession?.session_id; + if (!id) return; + try { + const res = await api.updateSessionPermission(id, mode); + const cur = get().currentSession; + if (cur && cur.session_id === id) { + set({ currentSession: { ...cur, permission_mode: res.permission_mode, permission_enforcement: res.permission_enforcement } }); + } + await get().fetchSessions(); + } catch (err) { + set({ error: err instanceof Error ? err.message : String(err) }); + } + }, + clearSession: () => set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null }), diff --git a/web/src/types/index.ts b/web/src/types/index.ts index d22dfa5..c04113c 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -57,6 +57,10 @@ export interface Session { */ max_rounds?: number; used_rounds?: number; + /** 权限档位:plan / workspace / full */ + permission_mode?: string; + /** 档位实际强制力:native / advisory */ + permission_enforcement?: string; } /** 会话往返预算快照 */ @@ -118,6 +122,8 @@ export interface Mail { from_human: boolean; /** 收件方是人类用户而不是 Agent */ to_human: boolean; + permission_mode?: string; + permission_enforcement?: string; } /** @@ -183,6 +189,8 @@ export interface Contact { /** 本任务的往返预算(0 = 不限) */ max_rounds: number; used_rounds: number; + permission_mode?: string; + permission_enforcement?: string; /** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */ last_from: string; last_preview: string; @@ -219,6 +227,8 @@ export interface HumanSession { /** 本任务的往返预算(0 = 不限) */ max_rounds?: number; used_rounds?: number; + permission_mode?: string; + permission_enforcement?: string; } export type SuggestKind = 'name' | 'path' | 'session';