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 {
|
export interface SendMailOpts {
|
||||||
cc?: string;
|
cc?: string;
|
||||||
reply_to?: string;
|
reply_to?: string;
|
||||||
/** 仅在用 .new 新建会话时生效:给新会话命名,之后可用 name@path.<别名> 续谈 */
|
|
||||||
session_alias?: string;
|
session_alias?: string;
|
||||||
/** 先用 uploadAttachment 上传取得的 id 列表 */
|
|
||||||
attachment_ids?: string[];
|
attachment_ids?: string[];
|
||||||
/**
|
|
||||||
* 本任务的往返预算(0/省略 = 不限)。仅在新建会话时生效;
|
|
||||||
* 续谈已有会话请用 updateSessionBudget(对话页里可随时改)。
|
|
||||||
*/
|
|
||||||
max_rounds?: number;
|
max_rounds?: number;
|
||||||
|
/** 权限档位(仅新建会话时生效):plan / workspace / full */
|
||||||
|
permission_mode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMail(
|
export async function sendMail(
|
||||||
@ -239,7 +235,8 @@ export async function sendMail(
|
|||||||
session_alias: opts.session_alias ?? '',
|
session_alias: opts.session_alias ?? '',
|
||||||
attachment_ids: opts.attachment_ids ?? [],
|
attachment_ids: opts.attachment_ids ?? [],
|
||||||
// null 而非 0:0 是「不限」的合法取值,省略才表示「不设置」
|
// 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);
|
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) {
|
export async function dismissRenameProposal(id: string) {
|
||||||
return request<{ status: string; dismissed?: string }>(
|
return request<{ status: string; dismissed?: string }>(
|
||||||
|
|||||||
@ -29,6 +29,8 @@ export default function ComposePage() {
|
|||||||
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
|
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
|
||||||
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
|
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||||||
const [maxRounds, setMaxRounds] = useState('');
|
const [maxRounds, setMaxRounds] = useState('');
|
||||||
|
// 权限档位(仅新建会话时生效):plan / workspace / full。
|
||||||
|
const [permissionMode, setPermissionMode] = useState('');
|
||||||
// 收件 Agent 的默认预算;null = 还没查到(未注册的收件人也是 null)
|
// 收件 Agent 的默认预算;null = 还没查到(未注册的收件人也是 null)
|
||||||
const [agentDefault, setAgentDefault] = useState<number | null>(null);
|
const [agentDefault, setAgentDefault] = useState<number | null>(null);
|
||||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||||
@ -106,11 +108,10 @@ export default function ComposePage() {
|
|||||||
cc: cc.trim(),
|
cc: cc.trim(),
|
||||||
session_alias: isNewSession ? sessionAlias.trim() : '',
|
session_alias: isNewSession ? sessionAlias.trim() : '',
|
||||||
attachment_ids: attachments.map(a => a.id),
|
attachment_ids: attachments.map(a => a.id),
|
||||||
// 只在新建会话时提交:续谈已有会话若也带这个字段,
|
|
||||||
// 每封新信都会悄悄改掉对方正在遵守的预算
|
|
||||||
...(isNewSession && maxRounds.trim() !== ''
|
...(isNewSession && maxRounds.trim() !== ''
|
||||||
? { max_rounds: Number(maxRounds.trim()) }
|
? { max_rounds: Number(maxRounds.trim()) }
|
||||||
: {})
|
: {}),
|
||||||
|
...(isNewSession && permissionMode ? { permission_mode: permissionMode } : {}),
|
||||||
});
|
});
|
||||||
const where = res.session_alias
|
const where = res.session_alias
|
||||||
? `会话别名 ${res.session_alias}`
|
? `会话别名 ${res.session_alias}`
|
||||||
@ -156,6 +157,7 @@ export default function ComposePage() {
|
|||||||
setBody('');
|
setBody('');
|
||||||
setSessionAlias('');
|
setSessionAlias('');
|
||||||
setMaxRounds('');
|
setMaxRounds('');
|
||||||
|
setPermissionMode('');
|
||||||
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
|
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
|
||||||
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
|
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
@ -189,6 +191,32 @@ export default function ComposePage() {
|
|||||||
</Field>
|
</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 && (
|
{isNewSession && (
|
||||||
<Field label="往返预算" hint="留空 = 用该 Agent 的默认值;之后可在对话页随时调整">
|
<Field label="往返预算" hint="留空 = 用该 Agent 的默认值;之后可在对话页随时调整">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, Forwar
|
|||||||
import AddressInput from './AddressInput';
|
import AddressInput from './AddressInput';
|
||||||
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
|
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||||
import ThreadView from './ThreadView';
|
import ThreadView from './ThreadView';
|
||||||
|
import PermissionChip, { permissionModeHint } from './PermissionChip';
|
||||||
import BackButton from './BackButton';
|
import BackButton from './BackButton';
|
||||||
|
|
||||||
export default function MailView() {
|
export default function MailView() {
|
||||||
@ -61,6 +62,7 @@ export default function MailView() {
|
|||||||
{currentSessionMails.length} 封
|
{currentSessionMails.length} 封
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<PermissionEditor />
|
||||||
<BudgetEditor />
|
<BudgetEditor />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
|
<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,
|
PersonIcon,
|
||||||
BotIcon
|
BotIcon
|
||||||
} from './icons';
|
} from './icons';
|
||||||
|
import PermissionChip from './PermissionChip';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工作卡片:中间栏的另一种呈现。
|
* 工作卡片:中间栏的另一种呈现。
|
||||||
@ -90,6 +91,7 @@ export function WorkCard({
|
|||||||
{c.mail_count} 封 · {time}
|
{c.mail_count} 封 · {time}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<PermissionChip mode={c.permission_mode} enforcement={c.permission_enforcement} compact />
|
||||||
<BudgetChip max={c.max_rounds} used={c.used_rounds} />
|
<BudgetChip max={c.max_rounds} used={c.used_rounds} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -25,7 +25,10 @@ interface SessionState {
|
|||||||
/** 本任务的往返预算;null = 尚未取到 */
|
/** 本任务的往返预算;null = 尚未取到 */
|
||||||
budget: SessionBudget | null;
|
budget: SessionBudget | null;
|
||||||
/** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */
|
/** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */
|
||||||
|
/** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */
|
||||||
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
|
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
|
||||||
|
/** 改本会话权限档位(对话页里随时调)。人是权限的源头,可以任改三档。 */
|
||||||
|
setPermissionMode: (mode: string) => Promise<void>;
|
||||||
/** 重新拉取预算(Agent 发信后剩余会变) */
|
/** 重新拉取预算(Agent 发信后剩余会变) */
|
||||||
refreshBudget: () => Promise<void>;
|
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: () =>
|
clearSession: () =>
|
||||||
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null }),
|
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null }),
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,10 @@ export interface Session {
|
|||||||
*/
|
*/
|
||||||
max_rounds?: number;
|
max_rounds?: number;
|
||||||
used_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;
|
from_human: boolean;
|
||||||
/** 收件方是人类用户而不是 Agent */
|
/** 收件方是人类用户而不是 Agent */
|
||||||
to_human: boolean;
|
to_human: boolean;
|
||||||
|
permission_mode?: string;
|
||||||
|
permission_enforcement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -183,6 +189,8 @@ export interface Contact {
|
|||||||
/** 本任务的往返预算(0 = 不限) */
|
/** 本任务的往返预算(0 = 不限) */
|
||||||
max_rounds: number;
|
max_rounds: number;
|
||||||
used_rounds: number;
|
used_rounds: number;
|
||||||
|
permission_mode?: string;
|
||||||
|
permission_enforcement?: string;
|
||||||
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
|
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
|
||||||
last_from: string;
|
last_from: string;
|
||||||
last_preview: string;
|
last_preview: string;
|
||||||
@ -219,6 +227,8 @@ export interface HumanSession {
|
|||||||
/** 本任务的往返预算(0 = 不限) */
|
/** 本任务的往返预算(0 = 不限) */
|
||||||
max_rounds?: number;
|
max_rounds?: number;
|
||||||
used_rounds?: number;
|
used_rounds?: number;
|
||||||
|
permission_mode?: string;
|
||||||
|
permission_enforcement?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SuggestKind = 'name' | 'path' | 'session';
|
export type SuggestKind = 'name' | 'path' | 'session';
|
||||||
|
|||||||
Reference in New Issue
Block a user