```
feat(agent): 添加对持久化子智能体的支持并增强委派管理 添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。 新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。 同时增强了委派管理器的功能: - 添加了对本地委派、插件委派和本地回退的开关控制 - 实现了持久化子智能体任务的自动检测和本地执行保护 - 增加了对不同委派类型的权限验证机制 修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。 BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。 ```
This commit is contained in:
@ -8,10 +8,17 @@ import { MessageSquare, Activity, Clock, Puzzle, Blocks, HelpCircle, FolderOpen,
|
||||
import { logout } from '@/lib/api';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
type NavItem = {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
matchPrefixes?: string[];
|
||||
};
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ name: '对话', href: '/', icon: MessageSquare },
|
||||
{ name: '状态', href: '/status', icon: Activity },
|
||||
{ name: '定时任务', href: '/cron', icon: Clock },
|
||||
{ name: '任务管理', href: '/office', icon: Clock, matchPrefixes: ['/office', '/cron'] },
|
||||
{ name: '技能', href: '/skills', icon: Puzzle },
|
||||
{ name: '插件', href: '/plugins', icon: Blocks },
|
||||
{ name: '智能体', href: '/agents', icon: Bot },
|
||||
@ -97,7 +104,7 @@ const Header = () => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href);
|
||||
: item.matchPrefixes?.some((prefix) => pathname.startsWith(prefix)) ?? pathname.startsWith(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
|
||||
@ -0,0 +1,533 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { CheckCircle2, Loader2, Sparkles, Square } from 'lucide-react';
|
||||
|
||||
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type RunCardPhase = 'live' | 'exiting' | 'collapsed';
|
||||
|
||||
type AgentFeedItem = {
|
||||
key: string;
|
||||
created_at: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
text: string;
|
||||
tone?: ProcessRun['status'];
|
||||
};
|
||||
|
||||
const TERMINAL_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cancelled']);
|
||||
|
||||
const AGENT_ACCENTS = [
|
||||
{
|
||||
frame: 'border-sky-500/25 bg-sky-500/[0.05]',
|
||||
title: 'text-sky-300',
|
||||
dot: 'bg-sky-400',
|
||||
result: 'border-sky-500/25 bg-sky-500/[0.08]',
|
||||
},
|
||||
{
|
||||
frame: 'border-emerald-500/25 bg-emerald-500/[0.05]',
|
||||
title: 'text-emerald-300',
|
||||
dot: 'bg-emerald-400',
|
||||
result: 'border-emerald-500/25 bg-emerald-500/[0.08]',
|
||||
},
|
||||
{
|
||||
frame: 'border-amber-500/25 bg-amber-500/[0.05]',
|
||||
title: 'text-amber-300',
|
||||
dot: 'bg-amber-400',
|
||||
result: 'border-amber-500/25 bg-amber-500/[0.08]',
|
||||
},
|
||||
{
|
||||
frame: 'border-fuchsia-500/25 bg-fuchsia-500/[0.05]',
|
||||
title: 'text-fuchsia-300',
|
||||
dot: 'bg-fuchsia-400',
|
||||
result: 'border-fuchsia-500/25 bg-fuchsia-500/[0.08]',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function accentFor(index: number) {
|
||||
return AGENT_ACCENTS[index % AGENT_ACCENTS.length];
|
||||
}
|
||||
|
||||
function statusLabel(status: ProcessRun['status']) {
|
||||
if (status === 'done') return '已完成';
|
||||
if (status === 'error') return '失败';
|
||||
if (status === 'cancelled') return '已取消';
|
||||
if (status === 'waiting') return '等待中';
|
||||
if (status === 'queued') return '排队中';
|
||||
return '进行中';
|
||||
}
|
||||
|
||||
function statusTone(status: ProcessRun['status']) {
|
||||
if (status === 'done') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300';
|
||||
if (status === 'error') return 'border-rose-500/20 bg-rose-500/10 text-rose-300';
|
||||
if (status === 'cancelled') return 'border-zinc-500/20 bg-zinc-500/10 text-zinc-300';
|
||||
if (status === 'waiting') return 'border-amber-500/20 bg-amber-500/10 text-amber-300';
|
||||
if (status === 'queued') return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
|
||||
return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
|
||||
}
|
||||
|
||||
function roleLabel(role: AgentFeedItem['role']) {
|
||||
if (role === 'user') return '主 agent';
|
||||
if (role === 'tool') return '工具输出';
|
||||
if (role === 'system') return '状态';
|
||||
return '子 agent';
|
||||
}
|
||||
|
||||
function feedTone(role: AgentFeedItem['role']) {
|
||||
if (role === 'user') {
|
||||
return 'ml-6 border-border/70 bg-muted/60 text-foreground';
|
||||
}
|
||||
if (role === 'system') {
|
||||
return 'mx-4 border-border/60 bg-accent/60 text-foreground/85';
|
||||
}
|
||||
if (role === 'tool') {
|
||||
return 'mr-6 border-border/70 bg-background/80 text-foreground';
|
||||
}
|
||||
return 'mr-6 border-border/70 bg-background/80 text-foreground';
|
||||
}
|
||||
|
||||
function artifactPreview(artifact: ProcessArtifact): string {
|
||||
if (artifact.artifact_type === 'link' && artifact.url) {
|
||||
return `${artifact.title}\n${artifact.url}`;
|
||||
}
|
||||
if ((artifact.artifact_type === 'text' || artifact.artifact_type === 'markdown') && artifact.content) {
|
||||
return `${artifact.title}\n${artifact.content}`;
|
||||
}
|
||||
if (artifact.artifact_type === 'json') {
|
||||
return `${artifact.title}\n已生成结构化结果`;
|
||||
}
|
||||
if (artifact.file_id) {
|
||||
return `${artifact.title}\n已生成文件输出`;
|
||||
}
|
||||
return artifact.title;
|
||||
}
|
||||
|
||||
function delegatedTask(run: ProcessRun): string | null {
|
||||
const value = run.metadata?.delegated_task;
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function buildFeed(
|
||||
run: ProcessRun,
|
||||
events: ProcessEvent[],
|
||||
artifacts: ProcessArtifact[],
|
||||
): AgentFeedItem[] {
|
||||
const items: AgentFeedItem[] = [];
|
||||
let hasLeadBubble = false;
|
||||
|
||||
for (const event of events) {
|
||||
if (!event.text?.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (event.kind === 'run_message') {
|
||||
const role = event.message_role || 'assistant';
|
||||
if (role === 'user') {
|
||||
hasLeadBubble = true;
|
||||
}
|
||||
items.push({
|
||||
key: event.event_id,
|
||||
created_at: event.created_at,
|
||||
role,
|
||||
text: event.text.trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.kind === 'run_progress') {
|
||||
items.push({
|
||||
key: event.event_id,
|
||||
created_at: event.created_at,
|
||||
role: 'assistant',
|
||||
text: event.text.trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (event.kind === 'run_status' && event.status && event.status !== 'running') {
|
||||
items.push({
|
||||
key: event.event_id,
|
||||
created_at: event.created_at,
|
||||
role: 'system',
|
||||
text: event.text.trim(),
|
||||
tone: event.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
items.push({
|
||||
key: artifact.artifact_id,
|
||||
created_at: artifact.created_at,
|
||||
role: artifact.actor_type === 'mcp' ? 'tool' : 'assistant',
|
||||
text: artifactPreview(artifact),
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasLeadBubble) {
|
||||
const task = delegatedTask(run);
|
||||
if (task) {
|
||||
items.push({
|
||||
key: `${run.run_id}:delegated-task`,
|
||||
created_at: run.started_at,
|
||||
role: 'user',
|
||||
text: task,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
.slice(-8);
|
||||
}
|
||||
|
||||
function runSummary(run: ProcessRun, feed: AgentFeedItem[]): string {
|
||||
if (run.summary?.trim()) {
|
||||
return run.summary.trim();
|
||||
}
|
||||
const latestAssistant = [...feed].reverse().find((item) => item.role === 'assistant' || item.role === 'tool');
|
||||
return latestAssistant?.text || '已完成子任务处理';
|
||||
}
|
||||
|
||||
function useRunCardPhases(runs: ProcessRun[]) {
|
||||
const [phases, setPhases] = React.useState<Record<string, RunCardPhase>>(() =>
|
||||
Object.fromEntries(
|
||||
runs.map((run) => [run.run_id, TERMINAL_STATUSES.has(run.status) ? 'collapsed' : 'live'])
|
||||
)
|
||||
);
|
||||
const timersRef = React.useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
setPhases((prev) => {
|
||||
const next = { ...prev };
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const run of runs) {
|
||||
seen.add(run.run_id);
|
||||
const isTerminal = TERMINAL_STATUSES.has(run.status);
|
||||
const current = next[run.run_id];
|
||||
if (!current) {
|
||||
next[run.run_id] = isTerminal ? 'collapsed' : 'live';
|
||||
continue;
|
||||
}
|
||||
if (!isTerminal) {
|
||||
next[run.run_id] = 'live';
|
||||
if (timersRef.current[run.run_id]) {
|
||||
clearTimeout(timersRef.current[run.run_id]);
|
||||
delete timersRef.current[run.run_id];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current === 'live') {
|
||||
next[run.run_id] = 'exiting';
|
||||
timersRef.current[run.run_id] = setTimeout(() => {
|
||||
setPhases((snapshot) => {
|
||||
if (snapshot[run.run_id] !== 'exiting') {
|
||||
return snapshot;
|
||||
}
|
||||
return { ...snapshot, [run.run_id]: 'collapsed' };
|
||||
});
|
||||
delete timersRef.current[run.run_id];
|
||||
}, 420);
|
||||
}
|
||||
}
|
||||
|
||||
for (const runId of Object.keys(next)) {
|
||||
if (!seen.has(runId)) {
|
||||
if (timersRef.current[runId]) {
|
||||
clearTimeout(timersRef.current[runId]);
|
||||
delete timersRef.current[runId];
|
||||
}
|
||||
delete next[runId];
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
return () => {
|
||||
for (const timer of Object.values(timersRef.current)) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timersRef.current = {};
|
||||
};
|
||||
}, [runs]);
|
||||
|
||||
return phases;
|
||||
}
|
||||
|
||||
function AgentBubble({ item }: { item: AgentFeedItem }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border px-3 py-2 text-[13px] leading-5 transition-colors',
|
||||
feedTone(item.role),
|
||||
item.role === 'system' && item.tone ? statusTone(item.tone) : ''
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
<span>{roleLabel(item.role)}</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveAgentCard({
|
||||
run,
|
||||
feed,
|
||||
artifactCount,
|
||||
selected,
|
||||
phase,
|
||||
accentIndex,
|
||||
onSelect,
|
||||
}: {
|
||||
run: ProcessRun;
|
||||
feed: AgentFeedItem[];
|
||||
artifactCount: number;
|
||||
selected: boolean;
|
||||
phase: RunCardPhase;
|
||||
accentIndex: number;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const showSpinner = !TERMINAL_STATUSES.has(run.status);
|
||||
const accent = accentFor(accentIndex);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'min-w-[308px] max-w-[308px] rounded-[22px] border bg-card/70 p-3.5 text-left backdrop-blur-sm transition-all duration-300',
|
||||
accent.frame,
|
||||
selected ? 'ring-1 ring-primary/40 shadow-[0_18px_36px_-30px_rgba(15,23,42,0.75)]' : 'hover:border-primary/30',
|
||||
phase === 'exiting' && 'pointer-events-none scale-[0.94] -translate-y-2 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
|
||||
<span>Sub-Agent</span>
|
||||
</div>
|
||||
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{run.title}</div>
|
||||
</div>
|
||||
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
|
||||
{statusLabel(run.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[18px] border border-border/60 bg-background/55 p-2.5">
|
||||
<div className="max-h-[280px] space-y-2.5 overflow-y-auto pr-1">
|
||||
{feed.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-background/60 px-4 py-5 text-center text-sm text-muted-foreground">
|
||||
等待子 agent 输出...
|
||||
</div>
|
||||
)}
|
||||
{feed.map((item) => (
|
||||
<AgentBubble key={item.key} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{showSpinner && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-2.5 py-1 text-foreground/80">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
运行中
|
||||
</span>
|
||||
)}
|
||||
{artifactCount > 0 && <span>{artifactCount} 个输出</span>}
|
||||
{typeof run.source === 'string' && run.source.trim() && <span>{run.source}</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCard({
|
||||
run,
|
||||
summary,
|
||||
artifactCount,
|
||||
selected,
|
||||
accentIndex,
|
||||
onSelect,
|
||||
}: {
|
||||
run: ProcessRun;
|
||||
summary: string;
|
||||
artifactCount: number;
|
||||
selected: boolean;
|
||||
accentIndex: number;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const accent = accentFor(accentIndex);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'min-w-[188px] max-w-[228px] rounded-2xl border bg-card/70 px-3.5 py-3 text-left backdrop-blur-sm transition-colors',
|
||||
accent.result,
|
||||
selected ? 'ring-1 ring-primary/35' : 'hover:border-primary/25'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Result</div>
|
||||
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
|
||||
</div>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-3 text-sm text-foreground/80">{summary}</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
|
||||
{statusLabel(run.status)}
|
||||
</Badge>
|
||||
{artifactCount > 0 && <span>{artifactCount} 个输出</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentTeamBlock({
|
||||
rootRun,
|
||||
memberRuns,
|
||||
events,
|
||||
artifacts,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onCancelRun,
|
||||
}: {
|
||||
rootRun: ProcessRun;
|
||||
memberRuns: ProcessRun[];
|
||||
events: ProcessEvent[];
|
||||
artifacts: ProcessArtifact[];
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
}) {
|
||||
const phases = useRunCardPhases(memberRuns);
|
||||
const sortedRuns = React.useMemo(
|
||||
() =>
|
||||
[...memberRuns].sort((a, b) => {
|
||||
const at = new Date(a.started_at).getTime();
|
||||
const bt = new Date(b.started_at).getTime();
|
||||
return at - bt;
|
||||
}),
|
||||
[memberRuns]
|
||||
);
|
||||
const liveRuns = sortedRuns.filter((run) => phases[run.run_id] === 'live');
|
||||
const terminalRuns = sortedRuns.filter((run) => TERMINAL_STATUSES.has(run.status));
|
||||
const collapsedRuns = sortedRuns.filter((run) => phases[run.run_id] === 'collapsed');
|
||||
const liveCount = liveRuns.filter((run) => !TERMINAL_STATUSES.has(run.status)).length;
|
||||
const canCancelRoot =
|
||||
!rootRun.parent_run_id &&
|
||||
(rootRun.status === 'running' || rootRun.status === 'waiting');
|
||||
|
||||
if (liveRuns.length === 0 && terminalRuns.length > 0) {
|
||||
return (
|
||||
<div className="inline-flex max-w-full flex-wrap items-start gap-2 rounded-2xl border border-border/60 bg-card/35 px-3 py-3 backdrop-blur-sm">
|
||||
<div className="mr-1 flex min-h-[68px] min-w-[132px] max-w-[180px] flex-col justify-center">
|
||||
<div className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Agent Results
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm font-medium text-foreground">{rootRun.title}</div>
|
||||
</div>
|
||||
{terminalRuns.map((run, index) => {
|
||||
const runEvents = events.filter((event) => event.run_id === run.run_id);
|
||||
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts);
|
||||
return (
|
||||
<ResultCard
|
||||
key={run.run_id}
|
||||
run={run}
|
||||
summary={runSummary(run, feed)}
|
||||
artifactCount={runArtifacts.length}
|
||||
selected={selectedRunId === run.run_id}
|
||||
accentIndex={index}
|
||||
onSelect={() => onSelectRun(run.run_id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[24px] border border-border/70 bg-card/45 p-3.5 backdrop-blur-sm shadow-[0_18px_42px_-34px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Agent Team
|
||||
</div>
|
||||
<div className="mt-1.5 text-base font-semibold text-foreground">{rootRun.title}</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{liveCount > 0 ? `主 agent 正在协调 ${liveCount} 个运行中的 sub-agent` : '子 agent 已完成,结果已折叠为摘要卡片'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCancelRoot && (
|
||||
<Button variant="outline" size="sm" className="bg-background/60" onClick={() => onCancelRun(rootRun.run_id)}>
|
||||
<Square className="mr-1.5 h-3.5 w-3.5" />
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="outline" className="border-border/70 bg-background/55 text-foreground/85">
|
||||
{memberRuns.length} 个 sub-agent
|
||||
</Badge>
|
||||
<Badge variant="outline" className={cn('border', statusTone(rootRun.status))}>
|
||||
{statusLabel(rootRun.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{liveRuns.length > 0 && (
|
||||
<div className="mt-3 -mx-1 overflow-x-auto pb-2">
|
||||
<div className="flex min-w-full gap-3 px-1">
|
||||
{liveRuns.map((run, index) => {
|
||||
const runEvents = events.filter((event) => event.run_id === run.run_id);
|
||||
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts);
|
||||
return (
|
||||
<LiveAgentCard
|
||||
key={run.run_id}
|
||||
run={run}
|
||||
feed={feed}
|
||||
artifactCount={runArtifacts.length}
|
||||
selected={selectedRunId === run.run_id}
|
||||
phase={phases[run.run_id] || 'live'}
|
||||
accentIndex={index}
|
||||
onSelect={() => onSelectRun(run.run_id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collapsedRuns.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{collapsedRuns.map((run, index) => {
|
||||
const runEvents = events.filter((event) => event.run_id === run.run_id);
|
||||
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
|
||||
const feed = buildFeed(run, runEvents, runArtifacts);
|
||||
return (
|
||||
<ResultCard
|
||||
key={run.run_id}
|
||||
run={run}
|
||||
summary={runSummary(run, feed)}
|
||||
artifactCount={runArtifacts.length}
|
||||
selected={selectedRunId === run.run_id}
|
||||
accentIndex={index}
|
||||
onSelect={() => onSelectRun(run.run_id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,6 @@ import React from 'react';
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { MessageList } from '@/components/chat-workbench/MessageList';
|
||||
import { ProcessLane } from '@/components/chat-workbench/ProcessLane';
|
||||
import { ArtifactSidebar } from '@/components/chat-workbench/ArtifactSidebar';
|
||||
|
||||
export function ChatWorkbench({
|
||||
@ -31,14 +30,15 @@ export function ChatWorkbench({
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
}) {
|
||||
const selectedRun = processRuns.find((item) => item.run_id === selectedRunId) || processRuns[0] || null;
|
||||
const selectedRun = selectedRunId
|
||||
? processRuns.find((item) => item.run_id === selectedRunId) || null
|
||||
: null;
|
||||
const selectedRunEvents = selectedRun
|
||||
? processEvents.filter((item) => item.run_id === selectedRun.run_id)
|
||||
: [];
|
||||
const selectedRunArtifacts = selectedRun
|
||||
? processArtifacts.filter((item) => item.run_id === selectedRun.run_id)
|
||||
: [];
|
||||
const hasProcessLane = processRuns.length > 0;
|
||||
const hasResultsPanel = Boolean(
|
||||
selectedRun &&
|
||||
(
|
||||
@ -47,13 +47,9 @@ export function ChatWorkbench({
|
||||
selectedRunArtifacts.length > 0
|
||||
)
|
||||
);
|
||||
const desktopColumns = hasProcessLane && hasResultsPanel
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px_360px]'
|
||||
: hasProcessLane
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
||||
: hasResultsPanel
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
||||
: 'lg:grid-cols-[minmax(0,1fr)]';
|
||||
const desktopColumns = hasResultsPanel
|
||||
? 'lg:grid-cols-[minmax(0,1fr)_360px]'
|
||||
: 'lg:grid-cols-[minmax(0,1fr)]';
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -64,19 +60,14 @@ export function ChatWorkbench({
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</div>
|
||||
{hasProcessLane && (
|
||||
<div className="min-h-0">
|
||||
<ProcessLane
|
||||
runs={processRuns}
|
||||
events={processEvents}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasResultsPanel && (
|
||||
<div className="min-h-0">
|
||||
<ArtifactSidebar
|
||||
@ -89,25 +80,24 @@ export function ChatWorkbench({
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden h-full">
|
||||
{!hasProcessLane && !hasResultsPanel ? (
|
||||
{!hasResultsPanel ? (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
) : (
|
||||
<Tabs defaultValue="chat" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-3 border-b border-border">
|
||||
<TabsList
|
||||
className={`grid w-full ${
|
||||
hasProcessLane && hasResultsPanel
|
||||
? 'grid-cols-3'
|
||||
: 'grid-cols-2'
|
||||
}`}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="chat">聊天</TabsTrigger>
|
||||
{hasProcessLane && <TabsTrigger value="process">过程</TabsTrigger>}
|
||||
{hasResultsPanel && <TabsTrigger value="results">结果</TabsTrigger>}
|
||||
</TabsList>
|
||||
</div>
|
||||
@ -117,19 +107,14 @@ export function ChatWorkbench({
|
||||
isThinking={isThinking}
|
||||
messagesEndRef={messagesEndRef}
|
||||
viewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</TabsContent>
|
||||
{hasProcessLane && (
|
||||
<TabsContent value="process" className="flex-1 min-h-0 mt-0">
|
||||
<ProcessLane
|
||||
runs={processRuns}
|
||||
events={processEvents}
|
||||
selectedRunId={selectedRun?.run_id || null}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{hasResultsPanel && (
|
||||
<TabsContent value="results" className="flex-1 min-h-0 mt-0">
|
||||
<ArtifactSidebar
|
||||
|
||||
@ -3,8 +3,9 @@
|
||||
import React from 'react';
|
||||
import { Bot, Loader2, Paperclip, User } from 'lucide-react';
|
||||
|
||||
import type { ChatMessage } from '@/types';
|
||||
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
import { getAccessToken, getFileUrl } from '@/lib/api';
|
||||
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
@ -108,21 +109,120 @@ function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
);
|
||||
}
|
||||
|
||||
type AgentTeamGroup = {
|
||||
rootRun: ProcessRun;
|
||||
memberRuns: ProcessRun[];
|
||||
startedAt: string;
|
||||
};
|
||||
|
||||
function parseTimelineTime(value?: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function buildAgentTeamGroups(processRuns: ProcessRun[]): AgentTeamGroup[] {
|
||||
const runMap = new Map(processRuns.map((run) => [run.run_id, run]));
|
||||
const groups = new Map<string, AgentTeamGroup>();
|
||||
|
||||
for (const run of processRuns) {
|
||||
if (run.actor_type !== 'agent') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let root = run;
|
||||
const seen = new Set<string>([run.run_id]);
|
||||
let parentId = run.parent_run_id ?? null;
|
||||
while (parentId) {
|
||||
const parent = runMap.get(parentId);
|
||||
if (!parent || seen.has(parent.run_id)) {
|
||||
break;
|
||||
}
|
||||
root = parent;
|
||||
seen.add(parent.run_id);
|
||||
parentId = parent.parent_run_id ?? null;
|
||||
}
|
||||
|
||||
const existing = groups.get(root.run_id);
|
||||
if (existing) {
|
||||
existing.memberRuns.push(run);
|
||||
continue;
|
||||
}
|
||||
groups.set(root.run_id, {
|
||||
rootRun: root,
|
||||
memberRuns: [run],
|
||||
startedAt: root.started_at || run.started_at,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(groups.values())
|
||||
.map((group) => ({
|
||||
...group,
|
||||
memberRuns: [...group.memberRuns].sort((a: ProcessRun, b: ProcessRun) => {
|
||||
const at = parseTimelineTime(a.started_at) ?? 0;
|
||||
const bt = parseTimelineTime(b.started_at) ?? 0;
|
||||
return at - bt;
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const at = parseTimelineTime(a.startedAt) ?? 0;
|
||||
const bt = parseTimelineTime(b.startedAt) ?? 0;
|
||||
return at - bt;
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
isThinking,
|
||||
messagesEndRef,
|
||||
viewportRef,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
onCancelRun,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
isThinking: boolean;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
viewportRef: React.RefObject<HTMLDivElement>;
|
||||
processRuns: ProcessRun[];
|
||||
processEvents: ProcessEvent[];
|
||||
processArtifacts: ProcessArtifact[];
|
||||
selectedRunId: string | null;
|
||||
onSelectRun: (runId: string) => void;
|
||||
onCancelRun: (runId: string) => void;
|
||||
}) {
|
||||
const teamGroups = React.useMemo(() => buildAgentTeamGroups(processRuns), [processRuns]);
|
||||
const timelineItems = React.useMemo(() => {
|
||||
const messageItems = messages.map((message, index) => ({
|
||||
kind: 'message' as const,
|
||||
key: `${message.role}:${message.timestamp || index}:${index}`,
|
||||
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
|
||||
order: index,
|
||||
message,
|
||||
}));
|
||||
const teamItems = teamGroups.map((group, index) => ({
|
||||
kind: 'team' as const,
|
||||
key: `team:${group.rootRun.run_id}`,
|
||||
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + messages.length + index,
|
||||
order: messages.length + index,
|
||||
group,
|
||||
}));
|
||||
|
||||
return [...messageItems, ...teamItems].sort((a, b) => {
|
||||
if (a.sortTime !== b.sortTime) {
|
||||
return a.sortTime - b.sortTime;
|
||||
}
|
||||
return a.order - b.order;
|
||||
});
|
||||
}, [messages, teamGroups]);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-4" viewportRef={viewportRef}>
|
||||
<div className="max-w-4xl mx-auto py-4 space-y-4">
|
||||
{messages.length === 0 && !isThinking && (
|
||||
<div className="max-w-6xl mx-auto py-4 space-y-4">
|
||||
{messages.length === 0 && teamGroups.length === 0 && !isThinking && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">Boardware Agent Sandbox</p>
|
||||
@ -130,9 +230,22 @@ export function MessageList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={`${msg.role}:${msg.timestamp || i}:${i}`} message={msg} />
|
||||
))}
|
||||
{timelineItems.map((item) =>
|
||||
item.kind === 'message' ? (
|
||||
<MessageBubble key={item.key} message={item.message} />
|
||||
) : (
|
||||
<AgentTeamBlock
|
||||
key={item.key}
|
||||
rootRun={item.group.rootRun}
|
||||
memberRuns={item.group.memberRuns}
|
||||
events={processEvents}
|
||||
artifacts={processArtifacts}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={onSelectRun}
|
||||
onCancelRun={onCancelRun}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{isThinking && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground px-1">
|
||||
|
||||
488
app-instance/frontend/components/office/OfficePhaserCanvas.tsx
Normal file
488
app-instance/frontend/components/office/OfficePhaserCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
app-instance/frontend/components/office/OfficeShared.tsx
Normal file
76
app-instance/frontend/components/office/OfficeShared.tsx
Normal 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'
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Building2, Clock3 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TASK_MANAGEMENT_TABS = [
|
||||
{
|
||||
label: 'Office',
|
||||
href: '/office',
|
||||
icon: Building2,
|
||||
match: (pathname: string) => pathname === '/office' || pathname.startsWith('/office/'),
|
||||
},
|
||||
{
|
||||
label: '定时任务',
|
||||
href: '/cron',
|
||||
icon: Clock3,
|
||||
match: (pathname: string) => pathname === '/cron' || pathname.startsWith('/cron/'),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function TaskManagementTabs() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{TASK_MANAGEMENT_TABS.map((tab) => {
|
||||
const isActive = tab.match(pathname);
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user