feat(agent): 添加对持久化子智能体的支持并增强委派管理

添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。
新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。

同时增强了委派管理器的功能:
- 添加了对本地委派、插件委派和本地回退的开关控制
- 实现了持久化子智能体任务的自动检测和本地执行保护
- 增加了对不同委派类型的权限验证机制

修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。

BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。
```
This commit is contained in:
2026-03-27 10:15:35 +08:00
parent bad1e16ab4
commit 29dfd14aa6
133 changed files with 11656 additions and 220 deletions

View File

@ -0,0 +1,488 @@
'use client';
import React from 'react';
import type { OfficeMemberView, OfficeTaskStatus, OfficeView, OfficeZoneId } from '@/lib/office';
import { cn } from '@/lib/utils';
type ZoneLayout = {
x: number;
y: number;
width: number;
height: number;
};
const WORLD_WIDTH = 400;
const WORLD_HEIGHT = 225;
const RENDER_SCALE = 2;
const SCENE_WIDTH = WORLD_WIDTH * RENDER_SCALE;
const SCENE_HEIGHT = WORLD_HEIGHT * RENDER_SCALE;
const TILE_SIZE = 16;
const MAP_KEY = 'office-winter-v1';
const TILESET_KEY = 'office-winter-tileset';
const MAP_PATH = '/office/maps/office-winter-v1.tmj';
const TILESET_PATH = '/office/tiles/office-winter-tileset.png';
const PIXEL_AGENTS_BASE = '/office/vendor/pixel-agents/assets';
const FURNITURE_ASSETS = {
deskFront: { key: 'pixel-agents-desk-front', path: `${PIXEL_AGENTS_BASE}/furniture/DESK/DESK_FRONT.png` },
chairFront: { key: 'pixel-agents-chair-front', path: `${PIXEL_AGENTS_BASE}/furniture/WOODEN_CHAIR/WOODEN_CHAIR_FRONT.png` },
sofaFront: { key: 'pixel-agents-sofa-front', path: `${PIXEL_AGENTS_BASE}/furniture/SOFA/SOFA_FRONT.png` },
tableFront: { key: 'pixel-agents-table-front', path: `${PIXEL_AGENTS_BASE}/furniture/TABLE_FRONT/TABLE_FRONT.png` },
coffeeTable: { key: 'pixel-agents-coffee-table', path: `${PIXEL_AGENTS_BASE}/furniture/COFFEE_TABLE/COFFEE_TABLE.png` },
doubleBookshelf: { key: 'pixel-agents-double-bookshelf', path: `${PIXEL_AGENTS_BASE}/furniture/DOUBLE_BOOKSHELF/DOUBLE_BOOKSHELF.png` },
pcOn: { key: 'pixel-agents-pc-on', path: `${PIXEL_AGENTS_BASE}/furniture/PC/PC_FRONT_ON_1.png` },
whiteboard: { key: 'pixel-agents-whiteboard', path: `${PIXEL_AGENTS_BASE}/furniture/WHITEBOARD/WHITEBOARD.png` },
} as const;
const CHARACTER_ASSETS = [
{ key: 'pixel-agent-char-0', path: `${PIXEL_AGENTS_BASE}/characters/char_0.png` },
{ key: 'pixel-agent-char-1', path: `${PIXEL_AGENTS_BASE}/characters/char_1.png` },
{ key: 'pixel-agent-char-2', path: `${PIXEL_AGENTS_BASE}/characters/char_2.png` },
{ key: 'pixel-agent-char-3', path: `${PIXEL_AGENTS_BASE}/characters/char_3.png` },
{ key: 'pixel-agent-char-4', path: `${PIXEL_AGENTS_BASE}/characters/char_4.png` },
{ key: 'pixel-agent-char-5', path: `${PIXEL_AGENTS_BASE}/characters/char_5.png` },
] as const;
const CHARACTER_FRAME = {
width: 16,
height: 24,
columnsPerRow: 7,
frontRow: 0,
idleColumns: [0, 1, 2],
};
const ZONE_LAYOUTS: Record<OfficeZoneId, ZoneLayout> = {
reception: { x: 144, y: 28, width: 68, height: 40 },
workspace: { x: 32, y: 28, width: 86, height: 100 },
collab: { x: 152, y: 118, width: 104, height: 62 },
research: { x: 272, y: 28, width: 66, height: 66 },
alert: { x: 284, y: 92, width: 52, height: 54 },
done: { x: 30, y: 154, width: 76, height: 40 },
};
const STATUS_TONES: Record<
OfficeTaskStatus,
{ body: number; outline: number; lamp: number; badge: number; badgeText: string; text: string }
> = {
queued: { body: 0x8aa0b8, outline: 0xe8f0f8, lamp: 0xcbd5e1, badge: 0x31425b, badgeText: 'Q', text: '#e8f0f8' },
running: { body: 0x90caf9, outline: 0xf5faff, lamp: 0xfff59d, badge: 0x4a5a72, badgeText: 'R', text: '#f5faff' },
waiting: { body: 0xd8c79a, outline: 0xfff7ed, lamp: 0xfde68a, badge: 0x7c6843, badgeText: 'W', text: '#fff7ed' },
blocked: { body: 0xd96c75, outline: 0xffe4e6, lamp: 0xffab91, badge: 0x7b3340, badgeText: '!', text: '#fff1f2' },
done: { body: 0x78c27a, outline: 0xe8f5e9, lamp: 0xc5e1a5, badge: 0x44664b, badgeText: 'D', text: '#f0fdf4' },
error: { body: 0xf36d7d, outline: 0xffd1dc, lamp: 0xffab91, badge: 0x7b2634, badgeText: 'X', text: '#fff1f2' },
cancelled: { body: 0x6b7280, outline: 0xe5e7eb, lamp: 0xd1d5db, badge: 0x374151, badgeText: 'S', text: '#f3f4f6' },
};
function groupMembersByZone(members: OfficeMemberView[]) {
const grouped = new Map<OfficeZoneId, OfficeMemberView[]>();
for (const member of members) {
const bucket = grouped.get(member.zoneId);
if (bucket) {
bucket.push(member);
} else {
grouped.set(member.zoneId, [member]);
}
}
return grouped;
}
function zoneGridPoints(layout: ZoneLayout, count: number) {
if (count <= 0) return [];
const innerLeft = layout.x + 12;
const innerTop = layout.y + 14;
const innerWidth = Math.max(layout.width - 24, 10);
const innerHeight = Math.max(layout.height - 20, 10);
const columns = count <= 2 ? count : count <= 4 ? 2 : 3;
const rows = Math.ceil(count / columns);
const points: Array<{ x: number; y: number }> = [];
for (let index = 0; index < count; index += 1) {
const column = index % columns;
const row = Math.floor(index / columns);
const x = innerLeft + ((column + 0.5) * innerWidth) / columns;
const y = innerTop + ((row + 0.5) * innerHeight) / rows;
points.push({ x: Math.round(x), y: Math.round(y) });
}
return points;
}
function buildMemberPositions(office: OfficeView) {
const grouped = groupMembersByZone(office.members);
const positions = new Map<string, { x: number; y: number }>();
for (const zone of office.zones) {
const layout = ZONE_LAYOUTS[zone.id];
const members = grouped.get(zone.id) ?? [];
const points = zoneGridPoints(layout, members.length);
members.forEach((member, index) => {
positions.set(member.currentRunId, points[index] ?? { x: layout.x + 20, y: layout.y + 20 });
});
}
return positions;
}
function truncateLabel(value: string, maxLength: number) {
if (value.length <= maxLength) return value;
return `${value.slice(0, Math.max(1, maxLength - 1))}`;
}
function pickCharacterAsset(member: OfficeMemberView, index: number) {
if (member.isPrimary) return CHARACTER_ASSETS[0];
return CHARACTER_ASSETS[(index % (CHARACTER_ASSETS.length - 1)) + 1];
}
function resolveCharacterPose() {
return {
row: CHARACTER_FRAME.frontRow,
columns: CHARACTER_FRAME.idleColumns,
interval: 220,
};
}
function addFurnitureSprite(scene: any, object: any) {
const x = object.x ?? 0;
const y = object.y ?? 0;
const width = object.width ?? TILE_SIZE;
const height = object.height ?? TILE_SIZE;
const centerX = x + width / 2;
const type = object.type ?? 'anchor';
const addImage = (assetKey: string, px: number, py: number, depth = 20) =>
scene.add.image(px, py, assetKey).setOrigin(0.5, 1).setDepth(depth);
if (type === 'desk-anchor') {
const desk = addImage(FURNITURE_ASSETS.deskFront.key, centerX, y + height + 4);
const pc = addImage(FURNITURE_ASSETS.pcOn.key, centerX, y + height + 2, 21);
return [desk, pc];
}
if (type === 'chair-anchor') return [addImage(FURNITURE_ASSETS.chairFront.key, centerX, y + height + 1)];
if (type === 'sofa-anchor') return [addImage(FURNITURE_ASSETS.sofaFront.key, centerX, y + height)];
if (type === 'coffee-anchor') return [addImage(FURNITURE_ASSETS.coffeeTable.key, centerX, y + height)];
if (type === 'meeting-anchor') return [addImage(FURNITURE_ASSETS.tableFront.key, centerX, y + height + 16)];
if (type === 'server-anchor') return [addImage(FURNITURE_ASSETS.doubleBookshelf.key, centerX, y + height)];
if (type === 'archive-anchor') return [addImage(FURNITURE_ASSETS.doubleBookshelf.key, centerX, y + height)];
if (type === 'whiteboard-anchor') return [addImage(FURNITURE_ASSETS.whiteboard.key, centerX, y + height)];
return [];
}
export function OfficePhaserCanvas({
office,
selectedRunId,
onRunSelect,
className,
showMetaBar = true,
}: {
office: OfficeView;
selectedRunId: string | null;
onRunSelect: (runId: string) => void;
className?: string;
showMetaBar?: boolean;
}) {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const selectRef = React.useRef(onRunSelect);
React.useEffect(() => {
selectRef.current = onRunSelect;
}, [onRunSelect]);
React.useEffect(() => {
let destroyed = false;
let game: any = null;
async function mountScene() {
if (!containerRef.current) return;
const PhaserImport = await import('phaser');
const Phaser = (PhaserImport.default ?? PhaserImport) as any;
if (destroyed || !containerRef.current) return;
const memberPositions = buildMemberPositions(office);
class OfficeScene extends Phaser.Scene {
preload(this: any) {
if (!this.textures.exists(TILESET_KEY)) {
this.load.image(TILESET_KEY, TILESET_PATH);
}
if (!this.cache.tilemap.exists(MAP_KEY)) {
this.load.tilemapTiledJSON(MAP_KEY, MAP_PATH);
}
Object.values(FURNITURE_ASSETS).forEach((asset) => {
if (!this.textures.exists(asset.key)) {
this.load.image(asset.key, asset.path);
}
});
CHARACTER_ASSETS.forEach((asset) => {
if (!this.textures.exists(asset.key)) {
this.load.spritesheet(asset.key, asset.path, {
frameWidth: CHARACTER_FRAME.width,
frameHeight: CHARACTER_FRAME.height,
});
}
});
}
create(this: any) {
this.cameras.main.setBackgroundColor('#1a2433');
this.cameras.main.roundPixels = true;
this.cameras.main.setZoom(RENDER_SCALE);
this.cameras.main.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT);
const map = this.make.tilemap({ key: MAP_KEY });
const tileset = map.addTilesetImage('office-winter-tileset', TILESET_KEY, TILE_SIZE, TILE_SIZE, 0, 0);
if (!tileset) {
throw new Error('Failed to load office-winter-tileset into tilemap');
}
['bg-floor', 'bg-rug', 'walls', 'windows', 'markers'].forEach((layerName, index) => {
const layer = map.createLayer(layerName, tileset, 0, 0);
layer?.setDepth(index);
});
const frame = this.add.rectangle(0, 0, WORLD_WIDTH, WORLD_HEIGHT, 0x000000, 0).setOrigin(0, 0);
frame.setStrokeStyle(4, 0x101827, 1);
frame.setDepth(10);
const objectLayer = map.getObjectLayer('furniture-anchors');
objectLayer?.objects.forEach((object: any) => {
const placed = addFurnitureSprite(this, object);
if (placed.length > 0) return;
const x = object.x ?? 0;
const y = object.y ?? 0;
const width = object.width ?? TILE_SIZE;
const height = object.height ?? TILE_SIZE;
const fallback = this.add.rectangle(x, y, width, height, 0x384b69, 0.18).setOrigin(0, 0);
fallback.setStrokeStyle(2, 0x90caf9, 0.9);
fallback.setDepth(20);
});
const assignmentLines = this.add.graphics();
assignmentLines.setDepth(50);
office.assignments.forEach((assignment) => {
const from = memberPositions.get(assignment.ownerRunId);
if (!from) return;
assignment.assigneeRunIds.forEach((assigneeRunId) => {
const to = memberPositions.get(assigneeRunId);
if (!to) return;
assignmentLines.lineStyle(1, 0xffd166, 0.75);
assignmentLines.beginPath();
assignmentLines.moveTo(from.x, from.y);
assignmentLines.lineTo(to.x, to.y);
assignmentLines.strokePath();
assignmentLines.fillStyle(0xffd166, 1);
assignmentLines.fillRect(to.x - 1, to.y - 1, 2, 2);
});
});
office.members.forEach((member, memberIndex) => {
const point = memberPositions.get(member.currentRunId);
if (!point) return;
const tone = STATUS_TONES[member.status];
const isSelected = selectedRunId === member.currentRunId;
const isPrimary = member.isPrimary;
const container = this.add.container(point.x, point.y);
container.setDepth(60);
const clickTarget = this.add.rectangle(0, 0, isPrimary ? 34 : 30, isPrimary ? 36 : 32, 0x000000, 0.001);
clickTarget.setInteractive({ useHandCursor: true });
clickTarget.setOrigin(0.5);
const shadow = this.add.rectangle(0, 9, isPrimary ? 15 : 13, 4, 0x0f172a, 0.7);
shadow.setOrigin(0.5);
const characterAsset = pickCharacterAsset(member, memberIndex);
const pose = resolveCharacterPose();
let frameIndex = 0;
const character = this.add
.sprite(0, 4, characterAsset.key, 0)
.setDisplaySize(isPrimary ? 24 : 21, isPrimary ? 36 : 32)
.setOrigin(0.5, 1);
const applyCharacterFrame = () => {
const column = pose.columns[frameIndex % pose.columns.length] ?? pose.columns[0] ?? 0;
const frame = pose.row * CHARACTER_FRAME.columnsPerRow + column;
character.setFrame(frame);
frameIndex += 1;
};
applyCharacterFrame();
this.time.addEvent({
delay: pose.interval,
loop: true,
callback: applyCharacterFrame,
});
const highlight = this.add.rectangle(0, -9, isPrimary ? 14 : 12, 19, tone.body, 0.12);
highlight.setStrokeStyle(isSelected ? 2 : 1, isSelected ? 0xfef3c7 : tone.outline, isSelected ? 1 : 0.7);
highlight.setOrigin(0.5);
const lamp = this.add.rectangle(isPrimary ? 8 : 7, -9, 3, 3, tone.lamp, 1);
lamp.setStrokeStyle(1, 0x101827, 1);
lamp.setOrigin(0.5);
const badge = this.add.rectangle(0, -14, isPrimary ? 12 : 10, 5, isPrimary ? 0xffd166 : tone.badge, 1);
badge.setStrokeStyle(1, 0x101827, 1);
badge.setOrigin(0.5);
const badgeText = this.add
.text(0, -16.5, isPrimary ? 'M' : tone.badgeText, {
color: isPrimary ? '#1a2433' : tone.text,
fontFamily: '"Courier New", monospace',
fontSize: '5px',
fontStyle: 'bold',
})
.setOrigin(0.5, 0);
const name = this.add
.text(0, 14, truncateLabel(member.actorName.toUpperCase(), isPrimary ? 10 : 8), {
color: '#f5faff',
fontFamily: '"Courier New", monospace',
fontSize: isPrimary ? '5px' : '4px',
fontStyle: 'bold',
align: 'center',
})
.setOrigin(0.5, 0);
const taskLabel = this.add
.text(0, 20, truncateLabel((member.stageLabel ?? member.currentTitle).toUpperCase(), 12), {
color: '#cbd5e1',
fontFamily: '"Courier New", monospace',
fontSize: '4px',
align: 'center',
})
.setOrigin(0.5, 0);
container.add([clickTarget, shadow, highlight, badge, badgeText, character, lamp, name, taskLabel]);
clickTarget.on('pointerdown', () => {
selectRef.current(member.currentRunId);
});
clickTarget.on('pointerover', () => {
this.tweens.add({ targets: container, scaleX: 1.08, scaleY: 1.08, duration: 90 });
});
clickTarget.on('pointerout', () => {
this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 90 });
});
if (member.status === 'running') {
this.tweens.add({
targets: container,
y: point.y - 1.5,
duration: 500,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
this.tweens.add({
targets: lamp,
alpha: 0.2,
duration: 180,
yoyo: true,
repeat: -1,
});
}
if (member.status === 'blocked' || member.status === 'error') {
const warn = this.add
.text(isPrimary ? 8 : 7, -3, '!', {
color: '#fff7ed',
fontFamily: '"Courier New", monospace',
fontSize: '8px',
fontStyle: 'bold',
})
.setOrigin(0.5);
container.add(warn);
this.tweens.add({
targets: warn,
alpha: 0.25,
duration: 180,
yoyo: true,
repeat: -1,
});
}
if (member.status === 'done') {
const doneMark = this.add.rectangle(isPrimary ? 7 : 6, 7, 3, 3, 0x78c27a, 1);
doneMark.setStrokeStyle(1, 0xf0fdf4, 1);
doneMark.setOrigin(0.5);
container.add(doneMark);
}
});
}
}
game = new Phaser.Game({
type: Phaser.CANVAS,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
parent: containerRef.current,
pixelArt: true,
antialias: false,
roundPixels: true,
backgroundColor: '#1a2433',
scene: OfficeScene,
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
});
}
mountScene().catch((error) => {
console.error('Failed to mount Office Phaser canvas', error);
});
return () => {
destroyed = true;
game?.destroy(true);
};
}, [office, selectedRunId]);
return (
<div className={cn('space-y-3', className)}>
{showMetaBar ? (
<div className="flex flex-wrap items-center gap-2 text-[#cbd5e1]">
<span className="rounded-none border-2 border-[#5a7092] bg-[#1a2433] px-3 py-1 text-[11px] font-semibold tracking-[0.2em] text-[#f5faff]">
WINTER OFFICE MAP
</span>
<span className="rounded-none border-2 border-[#30364d] bg-[#171b29] px-3 py-1 text-[11px]">
400 x 225 LOGIC / 800 x 450 RENDER
</span>
<span className="rounded-none border-2 border-[#30364d] bg-[#171b29] px-3 py-1 text-[11px]">
{office.members.length} AGENTS
</span>
<span className="rounded-none border-2 border-[#30364d] bg-[#171b29] px-3 py-1 text-[11px]">
{office.assignments.length} LINKS
</span>
</div>
) : null}
<div className="overflow-hidden rounded-none border-4 border-[#0e1119] bg-[#171522] p-3 shadow-[0_0_0_2px_#2a223b_inset]">
<div
className="mx-auto w-full max-w-[1200px] overflow-hidden border-4 border-[#5a7092] bg-[#1a2433]"
style={{ aspectRatio: `${WORLD_WIDTH} / ${WORLD_HEIGHT}` }}
>
<div
ref={containerRef}
className="h-full w-full [&_canvas]:!block [&_canvas]:!h-full [&_canvas]:!w-full [&_canvas]:image-rendering-[pixelated]"
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { OfficeTaskStatus, OfficeZoneView } from '@/lib/office';
import { officeTaskStatusLabel } from '@/lib/office';
export function OfficeStatusBadge({
status,
className,
}: {
status: OfficeTaskStatus;
className?: string;
}) {
return (
<Badge
variant="outline"
className={cn(
'border text-[11px]',
status === 'done' && 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700',
status === 'running' && 'border-sky-500/30 bg-sky-500/10 text-sky-700',
status === 'waiting' && 'border-amber-500/30 bg-amber-500/10 text-amber-700',
status === 'blocked' && 'border-orange-500/30 bg-orange-500/10 text-orange-700',
status === 'queued' && 'border-slate-500/30 bg-slate-500/10 text-slate-700',
status === 'error' && 'border-rose-500/30 bg-rose-500/10 text-rose-700',
status === 'cancelled' && 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700',
className
)}
>
{officeTaskStatusLabel(status)}
</Badge>
);
}
export function formatOfficeTime(value?: string | null): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function formatOfficeDuration(durationMs: number | null): string {
if (durationMs === null || durationMs < 0) return '-';
if (durationMs < 1000) return '<1s';
const seconds = Math.floor(durationMs / 1000);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${remainingSeconds}s`;
return `${remainingSeconds}s`;
}
export function progressPercent(value: number | null, max: number | null): number {
if (value === null || max === null || max <= 0) return 0;
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
}
export function zonePanelClassName(zone: OfficeZoneView): string {
return cn(
'relative min-h-[220px] overflow-hidden rounded-2xl border p-4 shadow-sm',
'before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.9),transparent_40%)]',
zone.tone === 'info' && 'border-sky-200 bg-[linear-gradient(180deg,rgba(240,249,255,0.95),rgba(224,242,254,0.7))]',
zone.tone === 'warn' && 'border-amber-200 bg-[linear-gradient(180deg,rgba(255,251,235,0.95),rgba(254,243,199,0.72))]',
zone.tone === 'danger' && 'border-rose-200 bg-[linear-gradient(180deg,rgba(255,241,242,0.96),rgba(255,228,230,0.76))]',
zone.tone === 'success' && 'border-emerald-200 bg-[linear-gradient(180deg,rgba(236,253,245,0.96),rgba(209,250,229,0.74))]',
zone.tone === 'neutral' && 'border-border bg-card'
);
}