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:
@ -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<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 }>(
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本任务的往返预算编辑器(会话头部)。
|
||||
*
|
||||
|
||||
80
web/src/components/PermissionChip.tsx
Normal file
80
web/src/components/PermissionChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 }),
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
Reference in New Issue
Block a user