P5 前端:权限档位选择器 + 卡片徽标 + 对话页改档

前端完整实现:
- 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 包全绿,已部署
This commit is contained in:
2026-09-06 21:24:41 +08:00
parent 36f1099c02
commit be61724314
7 changed files with 222 additions and 10 deletions

View File

@ -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 而非 00 是「不限」的合法取值,省略才表示「不设置」
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<SessionBudget>('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 }>(

View File

@ -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<number | null>(null);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
@ -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() {
</Field>
)}
{isNewSession && (
<Field label="权限档位" hint="Agent 在这类任务里被允许动手的程度;只在新建会话时生效">
<div className="flex items-center gap-2">
{[
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
{ value: 'workspace', label: '目录内', desc: '越界问人' },
{ value: 'full', label: '全权', desc: '自动放行' },
].map(o => (
<button
key={o.value}
type="button"
onClick={() => setPermissionMode(o.value)}
title={o.desc}
className={`px-2.5 py-1.5 rounded-md border text-xs font-medium transition-colors ${
permissionMode === o.value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{o.label}
</button>
))}
</div>
</Field>
)}
{isNewSession && (
<Field label="往返预算" hint="留空 = 用该 Agent 的默认值;之后可在对话页随时调整">
<div className="flex items-center gap-2">

View File

@ -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}
</span>
<div className="flex-1" />
<PermissionEditor />
<BudgetEditor />
</div>
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
@ -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 (
<button
onClick={() => setEditing(true)}
title={permissionModeHint(mode, enforcement)}
className="inline-flex items-center"
>
<PermissionChip mode={mode} enforcement={enforcement} />
</button>
);
}
const modes = [
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
{ value: 'workspace', label: '目录内', desc: '越界问人' },
{ value: 'full', label: '全权', desc: '自动放行' },
];
return (
<div className="flex items-center gap-1">
{modes.map(o => (
<button
key={o.value}
type="button"
disabled={busy}
onClick={async () => {
setBusy(true);
await setPermissionMode(o.value);
setBusy(false);
setEditing(false);
}}
title={o.desc}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors disabled:opacity-40 ${
mode === o.value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
{o.label}
</button>
))}
<button onClick={() => setEditing(false)} className="text-[10px] text-gray-400 hover:text-gray-700 ml-0.5">
×
</button>
</div>
);
}
/**
* 本任务的往返预算编辑器(会话头部)。
*

View File

@ -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<string, string> = {
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 (
<span
title={hint + (enforcement ? `(强制力:${enforcement === 'native' ? '平台强制' : '仅提示' }` : '')}
className={`inline-flex items-center gap-1 rounded border font-medium ${color} ${
compact ? 'px-1 text-[10px]' : 'px-1.5 text-xs'
}`}
>
{label}
{enforced ? (
// native平台强制 —— 实心圆点
<span className="inline-block w-1.5 h-1.5 rounded-full bg-current" />
) : (
// advisory仅提示 —— 空心圆点
<span className="inline-block w-1.5 h-1.5 rounded-full border border-current" />
)}
</span>
);
}

View File

@ -7,6 +7,7 @@ import {
PersonIcon,
BotIcon
} from './icons';
import PermissionChip from './PermissionChip';
/**
* 工作卡片:中间栏的另一种呈现。
@ -90,6 +91,7 @@ export function WorkCard({
{c.mail_count} · {time}
</span>
<div className="flex-1" />
<PermissionChip mode={c.permission_mode} enforcement={c.permission_enforcement} compact />
<BudgetChip max={c.max_rounds} used={c.used_rounds} />
</div>
</button>

View File

@ -25,7 +25,10 @@ interface SessionState {
/** 本任务的往返预算null = 尚未取到 */
budget: SessionBudget | null;
/** 改本会话预算对话页里随时调。reset 把已用次数归零。 */
/** 改本会话预算对话页里随时调。reset 把已用次数归零。 */
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
/** 改本会话权限档位(对话页里随时调)。人是权限的源头,可以任改三档。 */
setPermissionMode: (mode: string) => Promise<void>;
/** 重新拉取预算Agent 发信后剩余会变) */
refreshBudget: () => Promise<void>;
/** 归档后若正查看该会话则退出 */
@ -136,6 +139,21 @@ export const useSessionStore = create<SessionState>((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 }),

View File

@ -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';