Avatar Customization + Update Agent Brain (#23)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
# Browser/client gateway URL
|
# Browser/client gateway URL
|
||||||
NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
|
NEXT_PUBLIC_GATEWAY_URL=ws://localhost:18789
|
||||||
|
|
||||||
|
# Debug UI
|
||||||
|
DEBUG=true
|
||||||
|
|
||||||
# App server
|
# App server
|
||||||
# PORT=3000
|
# PORT=3000
|
||||||
# HOST=127.0.0.1
|
# HOST=127.0.0.1
|
||||||
|
|||||||
+29
-3
@@ -1,10 +1,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Claw3D will be documented in this file.
|
## [0.1.2] - 2026-03-20
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows Semantic Versioning where practical.
|
### Added
|
||||||
|
|
||||||
## [0.1.0] - 2026-03-19
|
- An in-app avatar creator for agents with live 3D preview, appearance presets, and accessory controls for customizing office avatars.
|
||||||
|
- A unified agent editor modal in the office that lets you edit avatars alongside agent brain files such as `IDENTITY.md`, `SOUL.md`, `AGENTS.md`, `USER.md`, `TOOLS.md`, `MEMORY.md`, and `HEARTBEAT.md`.
|
||||||
|
- Structured avatar profile persistence and normalization so studio settings can store full avatar appearance data per gateway and agent instead of only avatar seeds.
|
||||||
|
- A `DEBUG` environment toggle for controlling the OpenClaw event console in the office UI.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Reworked office avatar rendering so 3D agents reflect saved appearance profiles, including hair, clothing, hats, glasses, headsets, backpacks, and other visual variations.
|
||||||
|
- Replaced avatar shuffle entry points in the chat and office surfaces with avatar customization flows that open the editor directly.
|
||||||
|
- Updated the office HUD with a compact agent roster, overflow handling, and direct shortcuts into per-agent editing from the 3D office view.
|
||||||
|
- Expanded the brain editor so `IDENTITY.md` fields are edited in structured form and agent renames can be applied to the live gateway agent after saving.
|
||||||
|
- Defaulted the OpenClaw event console to a collapsed state and made it optional from environment configuration.
|
||||||
|
- Updated hydration and store state to carry full avatar profiles through agent loading, persistence, and rendering.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed WebSocket gateway authentication during the upgrade handshake by wiring access control through the `ws` `verifyClient` flow.
|
||||||
|
- Fixed the gym release directive TypeScript error by adding explicit `"release"` support to office gym directives and aligning release-hold logic.
|
||||||
|
- Corrected studio settings merging and normalization for avatar data so saved office appearances survive reloads and patch updates.
|
||||||
|
- Kept skill gym hold state active for release directives during office animation trigger reconciliation.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Added unit coverage for avatar profile persistence, studio settings normalization, and fleet hydration with structured avatar data.
|
||||||
|
- Expanded end-to-end coverage for avatar settings fixtures, office header and sidebar flows, voice reply settings persistence, disconnected office settings surfaces, and office route expectations.
|
||||||
|
|
||||||
|
## [0.1.1] - 2026-03-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -2,11 +2,21 @@ import { Suspense } from "react";
|
|||||||
import { AgentStoreProvider } from "@/features/agents/state/store";
|
import { AgentStoreProvider } from "@/features/agents/state/store";
|
||||||
import { OfficeScreen } from "@/features/office/screens/OfficeScreen";
|
import { OfficeScreen } from "@/features/office/screens/OfficeScreen";
|
||||||
|
|
||||||
|
const ENABLED_RE = /^(1|true|yes|on)$/i;
|
||||||
|
|
||||||
|
const readDebugFlag = (value: string | undefined): boolean => {
|
||||||
|
const normalized = (value ?? "").trim();
|
||||||
|
if (!normalized) return true;
|
||||||
|
return ENABLED_RE.test(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
export default function OfficePage() {
|
export default function OfficePage() {
|
||||||
|
const showOpenClawConsole = readDebugFlag(process.env.DEBUG);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentStoreProvider>
|
<AgentStoreProvider>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<OfficeScreen />
|
<OfficeScreen showOpenClawConsole={showOpenClawConsole} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AgentStoreProvider>
|
</AgentStoreProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel";
|
||||||
|
|
||||||
|
type AgentAvatarCreatorModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
initialProfile: AgentAvatarProfile | null | undefined;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (profile: AgentAvatarProfile) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentAvatarCreatorModal = ({
|
||||||
|
open,
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
initialProfile,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: AgentAvatarCreatorModalProps) => {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[140] flex items-center justify-center bg-background/85 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Customize avatar for ${agentName}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ui-panel grid w-full max-w-6xl gap-0 overflow-hidden shadow-xs xl:grid-cols-[360px_minmax(0,1fr)]"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AgentAvatarEditorPanel
|
||||||
|
agentId={agentId}
|
||||||
|
agentName={agentName}
|
||||||
|
initialProfile={initialProfile}
|
||||||
|
onCancel={onClose}
|
||||||
|
onSave={onSave}
|
||||||
|
onSaved={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { RefreshCcw, Shuffle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AGENT_AVATAR_BOTTOM_STYLE_OPTIONS,
|
||||||
|
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
|
||||||
|
AGENT_AVATAR_HAIR_COLOR_OPTIONS,
|
||||||
|
AGENT_AVATAR_HAIR_STYLE_OPTIONS,
|
||||||
|
AGENT_AVATAR_HAT_STYLE_OPTIONS,
|
||||||
|
AGENT_AVATAR_SHOE_COLOR_OPTIONS,
|
||||||
|
AGENT_AVATAR_SKIN_TONE_OPTIONS,
|
||||||
|
AGENT_AVATAR_TOP_STYLE_OPTIONS,
|
||||||
|
type AgentAvatarProfile,
|
||||||
|
createDefaultAgentAvatarProfile,
|
||||||
|
} from "@/lib/avatars/profile";
|
||||||
|
import { AgentAvatarPreview3D } from "@/features/agents/components/AgentAvatarPreview3D";
|
||||||
|
import { randomUUID } from "@/lib/uuid";
|
||||||
|
|
||||||
|
export type AgentAvatarEditorPanelProps = {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
initialProfile: AgentAvatarProfile | null | undefined;
|
||||||
|
onSave: (profile: AgentAvatarProfile) => Promise<void> | void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onSaved?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pillClassName =
|
||||||
|
"rounded-full border px-3 py-1.5 text-[11px] transition-colors";
|
||||||
|
|
||||||
|
const colorSwatchClassName =
|
||||||
|
"h-7 w-7 rounded-full border-2 transition-transform hover:scale-105";
|
||||||
|
|
||||||
|
export const AgentAvatarEditorPanel = ({
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
initialProfile,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onSaved,
|
||||||
|
}: AgentAvatarEditorPanelProps) => {
|
||||||
|
const fallbackProfile = useMemo(
|
||||||
|
() => createDefaultAgentAvatarProfile(agentId),
|
||||||
|
[agentId]
|
||||||
|
);
|
||||||
|
const resolvedInitialProfile = initialProfile ?? fallbackProfile;
|
||||||
|
const [draft, setDraft] = useState<AgentAvatarProfile>(resolvedInitialProfile);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(resolvedInitialProfile);
|
||||||
|
}, [resolvedInitialProfile]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(draft);
|
||||||
|
onSaved?.();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full min-h-0 gap-0 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<div className="border-b border-border/45 p-5 xl:border-b-0 xl:border-r">
|
||||||
|
<div className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Avatar creator
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-foreground">{agentName}</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Personalize this office avatar locally on this machine.
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 overflow-hidden rounded-xl border border-border/45 bg-[#070b16]">
|
||||||
|
<AgentAvatarPreview3D profile={draft} className="h-[360px] w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs"
|
||||||
|
onClick={() => setDraft(createDefaultAgentAvatarProfile(agentId))}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-3.5 w-3.5" />
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-secondary inline-flex items-center gap-2 px-3 py-2 text-xs"
|
||||||
|
onClick={() => setDraft(createDefaultAgentAvatarProfile(randomUUID()))}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Shuffle className="h-3.5 w-3.5" />
|
||||||
|
Randomize
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 overflow-y-auto p-5">
|
||||||
|
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-ghost px-3 py-2 text-xs"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ui-btn-primary px-3 py-2 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
void save();
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save avatar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Skin tone
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_SKIN_TONE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.body.skinTone === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
body: { ...current.body, skinTone: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Hair style
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_HAIR_STYLE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.hair.style === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
hair: { ...current.hair, style: option.id },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Hair color
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_HAIR_COLOR_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.hair.color === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
hair: { ...current.hair, color: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Top style
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_TOP_STYLE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.topStyle === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, topStyle: option.id },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Top color
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_CLOTHING_COLOR_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.topColor === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`top-${option.id}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, topColor: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Bottom style
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_BOTTOM_STYLE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.bottomStyle === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, bottomStyle: option.id },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Bottom color
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_CLOTHING_COLOR_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.bottomColor === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`bottom-${option.id}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, bottomColor: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Shoe color
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_SHOE_COLOR_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.clothing.shoesColor === option.color;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`shoes-${option.id}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={option.label}
|
||||||
|
className={`${colorSwatchClassName} ${selected ? "border-white" : "border-white/15"}`}
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
clothing: { ...current.clothing, shoesColor: option.color },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Hat
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{AGENT_AVATAR_HAT_STYLE_OPTIONS.map((option) => {
|
||||||
|
const selected = draft.accessories.hatStyle === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
accessories: { ...current.accessories, hatStyle: option.id },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3 xl:col-span-2">
|
||||||
|
<h3 className="font-mono text-[11px] font-semibold tracking-[0.06em] text-muted-foreground">
|
||||||
|
Accessories
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
key: "glasses" as const,
|
||||||
|
label: "Glasses",
|
||||||
|
enabled: draft.accessories.glasses,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "headset" as const,
|
||||||
|
label: "Headset",
|
||||||
|
enabled: draft.accessories.headset,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "backpack" as const,
|
||||||
|
label: "Backpack",
|
||||||
|
enabled: draft.accessories.backpack,
|
||||||
|
},
|
||||||
|
].map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.key}
|
||||||
|
type="button"
|
||||||
|
className={`${pillClassName} ${
|
||||||
|
option.enabled
|
||||||
|
? "border-primary bg-primary/15 text-foreground"
|
||||||
|
: "border-border/50 bg-muted/30 text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
accessories: {
|
||||||
|
...current.accessories,
|
||||||
|
[option.key]: !current.accessories[option.key],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Environment, OrbitControls } from "@react-three/drei";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
type AgentAvatarProfile,
|
||||||
|
createDefaultAgentAvatarProfile,
|
||||||
|
} from "@/lib/avatars/profile";
|
||||||
|
|
||||||
|
const PreviewFigure = ({
|
||||||
|
profile,
|
||||||
|
onFirstFrame,
|
||||||
|
}: {
|
||||||
|
profile: AgentAvatarProfile;
|
||||||
|
onFirstFrame: () => void;
|
||||||
|
}) => {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const reportedReadyRef = useRef(false);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!reportedReadyRef.current) {
|
||||||
|
reportedReadyRef.current = true;
|
||||||
|
onFirstFrame();
|
||||||
|
}
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
groupRef.current.rotation.y = Math.sin(state.clock.elapsedTime * 0.45) * 0.35 + 0.25;
|
||||||
|
});
|
||||||
|
|
||||||
|
const skin = profile.body.skinTone;
|
||||||
|
const topColor = profile.clothing.topColor;
|
||||||
|
const bottomColor = profile.clothing.bottomColor;
|
||||||
|
const shoeColor = profile.clothing.shoesColor;
|
||||||
|
const hairColor = profile.hair.color;
|
||||||
|
const accessoryColor = topColor;
|
||||||
|
const sleeveColor = profile.clothing.topStyle === "jacket" ? "#dbe4ff" : topColor;
|
||||||
|
const cuffColor = profile.clothing.topStyle === "hoodie" ? "#d1d5db" : sleeveColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef} position={[0, -0.72, 0]} scale={[1.45, 1.45, 1.45]}>
|
||||||
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.01, 0]}>
|
||||||
|
<circleGeometry args={[0.22, 24]} />
|
||||||
|
<meshBasicMaterial color="#000000" transparent opacity={0.16} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{profile.accessories.backpack ? (
|
||||||
|
<group position={[0, 0.31, -0.08]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.16, 0.2, 0.06]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<group position={[-0.05, 0.12, 0]}>
|
||||||
|
{profile.clothing.bottomStyle === "shorts" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.03, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.08, 0.08]} />
|
||||||
|
<meshLambertMaterial color={bottomColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.045, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.06, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
||||||
|
<meshLambertMaterial color={bottomColor} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
<mesh position={[0, -0.09, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
||||||
|
<meshLambertMaterial color={shoeColor} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
<group position={[0.05, 0.12, 0]}>
|
||||||
|
{profile.clothing.bottomStyle === "shorts" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.03, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.08, 0.08]} />
|
||||||
|
<meshLambertMaterial color={bottomColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.045, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.06, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
||||||
|
<meshLambertMaterial color={bottomColor} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
<mesh position={[0, -0.09, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
||||||
|
<meshLambertMaterial color={shoeColor} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<mesh position={[0, 0.3, 0]}>
|
||||||
|
<boxGeometry args={[0.2, 0.22, 0.1]} />
|
||||||
|
<meshLambertMaterial color={topColor} />
|
||||||
|
</mesh>
|
||||||
|
{profile.clothing.topStyle === "hoodie" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.37, -0.045]}>
|
||||||
|
<boxGeometry args={[0.18, 0.1, 0.03]} />
|
||||||
|
<meshLambertMaterial color={topColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.23, 0.056]}>
|
||||||
|
<boxGeometry args={[0.11, 0.03, 0.012]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{profile.clothing.topStyle === "jacket" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.3, 0.056]}>
|
||||||
|
<boxGeometry args={[0.202, 0.23, 0.012]} />
|
||||||
|
<meshLambertMaterial color="#1f2937" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.3, 0.063]}>
|
||||||
|
<boxGeometry args={[0.038, 0.21, 0.01]} />
|
||||||
|
<meshLambertMaterial color="#f8fafc" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<group position={[-0.13, 0.3, 0]}>
|
||||||
|
<mesh position={[0, -0.08, 0]}>
|
||||||
|
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
||||||
|
<meshLambertMaterial color={sleeveColor} />
|
||||||
|
</mesh>
|
||||||
|
{profile.clothing.topStyle === "hoodie" ? (
|
||||||
|
<mesh position={[0, -0.145, 0]}>
|
||||||
|
<boxGeometry args={[0.064, 0.03, 0.064]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
<mesh position={[0, -0.17, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
<group position={[0.13, 0.3, 0]}>
|
||||||
|
<mesh position={[0, -0.08, 0]}>
|
||||||
|
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
||||||
|
<meshLambertMaterial color={sleeveColor} />
|
||||||
|
</mesh>
|
||||||
|
{profile.clothing.topStyle === "hoodie" ? (
|
||||||
|
<mesh position={[0, -0.145, 0]}>
|
||||||
|
<boxGeometry args={[0.064, 0.03, 0.064]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
<mesh position={[0, -0.17, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<mesh position={[0, 0.42, 0]}>
|
||||||
|
<boxGeometry args={[0.07, 0.05, 0.07]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.5, 0]}>
|
||||||
|
<boxGeometry args={[0.17, 0.17, 0.15]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{profile.hair.style === "short" ? (
|
||||||
|
<mesh position={[0, 0.59, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.05, 0.15]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
{profile.hair.style === "parted" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.585, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.045, 0.15]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.03, 0.62, 0.01]} rotation={[0.1, 0, -0.2]}>
|
||||||
|
<boxGeometry args={[0.12, 0.03, 0.08]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{profile.hair.style === "spiky" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.58, 0]}>
|
||||||
|
<boxGeometry args={[0.17, 0.035, 0.14]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.05, 0.62, 0]} rotation={[0, 0, -0.2]}>
|
||||||
|
<boxGeometry args={[0.04, 0.06, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.635, 0]}>
|
||||||
|
<boxGeometry args={[0.04, 0.08, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.05, 0.62, 0]} rotation={[0, 0, 0.2]}>
|
||||||
|
<boxGeometry args={[0.04, 0.06, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{profile.hair.style === "bun" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.58, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.04, 0.15]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.63, -0.03]}>
|
||||||
|
<sphereGeometry args={[0.045, 16, 16]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{profile.accessories.hatStyle === "cap" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.63, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.03, 0.16]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.615, 0.07]}>
|
||||||
|
<boxGeometry args={[0.09, 0.012, 0.05]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{profile.accessories.hatStyle === "beanie" ? (
|
||||||
|
<mesh position={[0, 0.63, 0]}>
|
||||||
|
<boxGeometry args={[0.19, 0.06, 0.17]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{profile.accessories.headset ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.6, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||||
|
<torusGeometry args={[0.095, 0.008, 8, 24, Math.PI]} />
|
||||||
|
<meshLambertMaterial color="#94a3b8" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.105, 0.51, 0]}>
|
||||||
|
<boxGeometry args={[0.018, 0.05, 0.028]} />
|
||||||
|
<meshLambertMaterial color="#475569" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.105, 0.51, 0]}>
|
||||||
|
<boxGeometry args={[0.018, 0.05, 0.028]} />
|
||||||
|
<meshLambertMaterial color="#475569" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<mesh position={[-0.04, 0.505, 0.078]}>
|
||||||
|
<boxGeometry args={[0.03, 0.03, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.04, 0.505, 0.078]}>
|
||||||
|
<boxGeometry args={[0.03, 0.03, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" />
|
||||||
|
</mesh>
|
||||||
|
{profile.accessories.glasses ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[-0.04, 0.505, 0.084]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" wireframe />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.04, 0.505, 0.084]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" wireframe />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.505, 0.084]}>
|
||||||
|
<boxGeometry args={[0.02, 0.008, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<mesh position={[0, 0.46, 0.079]}>
|
||||||
|
<boxGeometry args={[0.05, 0.014, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#9c4a4a" />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AgentAvatarPreview3D = ({
|
||||||
|
profile,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
profile: AgentAvatarProfile | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
}) => {
|
||||||
|
const resolvedProfile = useMemo(
|
||||||
|
() => profile ?? createDefaultAgentAvatarProfile("preview"),
|
||||||
|
[profile]
|
||||||
|
);
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsReady(false);
|
||||||
|
}, [resolvedProfile]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{!isReady ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-[#070b16] text-white/70">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/15 border-t-cyan-300" />
|
||||||
|
<div className="font-mono text-[11px] tracking-[0.08em] text-white/55">
|
||||||
|
Loading avatar...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Canvas camera={{ position: [0, 0.7, 2.5], fov: 34 }}>
|
||||||
|
<color attach="background" args={["#070b16"]} />
|
||||||
|
<ambientLight intensity={1.4} />
|
||||||
|
<directionalLight position={[3, 4, 5]} intensity={2.4} />
|
||||||
|
<directionalLight position={[-4, 2, 3]} intensity={0.9} color="#89a6ff" />
|
||||||
|
<PreviewFigure
|
||||||
|
profile={resolvedProfile}
|
||||||
|
onFirstFrame={() => {
|
||||||
|
setIsReady(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Environment preset="city" />
|
||||||
|
<OrbitControls enablePan={false} enableZoom={false} maxPolarAngle={1.8} minPolarAngle={1.1} />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
|
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { Check, ChevronRight, Clock, Cog, Mic, Pencil, Shuffle, Square, Trash2, X } from "lucide-react";
|
import { Check, ChevronRight, Clock, Cog, Mic, Pencil, Square, Trash2, X } from "lucide-react";
|
||||||
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
import type { GatewayModelChoice } from "@/lib/gateway/models";
|
||||||
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
|
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
|
||||||
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
|
||||||
@@ -1481,15 +1481,15 @@ export const AgentChatPanel = ({
|
|||||||
className="nodrag ui-btn-icon ui-btn-icon-xs agent-avatar-shuffle-btn absolute bottom-0 right-0"
|
className="nodrag ui-btn-icon ui-btn-icon-xs agent-avatar-shuffle-btn absolute bottom-0 right-0"
|
||||||
style={{ "--ui-btn-icon-size": "1.1rem" } as React.CSSProperties}
|
style={{ "--ui-btn-icon-size": "1.1rem" } as React.CSSProperties}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Shuffle avatar"
|
aria-label="Customize avatar"
|
||||||
data-testid="agent-avatar-shuffle"
|
data-testid="agent-avatar-customize"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onAvatarShuffle();
|
onAvatarShuffle();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Shuffle className="h-2 w-2" />
|
<Pencil className="h-2 w-2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Brain,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
HeartPulse,
|
||||||
|
Palette,
|
||||||
|
Shield,
|
||||||
|
UserRound,
|
||||||
|
Wrench,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
import { AgentAvatarEditorPanel } from "@/features/agents/components/AgentAvatarEditorPanel";
|
||||||
|
import { AgentBrainPanel } from "@/features/agents/components/inspect/AgentBrainPanel";
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import type { AgentFileName } from "@/lib/agents/agentFiles";
|
||||||
|
import { AGENT_FILE_META } from "@/lib/agents/agentFiles";
|
||||||
|
import { renameGatewayAgent } from "@/lib/gateway/agentConfig";
|
||||||
|
|
||||||
|
export type AgentEditorSection = "avatar" | AgentFileName;
|
||||||
|
|
||||||
|
type AgentEditorModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
client: GatewayClient | null;
|
||||||
|
agents: AgentState[];
|
||||||
|
agent: AgentState;
|
||||||
|
initialSection?: AgentEditorSection;
|
||||||
|
onClose: () => void;
|
||||||
|
onAvatarSave: (agentId: string, profile: AgentAvatarProfile) => Promise<void> | void;
|
||||||
|
onRename?: (agentId: string, name: string) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuButtonClassName =
|
||||||
|
"flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors";
|
||||||
|
|
||||||
|
const editorSections: Array<{
|
||||||
|
id: AgentEditorSection;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
icon: typeof Palette;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "IDENTITY.md",
|
||||||
|
label: "Identity",
|
||||||
|
hint: AGENT_FILE_META["IDENTITY.md"].hint,
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "avatar",
|
||||||
|
label: "Avatar",
|
||||||
|
hint: "Office appearance.",
|
||||||
|
icon: Palette,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "SOUL.md",
|
||||||
|
label: "Soul",
|
||||||
|
hint: AGENT_FILE_META["SOUL.md"].hint,
|
||||||
|
icon: Brain,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "AGENTS.md",
|
||||||
|
label: "Agents",
|
||||||
|
hint: AGENT_FILE_META["AGENTS.md"].hint,
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "USER.md",
|
||||||
|
label: "User",
|
||||||
|
hint: AGENT_FILE_META["USER.md"].hint,
|
||||||
|
icon: UserRound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TOOLS.md",
|
||||||
|
label: "Tools",
|
||||||
|
hint: AGENT_FILE_META["TOOLS.md"].hint,
|
||||||
|
icon: Wrench,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "MEMORY.md",
|
||||||
|
label: "Memory",
|
||||||
|
hint: AGENT_FILE_META["MEMORY.md"].hint,
|
||||||
|
icon: Database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "HEARTBEAT.md",
|
||||||
|
label: "Heartbeat",
|
||||||
|
hint: AGENT_FILE_META["HEARTBEAT.md"].hint,
|
||||||
|
icon: HeartPulse,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AgentEditorModal = ({
|
||||||
|
open,
|
||||||
|
client,
|
||||||
|
agents,
|
||||||
|
agent,
|
||||||
|
initialSection = "avatar",
|
||||||
|
onClose,
|
||||||
|
onAvatarSave,
|
||||||
|
onRename,
|
||||||
|
}: AgentEditorModalProps) => {
|
||||||
|
const [activeSection, setActiveSection] = useState<AgentEditorSection>(initialSection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setActiveSection(initialSection);
|
||||||
|
}, [initialSection, open, agent.agentId]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[145] flex items-center justify-center bg-background/88 p-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Edit ${agent.name}`}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-7xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute -right-3 -top-3 z-20 inline-flex h-10 w-10 items-center justify-center rounded-full border border-border/50 bg-background/92 text-muted-foreground shadow-lg transition-colors hover:text-foreground"
|
||||||
|
aria-label="Close agent editor"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="ui-panel flex h-[min(90vh,920px)] w-full overflow-hidden shadow-xs">
|
||||||
|
<aside className="flex w-[240px] shrink-0 flex-col border-r border-border/50 bg-muted/20">
|
||||||
|
<div className="border-b border-border/40 px-5 py-4">
|
||||||
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
Agent editor
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-lg font-semibold text-foreground">
|
||||||
|
{agent.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Edit avatar and agent brain settings from the office.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||||
|
{editorSections.map((section) => {
|
||||||
|
const Icon = section.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
className={`${menuButtonClassName} ${
|
||||||
|
activeSection === section.id
|
||||||
|
? "border-primary/40 bg-primary/10 text-foreground"
|
||||||
|
: "border-border/45 bg-background/40 text-muted-foreground hover:border-border hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{section.label}</div>
|
||||||
|
<div className="text-xs opacity-75">{section.hint}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{activeSection === "avatar" ? (
|
||||||
|
<AgentAvatarEditorPanel
|
||||||
|
agentId={agent.agentId}
|
||||||
|
agentName={agent.name}
|
||||||
|
initialProfile={agent.avatarProfile}
|
||||||
|
onCancel={onClose}
|
||||||
|
onSave={(profile) => onAvatarSave(agent.agentId, profile)}
|
||||||
|
/>
|
||||||
|
) : client ? (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="border-b border-border/40 px-6 py-4">
|
||||||
|
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
Agent file editor
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Edit one agent file at a time and save it through the gateway.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<AgentBrainPanel
|
||||||
|
client={client}
|
||||||
|
agents={agents}
|
||||||
|
selectedAgentId={agent.agentId}
|
||||||
|
activeSection={activeSection}
|
||||||
|
onCancel={onClose}
|
||||||
|
onRename={
|
||||||
|
onRename ??
|
||||||
|
(async (agentId, name) => {
|
||||||
|
if (!client) return false;
|
||||||
|
try {
|
||||||
|
await renameGatewayAgent({ client, agentId, name });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
|
||||||
|
Connect to a gateway to edit brain files.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import type { AgentState } from "@/features/agents/state/store";
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
import {
|
||||||
|
AGENT_FILE_META,
|
||||||
|
AGENT_FILE_PLACEHOLDERS,
|
||||||
|
type AgentFileName,
|
||||||
|
} from "@/lib/agents/agentFiles";
|
||||||
import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder";
|
import { parsePersonalityFiles, serializePersonalityFiles } from "@/lib/agents/personalityBuilder";
|
||||||
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
|
import { useAgentFilesEditor } from "@/features/agents/hooks/useAgentFilesEditor";
|
||||||
|
|
||||||
@@ -11,7 +16,10 @@ export type AgentBrainPanelProps = {
|
|||||||
client: GatewayClient;
|
client: GatewayClient;
|
||||||
agents: AgentState[];
|
agents: AgentState[];
|
||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
|
activeSection?: AgentFileName;
|
||||||
|
onCancel?: () => void;
|
||||||
onUnsavedChangesChange?: (dirty: boolean) => void;
|
onUnsavedChangesChange?: (dirty: boolean) => void;
|
||||||
|
onRename?: (agentId: string, name: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AgentBrainPanelSection = ({
|
const AgentBrainPanelSection = ({
|
||||||
@@ -31,7 +39,10 @@ export const AgentBrainPanel = ({
|
|||||||
client,
|
client,
|
||||||
agents,
|
agents,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
|
activeSection,
|
||||||
|
onCancel,
|
||||||
onUnsavedChangesChange,
|
onUnsavedChangesChange,
|
||||||
|
onRename,
|
||||||
}: AgentBrainPanelProps) => {
|
}: AgentBrainPanelProps) => {
|
||||||
const selectedAgent = useMemo(
|
const selectedAgent = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -49,9 +60,9 @@ export const AgentBrainPanel = ({
|
|||||||
agentFilesError,
|
agentFilesError,
|
||||||
setAgentFileContent,
|
setAgentFileContent,
|
||||||
saveAgentFiles,
|
saveAgentFiles,
|
||||||
discardAgentFileChanges,
|
|
||||||
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
|
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
|
||||||
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
|
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
const setIdentityField = useCallback(
|
const setIdentityField = useCallback(
|
||||||
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
|
(field: "name" | "creature" | "vibe" | "emoji" | "avatar", value: string) => {
|
||||||
@@ -65,8 +76,29 @@ export const AgentBrainPanel = ({
|
|||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (agentFilesLoading || agentFilesSaving || !agentFilesDirty) return;
|
if (agentFilesLoading || agentFilesSaving || !agentFilesDirty) return;
|
||||||
await saveAgentFiles();
|
setSaveError(null);
|
||||||
}, [agentFilesDirty, agentFilesLoading, agentFilesSaving, saveAgentFiles]);
|
const saved = await saveAgentFiles();
|
||||||
|
if (!saved || !selectedAgent || !onRename) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextName = draft.identity.name.trim();
|
||||||
|
const currentName = selectedAgent.name.trim();
|
||||||
|
if (!nextName || nextName === currentName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const renamed = await onRename(selectedAgent.agentId, nextName);
|
||||||
|
if (!renamed) {
|
||||||
|
setSaveError("Saved IDENTITY.md, but could not rename the live agent.");
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
agentFilesDirty,
|
||||||
|
agentFilesLoading,
|
||||||
|
agentFilesSaving,
|
||||||
|
draft.identity.name,
|
||||||
|
onRename,
|
||||||
|
saveAgentFiles,
|
||||||
|
selectedAgent,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onUnsavedChangesChange?.(agentFilesDirty);
|
onUnsavedChangesChange?.(agentFilesDirty);
|
||||||
@@ -78,6 +110,102 @@ export const AgentBrainPanel = ({
|
|||||||
};
|
};
|
||||||
}, [onUnsavedChangesChange]);
|
}, [onUnsavedChangesChange]);
|
||||||
|
|
||||||
|
const renderMarkdownEditor = useCallback(
|
||||||
|
(name: Exclude<AgentFileName, "IDENTITY.md">) => (
|
||||||
|
<AgentBrainPanelSection title={AGENT_FILE_META[name].title}>
|
||||||
|
<div className="text-xs text-muted-foreground">{AGENT_FILE_META[name].hint}</div>
|
||||||
|
<textarea
|
||||||
|
aria-label={AGENT_FILE_META[name].title}
|
||||||
|
className="h-[min(56vh,480px)] w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
||||||
|
value={agentFiles[name].content}
|
||||||
|
placeholder={AGENT_FILE_PLACEHOLDERS[name]}
|
||||||
|
disabled={agentFilesLoading || agentFilesSaving}
|
||||||
|
onChange={(event) => {
|
||||||
|
setAgentFileContent(name, event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AgentBrainPanelSection>
|
||||||
|
),
|
||||||
|
[agentFiles, agentFilesLoading, agentFilesSaving, setAgentFileContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderIdentityEditor = useCallback(
|
||||||
|
() => (
|
||||||
|
<section className="space-y-3 border-t border-border/55 pt-8 first:border-t-0 first:pt-0">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">{AGENT_FILE_META["IDENTITY.md"].title}</h3>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{AGENT_FILE_META["IDENTITY.md"].hint}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Changing <span className="font-medium text-foreground">Name</span> here also renames the live agent
|
||||||
|
when you save.
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||||
|
value={draft.identity.name}
|
||||||
|
disabled={agentFilesLoading || agentFilesSaving}
|
||||||
|
onChange={(event) => {
|
||||||
|
setIdentityField("name", event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||||
|
Creature
|
||||||
|
<input
|
||||||
|
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||||
|
value={draft.identity.creature}
|
||||||
|
disabled={agentFilesLoading || agentFilesSaving}
|
||||||
|
onChange={(event) => {
|
||||||
|
setIdentityField("creature", event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||||
|
Vibe
|
||||||
|
<input
|
||||||
|
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||||
|
value={draft.identity.vibe}
|
||||||
|
disabled={agentFilesLoading || agentFilesSaving}
|
||||||
|
onChange={(event) => {
|
||||||
|
setIdentityField("vibe", event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
||||||
|
Emoji
|
||||||
|
<input
|
||||||
|
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
||||||
|
value={draft.identity.emoji}
|
||||||
|
disabled={agentFilesLoading || agentFilesSaving}
|
||||||
|
onChange={(event) => {
|
||||||
|
setIdentityField("emoji", event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
),
|
||||||
|
[agentFilesLoading, agentFilesSaving, draft.identity, setIdentityField],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderedSections = useMemo(() => {
|
||||||
|
if (activeSection === "IDENTITY.md") {
|
||||||
|
return [renderIdentityEditor()];
|
||||||
|
}
|
||||||
|
if (activeSection) {
|
||||||
|
return [renderMarkdownEditor(activeSection as Exclude<AgentFileName, "IDENTITY.md">)];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
renderMarkdownEditor("SOUL.md"),
|
||||||
|
renderMarkdownEditor("AGENTS.md"),
|
||||||
|
renderMarkdownEditor("USER.md"),
|
||||||
|
renderIdentityEditor(),
|
||||||
|
];
|
||||||
|
}, [activeSection, renderIdentityEditor, renderMarkdownEditor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="agent-inspect-panel flex min-h-0 flex-col overflow-hidden"
|
className="agent-inspect-panel flex min-h-0 flex-col overflow-hidden"
|
||||||
@@ -94,19 +222,24 @@ export const AgentBrainPanel = ({
|
|||||||
{agentFilesError}
|
{agentFilesError}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{saveError ? (
|
||||||
|
<div className="ui-alert-danger mb-4 rounded-md px-3 py-2 text-xs">
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mb-6 flex items-center justify-end gap-2">
|
<div className="mb-6 flex items-center justify-end gap-2 border-b border-border/40 pb-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ui-btn-secondary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:opacity-50"
|
className="ui-btn-ghost px-3 py-2 text-xs"
|
||||||
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
|
disabled={agentFilesLoading || agentFilesSaving}
|
||||||
onClick={discardAgentFileChanges}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
Discard
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ui-btn-primary px-3 py-1 font-mono text-[10px] font-semibold tracking-[0.06em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
|
className="ui-btn-primary px-3 py-2 text-xs disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
|
||||||
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
|
disabled={agentFilesLoading || agentFilesSaving || !agentFilesDirty}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleSave();
|
void handleSave();
|
||||||
@@ -116,104 +249,7 @@ export const AgentBrainPanel = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8 pb-8">
|
<div className="space-y-8 pb-8">{renderedSections}</div>
|
||||||
<AgentBrainPanelSection title="Persona">
|
|
||||||
<textarea
|
|
||||||
aria-label="Persona"
|
|
||||||
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
|
||||||
value={agentFiles["SOUL.md"].content}
|
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setAgentFileContent("SOUL.md", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AgentBrainPanelSection>
|
|
||||||
|
|
||||||
<AgentBrainPanelSection title="Directives">
|
|
||||||
<textarea
|
|
||||||
aria-label="Directives"
|
|
||||||
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
|
||||||
value={agentFiles["AGENTS.md"].content}
|
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setAgentFileContent("AGENTS.md", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AgentBrainPanelSection>
|
|
||||||
|
|
||||||
<AgentBrainPanelSection title="Context">
|
|
||||||
<textarea
|
|
||||||
aria-label="Context"
|
|
||||||
className="h-56 w-full resize-y rounded-md border border-border/80 bg-background px-4 py-3 font-mono text-sm leading-6 text-foreground outline-none"
|
|
||||||
value={agentFiles["USER.md"].content}
|
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setAgentFileContent("USER.md", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AgentBrainPanelSection>
|
|
||||||
|
|
||||||
<section className="space-y-3 border-t border-border/55 pt-8">
|
|
||||||
<h3 className="text-sm font-medium text-foreground">Identity</h3>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
|
||||||
Name
|
|
||||||
<input
|
|
||||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
|
||||||
value={draft.identity.name}
|
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setIdentityField("name", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
|
||||||
Creature
|
|
||||||
<input
|
|
||||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
|
||||||
value={draft.identity.creature}
|
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setIdentityField("creature", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
|
||||||
Vibe
|
|
||||||
<input
|
|
||||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
|
||||||
value={draft.identity.vibe}
|
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setIdentityField("vibe", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
|
||||||
Emoji
|
|
||||||
<input
|
|
||||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
|
||||||
value={draft.identity.emoji}
|
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setIdentityField("emoji", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="flex flex-col gap-2 text-xs text-muted-foreground">
|
|
||||||
Avatar
|
|
||||||
<input
|
|
||||||
className="h-10 rounded-md border border-border/80 bg-background px-3 text-sm text-foreground outline-none"
|
|
||||||
value={draft.identity.avatar}
|
|
||||||
disabled={agentFilesLoading || agentFilesSaving}
|
|
||||||
onChange={(event) => {
|
|
||||||
setIdentityField("avatar", event.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { buildAgentMainSessionKey } from "@/lib/gateway/GatewayClient";
|
import { buildAgentMainSessionKey } from "@/lib/gateway/GatewayClient";
|
||||||
|
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
import { resolveConfiguredModelKey, type GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
import { resolveConfiguredModelKey, type GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||||
import {
|
import {
|
||||||
|
resolveAgentAvatarProfile,
|
||||||
resolveAgentAvatarSeed,
|
resolveAgentAvatarSeed,
|
||||||
type StudioSettings,
|
type StudioSettings,
|
||||||
type StudioSettingsPublic,
|
type StudioSettingsPublic,
|
||||||
@@ -203,9 +205,13 @@ export const deriveHydrateAgentFleetResult = (
|
|||||||
|
|
||||||
const needsSessionSettingsSync = new Set<string>();
|
const needsSessionSettingsSync = new Set<string>();
|
||||||
const seeds: AgentStoreSeed[] = input.agentsResult.agents.map((agent) => {
|
const seeds: AgentStoreSeed[] = input.agentsResult.agents.map((agent) => {
|
||||||
|
const defaultAvatarProfile = createDefaultAgentAvatarProfile(agent.id);
|
||||||
|
const persistedProfile =
|
||||||
|
input.settings && gatewayKey ? resolveAgentAvatarProfile(input.settings, gatewayKey, agent.id) : null;
|
||||||
const persistedSeed =
|
const persistedSeed =
|
||||||
input.settings && gatewayKey ? resolveAgentAvatarSeed(input.settings, gatewayKey, agent.id) : null;
|
input.settings && gatewayKey ? resolveAgentAvatarSeed(input.settings, gatewayKey, agent.id) : null;
|
||||||
const avatarSeed = persistedSeed ?? agent.id;
|
const avatarProfile = persistedProfile ?? defaultAvatarProfile;
|
||||||
|
const avatarSeed = persistedSeed ?? avatarProfile.seed ?? agent.id;
|
||||||
const avatarUrl = resolveAgentAvatarUrl(agent);
|
const avatarUrl = resolveAgentAvatarUrl(agent);
|
||||||
const name = resolveAgentName(agent);
|
const name = resolveAgentName(agent);
|
||||||
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
|
const mainSession = input.mainSessionByAgentId.get(agent.id) ?? null;
|
||||||
@@ -247,6 +253,7 @@ export const deriveHydrateAgentFleetResult = (
|
|||||||
name,
|
name,
|
||||||
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
|
||||||
avatarSeed,
|
avatarSeed,
|
||||||
|
avatarProfile,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
model,
|
model,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||||
|
import { AgentAvatarCreatorModal } from "@/features/agents/components/AgentAvatarCreatorModal";
|
||||||
import { AgentCreateModal } from "@/features/agents/components/AgentCreateModal";
|
import { AgentCreateModal } from "@/features/agents/components/AgentCreateModal";
|
||||||
import {
|
import {
|
||||||
AgentBrainPanel,
|
AgentBrainPanel,
|
||||||
@@ -45,6 +46,10 @@ import {
|
|||||||
} from "@/lib/gateway/agentConfig";
|
} from "@/lib/gateway/agentConfig";
|
||||||
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
|
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
|
||||||
import { createStudioSettingsCoordinator } from "@/lib/studio/coordinator";
|
import { createStudioSettingsCoordinator } from "@/lib/studio/coordinator";
|
||||||
|
import {
|
||||||
|
type AgentAvatarProfile,
|
||||||
|
createDefaultAgentAvatarProfile,
|
||||||
|
} from "@/lib/avatars/profile";
|
||||||
import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations";
|
import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations";
|
||||||
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
|
||||||
import {
|
import {
|
||||||
@@ -267,6 +272,7 @@ const AgentsPageScreen = () => {
|
|||||||
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(null);
|
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(null);
|
||||||
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
||||||
const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null);
|
const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null);
|
||||||
|
const [avatarCreatorAgentId, setAvatarCreatorAgentId] = useState<string | null>(null);
|
||||||
const [systemInitialSkillKey, setSystemInitialSkillKey] = useState<string | null>(null);
|
const [systemInitialSkillKey, setSystemInitialSkillKey] = useState<string | null>(null);
|
||||||
const [personalityHasUnsavedChanges, setPersonalityHasUnsavedChanges] = useState(false);
|
const [personalityHasUnsavedChanges, setPersonalityHasUnsavedChanges] = useState(false);
|
||||||
const [settingsSidebarItem, setSettingsSidebarItem] = useState<SettingsSidebarItem>("personality");
|
const [settingsSidebarItem, setSettingsSidebarItem] = useState<SettingsSidebarItem>("personality");
|
||||||
@@ -308,6 +314,13 @@ const AgentsPageScreen = () => {
|
|||||||
: null;
|
: null;
|
||||||
return selectedInFilter ?? filteredAgents[0] ?? null;
|
return selectedInFilter ?? filteredAgents[0] ?? null;
|
||||||
}, [filteredAgents, selectedAgent]);
|
}, [filteredAgents, selectedAgent]);
|
||||||
|
const avatarCreatorAgent = useMemo(
|
||||||
|
() =>
|
||||||
|
avatarCreatorAgentId
|
||||||
|
? state.agents.find((entry) => entry.agentId === avatarCreatorAgentId) ?? null
|
||||||
|
: null,
|
||||||
|
[avatarCreatorAgentId, state.agents]
|
||||||
|
);
|
||||||
const focusedAgentId = focusedAgent?.agentId ?? null;
|
const focusedAgentId = focusedAgent?.agentId ?? null;
|
||||||
const focusedAgentRunning = focusedAgent?.status === "running";
|
const focusedAgentRunning = focusedAgent?.status === "running";
|
||||||
const focusedAgentStopDisabledReason = useMemo(() => {
|
const focusedAgentStopDisabledReason = useMemo(() => {
|
||||||
@@ -892,17 +905,16 @@ const AgentsPageScreen = () => {
|
|||||||
setCreateAgentModalOpen(true);
|
setCreateAgentModalOpen(true);
|
||||||
}, [createAgentBlock, createAgentBusy, restartingMutationBlock]);
|
}, [createAgentBlock, createAgentBusy, restartingMutationBlock]);
|
||||||
|
|
||||||
const persistAvatarSeed = useCallback(
|
const persistAvatarProfile = useCallback(
|
||||||
(agentId: string, avatarSeed: string) => {
|
(agentId: string, profile: AgentAvatarProfile) => {
|
||||||
const resolvedAgentId = agentId.trim();
|
const resolvedAgentId = agentId.trim();
|
||||||
const resolvedAvatarSeed = avatarSeed.trim();
|
|
||||||
const key = gatewayUrl.trim();
|
const key = gatewayUrl.trim();
|
||||||
if (!resolvedAgentId || !resolvedAvatarSeed || !key) return;
|
if (!resolvedAgentId || !key) return;
|
||||||
settingsCoordinator.schedulePatch(
|
settingsCoordinator.schedulePatch(
|
||||||
{
|
{
|
||||||
avatars: {
|
avatars: {
|
||||||
[key]: {
|
[key]: {
|
||||||
[resolvedAgentId]: resolvedAvatarSeed,
|
[resolvedAgentId]: profile,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -928,7 +940,10 @@ const AgentsPageScreen = () => {
|
|||||||
createAgent: async (name, avatarSeed) => {
|
createAgent: async (name, avatarSeed) => {
|
||||||
const created = await createGatewayAgent({ client, name });
|
const created = await createGatewayAgent({ client, name });
|
||||||
if (avatarSeed) {
|
if (avatarSeed) {
|
||||||
persistAvatarSeed(created.id, avatarSeed);
|
persistAvatarProfile(
|
||||||
|
created.id,
|
||||||
|
createDefaultAgentAvatarProfile(avatarSeed)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
flushPendingDraft(focusedAgent?.agentId ?? null);
|
flushPendingDraft(focusedAgent?.agentId ?? null);
|
||||||
focusFilterTouchedRef.current = true;
|
focusFilterTouchedRef.current = true;
|
||||||
@@ -1013,7 +1028,7 @@ const AgentsPageScreen = () => {
|
|||||||
hasDeleteMutationBlock,
|
hasDeleteMutationBlock,
|
||||||
hasRenameMutationBlock,
|
hasRenameMutationBlock,
|
||||||
loadAgents,
|
loadAgents,
|
||||||
persistAvatarSeed,
|
persistAvatarProfile,
|
||||||
refreshGatewayConfigSnapshot,
|
refreshGatewayConfigSnapshot,
|
||||||
setError,
|
setError,
|
||||||
status,
|
status,
|
||||||
@@ -1254,16 +1269,15 @@ const AgentsPageScreen = () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleAvatarShuffle = useCallback(
|
const handleAvatarShuffle = useCallback(
|
||||||
async (agentId: string) => {
|
(agentId: string, profile: AgentAvatarProfile) => {
|
||||||
const avatarSeed = randomUUID();
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "updateAgent",
|
type: "updateAgent",
|
||||||
agentId,
|
agentId,
|
||||||
patch: { avatarSeed },
|
patch: { avatarProfile: profile, avatarSeed: profile.seed },
|
||||||
});
|
});
|
||||||
persistAvatarSeed(agentId, avatarSeed);
|
persistAvatarProfile(agentId, profile);
|
||||||
},
|
},
|
||||||
[dispatch, persistAvatarSeed]
|
[dispatch, persistAvatarProfile]
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectionPanelVisible = showConnectionPanel;
|
const connectionPanelVisible = showConnectionPanel;
|
||||||
@@ -1547,6 +1561,7 @@ const AgentsPageScreen = () => {
|
|||||||
agents={agents}
|
agents={agents}
|
||||||
selectedAgentId={inspectSidebarAgent.agentId}
|
selectedAgentId={inspectSidebarAgent.agentId}
|
||||||
onUnsavedChangesChange={setPersonalityHasUnsavedChanges}
|
onUnsavedChangesChange={setPersonalityHasUnsavedChanges}
|
||||||
|
onRename={settingsMutationController.handleRenameAgent}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full overflow-y-auto px-6 py-6">
|
<div className="h-full overflow-y-auto px-6 py-6">
|
||||||
@@ -1748,7 +1763,7 @@ const AgentsPageScreen = () => {
|
|||||||
removeQueuedMessage(focusedAgent.agentId, index)
|
removeQueuedMessage(focusedAgent.agentId, index)
|
||||||
}
|
}
|
||||||
onStopRun={() => handleStopRun(focusedAgent.agentId, focusedAgent.sessionKey)}
|
onStopRun={() => handleStopRun(focusedAgent.agentId, focusedAgent.sessionKey)}
|
||||||
onAvatarShuffle={() => handleAvatarShuffle(focusedAgent.agentId)}
|
onAvatarShuffle={() => setAvatarCreatorAgentId(focusedAgent.agentId)}
|
||||||
pendingExecApprovals={focusedPendingExecApprovals}
|
pendingExecApprovals={focusedPendingExecApprovals}
|
||||||
onResolveExecApproval={(id, decision) => {
|
onResolveExecApproval={(id, decision) => {
|
||||||
void handleResolveExecApproval(id, decision);
|
void handleResolveExecApproval(id, decision);
|
||||||
@@ -1791,6 +1806,20 @@ const AgentsPageScreen = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{avatarCreatorAgent ? (
|
||||||
|
<AgentAvatarCreatorModal
|
||||||
|
open
|
||||||
|
agentId={avatarCreatorAgent.agentId}
|
||||||
|
agentName={avatarCreatorAgent.name}
|
||||||
|
initialProfile={avatarCreatorAgent.avatarProfile}
|
||||||
|
onClose={() => {
|
||||||
|
setAvatarCreatorAgentId(null);
|
||||||
|
}}
|
||||||
|
onSave={(profile) => {
|
||||||
|
handleAvatarShuffle(avatarCreatorAgent.agentId, profile);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{createAgentBlock && createAgentBlock.phase !== "queued" ? (
|
{createAgentBlock && createAgentBlock.phase !== "queued" ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80"
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useReducer,
|
useReducer,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
import {
|
import {
|
||||||
areTranscriptEntriesEqual,
|
areTranscriptEntriesEqual,
|
||||||
buildOutputLinesFromTranscriptEntries,
|
buildOutputLinesFromTranscriptEntries,
|
||||||
@@ -27,6 +28,7 @@ export type AgentStoreSeed = {
|
|||||||
name: string;
|
name: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
avatarSeed?: string | null;
|
avatarSeed?: string | null;
|
||||||
|
avatarProfile?: AgentAvatarProfile | null;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
thinkingLevel?: string | null;
|
thinkingLevel?: string | null;
|
||||||
@@ -184,6 +186,7 @@ const createRuntimeAgentState = (
|
|||||||
return {
|
return {
|
||||||
...seed,
|
...seed,
|
||||||
avatarSeed: seed.avatarSeed ?? existing?.avatarSeed ?? seed.agentId,
|
avatarSeed: seed.avatarSeed ?? existing?.avatarSeed ?? seed.agentId,
|
||||||
|
avatarProfile: seed.avatarProfile ?? existing?.avatarProfile ?? null,
|
||||||
avatarUrl: seed.avatarUrl ?? existing?.avatarUrl ?? null,
|
avatarUrl: seed.avatarUrl ?? existing?.avatarUrl ?? null,
|
||||||
model: seed.model ?? existing?.model ?? null,
|
model: seed.model ?? existing?.model ?? null,
|
||||||
thinkingLevel: seed.thinkingLevel ?? existing?.thinkingLevel ?? "high",
|
thinkingLevel: seed.thinkingLevel ?? existing?.thinkingLevel ?? "high",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ type SettingsPanelProps = {
|
|||||||
gatewayStatus?: string;
|
gatewayStatus?: string;
|
||||||
gatewayUrl?: string;
|
gatewayUrl?: string;
|
||||||
onGatewayDisconnect?: () => void;
|
onGatewayDisconnect?: () => void;
|
||||||
|
officeTitle: string;
|
||||||
|
officeTitleLoaded: boolean;
|
||||||
|
onOfficeTitleChange: (title: string) => void;
|
||||||
voiceRepliesEnabled: boolean;
|
voiceRepliesEnabled: boolean;
|
||||||
voiceRepliesVoiceId: string | null;
|
voiceRepliesVoiceId: string | null;
|
||||||
voiceRepliesSpeed: number;
|
voiceRepliesSpeed: number;
|
||||||
@@ -20,6 +23,9 @@ export function SettingsPanel({
|
|||||||
gatewayStatus,
|
gatewayStatus,
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
onGatewayDisconnect,
|
onGatewayDisconnect,
|
||||||
|
officeTitle,
|
||||||
|
officeTitleLoaded,
|
||||||
|
onOfficeTitleChange,
|
||||||
voiceRepliesEnabled,
|
voiceRepliesEnabled,
|
||||||
voiceRepliesVoiceId,
|
voiceRepliesVoiceId,
|
||||||
voiceRepliesSpeed,
|
voiceRepliesSpeed,
|
||||||
@@ -38,6 +44,31 @@ export function SettingsPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="px-4 py-4">
|
<div className="px-4 py-4">
|
||||||
<div className="rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
<div className="rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-medium text-white">Studio title</div>
|
||||||
|
<div className="mt-1 text-[10px] text-white/75">
|
||||||
|
Customize the banner shown at the top of the office.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
|
||||||
|
{officeTitleLoaded ? "Ready" : "Loading"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={officeTitle}
|
||||||
|
maxLength={48}
|
||||||
|
disabled={!officeTitleLoaded}
|
||||||
|
onChange={(event) => onOfficeTitleChange(event.target.value)}
|
||||||
|
placeholder="Luke Headquarters"
|
||||||
|
className="mt-3 w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] uppercase tracking-[0.18em] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 text-[10px] text-white/50">
|
||||||
|
Used in the office scene header.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-medium text-white">Gateway</div>
|
<div className="text-[11px] font-medium text-white">Gateway</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type StudioSettingsLoadOptions,
|
type StudioSettingsLoadOptions,
|
||||||
} from "@/lib/studio/coordinator";
|
} from "@/lib/studio/coordinator";
|
||||||
import { resolveDeskAssignments } from "@/lib/studio/settings";
|
import { resolveDeskAssignments } from "@/lib/studio/settings";
|
||||||
|
import { renameGatewayAgent } from "@/lib/gateway/agentConfig";
|
||||||
import {
|
import {
|
||||||
runStudioBootstrapLoadOperation,
|
runStudioBootstrapLoadOperation,
|
||||||
executeStudioBootstrapLoadCommands,
|
executeStudioBootstrapLoadCommands,
|
||||||
@@ -48,6 +49,10 @@ import {
|
|||||||
} from "@/lib/text/message-extract";
|
} from "@/lib/text/message-extract";
|
||||||
import { resolveOfficeIntentSnapshot } from "@/lib/office/deskDirectives";
|
import { resolveOfficeIntentSnapshot } from "@/lib/office/deskDirectives";
|
||||||
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
import { AgentChatPanel } from "@/features/agents/components/AgentChatPanel";
|
||||||
|
import {
|
||||||
|
AgentEditorModal,
|
||||||
|
type AgentEditorSection,
|
||||||
|
} from "@/features/agents/components/AgentEditorModal";
|
||||||
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
|
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
|
||||||
import {
|
import {
|
||||||
executeHistorySyncCommands,
|
executeHistorySyncCommands,
|
||||||
@@ -66,6 +71,7 @@ import {
|
|||||||
type GatewayModelChoice,
|
type GatewayModelChoice,
|
||||||
} from "@/lib/gateway/models";
|
} from "@/lib/gateway/models";
|
||||||
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
import { randomUUID } from "@/lib/uuid";
|
import { randomUUID } from "@/lib/uuid";
|
||||||
import {
|
import {
|
||||||
HQSidebar,
|
HQSidebar,
|
||||||
@@ -80,6 +86,7 @@ import { useOfficeSkillsMarketplace } from "@/features/office/hooks/useOfficeSki
|
|||||||
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
import { useOfficeStandupController } from "@/features/office/hooks/useOfficeStandupController";
|
||||||
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
import { useRunLog } from "@/features/office/hooks/useRunLog";
|
||||||
import { useFinalizedAssistantReplyListener } from "@/hooks/useFinalizedAssistantReplyListener";
|
import { useFinalizedAssistantReplyListener } from "@/hooks/useFinalizedAssistantReplyListener";
|
||||||
|
import { useStudioOfficePreference } from "@/hooks/useStudioOfficePreference";
|
||||||
import { useStudioVoiceRepliesPreference } from "@/hooks/useStudioVoiceRepliesPreference";
|
import { useStudioVoiceRepliesPreference } from "@/hooks/useStudioVoiceRepliesPreference";
|
||||||
import {
|
import {
|
||||||
useVoiceRecorder,
|
useVoiceRecorder,
|
||||||
@@ -370,6 +377,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
|
|||||||
status: "error",
|
status: "error",
|
||||||
color: stringToColor(agent.agentId),
|
color: stringToColor(agent.agentId),
|
||||||
item: getDeterministicItem(agent.agentId),
|
item: getDeterministicItem(agent.agentId),
|
||||||
|
avatarProfile: agent.avatarProfile ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const isWorking = agent.status === "running" || Boolean(agent.runId);
|
const isWorking = agent.status === "running" || Boolean(agent.runId);
|
||||||
@@ -379,6 +387,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
|
|||||||
status: isWorking ? "working" : "idle",
|
status: isWorking ? "working" : "idle",
|
||||||
color: stringToColor(agent.agentId),
|
color: stringToColor(agent.agentId),
|
||||||
item: getDeterministicItem(agent.agentId),
|
item: getDeterministicItem(agent.agentId),
|
||||||
|
avatarProfile: agent.avatarProfile ?? null,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -628,7 +637,13 @@ const inferRunningFromAgentSessions = async (params: {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OfficeScreen() {
|
type OfficeScreenProps = {
|
||||||
|
showOpenClawConsole?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OfficeScreen({
|
||||||
|
showOpenClawConsole = true,
|
||||||
|
}: OfficeScreenProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const debugEnabled = searchParams.get("officeDebug") === "1";
|
const debugEnabled = searchParams.get("officeDebug") === "1";
|
||||||
const [settingsCoordinator] = useState(() =>
|
const [settingsCoordinator] = useState(() =>
|
||||||
@@ -679,7 +694,7 @@ export function OfficeScreen() {
|
|||||||
OpenClawLogEntry[]
|
OpenClawLogEntry[]
|
||||||
>([]);
|
>([]);
|
||||||
const [openClawConsoleCollapsed, setOpenClawConsoleCollapsed] =
|
const [openClawConsoleCollapsed, setOpenClawConsoleCollapsed] =
|
||||||
useState(false);
|
useState(true);
|
||||||
const [openClawConsoleSearch, setOpenClawConsoleSearch] = useState("");
|
const [openClawConsoleSearch, setOpenClawConsoleSearch] = useState("");
|
||||||
const [openClawConsoleCopyStatus, setOpenClawConsoleCopyStatus] = useState<
|
const [openClawConsoleCopyStatus, setOpenClawConsoleCopyStatus] = useState<
|
||||||
"idle" | "copied" | "error"
|
"idle" | "copied" | "error"
|
||||||
@@ -721,6 +736,9 @@ export function OfficeScreen() {
|
|||||||
const [selectedChatAgentId, setSelectedChatAgentId] = useState<string | null>(
|
const [selectedChatAgentId, setSelectedChatAgentId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [agentEditorAgentId, setAgentEditorAgentId] = useState<string | null>(null);
|
||||||
|
const [agentEditorInitialSection, setAgentEditorInitialSection] =
|
||||||
|
useState<AgentEditorSection>("avatar");
|
||||||
const [preparedPhoneCallsByAgentId, setPreparedPhoneCallsByAgentId] = useState<
|
const [preparedPhoneCallsByAgentId, setPreparedPhoneCallsByAgentId] = useState<
|
||||||
Record<string, PreparedPhoneCallEntry>
|
Record<string, PreparedPhoneCallEntry>
|
||||||
>({});
|
>({});
|
||||||
@@ -740,6 +758,14 @@ export function OfficeScreen() {
|
|||||||
const [activeSidebarTab, setActiveSidebarTab] =
|
const [activeSidebarTab, setActiveSidebarTab] =
|
||||||
useState<HQSidebarTab>("inbox");
|
useState<HQSidebarTab>("inbox");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
loaded: officeTitleLoaded,
|
||||||
|
title: officeTitle,
|
||||||
|
setTitle: setOfficeTitle,
|
||||||
|
} = useStudioOfficePreference({
|
||||||
|
gatewayUrl,
|
||||||
|
settingsCoordinator,
|
||||||
|
});
|
||||||
const {
|
const {
|
||||||
loaded: voiceRepliesLoaded,
|
loaded: voiceRepliesLoaded,
|
||||||
preference: voiceRepliesPreference,
|
preference: voiceRepliesPreference,
|
||||||
@@ -764,20 +790,31 @@ export function OfficeScreen() {
|
|||||||
speed: voiceRepliesPreference.speed,
|
speed: voiceRepliesPreference.speed,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAvatarShuffle = useCallback(
|
const handleAvatarProfileSave = useCallback(
|
||||||
(agentId: string) => {
|
(agentId: string, profile: AgentAvatarProfile) => {
|
||||||
const seed = randomUUID();
|
dispatch({
|
||||||
dispatch({ type: "updateAgent", agentId, patch: { avatarSeed: seed } });
|
type: "updateAgent",
|
||||||
|
agentId,
|
||||||
|
patch: { avatarProfile: profile, avatarSeed: profile.seed },
|
||||||
|
});
|
||||||
const key = gatewayUrl.trim();
|
const key = gatewayUrl.trim();
|
||||||
if (key) {
|
if (!key) return;
|
||||||
settingsCoordinator.schedulePatch(
|
settingsCoordinator.schedulePatch(
|
||||||
{ avatars: { [key]: { [agentId]: seed } } },
|
{ avatars: { [key]: { [agentId]: profile } } },
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[dispatch, gatewayUrl, settingsCoordinator],
|
[dispatch, gatewayUrl, settingsCoordinator],
|
||||||
);
|
);
|
||||||
|
const openAgentEditor = useCallback(
|
||||||
|
(agentId: string, initialSection: AgentEditorSection = "avatar") => {
|
||||||
|
setAgentEditorAgentId(agentId);
|
||||||
|
setAgentEditorInitialSection(initialSection);
|
||||||
|
setSelectedChatAgentId(agentId);
|
||||||
|
dispatch({ type: "selectAgent", agentId });
|
||||||
|
},
|
||||||
|
[dispatch],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeskAssignmentChange = useCallback(
|
const handleDeskAssignmentChange = useCallback(
|
||||||
(deskUid: string, agentId: string | null) => {
|
(deskUid: string, agentId: string | null) => {
|
||||||
@@ -1604,6 +1641,9 @@ export function OfficeScreen() {
|
|||||||
? (state.agents.find((agent) => agent.agentId === selectedChatAgentId) ??
|
? (state.agents.find((agent) => agent.agentId === selectedChatAgentId) ??
|
||||||
null)
|
null)
|
||||||
: null;
|
: null;
|
||||||
|
const agentEditorAgent = agentEditorAgentId
|
||||||
|
? (state.agents.find((agent) => agent.agentId === agentEditorAgentId) ?? null)
|
||||||
|
: null;
|
||||||
const mainAgent =
|
const mainAgent =
|
||||||
state.agents.find((agent) => agent.agentId === MAIN_AGENT_ID) ?? null;
|
state.agents.find((agent) => agent.agentId === MAIN_AGENT_ID) ?? null;
|
||||||
const runLog = useRunLog({ client, status, agents: state.agents });
|
const runLog = useRunLog({ client, status, agents: state.agents });
|
||||||
@@ -2715,10 +2755,13 @@ export function OfficeScreen() {
|
|||||||
monitorAgentId={monitorAgentId}
|
monitorAgentId={monitorAgentId}
|
||||||
monitorByAgentId={monitorByAgentId}
|
monitorByAgentId={monitorByAgentId}
|
||||||
githubSkill={githubSkill}
|
githubSkill={githubSkill}
|
||||||
|
officeTitle={officeTitle}
|
||||||
|
officeTitleLoaded={officeTitleLoaded}
|
||||||
voiceRepliesEnabled={voiceRepliesEnabled}
|
voiceRepliesEnabled={voiceRepliesEnabled}
|
||||||
voiceRepliesVoiceId={voiceRepliesVoiceId}
|
voiceRepliesVoiceId={voiceRepliesVoiceId}
|
||||||
voiceRepliesSpeed={voiceRepliesSpeed}
|
voiceRepliesSpeed={voiceRepliesSpeed}
|
||||||
voiceRepliesLoaded={voiceRepliesLoaded}
|
voiceRepliesLoaded={voiceRepliesLoaded}
|
||||||
|
onOfficeTitleChange={setOfficeTitle}
|
||||||
onVoiceRepliesToggle={setVoiceRepliesEnabled}
|
onVoiceRepliesToggle={setVoiceRepliesEnabled}
|
||||||
onVoiceRepliesVoiceChange={setVoiceRepliesVoiceId}
|
onVoiceRepliesVoiceChange={setVoiceRepliesVoiceId}
|
||||||
onVoiceRepliesSpeedChange={setVoiceRepliesSpeed}
|
onVoiceRepliesSpeedChange={setVoiceRepliesSpeed}
|
||||||
@@ -2762,6 +2805,9 @@ export function OfficeScreen() {
|
|||||||
dispatch({ type: "selectAgent", agentId });
|
dispatch({ type: "selectAgent", agentId });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onAgentEdit={(agentId) => {
|
||||||
|
openAgentEditor(agentId, "avatar");
|
||||||
|
}}
|
||||||
onDeskAssignmentChange={handleDeskAssignmentChange}
|
onDeskAssignmentChange={handleDeskAssignmentChange}
|
||||||
onDeskAssignmentsReset={handleDeskAssignmentsReset}
|
onDeskAssignmentsReset={handleDeskAssignmentsReset}
|
||||||
onGithubReviewDismiss={() => {
|
onGithubReviewDismiss={() => {
|
||||||
@@ -2864,54 +2910,55 @@ export function OfficeScreen() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="pointer-events-auto fixed bottom-3 left-3 z-30 flex w-[520px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded border border-cyan-500/25 bg-black/78 shadow-2xl backdrop-blur">
|
{showOpenClawConsole ? (
|
||||||
<div className="flex items-center justify-between border-b border-cyan-500/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-cyan-200/80">
|
<section className="pointer-events-auto fixed bottom-3 left-3 z-30 flex w-[520px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded border border-cyan-500/25 bg-black/78 shadow-2xl backdrop-blur">
|
||||||
<span>OpenClaw Event Console</span>
|
<div className="flex items-center justify-between border-b border-cyan-500/15 px-3 py-2 font-mono text-[11px] uppercase tracking-[0.18em] text-cyan-200/80">
|
||||||
<div className="flex items-center gap-2">
|
<span>OpenClaw Event Console</span>
|
||||||
<span className="text-[10px] text-cyan-100/45">
|
<div className="flex items-center gap-2">
|
||||||
agents {state.agents.length} | events{" "}
|
<span className="text-[10px] text-cyan-100/45">
|
||||||
{filteredOpenClawLogEntries.length}/{openClawLogEntries.length}
|
agents {state.agents.length} | events{" "}
|
||||||
</span>
|
{filteredOpenClawLogEntries.length}/{openClawLogEntries.length}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
void handleCopyOpenClawConsoleJson();
|
onClick={() => {
|
||||||
}}
|
void handleCopyOpenClawConsoleJson();
|
||||||
className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
|
}}
|
||||||
>
|
className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
|
||||||
{openClawConsoleCopyStatus === "copied"
|
>
|
||||||
? "Copied"
|
{openClawConsoleCopyStatus === "copied"
|
||||||
: openClawConsoleCopyStatus === "error"
|
? "Copied"
|
||||||
? "Copy Failed"
|
: openClawConsoleCopyStatus === "error"
|
||||||
: "Copy JSON"}
|
? "Copy Failed"
|
||||||
</button>
|
: "Copy JSON"}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
onClick={handleDownloadOpenClawConsoleJson}
|
type="button"
|
||||||
className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
|
onClick={handleDownloadOpenClawConsoleJson}
|
||||||
>
|
className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
|
||||||
Download JSON
|
>
|
||||||
</button>
|
Download JSON
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
onClick={handleClearOpenClawConsole}
|
type="button"
|
||||||
className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
|
onClick={handleClearOpenClawConsole}
|
||||||
>
|
className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
|
||||||
Clear
|
>
|
||||||
</button>
|
Clear
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
setOpenClawConsoleCollapsed((previous) => !previous)
|
onClick={() =>
|
||||||
}
|
setOpenClawConsoleCollapsed((previous) => !previous)
|
||||||
className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
|
}
|
||||||
>
|
className="rounded border border-cyan-500/20 px-2 py-0.5 text-[9px] text-cyan-100/70 transition-colors hover:border-cyan-400/45 hover:text-cyan-50"
|
||||||
{openClawConsoleCollapsed ? "Expand" : "Minimize"}
|
>
|
||||||
</button>
|
{openClawConsoleCollapsed ? "Expand" : "Minimize"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{!openClawConsoleCollapsed ? (
|
||||||
{!openClawConsoleCollapsed ? (
|
<div className="flex h-[320px] flex-col gap-3 overflow-y-auto bg-[#02090b]/96 px-3 py-2 font-mono text-[10px] leading-4">
|
||||||
<div className="flex h-[320px] flex-col gap-3 overflow-y-auto bg-[#02090b]/96 px-3 py-2 font-mono text-[10px] leading-4">
|
|
||||||
<div className="rounded border border-cyan-500/10 bg-cyan-950/10 p-2">
|
<div className="rounded border border-cyan-500/10 bg-cyan-950/10 p-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -3070,9 +3117,10 @@ export function OfficeScreen() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-3 z-30 flex flex-col items-end gap-2 ${sidebarOpen ? "right-84" : "right-3"} ${
|
className={`fixed bottom-3 z-30 flex flex-col items-end gap-2 ${sidebarOpen ? "right-84" : "right-3"} ${
|
||||||
@@ -3183,7 +3231,7 @@ export function OfficeScreen() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onAvatarShuffle={() =>
|
onAvatarShuffle={() =>
|
||||||
handleAvatarShuffle(focusedChatAgent.agentId)
|
openAgentEditor(focusedChatAgent.agentId, "avatar")
|
||||||
}
|
}
|
||||||
onVoiceSend={handleVoiceSend}
|
onVoiceSend={handleVoiceSend}
|
||||||
/>
|
/>
|
||||||
@@ -3307,6 +3355,29 @@ export function OfficeScreen() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
{agentEditorAgent ? (
|
||||||
|
<AgentEditorModal
|
||||||
|
open
|
||||||
|
client={client}
|
||||||
|
agents={state.agents}
|
||||||
|
agent={agentEditorAgent}
|
||||||
|
initialSection={agentEditorInitialSection}
|
||||||
|
onClose={() => {
|
||||||
|
setAgentEditorAgentId(null);
|
||||||
|
}}
|
||||||
|
onAvatarSave={handleAvatarProfileSave}
|
||||||
|
onRename={async (agentId, name) => {
|
||||||
|
if (!client) return false;
|
||||||
|
try {
|
||||||
|
await renameGatewayAgent({ client, agentId, name });
|
||||||
|
dispatch({ type: "updateAgent", agentId, patch: { name } });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Armchair,
|
Armchair,
|
||||||
Settings2,
|
Settings2,
|
||||||
Camera,
|
Camera,
|
||||||
|
Users,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -1842,6 +1843,7 @@ function useAgentTick(
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const AWAY_THRESHOLD_MS = 15 * 60 * 1000;
|
const AWAY_THRESHOLD_MS = 15 * 60 * 1000;
|
||||||
|
const COMPACT_AGENT_BADGE_LIMIT = 6;
|
||||||
|
|
||||||
const estimatePhoneSpeechDurationMs = (text: string | null | undefined): number => {
|
const estimatePhoneSpeechDurationMs = (text: string | null | undefined): number => {
|
||||||
const normalized = text?.trim() ?? "";
|
const normalized = text?.trim() ?? "";
|
||||||
@@ -1850,6 +1852,15 @@ const estimatePhoneSpeechDurationMs = (text: string | null | undefined): number
|
|||||||
return Math.max(5_000, Math.min(12_000, 1_800 + wordCount * 380));
|
return Math.max(5_000, Math.min(12_000, 1_800 + wordCount * 380));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAgentInitials = (name: string | null | undefined): string => {
|
||||||
|
const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return "?";
|
||||||
|
return parts
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? "")
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
export function RetroOffice3D({
|
export function RetroOffice3D({
|
||||||
agents,
|
agents,
|
||||||
animationState = null,
|
animationState = null,
|
||||||
@@ -1869,10 +1880,13 @@ export function RetroOffice3D({
|
|||||||
monitorAgentId = null,
|
monitorAgentId = null,
|
||||||
monitorByAgentId = {},
|
monitorByAgentId = {},
|
||||||
githubSkill = null,
|
githubSkill = null,
|
||||||
|
officeTitle = "Luke Headquarters",
|
||||||
|
officeTitleLoaded = false,
|
||||||
voiceRepliesEnabled = false,
|
voiceRepliesEnabled = false,
|
||||||
voiceRepliesVoiceId = null,
|
voiceRepliesVoiceId = null,
|
||||||
voiceRepliesSpeed = 1,
|
voiceRepliesSpeed = 1,
|
||||||
voiceRepliesLoaded = false,
|
voiceRepliesLoaded = false,
|
||||||
|
onOfficeTitleChange,
|
||||||
onVoiceRepliesToggle,
|
onVoiceRepliesToggle,
|
||||||
onVoiceRepliesVoiceChange,
|
onVoiceRepliesVoiceChange,
|
||||||
onVoiceRepliesSpeedChange,
|
onVoiceRepliesSpeedChange,
|
||||||
@@ -1886,6 +1900,7 @@ export function RetroOffice3D({
|
|||||||
onStandupArrivalsChange,
|
onStandupArrivalsChange,
|
||||||
onStandupStartRequested,
|
onStandupStartRequested,
|
||||||
onMonitorSelect,
|
onMonitorSelect,
|
||||||
|
onAgentEdit,
|
||||||
onDeskAssignmentChange,
|
onDeskAssignmentChange,
|
||||||
onDeskAssignmentsReset,
|
onDeskAssignmentsReset,
|
||||||
onGithubReviewDismiss,
|
onGithubReviewDismiss,
|
||||||
@@ -1922,10 +1937,13 @@ export function RetroOffice3D({
|
|||||||
monitorAgentId?: string | null;
|
monitorAgentId?: string | null;
|
||||||
monitorByAgentId?: OfficeDeskMonitorMap;
|
monitorByAgentId?: OfficeDeskMonitorMap;
|
||||||
githubSkill?: SkillStatusEntry | null;
|
githubSkill?: SkillStatusEntry | null;
|
||||||
|
officeTitle?: string;
|
||||||
|
officeTitleLoaded?: boolean;
|
||||||
voiceRepliesEnabled?: boolean;
|
voiceRepliesEnabled?: boolean;
|
||||||
voiceRepliesVoiceId?: string | null;
|
voiceRepliesVoiceId?: string | null;
|
||||||
voiceRepliesSpeed?: number;
|
voiceRepliesSpeed?: number;
|
||||||
voiceRepliesLoaded?: boolean;
|
voiceRepliesLoaded?: boolean;
|
||||||
|
onOfficeTitleChange?: (title: string) => void;
|
||||||
onVoiceRepliesToggle?: (enabled: boolean) => void;
|
onVoiceRepliesToggle?: (enabled: boolean) => void;
|
||||||
onVoiceRepliesVoiceChange?: (voiceId: string | null) => void;
|
onVoiceRepliesVoiceChange?: (voiceId: string | null) => void;
|
||||||
onVoiceRepliesSpeedChange?: (speed: number) => void;
|
onVoiceRepliesSpeedChange?: (speed: number) => void;
|
||||||
@@ -1945,6 +1963,7 @@ export function RetroOffice3D({
|
|||||||
onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void;
|
onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void;
|
||||||
onStandupStartRequested?: () => void;
|
onStandupStartRequested?: () => void;
|
||||||
onMonitorSelect?: (agentId: string | null) => void;
|
onMonitorSelect?: (agentId: string | null) => void;
|
||||||
|
onAgentEdit?: (agentId: string) => void;
|
||||||
onDeskAssignmentChange?: (deskUid: string, agentId: string | null) => void;
|
onDeskAssignmentChange?: (deskUid: string, agentId: string | null) => void;
|
||||||
onDeskAssignmentsReset?: (deskUids: string[]) => void;
|
onDeskAssignmentsReset?: (deskUids: string[]) => void;
|
||||||
onGithubReviewDismiss?: () => void;
|
onGithubReviewDismiss?: () => void;
|
||||||
@@ -2006,6 +2025,7 @@ export function RetroOffice3D({
|
|||||||
const [spaceDown, setSpaceDown] = useState(false);
|
const [spaceDown, setSpaceDown] = useState(false);
|
||||||
const [spaceDragging, setSpaceDragging] = useState(false);
|
const [spaceDragging, setSpaceDragging] = useState(false);
|
||||||
const [standupBoardOpen, setStandupBoardOpen] = useState(false);
|
const [standupBoardOpen, setStandupBoardOpen] = useState(false);
|
||||||
|
const [agentRosterOpen, setAgentRosterOpen] = useState(false);
|
||||||
const autoOpenedStandupIdRef = useRef<string | null>(null);
|
const autoOpenedStandupIdRef = useRef<string | null>(null);
|
||||||
// Idea 1 (original): hovered agent for tooltip overlay.
|
// Idea 1 (original): hovered agent for tooltip overlay.
|
||||||
const [hoveredAgentId, setHoveredAgentId] = useState<string | null>(null);
|
const [hoveredAgentId, setHoveredAgentId] = useState<string | null>(null);
|
||||||
@@ -2447,6 +2467,11 @@ export function RetroOffice3D({
|
|||||||
githubImmersive ||
|
githubImmersive ||
|
||||||
qaImmersive ||
|
qaImmersive ||
|
||||||
standupImmersive;
|
standupImmersive;
|
||||||
|
const compactRosterAgents = useMemo(
|
||||||
|
() => agents.slice(0, COMPACT_AGENT_BADGE_LIMIT),
|
||||||
|
[agents],
|
||||||
|
);
|
||||||
|
const hiddenAgentCount = Math.max(0, agents.length - compactRosterAgents.length);
|
||||||
const standupActive =
|
const standupActive =
|
||||||
standupMeeting?.phase === "gathering" ||
|
standupMeeting?.phase === "gathering" ||
|
||||||
standupMeeting?.phase === "in_progress";
|
standupMeeting?.phase === "in_progress";
|
||||||
@@ -2465,6 +2490,10 @@ export function RetroOffice3D({
|
|||||||
) ?? null
|
) ?? null
|
||||||
);
|
);
|
||||||
}, [assignedDeskIndexByAgentId, deskLocations, furniture, monitorAgentId]);
|
}, [assignedDeskIndexByAgentId, deskLocations, furniture, monitorAgentId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!immersiveOverlayActive) return;
|
||||||
|
setAgentRosterOpen(false);
|
||||||
|
}, [immersiveOverlayActive]);
|
||||||
const selectedItem = useMemo(
|
const selectedItem = useMemo(
|
||||||
() => furniture.find((item) => item._uid === selectedUid) ?? null,
|
() => furniture.find((item) => item._uid === selectedUid) ?? null,
|
||||||
[furniture, selectedUid],
|
[furniture, selectedUid],
|
||||||
@@ -4824,6 +4853,7 @@ export function RetroOffice3D({
|
|||||||
name={agent.name}
|
name={agent.name}
|
||||||
status={agent.status}
|
status={agent.status}
|
||||||
color={agentColorMap.get(agent.id) ?? "#888"}
|
color={agentColorMap.get(agent.id) ?? "#888"}
|
||||||
|
appearance={"avatarProfile" in agent ? agent.avatarProfile ?? null : null}
|
||||||
agentsRef={renderAgentsRef}
|
agentsRef={renderAgentsRef}
|
||||||
agentLookupRef={renderAgentLookupRef}
|
agentLookupRef={renderAgentLookupRef}
|
||||||
onHover={isJanitor ? undefined : handleAgentHover}
|
onHover={isJanitor ? undefined : handleAgentHover}
|
||||||
@@ -4943,33 +4973,57 @@ export function RetroOffice3D({
|
|||||||
|
|
||||||
{/* New Idea 2: Camera preset buttons — top left. */}
|
{/* New Idea 2: Camera preset buttons — top left. */}
|
||||||
{!immersiveOverlayActive ? (
|
{!immersiveOverlayActive ? (
|
||||||
<div className="absolute top-3 left-3 flex items-center gap-1 z-10">
|
<div className="absolute top-3 left-3 z-20 flex flex-col items-start gap-2">
|
||||||
{(
|
<div className="flex items-center gap-1">
|
||||||
[
|
{(
|
||||||
{
|
[
|
||||||
key: "overview",
|
{
|
||||||
icon: <Maximize size={12} />,
|
key: "overview",
|
||||||
title: "Overview",
|
icon: <Maximize size={12} />,
|
||||||
},
|
title: "Overview",
|
||||||
{
|
},
|
||||||
key: "frontDesk",
|
{
|
||||||
icon: <Monitor size={12} />,
|
key: "frontDesk",
|
||||||
title: "Front desk",
|
icon: <Monitor size={12} />,
|
||||||
},
|
title: "Front desk",
|
||||||
{ key: "lounge", icon: <Armchair size={12} />, title: "Lounge" },
|
},
|
||||||
] as const
|
{ key: "lounge", icon: <Armchair size={12} />, title: "Lounge" },
|
||||||
).map(({ key, icon, title }) => (
|
] as const
|
||||||
|
).map(({ key, icon, title }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
title={title}
|
||||||
|
onClick={() => {
|
||||||
|
cameraPresetRef.current = CAMERA_PRESET_MAP[key];
|
||||||
|
}}
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded-md bg-[#1c1610]/80 text-amber-500/60 border border-amber-900/20 hover:bg-[#2a1e14] hover:text-amber-400 backdrop-blur-sm transition-colors"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{standupMeeting ? (
|
||||||
<button
|
<button
|
||||||
key={key}
|
type="button"
|
||||||
title={title}
|
onClick={() => setStandupBoardOpen(true)}
|
||||||
onClick={() => {
|
className="rounded-xl border border-emerald-500/20 bg-[#0b1410]/90 px-3 py-2 text-left shadow-lg backdrop-blur-sm transition-colors hover:border-emerald-400/35 hover:bg-[#102017]/95"
|
||||||
cameraPresetRef.current = CAMERA_PRESET_MAP[key];
|
|
||||||
}}
|
|
||||||
className="w-7 h-7 flex items-center justify-center rounded-md bg-[#1c1610]/80 text-amber-500/60 border border-amber-900/20 hover:bg-[#2a1e14] hover:text-amber-400 backdrop-blur-sm transition-colors"
|
|
||||||
>
|
>
|
||||||
{icon}
|
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-200/80">
|
||||||
|
Standup
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] font-semibold text-white/90">
|
||||||
|
{standupMeeting.phase === "gathering"
|
||||||
|
? "Gathering in meeting room."
|
||||||
|
: standupMeeting.phase === "in_progress"
|
||||||
|
? `Speaking: ${standupSpeakerCard?.agentName ?? "Team"}`
|
||||||
|
: "Standup complete."}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-mono text-[10px] text-white/50">
|
||||||
|
{standupMeeting.arrivedAgentIds.length}/
|
||||||
|
{standupMeeting.participantOrder.length} arrived
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -4978,135 +5032,189 @@ export function RetroOffice3D({
|
|||||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-3 pointer-events-none select-none z-10">
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-3 pointer-events-none select-none z-10">
|
||||||
<div className="h-px w-12 bg-gradient-to-r from-transparent to-amber-500/40" />
|
<div className="h-px w-12 bg-gradient-to-r from-transparent to-amber-500/40" />
|
||||||
<span className="text-sm tracking-[0.3em] text-amber-300/80 font-bold uppercase">
|
<span className="text-sm tracking-[0.3em] text-amber-300/80 font-bold uppercase">
|
||||||
Luke Headquarters
|
{officeTitle}
|
||||||
</span>
|
</span>
|
||||||
<div className="h-px w-12 bg-gradient-to-l from-transparent to-amber-500/40" />
|
<div className="h-px w-12 bg-gradient-to-l from-transparent to-amber-500/40" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!immersiveOverlayActive && standupMeeting ? (
|
{/* Agent roster — compact top summary with overflow panel. */}
|
||||||
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
|
|
||||||
<div className="rounded-xl border border-emerald-500/20 bg-[#0b1410]/90 px-3 py-2 text-right shadow-lg backdrop-blur-sm">
|
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-200/80">
|
|
||||||
Standup
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-[11px] font-semibold text-white/90">
|
|
||||||
{standupMeeting.phase === "gathering"
|
|
||||||
? "Gathering in meeting room."
|
|
||||||
: standupMeeting.phase === "in_progress"
|
|
||||||
? `Speaking: ${standupSpeakerCard?.agentName ?? "Team"}`
|
|
||||||
: "Standup complete."}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 font-mono text-[10px] text-white/50">
|
|
||||||
{standupMeeting.arrivedAgentIds.length}/
|
|
||||||
{standupMeeting.participantOrder.length} arrived
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setStandupBoardOpen(true)}
|
|
||||||
className="rounded-lg border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.16em] text-emerald-100 transition-colors hover:border-emerald-400/50 hover:text-white"
|
|
||||||
>
|
|
||||||
Standup board
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Agent cards — compact single row pinned to top. */}
|
|
||||||
{!immersiveOverlayActive ? (
|
{!immersiveOverlayActive ? (
|
||||||
<div className="absolute top-10 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
|
<div className="absolute top-10 left-1/2 z-20 -translate-x-1/2">
|
||||||
{agents.map((agent) => {
|
<div className="flex items-center gap-2 rounded-full border border-amber-900/25 bg-[#1c1610]/92 px-2 py-2 shadow-lg backdrop-blur-sm">
|
||||||
const status = agentStatusLookup[agent.id];
|
<div className="flex items-center -space-x-1.5">
|
||||||
const isError = status?.isError ?? agent.status === "error";
|
{compactRosterAgents.map((agent) => {
|
||||||
const working = status?.working ?? agent.status === "working";
|
const status = agentStatusLookup[agent.id];
|
||||||
const mood = moodByAgentId[agent.id];
|
const isError = status?.isError ?? agent.status === "error";
|
||||||
const dotClass = isError
|
const working = status?.working ?? agent.status === "working";
|
||||||
? "bg-red-400"
|
const mood = moodByAgentId[agent.id];
|
||||||
: working
|
const dotClass = isError
|
||||||
? "bg-green-400"
|
? "bg-red-400"
|
||||||
: "bg-yellow-400";
|
: working
|
||||||
return (
|
? "bg-green-400"
|
||||||
<div
|
: "bg-yellow-400";
|
||||||
key={agent.id}
|
return (
|
||||||
className="relative flex items-center gap-2 bg-[#1c1610]/90 backdrop-blur-sm px-2.5 py-1.5 rounded-lg border border-amber-900/20 shadow cursor-pointer select-none"
|
<button
|
||||||
onClick={() =>
|
key={agent.id}
|
||||||
setSpotlightAgentId((prev) =>
|
type="button"
|
||||||
prev === agent.id ? null : agent.id,
|
title={agent.name}
|
||||||
)
|
onMouseEnter={() => handleAgentHover(agent.id)}
|
||||||
}
|
onMouseLeave={handleAgentUnhover}
|
||||||
>
|
onClick={() => {
|
||||||
{/* E3 Idea 1: Mood emoji float. */}
|
setSpotlightAgentId(agent.id);
|
||||||
{mood && (
|
onAgentEdit?.(agent.id);
|
||||||
<span
|
}}
|
||||||
key={mood.ts}
|
className={`relative flex h-8 w-8 items-center justify-center rounded-full border text-[9px] font-bold text-[#120e08] shadow transition-transform hover:-translate-y-0.5 ${
|
||||||
className="absolute -top-6 left-1/2 -translate-x-1/2 text-sm pointer-events-none"
|
spotlightAgentId === agent.id
|
||||||
style={{ animation: "mood-float 2.5s ease-out forwards" }}
|
? "border-amber-200/80 ring-2 ring-amber-200/20"
|
||||||
>
|
: "border-[#120e08] hover:border-amber-200/50"
|
||||||
{mood.emoji}
|
}`}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 rounded-sm"
|
|
||||||
style={{ backgroundColor: agent.color }}
|
style={{ backgroundColor: agent.color }}
|
||||||
/>
|
>
|
||||||
<div
|
{/* E3 Idea 1: Mood emoji float. */}
|
||||||
className={`absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-[#1c1610] ${dotClass}`}
|
{mood ? (
|
||||||
/>
|
<span
|
||||||
|
key={mood.ts}
|
||||||
|
className="absolute -top-6 left-1/2 -translate-x-1/2 text-sm pointer-events-none"
|
||||||
|
style={{ animation: "mood-float 2.5s ease-out forwards" }}
|
||||||
|
>
|
||||||
|
{mood.emoji}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span>{getAgentInitials(agent.name)}</span>
|
||||||
|
<span
|
||||||
|
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-[#1c1610] ${dotClass}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hiddenAgentCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAgentRosterOpen(true)}
|
||||||
|
className="flex h-8 min-w-8 items-center justify-center rounded-full border border-amber-900/30 bg-[#120e08] px-2 text-[10px] font-semibold text-amber-200 transition-colors hover:border-amber-500/40 hover:text-white"
|
||||||
|
>
|
||||||
|
+{hiddenAgentCount}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAgentRosterOpen((prev) => !prev)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-amber-900/25 bg-black/20 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.16em] text-amber-100 transition-colors hover:border-amber-500/35 hover:text-white"
|
||||||
|
>
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
<span>{agents.length}</span>
|
||||||
|
<span className="hidden sm:inline">agents</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agentRosterOpen ? (
|
||||||
|
<div className="absolute left-1/2 top-full mt-2 w-[min(92vw,560px)] -translate-x-1/2 rounded-2xl border border-amber-900/25 bg-[#120e08]/96 p-3 shadow-2xl backdrop-blur-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-amber-500/70">
|
||||||
|
Team roster
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-amber-100">
|
||||||
|
Compact view for larger fleets.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-semibold text-amber-100 whitespace-nowrap">
|
|
||||||
{agent.name}
|
|
||||||
</span>
|
|
||||||
{/* Follow cam toggle button. */}
|
|
||||||
<button
|
<button
|
||||||
title={
|
type="button"
|
||||||
followAgentId === agent.id
|
onClick={() => setAgentRosterOpen(false)}
|
||||||
? "Exit follow cam"
|
className="rounded-full border border-amber-900/25 p-2 text-amber-200 transition-colors hover:border-amber-500/35 hover:text-white"
|
||||||
: "Follow cam"
|
aria-label="Close roster"
|
||||||
}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setFollowAgentId((prev) =>
|
|
||||||
prev === agent.id ? null : agent.id,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className={`w-4 h-4 flex items-center justify-center rounded transition-colors ${
|
|
||||||
followAgentId === agent.id
|
|
||||||
? "text-white opacity-100"
|
|
||||||
: "text-white/50 hover:text-white opacity-70 hover:opacity-100"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Camera size={9} />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
title={
|
|
||||||
monitorAgentId === agent.id
|
|
||||||
? "Close desk monitor"
|
|
||||||
: "Open desk monitor"
|
|
||||||
}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMonitorSelect?.(
|
|
||||||
monitorAgentId === agent.id ? null : agent.id,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className={`w-4 h-4 flex items-center justify-center rounded transition-colors ${
|
|
||||||
monitorAgentId === agent.id
|
|
||||||
? "text-emerald-300 opacity-100"
|
|
||||||
: "text-white/50 hover:text-emerald-200 opacity-70 hover:opacity-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Monitor size={9} />
|
|
||||||
</button>
|
|
||||||
{/* Idea 9 (original): Run counter badge. */}
|
|
||||||
{(runCountByAgentId[agent.id] ?? 0) > 0 && (
|
|
||||||
<span className="text-[8px] font-bold bg-amber-600/30 text-amber-400 border border-amber-700/30 rounded-full px-1.5 py-0.5 leading-none">
|
|
||||||
{runCountByAgentId[agent.id]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
<div className="grid max-h-[min(60vh,420px)] gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
|
||||||
|
{agents.map((agent) => {
|
||||||
|
const status = agentStatusLookup[agent.id];
|
||||||
|
const isError = status?.isError ?? agent.status === "error";
|
||||||
|
const working = status?.working ?? agent.status === "working";
|
||||||
|
const dotClass = isError
|
||||||
|
? "bg-red-400"
|
||||||
|
: working
|
||||||
|
? "bg-green-400"
|
||||||
|
: "bg-yellow-400";
|
||||||
|
const runCount = runCountByAgentId[agent.id] ?? 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-amber-900/20 bg-black/20 px-3 py-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSpotlightAgentId(agent.id);
|
||||||
|
onAgentEdit?.(agent.id);
|
||||||
|
setAgentRosterOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[10px] font-bold text-[#120e08]"
|
||||||
|
style={{ backgroundColor: agent.color }}
|
||||||
|
>
|
||||||
|
{getAgentInitials(agent.name)}
|
||||||
|
<span
|
||||||
|
className={`absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-[#120e08] ${dotClass}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-amber-100">
|
||||||
|
{agent.name}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.16em] text-amber-500/70">
|
||||||
|
{isError ? "error" : working ? "working" : "idle"}
|
||||||
|
{runCount > 0 ? ` · ${runCount} runs` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={
|
||||||
|
followAgentId === agent.id ? "Exit follow cam" : "Follow cam"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
setFollowAgentId((prev) => (prev === agent.id ? null : agent.id))
|
||||||
|
}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-lg border transition-colors ${
|
||||||
|
followAgentId === agent.id
|
||||||
|
? "border-amber-200/30 bg-amber-100/10 text-white"
|
||||||
|
: "border-amber-900/20 text-white/60 hover:border-amber-500/35 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Camera size={12} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={
|
||||||
|
monitorAgentId === agent.id
|
||||||
|
? "Close desk monitor"
|
||||||
|
: "Open desk monitor"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
onMonitorSelect?.(monitorAgentId === agent.id ? null : agent.id)
|
||||||
|
}
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-lg border transition-colors ${
|
||||||
|
monitorAgentId === agent.id
|
||||||
|
? "border-emerald-300/30 bg-emerald-300/10 text-emerald-200"
|
||||||
|
: "border-amber-900/20 text-white/60 hover:border-emerald-400/30 hover:text-emerald-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Monitor size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -5901,51 +6009,55 @@ export function RetroOffice3D({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{settingsModalOpen ? (
|
{settingsModalOpen ? (
|
||||||
<div className="absolute inset-0 z-30 flex items-start justify-end bg-black/35 p-4 backdrop-blur-[1px]">
|
<div className="absolute inset-0 z-30 flex items-start justify-end overflow-y-auto bg-black/35 p-4 backdrop-blur-[1px]">
|
||||||
<div className="w-full max-w-sm overflow-hidden rounded-xl border border-cyan-500/20 bg-[#05090d]/95 shadow-2xl">
|
<div className="flex max-h-[calc(100vh-2rem)] w-full max-w-sm flex-col overflow-hidden rounded-xl border border-cyan-500/20 bg-[#05090d]/95 shadow-2xl">
|
||||||
<div className="flex items-start justify-between border-b border-cyan-500/10 px-4 py-3">
|
<div className="flex items-start justify-between border-b border-cyan-500/10 px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono text-[10px] font-semibold tracking-[0.28em] text-cyan-300/75">
|
<div className="font-mono text-[10px] font-semibold tracking-[0.28em] text-cyan-300/75">
|
||||||
VOICE SETTINGS
|
STUDIO SETTINGS
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-[11px] text-white/45">
|
<div className="mt-1 text-[11px] text-white/45">
|
||||||
Control natural-sounding spoken replies for agents across the
|
Customize the office banner and spoken replies across the app.
|
||||||
app.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSettingsModalOpen(false)}
|
onClick={() => setSettingsModalOpen(false)}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md border border-cyan-500/10 bg-black/20 text-cyan-100/70 transition-colors hover:border-cyan-400/30 hover:text-cyan-100"
|
className="flex h-7 w-7 items-center justify-center rounded-md border border-cyan-500/10 bg-black/20 text-cyan-100/70 transition-colors hover:border-cyan-400/30 hover:text-cyan-100"
|
||||||
aria-label="Close voice settings"
|
aria-label="Close studio settings"
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SettingsPanel
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
gatewayStatus={gatewayStatus}
|
<SettingsPanel
|
||||||
gatewayUrl={atmAnalytics?.gatewayUrl}
|
gatewayStatus={gatewayStatus}
|
||||||
onGatewayDisconnect={() => {
|
gatewayUrl={atmAnalytics?.gatewayUrl}
|
||||||
onGatewayDisconnect?.();
|
onGatewayDisconnect={() => {
|
||||||
setSettingsModalOpen(false);
|
onGatewayDisconnect?.();
|
||||||
}}
|
setSettingsModalOpen(false);
|
||||||
voiceRepliesEnabled={voiceRepliesEnabled}
|
}}
|
||||||
voiceRepliesVoiceId={voiceRepliesVoiceId}
|
officeTitle={officeTitle}
|
||||||
voiceRepliesSpeed={voiceRepliesSpeed}
|
officeTitleLoaded={officeTitleLoaded}
|
||||||
voiceRepliesLoaded={voiceRepliesLoaded}
|
onOfficeTitleChange={(title) => onOfficeTitleChange?.(title)}
|
||||||
onVoiceRepliesToggle={(enabled) =>
|
voiceRepliesEnabled={voiceRepliesEnabled}
|
||||||
onVoiceRepliesToggle?.(enabled)
|
voiceRepliesVoiceId={voiceRepliesVoiceId}
|
||||||
}
|
voiceRepliesSpeed={voiceRepliesSpeed}
|
||||||
onVoiceRepliesVoiceChange={(voiceId) =>
|
voiceRepliesLoaded={voiceRepliesLoaded}
|
||||||
onVoiceRepliesVoiceChange?.(voiceId)
|
onVoiceRepliesToggle={(enabled) =>
|
||||||
}
|
onVoiceRepliesToggle?.(enabled)
|
||||||
onVoiceRepliesSpeedChange={(speed) =>
|
}
|
||||||
onVoiceRepliesSpeedChange?.(speed)
|
onVoiceRepliesVoiceChange={(voiceId) =>
|
||||||
}
|
onVoiceRepliesVoiceChange?.(voiceId)
|
||||||
onVoiceRepliesPreview={(voiceId, voiceName) =>
|
}
|
||||||
onVoiceRepliesPreview?.(voiceId, voiceName)
|
onVoiceRepliesSpeedChange={(speed) =>
|
||||||
}
|
onVoiceRepliesSpeedChange?.(speed)
|
||||||
/>
|
}
|
||||||
|
onVoiceRepliesPreview={(voiceId, voiceName) =>
|
||||||
|
onVoiceRepliesPreview?.(voiceId, voiceName)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
|
||||||
export type OfficeAgent = {
|
export type OfficeAgent = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: "working" | "idle" | "error";
|
status: "working" | "idle" | "error";
|
||||||
color: string;
|
color: string;
|
||||||
item: string;
|
item: string;
|
||||||
|
avatarProfile?: AgentAvatarProfile | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JanitorTool = "broom" | "vacuum" | "floor_scrubber";
|
export type JanitorTool = "broom" | "vacuum" | "floor_scrubber";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Billboard, Text } from "@react-three/drei";
|
|||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { memo, useMemo, useRef } from "react";
|
import { memo, useMemo, useRef } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
import {
|
import {
|
||||||
AGENT_SCALE,
|
AGENT_SCALE,
|
||||||
WALK_ANIM_SPEED,
|
WALK_ANIM_SPEED,
|
||||||
@@ -15,6 +16,7 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
name,
|
name,
|
||||||
status,
|
status,
|
||||||
color,
|
color,
|
||||||
|
appearance,
|
||||||
agentsRef,
|
agentsRef,
|
||||||
agentLookupRef,
|
agentLookupRef,
|
||||||
onHover,
|
onHover,
|
||||||
@@ -53,6 +55,10 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
const awayBubbleRef = useRef<THREE.Group>(null);
|
const awayBubbleRef = useRef<THREE.Group>(null);
|
||||||
const bodyMatRef = useRef<THREE.MeshLambertMaterial>(null);
|
const bodyMatRef = useRef<THREE.MeshLambertMaterial>(null);
|
||||||
const pos = useRef(new THREE.Vector3(0, 0, 0));
|
const pos = useRef(new THREE.Vector3(0, 0, 0));
|
||||||
|
const resolvedAppearance = useMemo(
|
||||||
|
() => appearance ?? createDefaultAgentAvatarProfile(agentId),
|
||||||
|
[agentId, appearance]
|
||||||
|
);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const agent =
|
const agent =
|
||||||
@@ -463,10 +469,22 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const skin = "#f4c58a";
|
const skin = resolvedAppearance.body.skinTone;
|
||||||
const trouserColor = "#2d3748";
|
const topColor = resolvedAppearance.clothing.topColor;
|
||||||
const shoeColor = "#1a1a1a";
|
const trouserColor = resolvedAppearance.clothing.bottomColor;
|
||||||
const hairColor = "#3e2723";
|
const shoeColor = resolvedAppearance.clothing.shoesColor;
|
||||||
|
const hairColor = resolvedAppearance.hair.color;
|
||||||
|
const hairStyle = resolvedAppearance.hair.style;
|
||||||
|
const topStyle = resolvedAppearance.clothing.topStyle;
|
||||||
|
const bottomStyle = resolvedAppearance.clothing.bottomStyle;
|
||||||
|
const hatStyle = resolvedAppearance.accessories.hatStyle;
|
||||||
|
const showGlasses = resolvedAppearance.accessories.glasses;
|
||||||
|
const showHeadset = resolvedAppearance.accessories.headset;
|
||||||
|
const showBackpack = resolvedAppearance.accessories.backpack;
|
||||||
|
const accessoryColor = topColor;
|
||||||
|
const sleeveColor = topStyle === "jacket" ? "#dbe4ff" : topColor;
|
||||||
|
const cuffColor = topStyle === "hoodie" ? "#d1d5db" : sleeveColor;
|
||||||
|
const topAccentColor = topStyle === "jacket" ? "#1f2937" : cuffColor;
|
||||||
|
|
||||||
const faceTexture = useMemo(() => {
|
const faceTexture = useMemo(() => {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
@@ -574,34 +592,122 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
<meshBasicMaterial color="#000" transparent opacity={0.2} />
|
<meshBasicMaterial color="#000" transparent opacity={0.2} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<group ref={rightLegRef} position={[-0.045, 0.1, 0]}>
|
<group ref={rightLegRef} position={[-0.045, 0.1, 0]}>
|
||||||
<mesh>
|
{bottomStyle === "shorts" ? (
|
||||||
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
<>
|
||||||
<meshLambertMaterial color={trouserColor} />
|
<mesh position={[0, 0.03, 0]}>
|
||||||
</mesh>
|
<boxGeometry args={[0.07, 0.08, 0.08]} />
|
||||||
|
<meshLambertMaterial color={trouserColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.045, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.06, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
||||||
|
<meshLambertMaterial color={trouserColor} />
|
||||||
|
</mesh>
|
||||||
|
{bottomStyle === "cuffed" ? (
|
||||||
|
<mesh position={[0, -0.05, 0]}>
|
||||||
|
<boxGeometry args={[0.074, 0.022, 0.084]} />
|
||||||
|
<meshLambertMaterial color="#d1d5db" />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<mesh position={[0, -0.09, 0]}>
|
<mesh position={[0, -0.09, 0]}>
|
||||||
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
||||||
<meshLambertMaterial color={shoeColor} />
|
<meshLambertMaterial color={shoeColor} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
<group ref={leftLegRef} position={[0.045, 0.1, 0]}>
|
<group ref={leftLegRef} position={[0.045, 0.1, 0]}>
|
||||||
<mesh>
|
{bottomStyle === "shorts" ? (
|
||||||
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
<>
|
||||||
<meshLambertMaterial color={trouserColor} />
|
<mesh position={[0, 0.03, 0]}>
|
||||||
</mesh>
|
<boxGeometry args={[0.07, 0.08, 0.08]} />
|
||||||
|
<meshLambertMaterial color={trouserColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, -0.045, 0]}>
|
||||||
|
<boxGeometry args={[0.05, 0.06, 0.05]} />
|
||||||
|
<meshLambertMaterial color={skin} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.07, 0.14, 0.08]} />
|
||||||
|
<meshLambertMaterial color={trouserColor} />
|
||||||
|
</mesh>
|
||||||
|
{bottomStyle === "cuffed" ? (
|
||||||
|
<mesh position={[0, -0.05, 0]}>
|
||||||
|
<boxGeometry args={[0.074, 0.022, 0.084]} />
|
||||||
|
<meshLambertMaterial color="#d1d5db" />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<mesh position={[0, -0.09, 0]}>
|
<mesh position={[0, -0.09, 0]}>
|
||||||
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
<boxGeometry args={[0.07, 0.05, 0.12]} />
|
||||||
<meshLambertMaterial color={shoeColor} />
|
<meshLambertMaterial color={shoeColor} />
|
||||||
</mesh>
|
</mesh>
|
||||||
</group>
|
</group>
|
||||||
|
{showBackpack ? (
|
||||||
|
<group position={[0, 0.28, -0.08]}>
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.15, 0.18, 0.06]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.06, 0.02, 0.02]}>
|
||||||
|
<boxGeometry args={[0.018, 0.16, 0.018]} />
|
||||||
|
<meshLambertMaterial color="#cbd5e1" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.06, 0.02, 0.02]}>
|
||||||
|
<boxGeometry args={[0.018, 0.16, 0.018]} />
|
||||||
|
<meshLambertMaterial color="#cbd5e1" />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
) : null}
|
||||||
<mesh position={[0, 0.28, 0]}>
|
<mesh position={[0, 0.28, 0]}>
|
||||||
<boxGeometry args={[0.18, 0.2, 0.1]} />
|
<boxGeometry args={[0.18, 0.2, 0.1]} />
|
||||||
<meshLambertMaterial ref={bodyMatRef} color={color} />
|
<meshLambertMaterial ref={bodyMatRef} color={topColor} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{topStyle === "hoodie" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.35, -0.045]}>
|
||||||
|
<boxGeometry args={[0.17, 0.1, 0.03]} />
|
||||||
|
<meshLambertMaterial color={topColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.22, 0.056]}>
|
||||||
|
<boxGeometry args={[0.11, 0.03, 0.012]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{topStyle === "jacket" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.28, 0.056]}>
|
||||||
|
<boxGeometry args={[0.182, 0.21, 0.012]} />
|
||||||
|
<meshLambertMaterial color={topAccentColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.28, 0.063]}>
|
||||||
|
<boxGeometry args={[0.034, 0.2, 0.01]} />
|
||||||
|
<meshLambertMaterial color="#f8fafc" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<group ref={rightArmRef} position={[-0.12, 0.28, 0]}>
|
<group ref={rightArmRef} position={[-0.12, 0.28, 0]}>
|
||||||
<mesh position={[0, -0.08, 0]}>
|
<mesh position={[0, -0.08, 0]}>
|
||||||
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
||||||
<meshLambertMaterial color={color} />
|
<meshLambertMaterial color={sleeveColor} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{topStyle === "hoodie" ? (
|
||||||
|
<mesh position={[0, -0.145, 0]}>
|
||||||
|
<boxGeometry args={[0.064, 0.03, 0.064]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
<mesh position={[0, -0.17, 0]}>
|
<mesh position={[0, -0.17, 0]}>
|
||||||
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
||||||
<meshLambertMaterial color={skin} />
|
<meshLambertMaterial color={skin} />
|
||||||
@@ -681,8 +787,14 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
<group ref={leftArmRef} position={[0.12, 0.28, 0]}>
|
<group ref={leftArmRef} position={[0.12, 0.28, 0]}>
|
||||||
<mesh position={[0, -0.08, 0]}>
|
<mesh position={[0, -0.08, 0]}>
|
||||||
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
<boxGeometry args={[0.06, 0.16, 0.06]} />
|
||||||
<meshLambertMaterial color={color} />
|
<meshLambertMaterial color={sleeveColor} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{topStyle === "hoodie" ? (
|
||||||
|
<mesh position={[0, -0.145, 0]}>
|
||||||
|
<boxGeometry args={[0.064, 0.03, 0.064]} />
|
||||||
|
<meshLambertMaterial color={cuffColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
<mesh position={[0, -0.17, 0]}>
|
<mesh position={[0, -0.17, 0]}>
|
||||||
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
<boxGeometry args={[0.05, 0.05, 0.05]} />
|
||||||
<meshLambertMaterial color={skin} />
|
<meshLambertMaterial color={skin} />
|
||||||
@@ -701,10 +813,94 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
<meshLambertMaterial attach="material-4" map={faceTexture} />
|
<meshLambertMaterial attach="material-4" map={faceTexture} />
|
||||||
<meshLambertMaterial attach="material-5" color={skin} />
|
<meshLambertMaterial attach="material-5" color={skin} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<mesh position={[0, 0.555, 0]}>
|
{hairStyle === "short" ? (
|
||||||
<boxGeometry args={[0.17, 0.05, 0.15]} />
|
<mesh position={[0, 0.555, 0]}>
|
||||||
<meshLambertMaterial color={hairColor} />
|
<boxGeometry args={[0.17, 0.05, 0.15]} />
|
||||||
</mesh>
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
{hairStyle === "parted" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.555, 0]}>
|
||||||
|
<boxGeometry args={[0.17, 0.045, 0.15]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.035, 0.59, 0.01]} rotation={[0.1, 0, -0.2]}>
|
||||||
|
<boxGeometry args={[0.12, 0.03, 0.08]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{hairStyle === "spiky" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.55, 0]}>
|
||||||
|
<boxGeometry args={[0.16, 0.035, 0.14]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.05, 0.59, 0]} rotation={[0, 0, -0.2]}>
|
||||||
|
<boxGeometry args={[0.04, 0.06, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.605, 0]} rotation={[0, 0, 0]}>
|
||||||
|
<boxGeometry args={[0.04, 0.08, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.05, 0.59, 0]} rotation={[0, 0, 0.2]}>
|
||||||
|
<boxGeometry args={[0.04, 0.06, 0.04]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{hairStyle === "bun" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.548, 0]}>
|
||||||
|
<boxGeometry args={[0.17, 0.04, 0.15]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.6, -0.035]}>
|
||||||
|
<sphereGeometry args={[0.042, 14, 14]} />
|
||||||
|
<meshLambertMaterial color={hairColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{hatStyle === "cap" ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.59, 0]}>
|
||||||
|
<boxGeometry args={[0.172, 0.03, 0.152]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.575, 0.07]}>
|
||||||
|
<boxGeometry args={[0.09, 0.012, 0.05]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{hatStyle === "beanie" ? (
|
||||||
|
<mesh position={[0, 0.59, 0]}>
|
||||||
|
<boxGeometry args={[0.18, 0.06, 0.16]} />
|
||||||
|
<meshLambertMaterial color={accessoryColor} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
{showHeadset ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[0, 0.57, 0]} rotation={[0, 0, Math.PI / 2]}>
|
||||||
|
<torusGeometry args={[0.09, 0.008, 8, 24, Math.PI]} />
|
||||||
|
<meshLambertMaterial color="#94a3b8" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[-0.1, 0.48, 0]}>
|
||||||
|
<boxGeometry args={[0.018, 0.05, 0.028]} />
|
||||||
|
<meshLambertMaterial color="#475569" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.1, 0.48, 0]}>
|
||||||
|
<boxGeometry args={[0.018, 0.05, 0.028]} />
|
||||||
|
<meshLambertMaterial color="#475569" />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.085, 0.43, 0.06]} rotation={[0.25, 0.25, -0.4]}>
|
||||||
|
<boxGeometry args={[0.012, 0.06, 0.012]} />
|
||||||
|
<meshLambertMaterial color="#94a3b8" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<mesh ref={leftBrowRef} position={[-0.04, 0.52, 0.074]}>
|
<mesh ref={leftBrowRef} position={[-0.04, 0.52, 0.074]}>
|
||||||
<boxGeometry args={[0.04, 0.01, 0.01]} />
|
<boxGeometry args={[0.04, 0.01, 0.01]} />
|
||||||
<meshBasicMaterial color="#342016" />
|
<meshBasicMaterial color="#342016" />
|
||||||
@@ -729,6 +925,22 @@ export const AgentModel = memo(function AgentModel({
|
|||||||
<boxGeometry args={[0.008, 0.008, 0.01]} />
|
<boxGeometry args={[0.008, 0.008, 0.01]} />
|
||||||
<meshBasicMaterial color="#fff" />
|
<meshBasicMaterial color="#fff" />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{showGlasses ? (
|
||||||
|
<>
|
||||||
|
<mesh position={[-0.04, 0.475, 0.078]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" wireframe />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0.04, 0.475, 0.078]}>
|
||||||
|
<boxGeometry args={[0.05, 0.05, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" wireframe />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[0, 0.475, 0.078]}>
|
||||||
|
<boxGeometry args={[0.02, 0.008, 0.01]} />
|
||||||
|
<meshBasicMaterial color="#111827" />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<mesh ref={mouthRef} position={[0, 0.436, 0.074]}>
|
<mesh ref={mouthRef} position={[0, 0.436, 0.074]}>
|
||||||
<boxGeometry args={[0.05, 0.014, 0.01]} />
|
<boxGeometry args={[0.05, 0.014, 0.01]} />
|
||||||
<meshBasicMaterial color="#9c4a4a" />
|
<meshBasicMaterial color="#9c4a4a" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
import type { RefObject } from "react";
|
import type { RefObject } from "react";
|
||||||
import type {
|
import type {
|
||||||
FurnitureItem,
|
FurnitureItem,
|
||||||
@@ -30,6 +31,7 @@ export type AgentModelProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
status: OfficeAgent["status"];
|
status: OfficeAgent["status"];
|
||||||
color: string;
|
color: string;
|
||||||
|
appearance?: AgentAvatarProfile | null;
|
||||||
agentsRef: RefObject<RenderAgent[]>;
|
agentsRef: RefObject<RenderAgent[]>;
|
||||||
agentLookupRef?: RefObject<Map<string, RenderAgent>>;
|
agentLookupRef?: RefObject<Map<string, RenderAgent>>;
|
||||||
onHover?: (id: string) => void;
|
onHover?: (id: string) => void;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator";
|
||||||
|
import {
|
||||||
|
defaultStudioOfficePreference,
|
||||||
|
resolveOfficePreference,
|
||||||
|
type StudioOfficePreference,
|
||||||
|
} from "@/lib/studio/settings";
|
||||||
|
|
||||||
|
type UseStudioOfficePreferenceParams = {
|
||||||
|
gatewayUrl: string;
|
||||||
|
settingsCoordinator: StudioSettingsCoordinator;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useStudioOfficePreference = ({
|
||||||
|
gatewayUrl,
|
||||||
|
settingsCoordinator,
|
||||||
|
}: UseStudioOfficePreferenceParams) => {
|
||||||
|
const [preference, setPreference] = useState<StudioOfficePreference>(
|
||||||
|
defaultStudioOfficePreference()
|
||||||
|
);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const gatewayKey = gatewayUrl.trim();
|
||||||
|
if (!gatewayKey) {
|
||||||
|
setPreference(defaultStudioOfficePreference());
|
||||||
|
setLoaded(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoaded(false);
|
||||||
|
const loadPreference = async () => {
|
||||||
|
try {
|
||||||
|
const settings = await settingsCoordinator.loadSettings({ maxAgeMs: 30_000 });
|
||||||
|
if (cancelled) return;
|
||||||
|
setPreference(
|
||||||
|
settings ? resolveOfficePreference(settings, gatewayKey) : defaultStudioOfficePreference()
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error("Failed to load office preference.", error);
|
||||||
|
setPreference(defaultStudioOfficePreference());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void loadPreference();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [gatewayUrl, settingsCoordinator]);
|
||||||
|
|
||||||
|
const setTitle = useCallback(
|
||||||
|
(title: string) => {
|
||||||
|
const gatewayKey = gatewayUrl.trim();
|
||||||
|
setPreference((current) => ({ ...current, title }));
|
||||||
|
if (!gatewayKey) return;
|
||||||
|
settingsCoordinator.schedulePatch(
|
||||||
|
{
|
||||||
|
office: {
|
||||||
|
[gatewayKey]: {
|
||||||
|
title,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[gatewayUrl, settingsCoordinator]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loaded,
|
||||||
|
preference,
|
||||||
|
title: preference.title,
|
||||||
|
setTitle,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
export type AgentAvatarHairStyle = "short" | "parted" | "spiky" | "bun";
|
||||||
|
export type AgentAvatarTopStyle = "tee" | "hoodie" | "jacket";
|
||||||
|
export type AgentAvatarBottomStyle = "pants" | "shorts" | "cuffed";
|
||||||
|
export type AgentAvatarHatStyle = "none" | "cap" | "beanie";
|
||||||
|
|
||||||
|
export type AgentAvatarProfile = {
|
||||||
|
version: 1;
|
||||||
|
seed: string;
|
||||||
|
body: {
|
||||||
|
skinTone: string;
|
||||||
|
};
|
||||||
|
hair: {
|
||||||
|
style: AgentAvatarHairStyle;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
clothing: {
|
||||||
|
topStyle: AgentAvatarTopStyle;
|
||||||
|
topColor: string;
|
||||||
|
bottomStyle: AgentAvatarBottomStyle;
|
||||||
|
bottomColor: string;
|
||||||
|
shoesColor: string;
|
||||||
|
};
|
||||||
|
accessories: {
|
||||||
|
glasses: boolean;
|
||||||
|
headset: boolean;
|
||||||
|
hatStyle: AgentAvatarHatStyle;
|
||||||
|
backpack: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColorOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EnumOption<T extends string> = {
|
||||||
|
id: T;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AGENT_AVATAR_SKIN_TONE_OPTIONS: ColorOption[] = [
|
||||||
|
{ id: "fair", label: "Fair", color: "#f7d7c2" },
|
||||||
|
{ id: "light", label: "Light", color: "#f4c58a" },
|
||||||
|
{ id: "warm", label: "Warm", color: "#d8a06e" },
|
||||||
|
{ id: "tan", label: "Tan", color: "#b7794e" },
|
||||||
|
{ id: "deep", label: "Deep", color: "#8a5a3b" },
|
||||||
|
{ id: "rich", label: "Rich", color: "#5d3a24" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AGENT_AVATAR_HAIR_STYLE_OPTIONS: EnumOption<AgentAvatarHairStyle>[] = [
|
||||||
|
{ id: "short", label: "Short" },
|
||||||
|
{ id: "parted", label: "Parted" },
|
||||||
|
{ id: "spiky", label: "Spiky" },
|
||||||
|
{ id: "bun", label: "Bun" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AGENT_AVATAR_HAIR_COLOR_OPTIONS: ColorOption[] = [
|
||||||
|
{ id: "ink", label: "Ink", color: "#151515" },
|
||||||
|
{ id: "espresso", label: "Espresso", color: "#3e2723" },
|
||||||
|
{ id: "walnut", label: "Walnut", color: "#6b4f3a" },
|
||||||
|
{ id: "auburn", label: "Auburn", color: "#7b341e" },
|
||||||
|
{ id: "blonde", label: "Blonde", color: "#d6b56c" },
|
||||||
|
{ id: "violet", label: "Violet", color: "#7c3aed" },
|
||||||
|
{ id: "cyan", label: "Cyan", color: "#0891b2" },
|
||||||
|
{ id: "pink", label: "Pink", color: "#db2777" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AGENT_AVATAR_TOP_STYLE_OPTIONS: EnumOption<AgentAvatarTopStyle>[] = [
|
||||||
|
{ id: "tee", label: "Tee" },
|
||||||
|
{ id: "hoodie", label: "Hoodie" },
|
||||||
|
{ id: "jacket", label: "Jacket" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AGENT_AVATAR_BOTTOM_STYLE_OPTIONS: EnumOption<AgentAvatarBottomStyle>[] = [
|
||||||
|
{ id: "pants", label: "Pants" },
|
||||||
|
{ id: "shorts", label: "Shorts" },
|
||||||
|
{ id: "cuffed", label: "Cuffed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AGENT_AVATAR_HAT_STYLE_OPTIONS: EnumOption<AgentAvatarHatStyle>[] = [
|
||||||
|
{ id: "none", label: "None" },
|
||||||
|
{ id: "cap", label: "Cap" },
|
||||||
|
{ id: "beanie", label: "Beanie" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AGENT_AVATAR_CLOTHING_COLOR_OPTIONS: ColorOption[] = [
|
||||||
|
{ id: "graphite", label: "Graphite", color: "#2d3748" },
|
||||||
|
{ id: "sky", label: "Sky", color: "#7090ff" },
|
||||||
|
{ id: "mint", label: "Mint", color: "#34d399" },
|
||||||
|
{ id: "amber", label: "Amber", color: "#f59e0b" },
|
||||||
|
{ id: "rose", label: "Rose", color: "#f43f5e" },
|
||||||
|
{ id: "violet", label: "Violet", color: "#8b5cf6" },
|
||||||
|
{ id: "cream", label: "Cream", color: "#f5f5f4" },
|
||||||
|
{ id: "slate", label: "Slate", color: "#64748b" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AGENT_AVATAR_SHOE_COLOR_OPTIONS: ColorOption[] = [
|
||||||
|
{ id: "black", label: "Black", color: "#1a1a1a" },
|
||||||
|
{ id: "navy", label: "Navy", color: "#1e3a8a" },
|
||||||
|
{ id: "brown", label: "Brown", color: "#7c4a2d" },
|
||||||
|
{ id: "white", label: "White", color: "#e5e7eb" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AGENT_AVATAR_VERSION = 1 as const;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
|
||||||
|
const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||||
|
|
||||||
|
const hashSeed = (seed: string) => {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < seed.length; index += 1) {
|
||||||
|
hash ^= seed.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = <T,>(values: readonly T[], index: number) => values[index % values.length];
|
||||||
|
|
||||||
|
const resolveColor = (value: unknown, options: ColorOption[], fallback: string) => {
|
||||||
|
const color = coerceString(value).toLowerCase();
|
||||||
|
if (!color) return fallback;
|
||||||
|
const option =
|
||||||
|
options.find((entry) => entry.id === color) ??
|
||||||
|
options.find((entry) => entry.color.toLowerCase() === color);
|
||||||
|
return option?.color ?? fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveEnumOption = <T extends string>(
|
||||||
|
value: unknown,
|
||||||
|
options: EnumOption<T>[],
|
||||||
|
fallback: T,
|
||||||
|
): T => {
|
||||||
|
const normalized = coerceString(value).toLowerCase();
|
||||||
|
const match = options.find((entry) => entry.id === normalized);
|
||||||
|
return match?.id ?? fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAgentAvatarProfileFromSeed = (seed: string): AgentAvatarProfile => {
|
||||||
|
const normalizedSeed = seed.trim() || "agent";
|
||||||
|
const hash = hashSeed(normalizedSeed);
|
||||||
|
const skinTone = pick(AGENT_AVATAR_SKIN_TONE_OPTIONS, hash).color;
|
||||||
|
const hairStyle = pick(AGENT_AVATAR_HAIR_STYLE_OPTIONS, hash >>> 3).id;
|
||||||
|
const hairColor = pick(AGENT_AVATAR_HAIR_COLOR_OPTIONS, hash >>> 5).color;
|
||||||
|
const topStyle = pick(AGENT_AVATAR_TOP_STYLE_OPTIONS, hash >>> 7).id;
|
||||||
|
const topColor = pick(AGENT_AVATAR_CLOTHING_COLOR_OPTIONS, hash >>> 9).color;
|
||||||
|
const bottomStyle = pick(AGENT_AVATAR_BOTTOM_STYLE_OPTIONS, hash >>> 11).id;
|
||||||
|
const bottomColor = pick(AGENT_AVATAR_CLOTHING_COLOR_OPTIONS, hash >>> 13).color;
|
||||||
|
const shoesColor = pick(AGENT_AVATAR_SHOE_COLOR_OPTIONS, hash >>> 15).color;
|
||||||
|
const hatStyle = pick(AGENT_AVATAR_HAT_STYLE_OPTIONS, hash >>> 17).id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: AGENT_AVATAR_VERSION,
|
||||||
|
seed: normalizedSeed,
|
||||||
|
body: {
|
||||||
|
skinTone,
|
||||||
|
},
|
||||||
|
hair: {
|
||||||
|
style: hairStyle,
|
||||||
|
color: hairColor,
|
||||||
|
},
|
||||||
|
clothing: {
|
||||||
|
topStyle,
|
||||||
|
topColor,
|
||||||
|
bottomStyle,
|
||||||
|
bottomColor,
|
||||||
|
shoesColor,
|
||||||
|
},
|
||||||
|
accessories: {
|
||||||
|
glasses: Boolean((hash >>> 19) % 2),
|
||||||
|
headset: Boolean((hash >>> 20) % 2),
|
||||||
|
hatStyle,
|
||||||
|
backpack: Boolean((hash >>> 21) % 2),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDefaultAgentAvatarProfile = (seed: string): AgentAvatarProfile =>
|
||||||
|
createAgentAvatarProfileFromSeed(seed);
|
||||||
|
|
||||||
|
export const normalizeAgentAvatarProfile = (
|
||||||
|
value: unknown,
|
||||||
|
fallbackSeed: string,
|
||||||
|
): AgentAvatarProfile => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return createAgentAvatarProfileFromSeed(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseProfile = createAgentAvatarProfileFromSeed(fallbackSeed);
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return baseProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = isRecord(value.body) ? value.body : {};
|
||||||
|
const hair = isRecord(value.hair) ? value.hair : {};
|
||||||
|
const clothing = isRecord(value.clothing) ? value.clothing : {};
|
||||||
|
const accessories = isRecord(value.accessories) ? value.accessories : {};
|
||||||
|
const normalizedSeed = coerceString(value.seed) || baseProfile.seed;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: AGENT_AVATAR_VERSION,
|
||||||
|
seed: normalizedSeed,
|
||||||
|
body: {
|
||||||
|
skinTone: resolveColor(
|
||||||
|
body.skinTone,
|
||||||
|
AGENT_AVATAR_SKIN_TONE_OPTIONS,
|
||||||
|
baseProfile.body.skinTone,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
hair: {
|
||||||
|
style: resolveEnumOption(
|
||||||
|
hair.style,
|
||||||
|
AGENT_AVATAR_HAIR_STYLE_OPTIONS,
|
||||||
|
baseProfile.hair.style,
|
||||||
|
),
|
||||||
|
color: resolveColor(
|
||||||
|
hair.color,
|
||||||
|
AGENT_AVATAR_HAIR_COLOR_OPTIONS,
|
||||||
|
baseProfile.hair.color,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
clothing: {
|
||||||
|
topStyle: resolveEnumOption(
|
||||||
|
clothing.topStyle,
|
||||||
|
AGENT_AVATAR_TOP_STYLE_OPTIONS,
|
||||||
|
baseProfile.clothing.topStyle,
|
||||||
|
),
|
||||||
|
topColor: resolveColor(
|
||||||
|
clothing.topColor,
|
||||||
|
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
|
||||||
|
baseProfile.clothing.topColor,
|
||||||
|
),
|
||||||
|
bottomStyle: resolveEnumOption(
|
||||||
|
clothing.bottomStyle,
|
||||||
|
AGENT_AVATAR_BOTTOM_STYLE_OPTIONS,
|
||||||
|
baseProfile.clothing.bottomStyle,
|
||||||
|
),
|
||||||
|
bottomColor: resolveColor(
|
||||||
|
clothing.bottomColor,
|
||||||
|
AGENT_AVATAR_CLOTHING_COLOR_OPTIONS,
|
||||||
|
baseProfile.clothing.bottomColor,
|
||||||
|
),
|
||||||
|
shoesColor: resolveColor(
|
||||||
|
clothing.shoesColor,
|
||||||
|
AGENT_AVATAR_SHOE_COLOR_OPTIONS,
|
||||||
|
baseProfile.clothing.shoesColor,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
accessories: {
|
||||||
|
glasses:
|
||||||
|
typeof accessories.glasses === "boolean"
|
||||||
|
? accessories.glasses
|
||||||
|
: baseProfile.accessories.glasses,
|
||||||
|
headset:
|
||||||
|
typeof accessories.headset === "boolean"
|
||||||
|
? accessories.headset
|
||||||
|
: baseProfile.accessories.headset,
|
||||||
|
hatStyle: resolveEnumOption(
|
||||||
|
accessories.hatStyle,
|
||||||
|
AGENT_AVATAR_HAT_STYLE_OPTIONS,
|
||||||
|
baseProfile.accessories.hatStyle,
|
||||||
|
),
|
||||||
|
backpack:
|
||||||
|
typeof accessories.backpack === "boolean"
|
||||||
|
? accessories.backpack
|
||||||
|
: baseProfile.accessories.backpack,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { fetchJson } from "@/lib/http";
|
import { fetchJson } from "@/lib/http";
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
import type {
|
import type {
|
||||||
StudioAnalyticsPreferencePatch,
|
StudioAnalyticsPreferencePatch,
|
||||||
StudioFocusedPreference,
|
StudioFocusedPreference,
|
||||||
StudioGatewaySettingsPublic,
|
StudioGatewaySettingsPublic,
|
||||||
|
StudioOfficePreferencePatch,
|
||||||
StudioSettingsPublic,
|
StudioSettingsPublic,
|
||||||
StudioSettingsPatch,
|
StudioSettingsPatch,
|
||||||
StudioStandupPreferencePatch,
|
StudioStandupPreferencePatch,
|
||||||
@@ -20,10 +22,11 @@ export type StudioSettingsLoadOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>;
|
type FocusedPatch = Record<string, Partial<StudioFocusedPreference> | null>;
|
||||||
type AvatarsPatch = Record<string, Record<string, string | null> | null>;
|
type AvatarsPatch = Record<string, Record<string, AgentAvatarProfile | null> | null>;
|
||||||
type DeskAssignmentsPatch = Record<string, Record<string, string | null> | null>;
|
type DeskAssignmentsPatch = Record<string, Record<string, string | null> | null>;
|
||||||
type AnalyticsPatch = Record<string, StudioAnalyticsPreferencePatch | null>;
|
type AnalyticsPatch = Record<string, StudioAnalyticsPreferencePatch | null>;
|
||||||
type VoiceRepliesPatch = Record<string, StudioVoiceRepliesPreferencePatch | null>;
|
type VoiceRepliesPatch = Record<string, StudioVoiceRepliesPreferencePatch | null>;
|
||||||
|
type OfficePatch = Record<string, StudioOfficePreferencePatch | null>;
|
||||||
type StandupPatch = Record<string, StudioStandupPreferencePatch | null>;
|
type StandupPatch = Record<string, StudioStandupPreferencePatch | null>;
|
||||||
|
|
||||||
export type StudioSettingsCoordinatorTransport = {
|
export type StudioSettingsCoordinatorTransport = {
|
||||||
@@ -143,6 +146,30 @@ const mergeVoiceRepliesPatch = (
|
|||||||
return merged;
|
return merged;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeOfficePatch = (
|
||||||
|
current: OfficePatch | undefined,
|
||||||
|
next: OfficePatch | undefined
|
||||||
|
): OfficePatch | undefined => {
|
||||||
|
if (!current && !next) return undefined;
|
||||||
|
const merged: OfficePatch = { ...(current ?? {}) };
|
||||||
|
for (const [gatewayKey, value] of Object.entries(next ?? {})) {
|
||||||
|
if (value === null) {
|
||||||
|
merged[gatewayKey] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = merged[gatewayKey];
|
||||||
|
if (existing && existing !== null) {
|
||||||
|
merged[gatewayKey] = {
|
||||||
|
...existing,
|
||||||
|
...value,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged[gatewayKey] = { ...value };
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
const mergeStandupPatch = (
|
const mergeStandupPatch = (
|
||||||
current: StandupPatch | undefined,
|
current: StandupPatch | undefined,
|
||||||
next: StandupPatch | undefined
|
next: StandupPatch | undefined
|
||||||
@@ -210,6 +237,7 @@ const mergeStudioPatch = (
|
|||||||
...(next.deskAssignments ? { deskAssignments: { ...next.deskAssignments } } : {}),
|
...(next.deskAssignments ? { deskAssignments: { ...next.deskAssignments } } : {}),
|
||||||
...(next.analytics ? { analytics: { ...next.analytics } } : {}),
|
...(next.analytics ? { analytics: { ...next.analytics } } : {}),
|
||||||
...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}),
|
...(next.voiceReplies ? { voiceReplies: { ...next.voiceReplies } } : {}),
|
||||||
|
...(next.office ? { office: { ...next.office } } : {}),
|
||||||
...(next.standup ? { standup: { ...next.standup } } : {}),
|
...(next.standup ? { standup: { ...next.standup } } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -221,6 +249,7 @@ const mergeStudioPatch = (
|
|||||||
);
|
);
|
||||||
const analytics = mergeAnalyticsPatch(current.analytics, next.analytics);
|
const analytics = mergeAnalyticsPatch(current.analytics, next.analytics);
|
||||||
const voiceReplies = mergeVoiceRepliesPatch(current.voiceReplies, next.voiceReplies);
|
const voiceReplies = mergeVoiceRepliesPatch(current.voiceReplies, next.voiceReplies);
|
||||||
|
const office = mergeOfficePatch(current.office, next.office);
|
||||||
const standup = mergeStandupPatch(current.standup, next.standup);
|
const standup = mergeStandupPatch(current.standup, next.standup);
|
||||||
return {
|
return {
|
||||||
...(next.gateway !== undefined
|
...(next.gateway !== undefined
|
||||||
@@ -233,6 +262,7 @@ const mergeStudioPatch = (
|
|||||||
...(deskAssignments ? { deskAssignments } : {}),
|
...(deskAssignments ? { deskAssignments } : {}),
|
||||||
...(analytics ? { analytics } : {}),
|
...(analytics ? { analytics } : {}),
|
||||||
...(voiceReplies ? { voiceReplies } : {}),
|
...(voiceReplies ? { voiceReplies } : {}),
|
||||||
|
...(office ? { office } : {}),
|
||||||
...(standup ? { standup } : {}),
|
...(standup ? { standup } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+99
-17
@@ -4,6 +4,8 @@ import type {
|
|||||||
StandupManualEntry,
|
StandupManualEntry,
|
||||||
StandupScheduleConfig,
|
StandupScheduleConfig,
|
||||||
} from "@/lib/office/standup/types";
|
} from "@/lib/office/standup/types";
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
import { normalizeAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
|
||||||
export type StudioGatewaySettings = {
|
export type StudioGatewaySettings = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -60,7 +62,16 @@ export type StudioVoiceRepliesPreferencePatch = {
|
|||||||
speed?: number;
|
speed?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StudioOfficePreference = {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StudioOfficePreferencePatch = {
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type StudioDeskAssignments = Record<string, string>;
|
export type StudioDeskAssignments = Record<string, string>;
|
||||||
|
export type StudioAgentAvatars = Record<string, AgentAvatarProfile>;
|
||||||
|
|
||||||
export type StudioStandupPreference = StandupConfig;
|
export type StudioStandupPreference = StandupConfig;
|
||||||
|
|
||||||
@@ -83,10 +94,11 @@ export type StudioSettings = {
|
|||||||
version: 1;
|
version: 1;
|
||||||
gateway: StudioGatewaySettings | null;
|
gateway: StudioGatewaySettings | null;
|
||||||
focused: Record<string, StudioFocusedPreference>;
|
focused: Record<string, StudioFocusedPreference>;
|
||||||
avatars: Record<string, Record<string, string>>;
|
avatars: Record<string, StudioAgentAvatars>;
|
||||||
deskAssignments: Record<string, StudioDeskAssignments>;
|
deskAssignments: Record<string, StudioDeskAssignments>;
|
||||||
analytics: Record<string, StudioAnalyticsPreference>;
|
analytics: Record<string, StudioAnalyticsPreference>;
|
||||||
voiceReplies: Record<string, StudioVoiceRepliesPreference>;
|
voiceReplies: Record<string, StudioVoiceRepliesPreference>;
|
||||||
|
office: Record<string, StudioOfficePreference>;
|
||||||
standup?: Record<string, StudioStandupPreference>;
|
standup?: Record<string, StudioStandupPreference>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,10 +110,11 @@ export type StudioSettingsPublic = Omit<StudioSettings, "gateway" | "standup"> &
|
|||||||
export type StudioSettingsPatch = {
|
export type StudioSettingsPatch = {
|
||||||
gateway?: StudioGatewaySettingsPatch | null;
|
gateway?: StudioGatewaySettingsPatch | null;
|
||||||
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
|
focused?: Record<string, Partial<StudioFocusedPreference> | null>;
|
||||||
avatars?: Record<string, Record<string, string | null> | null>;
|
avatars?: Record<string, Record<string, AgentAvatarProfile | null> | null>;
|
||||||
deskAssignments?: Record<string, Record<string, string | null> | null>;
|
deskAssignments?: Record<string, Record<string, string | null> | null>;
|
||||||
analytics?: Record<string, StudioAnalyticsPreferencePatch | null>;
|
analytics?: Record<string, StudioAnalyticsPreferencePatch | null>;
|
||||||
voiceReplies?: Record<string, StudioVoiceRepliesPreferencePatch | null>;
|
voiceReplies?: Record<string, StudioVoiceRepliesPreferencePatch | null>;
|
||||||
|
office?: Record<string, StudioOfficePreferencePatch | null>;
|
||||||
standup?: Record<string, StudioStandupPreferencePatch | null>;
|
standup?: Record<string, StudioStandupPreferencePatch | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,6 +270,20 @@ const normalizeOptionalIsoString = (
|
|||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_OFFICE_TITLE = "Luke Headquarters";
|
||||||
|
|
||||||
|
const normalizeOfficeTitle = (
|
||||||
|
value: unknown,
|
||||||
|
fallback: string = DEFAULT_OFFICE_TITLE
|
||||||
|
) => {
|
||||||
|
const title = coerceString(value);
|
||||||
|
return (title || fallback).slice(0, 48);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultStudioOfficePreference = (): StudioOfficePreference => ({
|
||||||
|
title: DEFAULT_OFFICE_TITLE,
|
||||||
|
});
|
||||||
|
|
||||||
const normalizeStandupScheduleConfig = (
|
const normalizeStandupScheduleConfig = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
fallback: StandupScheduleConfig = defaultStudioStandupScheduleConfig()
|
fallback: StandupScheduleConfig = defaultStudioStandupScheduleConfig()
|
||||||
@@ -400,20 +427,18 @@ const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreferenc
|
|||||||
return focused;
|
return focused;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeAvatars = (value: unknown): Record<string, Record<string, string>> => {
|
const normalizeAvatars = (value: unknown): Record<string, StudioAgentAvatars> => {
|
||||||
if (!isRecord(value)) return {};
|
if (!isRecord(value)) return {};
|
||||||
const avatars: Record<string, Record<string, string>> = {};
|
const avatars: Record<string, StudioAgentAvatars> = {};
|
||||||
for (const [gatewayKeyRaw, gatewayRaw] of Object.entries(value)) {
|
for (const [gatewayKeyRaw, gatewayRaw] of Object.entries(value)) {
|
||||||
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
|
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
|
||||||
if (!gatewayKey) continue;
|
if (!gatewayKey) continue;
|
||||||
if (!isRecord(gatewayRaw)) continue;
|
if (!isRecord(gatewayRaw)) continue;
|
||||||
const entries: Record<string, string> = {};
|
const entries: StudioAgentAvatars = {};
|
||||||
for (const [agentIdRaw, seedRaw] of Object.entries(gatewayRaw)) {
|
for (const [agentIdRaw, avatarRaw] of Object.entries(gatewayRaw)) {
|
||||||
const agentId = coerceString(agentIdRaw);
|
const agentId = coerceString(agentIdRaw);
|
||||||
if (!agentId) continue;
|
if (!agentId) continue;
|
||||||
const seed = coerceString(seedRaw);
|
entries[agentId] = normalizeAgentAvatarProfile(avatarRaw, agentId);
|
||||||
if (!seed) continue;
|
|
||||||
entries[agentId] = seed;
|
|
||||||
}
|
}
|
||||||
avatars[gatewayKey] = entries;
|
avatars[gatewayKey] = entries;
|
||||||
}
|
}
|
||||||
@@ -522,6 +547,27 @@ const normalizeVoiceReplies = (
|
|||||||
return voiceReplies;
|
return voiceReplies;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeOfficePreference = (
|
||||||
|
value: unknown,
|
||||||
|
fallback: StudioOfficePreference = defaultStudioOfficePreference()
|
||||||
|
): StudioOfficePreference => {
|
||||||
|
if (!isRecord(value)) return fallback;
|
||||||
|
return {
|
||||||
|
title: normalizeOfficeTitle(value.title, fallback.title),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeOffice = (value: unknown): Record<string, StudioOfficePreference> => {
|
||||||
|
if (!isRecord(value)) return {};
|
||||||
|
const office: Record<string, StudioOfficePreference> = {};
|
||||||
|
for (const [gatewayKeyRaw, officeRaw] of Object.entries(value)) {
|
||||||
|
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
|
||||||
|
if (!gatewayKey) continue;
|
||||||
|
office[gatewayKey] = normalizeOfficePreference(officeRaw);
|
||||||
|
}
|
||||||
|
return office;
|
||||||
|
};
|
||||||
|
|
||||||
export const defaultStudioSettings = (): StudioSettings => ({
|
export const defaultStudioSettings = (): StudioSettings => ({
|
||||||
version: SETTINGS_VERSION,
|
version: SETTINGS_VERSION,
|
||||||
gateway: null,
|
gateway: null,
|
||||||
@@ -530,6 +576,7 @@ export const defaultStudioSettings = (): StudioSettings => ({
|
|||||||
deskAssignments: {},
|
deskAssignments: {},
|
||||||
analytics: {},
|
analytics: {},
|
||||||
voiceReplies: {},
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
standup: {},
|
standup: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -579,6 +626,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
|
|||||||
const deskAssignments = normalizeDeskAssignments(raw.deskAssignments);
|
const deskAssignments = normalizeDeskAssignments(raw.deskAssignments);
|
||||||
const analytics = normalizeAnalytics(raw.analytics);
|
const analytics = normalizeAnalytics(raw.analytics);
|
||||||
const voiceReplies = normalizeVoiceReplies(raw.voiceReplies);
|
const voiceReplies = normalizeVoiceReplies(raw.voiceReplies);
|
||||||
|
const office = normalizeOffice(raw.office);
|
||||||
const standup = normalizeStandup(raw.standup);
|
const standup = normalizeStandup(raw.standup);
|
||||||
return {
|
return {
|
||||||
version: SETTINGS_VERSION,
|
version: SETTINGS_VERSION,
|
||||||
@@ -588,6 +636,7 @@ export const normalizeStudioSettings = (raw: unknown): StudioSettings => {
|
|||||||
deskAssignments,
|
deskAssignments,
|
||||||
analytics,
|
analytics,
|
||||||
voiceReplies,
|
voiceReplies,
|
||||||
|
office,
|
||||||
standup,
|
standup,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -603,6 +652,7 @@ export const mergeStudioSettings = (
|
|||||||
const nextDeskAssignments = { ...current.deskAssignments };
|
const nextDeskAssignments = { ...current.deskAssignments };
|
||||||
const nextAnalytics = { ...current.analytics };
|
const nextAnalytics = { ...current.analytics };
|
||||||
const nextVoiceReplies = { ...current.voiceReplies };
|
const nextVoiceReplies = { ...current.voiceReplies };
|
||||||
|
const nextOffice = { ...current.office };
|
||||||
const nextStandup = { ...(current.standup ?? {}) };
|
const nextStandup = { ...(current.standup ?? {}) };
|
||||||
if (patch.focused) {
|
if (patch.focused) {
|
||||||
for (const [keyRaw, value] of Object.entries(patch.focused)) {
|
for (const [keyRaw, value] of Object.entries(patch.focused)) {
|
||||||
@@ -626,19 +676,14 @@ export const mergeStudioSettings = (
|
|||||||
}
|
}
|
||||||
if (!isRecord(gatewayPatch)) continue;
|
if (!isRecord(gatewayPatch)) continue;
|
||||||
const existing = nextAvatars[gatewayKey] ? { ...nextAvatars[gatewayKey] } : {};
|
const existing = nextAvatars[gatewayKey] ? { ...nextAvatars[gatewayKey] } : {};
|
||||||
for (const [agentIdRaw, seedPatchRaw] of Object.entries(gatewayPatch)) {
|
for (const [agentIdRaw, avatarPatchRaw] of Object.entries(gatewayPatch)) {
|
||||||
const agentId = coerceString(agentIdRaw);
|
const agentId = coerceString(agentIdRaw);
|
||||||
if (!agentId) continue;
|
if (!agentId) continue;
|
||||||
if (seedPatchRaw === null) {
|
if (avatarPatchRaw === null) {
|
||||||
delete existing[agentId];
|
delete existing[agentId];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const seed = coerceString(seedPatchRaw);
|
existing[agentId] = normalizeAgentAvatarProfile(avatarPatchRaw, agentId);
|
||||||
if (!seed) {
|
|
||||||
delete existing[agentId];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
existing[agentId] = seed;
|
|
||||||
}
|
}
|
||||||
nextAvatars[gatewayKey] = existing;
|
nextAvatars[gatewayKey] = existing;
|
||||||
}
|
}
|
||||||
@@ -713,6 +758,24 @@ export const mergeStudioSettings = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (patch.office) {
|
||||||
|
for (const [gatewayKeyRaw, officePatch] of Object.entries(patch.office)) {
|
||||||
|
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
|
||||||
|
if (!gatewayKey) continue;
|
||||||
|
if (officePatch === null) {
|
||||||
|
delete nextOffice[gatewayKey];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fallback = nextOffice[gatewayKey] ?? defaultStudioOfficePreference();
|
||||||
|
nextOffice[gatewayKey] = normalizeOfficePreference(
|
||||||
|
{
|
||||||
|
...fallback,
|
||||||
|
...officePatch,
|
||||||
|
},
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (patch.standup) {
|
if (patch.standup) {
|
||||||
for (const [gatewayKeyRaw, standupPatch] of Object.entries(patch.standup)) {
|
for (const [gatewayKeyRaw, standupPatch] of Object.entries(patch.standup)) {
|
||||||
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
|
const gatewayKey = normalizeGatewayKey(gatewayKeyRaw);
|
||||||
@@ -769,6 +832,7 @@ export const mergeStudioSettings = (
|
|||||||
deskAssignments: nextDeskAssignments,
|
deskAssignments: nextDeskAssignments,
|
||||||
analytics: nextAnalytics,
|
analytics: nextAnalytics,
|
||||||
voiceReplies: nextVoiceReplies,
|
voiceReplies: nextVoiceReplies,
|
||||||
|
office: nextOffice,
|
||||||
standup: nextStandup,
|
standup: nextStandup,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -787,6 +851,15 @@ export const resolveAgentAvatarSeed = (
|
|||||||
gatewayUrl: string,
|
gatewayUrl: string,
|
||||||
agentId: string
|
agentId: string
|
||||||
): string | null => {
|
): string | null => {
|
||||||
|
const profile = resolveAgentAvatarProfile(settings, gatewayUrl, agentId);
|
||||||
|
return profile?.seed ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAgentAvatarProfile = (
|
||||||
|
settings: StudioSettings | StudioSettingsPublic,
|
||||||
|
gatewayUrl: string,
|
||||||
|
agentId: string
|
||||||
|
): AgentAvatarProfile | null => {
|
||||||
const gatewayKey = normalizeGatewayKey(gatewayUrl);
|
const gatewayKey = normalizeGatewayKey(gatewayUrl);
|
||||||
if (!gatewayKey) return null;
|
if (!gatewayKey) return null;
|
||||||
const agentKey = coerceString(agentId);
|
const agentKey = coerceString(agentId);
|
||||||
@@ -821,6 +894,15 @@ export const resolveVoiceRepliesPreference = (
|
|||||||
return settings.voiceReplies[gatewayKey] ?? defaultStudioVoiceRepliesPreference();
|
return settings.voiceReplies[gatewayKey] ?? defaultStudioVoiceRepliesPreference();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveOfficePreference = (
|
||||||
|
settings: StudioSettings | StudioSettingsPublic,
|
||||||
|
gatewayUrl: string
|
||||||
|
): StudioOfficePreference => {
|
||||||
|
const gatewayKey = normalizeGatewayKey(gatewayUrl);
|
||||||
|
if (!gatewayKey) return defaultStudioOfficePreference();
|
||||||
|
return settings.office[gatewayKey] ?? defaultStudioOfficePreference();
|
||||||
|
};
|
||||||
|
|
||||||
export const resolveStandupPreference = (
|
export const resolveStandupPreference = (
|
||||||
settings: StudioSettings | StudioSettingsPublic,
|
settings: StudioSettings | StudioSettingsPublic,
|
||||||
gatewayUrl: string
|
gatewayUrl: string
|
||||||
|
|||||||
@@ -1,34 +1,23 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
import { stubStudioRoute } from "./helpers/studioRoute";
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.route("**/api/studio", async (route, request) => {
|
await stubStudioRoute(page, {
|
||||||
if (request.method() === "PUT") {
|
version: 1,
|
||||||
await route.fulfill({
|
gateway: null,
|
||||||
status: 200,
|
focused: {},
|
||||||
contentType: "application/json",
|
avatars: {
|
||||||
body: JSON.stringify({
|
"ws://localhost:18789": {
|
||||||
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
|
"agent-1": createDefaultAgentAvatarProfile("seed-1"),
|
||||||
}),
|
},
|
||||||
});
|
},
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request.method() !== "GET") {
|
|
||||||
await route.fallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("empty focused view shows zero agents when disconnected", async ({ page }) => {
|
test("structured avatar settings fixture does not break focused load", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByTestId("studio-menu-toggle")).toBeVisible();
|
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Connect" }).first()).toBeVisible();
|
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,44 +1,23 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { stubStudioRoute } from "./helpers/studioRoute";
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.route("**/api/studio", async (route, request) => {
|
await stubStudioRoute(page);
|
||||||
if (request.method() === "PUT") {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request.method() !== "GET") {
|
|
||||||
await route.fallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows_connection_settings_control_in_header", async ({ page }) => {
|
test("shows_office_header_controls", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByTestId("brain-files-toggle")).toHaveCount(0);
|
await expect(page.getByTestId("brain-files-toggle")).toHaveCount(0);
|
||||||
await page.getByTestId("studio-menu-toggle").click();
|
await expect(page.getByTitle("Voice reply settings")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible();
|
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile_header_shows_connection_control", async ({ page }) => {
|
test("mobile_header_shows_office_controls", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByTestId("brain-files-toggle")).toHaveCount(0);
|
await expect(page.getByTestId("brain-files-toggle")).toHaveCount(0);
|
||||||
await page.getByTestId("studio-menu-toggle").click();
|
await expect(page.getByTitle("Voice reply settings")).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible();
|
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,13 +5,10 @@ test.beforeEach(async ({ page }) => {
|
|||||||
await stubStudioRoute(page);
|
await stubStudioRoute(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("connection panel reflects disconnected state", async ({ page }) => {
|
test("office settings panel reflects current gateway state", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await page.getByTestId("studio-menu-toggle").click();
|
await page.getByTitle("Voice reply settings").click();
|
||||||
await page.getByTestId("gateway-settings-toggle").click();
|
await expect(page.getByRole("button", { name: "Disconnect gateway" })).toBeVisible();
|
||||||
await expect(page.getByLabel("Upstream URL")).toBeVisible();
|
await expect(page.getByText("Current studio connection and endpoint details.")).toBeVisible();
|
||||||
await expect(
|
|
||||||
page.getByRole("button", { name: /^(Connect|Disconnect)$/ })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
import { stubStudioRoute } from "./helpers/studioRoute";
|
import { stubStudioRoute } from "./helpers/studioRoute";
|
||||||
|
|
||||||
test("connection settings persist to the studio settings API", async ({ page }) => {
|
test("voice reply settings persist to the studio settings API", async ({ page }) => {
|
||||||
await stubStudioRoute(page);
|
await stubStudioRoute(page);
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("studio-menu-toggle").click();
|
await page.getByTitle("Voice reply settings").click();
|
||||||
await page.getByTestId("gateway-settings-toggle").click();
|
await expect(page.getByRole("switch", { name: "Voice replies" })).toBeVisible();
|
||||||
await expect(page.getByLabel("Upstream URL")).toBeVisible();
|
await page.waitForFunction(() => {
|
||||||
|
const element = document.querySelector('[aria-label="Voice replies"]');
|
||||||
|
return element instanceof HTMLButtonElement && !element.disabled;
|
||||||
|
});
|
||||||
|
|
||||||
await page.getByLabel("Upstream URL").fill("ws://gateway.example:18789");
|
const requestPromise = page.waitForRequest((req) => {
|
||||||
await page.getByLabel("Upstream token").fill("token-123");
|
|
||||||
|
|
||||||
const request = await page.waitForRequest((req) => {
|
|
||||||
if (!req.url().includes("/api/studio") || req.method() !== "PUT") {
|
if (!req.url().includes("/api/studio") || req.method() !== "PUT") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const payload = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>;
|
const payload = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>;
|
||||||
const gateway = (payload.gateway ?? {}) as { url?: string; token?: string };
|
const voiceReplies = (payload.voiceReplies ?? {}) as Record<string, { enabled?: boolean }>;
|
||||||
return gateway.url === "ws://gateway.example:18789" && gateway.token === "token-123";
|
return Object.values(voiceReplies).some((entry) => entry.enabled === true);
|
||||||
});
|
});
|
||||||
|
await page.getByRole("switch", { name: "Voice replies" }).click();
|
||||||
|
const request = await requestPromise;
|
||||||
|
|
||||||
const payload = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>;
|
const payload = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>;
|
||||||
const gateway = (payload.gateway ?? {}) as { url?: string; token?: string };
|
const voiceReplies = (payload.voiceReplies ?? {}) as Record<string, { enabled?: boolean }>;
|
||||||
expect(gateway.url).toBe("ws://gateway.example:18789");
|
expect(Object.keys(voiceReplies).length).toBeGreaterThan(0);
|
||||||
expect(gateway.token).toBe("token-123");
|
expect(Object.values(voiceReplies).some((entry) => entry.enabled === true)).toBe(true);
|
||||||
await expect(
|
|
||||||
page.getByRole("button", { name: /^(Connect|Disconnect)$/ })
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ test.beforeEach(async ({ page }) => {
|
|||||||
await stubStudioRoute(page);
|
await stubStudioRoute(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows_disconnected_connect_surface", async ({ page }) => {
|
test("shows_office_shell_from_root_redirect", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByLabel("Upstream URL")).toBeVisible();
|
await expect
|
||||||
await expect(page.getByRole("button", { name: /^(Connect|Connecting…)$/ })).toBeVisible();
|
.poll(() => new URL(page.url()).pathname)
|
||||||
|
.toBe("/office");
|
||||||
|
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("persists_gateway_fields_to_studio_settings", async ({ page }) => {
|
test("persists_gateway_fields_to_studio_settings", async ({ page }) => {
|
||||||
@@ -35,17 +38,20 @@ test("persists_gateway_fields_to_studio_settings", async ({ page }) => {
|
|||||||
test("focused_preferences_persist_across_reload", async ({ page }) => {
|
test("focused_preferences_persist_across_reload", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await page.getByTestId("studio-menu-toggle").click();
|
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible();
|
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|
||||||
await expect(page.getByTestId("studio-menu-toggle")).toBeVisible();
|
await expect
|
||||||
|
.poll(() => new URL(page.url()).pathname)
|
||||||
|
.toBe("/office");
|
||||||
|
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("clears_unseen_indicator_on_focus", async ({ page }) => {
|
test("shows_chat_entrypoint_in_office_shell", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await page.getByTestId("studio-menu-toggle").click();
|
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
|
||||||
await expect(page.getByTestId("gateway-settings-toggle")).toBeVisible();
|
await expect(page.getByTitle("Voice reply settings")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,32 +1,13 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { stubStudioRoute } from "./helpers/studioRoute";
|
||||||
|
|
||||||
test("loads focused studio empty state", async ({ page }) => {
|
test("loads office shell from root", async ({ page }) => {
|
||||||
await page.route("**/api/studio", async (route, request) => {
|
await stubStudioRoute(page);
|
||||||
if (request.method() === "PUT") {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request.method() !== "GET") {
|
|
||||||
await route.fallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
settings: { version: 1, gateway: null, focused: {}, avatars: {} },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByTestId("studio-menu-toggle")).toBeVisible();
|
await expect
|
||||||
await expect(page.getByRole("button", { name: "Connect" }).first()).toBeVisible();
|
.poll(() => new URL(page.url()).pathname)
|
||||||
|
.toBe("/office");
|
||||||
|
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "CHAT" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { Page, Route, Request } from "@playwright/test";
|
import type { Page, Route, Request } from "@playwright/test";
|
||||||
|
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
|
||||||
export type StudioSettingsFixture = {
|
export type StudioSettingsFixture = {
|
||||||
version: 1;
|
version: 1;
|
||||||
gateway: { url: string; token: string } | null;
|
gateway: { url: string; token: string } | null;
|
||||||
focused: Record<string, { mode: "focused"; filter: string; selectedAgentId: string | null }>;
|
focused: Record<string, { mode: "focused"; filter: string; selectedAgentId: string | null }>;
|
||||||
avatars: Record<string, Record<string, string>>;
|
avatars: Record<string, Record<string, AgentAvatarProfile>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: StudioSettingsFixture = {
|
const DEFAULT_SETTINGS: StudioSettingsFixture = {
|
||||||
@@ -65,25 +66,31 @@ const createStudioRoute = (initial: StudioSettingsFixture = DEFAULT_SETTINGS) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (patch.avatars && typeof patch.avatars === "object") {
|
if (patch.avatars && typeof patch.avatars === "object") {
|
||||||
const avatarsPatch = patch.avatars as Record<string, Record<string, string | null> | null>;
|
const avatarsPatch = patch.avatars as
|
||||||
|
| Record<string, Record<string, AgentAvatarProfile | null> | null>
|
||||||
|
| null;
|
||||||
const avatarsNext: StudioSettingsFixture["avatars"] = { ...next.avatars };
|
const avatarsNext: StudioSettingsFixture["avatars"] = { ...next.avatars };
|
||||||
for (const [gatewayKey, gatewayPatch] of Object.entries(avatarsPatch)) {
|
for (const [gatewayKey, gatewayPatch] of Object.entries(avatarsPatch ?? {})) {
|
||||||
if (gatewayPatch === null) {
|
if (gatewayPatch === null) {
|
||||||
delete avatarsNext[gatewayKey];
|
delete avatarsNext[gatewayKey];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const existing = avatarsNext[gatewayKey] ? { ...avatarsNext[gatewayKey] } : {};
|
const existing = avatarsNext[gatewayKey] ? { ...avatarsNext[gatewayKey] } : {};
|
||||||
for (const [agentId, seedPatch] of Object.entries(gatewayPatch)) {
|
for (const [agentId, avatarPatch] of Object.entries(gatewayPatch)) {
|
||||||
if (seedPatch === null) {
|
if (avatarPatch === null) {
|
||||||
delete existing[agentId];
|
delete existing[agentId];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const seed = typeof seedPatch === "string" ? seedPatch.trim() : "";
|
if (
|
||||||
if (!seed) {
|
typeof avatarPatch !== "object" ||
|
||||||
|
avatarPatch === null ||
|
||||||
|
typeof avatarPatch.seed !== "string" ||
|
||||||
|
avatarPatch.seed.trim().length === 0
|
||||||
|
) {
|
||||||
delete existing[agentId];
|
delete existing[agentId];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
existing[agentId] = seed;
|
existing[agentId] = avatarPatch;
|
||||||
}
|
}
|
||||||
avatarsNext[gatewayKey] = existing;
|
avatarsNext[gatewayKey] = existing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ test.beforeEach(async ({ page }) => {
|
|||||||
await stubStudioRoute(page);
|
await stubStudioRoute(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("redirects unknown app routes to root", async ({ page }) => {
|
test("redirects unknown app routes to office", async ({ page }) => {
|
||||||
await page.goto("/not-a-real-route");
|
await page.goto("/not-a-real-route");
|
||||||
await expect
|
await expect
|
||||||
.poll(() => new URL(page.url()).pathname, {
|
.poll(() => new URL(page.url()).pathname, {
|
||||||
message: "Expected invalid route to redirect to root path.",
|
message: "Expected invalid route to redirect to office path.",
|
||||||
})
|
})
|
||||||
.toBe("/");
|
.toBe("/office");
|
||||||
await expect(page.getByTestId("studio-menu-toggle")).toBeVisible();
|
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,16 +5,13 @@ test.beforeEach(async ({ page }) => {
|
|||||||
await stubStudioRoute(page);
|
await stubStudioRoute(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("settings route shows connect UI while disconnected and can return to chat", async ({ page }) => {
|
test("settings route redirects to office", async ({ page }) => {
|
||||||
await page.goto("/agents/main/settings");
|
await page.goto("/agents/main/settings");
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Back to chat" })).toBeVisible();
|
|
||||||
await expect(page.getByLabel("Upstream URL")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Back to chat" }).click();
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => new URL(page.url()).pathname, {
|
.poll(() => new URL(page.url()).pathname, {
|
||||||
message: "Expected back button to return to chat route.",
|
message: "Expected settings route to redirect to office.",
|
||||||
})
|
})
|
||||||
.toBe("/");
|
.toBe("/office");
|
||||||
|
await expect(page.getByRole("button", { name: "Open headquarters sidebar" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { AgentAvatarCreatorModal } from "@/features/agents/components/AgentAvatarCreatorModal";
|
||||||
|
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
|
||||||
|
vi.mock("@/features/agents/components/AgentAvatarPreview3D", () => ({
|
||||||
|
AgentAvatarPreview3D: () => <div data-testid="avatar-preview-3d">preview</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AgentAvatarCreatorModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the edited avatar profile", async () => {
|
||||||
|
const initialProfile = createDefaultAgentAvatarProfile("seed-a");
|
||||||
|
const onSave = vi.fn(async () => {});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AgentAvatarCreatorModal
|
||||||
|
open
|
||||||
|
agentId="agent-1"
|
||||||
|
agentName="Agent One"
|
||||||
|
initialProfile={initialProfile}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSave={onSave}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Backpack" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save avatar" }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
seed: "seed-a",
|
||||||
|
accessories: expect.objectContaining({
|
||||||
|
backpack: !initialProfile.accessories.backpack,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { createElement } from "react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { AgentEditorModal } from "@/features/agents/components/AgentEditorModal";
|
||||||
|
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
import type { AgentState } from "@/features/agents/state/store";
|
||||||
|
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||||
|
|
||||||
|
vi.mock("@/features/agents/components/AgentAvatarPreview3D", () => ({
|
||||||
|
AgentAvatarPreview3D: () => createElement("div", { "data-testid": "avatar-preview-3d" }, "preview"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/agents/components/inspect/AgentBrainPanel", () => ({
|
||||||
|
AgentBrainPanel: ({
|
||||||
|
selectedAgentId,
|
||||||
|
activeSection,
|
||||||
|
}: {
|
||||||
|
selectedAgentId: string | null;
|
||||||
|
activeSection?: string;
|
||||||
|
}) =>
|
||||||
|
createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "brain-panel" },
|
||||||
|
`brain:${selectedAgentId}:${activeSection ?? "all"}`,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const buildAgent = (): AgentState =>
|
||||||
|
({
|
||||||
|
agentId: "agent-1",
|
||||||
|
name: "Agent One",
|
||||||
|
avatarProfile: createDefaultAgentAvatarProfile("seed-a"),
|
||||||
|
avatarSeed: "seed-a",
|
||||||
|
avatarUrl: null,
|
||||||
|
status: "idle",
|
||||||
|
sessionCreated: false,
|
||||||
|
awaitingUserInput: false,
|
||||||
|
hasUnseenActivity: false,
|
||||||
|
outputLines: [],
|
||||||
|
lastResult: null,
|
||||||
|
lastDiff: null,
|
||||||
|
runId: null,
|
||||||
|
runStartedAt: null,
|
||||||
|
streamText: null,
|
||||||
|
thinkingTrace: null,
|
||||||
|
latestOverride: null,
|
||||||
|
latestOverrideKind: null,
|
||||||
|
lastAssistantMessageAt: null,
|
||||||
|
lastActivityAt: null,
|
||||||
|
latestPreview: null,
|
||||||
|
lastUserMessage: null,
|
||||||
|
draft: "",
|
||||||
|
queuedMessages: [],
|
||||||
|
sessionSettingsSynced: false,
|
||||||
|
historyLoadedAt: null,
|
||||||
|
historyFetchLimit: null,
|
||||||
|
historyFetchedCount: null,
|
||||||
|
historyMaybeTruncated: false,
|
||||||
|
toolCallingEnabled: true,
|
||||||
|
showThinkingTraces: false,
|
||||||
|
sessionKey: "session-1",
|
||||||
|
model: undefined,
|
||||||
|
thinkingLevel: undefined,
|
||||||
|
}) as AgentState;
|
||||||
|
|
||||||
|
describe("AgentEditorModal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves avatar changes from the avatar section", async () => {
|
||||||
|
const agent = buildAgent();
|
||||||
|
const onAvatarSave = vi.fn(async () => {});
|
||||||
|
const initialBackpack = agent.avatarProfile?.accessories.backpack;
|
||||||
|
|
||||||
|
render(
|
||||||
|
createElement(AgentEditorModal, {
|
||||||
|
open: true,
|
||||||
|
client: {} as GatewayClient,
|
||||||
|
agents: [agent],
|
||||||
|
agent,
|
||||||
|
onClose: () => {},
|
||||||
|
onAvatarSave,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Backpack" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save avatar" }));
|
||||||
|
|
||||||
|
expect(onAvatarSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAvatarSave).toHaveBeenCalledWith(
|
||||||
|
"agent-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
seed: "seed-a",
|
||||||
|
accessories: expect.objectContaining({ backpack: !initialBackpack }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches to another file section", () => {
|
||||||
|
const agent = buildAgent();
|
||||||
|
|
||||||
|
render(
|
||||||
|
createElement(AgentEditorModal, {
|
||||||
|
open: true,
|
||||||
|
client: {} as GatewayClient,
|
||||||
|
agents: [agent],
|
||||||
|
agent,
|
||||||
|
onClose: () => {},
|
||||||
|
onAvatarSave: () => {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Tools/i }));
|
||||||
|
|
||||||
|
expect(screen.getByTestId("brain-panel")).toHaveTextContent("brain:agent-1:TOOLS.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors the initial file section", () => {
|
||||||
|
const agent = buildAgent();
|
||||||
|
|
||||||
|
render(
|
||||||
|
createElement(AgentEditorModal, {
|
||||||
|
open: true,
|
||||||
|
client: {} as GatewayClient,
|
||||||
|
agents: [agent],
|
||||||
|
agent,
|
||||||
|
initialSection: "MEMORY.md",
|
||||||
|
onClose: () => {},
|
||||||
|
onAvatarSave: () => {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("brain-panel")).toHaveTextContent("brain:agent-1:MEMORY.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
|
import { hydrateAgentFleetFromGateway } from "@/features/agents/operations/agentFleetHydration";
|
||||||
|
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
import type { StudioSettings } from "@/lib/studio/settings";
|
import type { StudioSettings } from "@/lib/studio/settings";
|
||||||
|
|
||||||
describe("hydrateAgentFleetFromGateway", () => {
|
describe("hydrateAgentFleetFromGateway", () => {
|
||||||
@@ -13,12 +14,13 @@ describe("hydrateAgentFleetFromGateway", () => {
|
|||||||
focused: {},
|
focused: {},
|
||||||
avatars: {
|
avatars: {
|
||||||
[gatewayUrl]: {
|
[gatewayUrl]: {
|
||||||
"agent-1": "persisted-seed",
|
"agent-1": createDefaultAgentAvatarProfile("persisted-seed"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deskAssignments: {},
|
deskAssignments: {},
|
||||||
analytics: {},
|
analytics: {},
|
||||||
voiceReplies: {},
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const call = vi.fn(async (method: string, params: unknown) => {
|
const call = vi.fn(async (method: string, params: unknown) => {
|
||||||
@@ -127,6 +129,7 @@ describe("hydrateAgentFleetFromGateway", () => {
|
|||||||
name: "One",
|
name: "One",
|
||||||
sessionKey: "agent:agent-1:main",
|
sessionKey: "agent:agent-1:main",
|
||||||
avatarSeed: "persisted-seed",
|
avatarSeed: "persisted-seed",
|
||||||
|
avatarProfile: expect.objectContaining({ seed: "persisted-seed" }),
|
||||||
avatarUrl: "https://example.com/one.png",
|
avatarUrl: "https://example.com/one.png",
|
||||||
model: "openai/gpt-4.1",
|
model: "openai/gpt-4.1",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { deriveHydrateAgentFleetResult } from "@/features/agents/operations/agentFleetHydrationDerivation";
|
import { deriveHydrateAgentFleetResult } from "@/features/agents/operations/agentFleetHydrationDerivation";
|
||||||
|
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
import type { StudioSettings } from "@/lib/studio/settings";
|
import type { StudioSettings } from "@/lib/studio/settings";
|
||||||
|
|
||||||
describe("deriveHydrateAgentFleetResult", () => {
|
describe("deriveHydrateAgentFleetResult", () => {
|
||||||
@@ -13,12 +14,13 @@ describe("deriveHydrateAgentFleetResult", () => {
|
|||||||
focused: {},
|
focused: {},
|
||||||
avatars: {
|
avatars: {
|
||||||
[gatewayUrl]: {
|
[gatewayUrl]: {
|
||||||
"agent-1": "persisted-seed",
|
"agent-1": createDefaultAgentAvatarProfile("persisted-seed"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deskAssignments: {},
|
deskAssignments: {},
|
||||||
analytics: {},
|
analytics: {},
|
||||||
voiceReplies: {},
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = deriveHydrateAgentFleetResult({
|
const result = deriveHydrateAgentFleetResult({
|
||||||
@@ -93,6 +95,7 @@ describe("deriveHydrateAgentFleetResult", () => {
|
|||||||
name: "One",
|
name: "One",
|
||||||
sessionKey: "agent:agent-1:main",
|
sessionKey: "agent:agent-1:main",
|
||||||
avatarSeed: "persisted-seed",
|
avatarSeed: "persisted-seed",
|
||||||
|
avatarProfile: expect.objectContaining({ seed: "persisted-seed" }),
|
||||||
avatarUrl: "https://example.com/one.png",
|
avatarUrl: "https://example.com/one.png",
|
||||||
model: "openai/gpt-4.1",
|
model: "openai/gpt-4.1",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ describe("studioBootstrapOperation", () => {
|
|||||||
deskAssignments: {},
|
deskAssignments: {},
|
||||||
analytics: {},
|
analytics: {},
|
||||||
voiceReplies: {},
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
}),
|
}),
|
||||||
isFocusFilterTouched: () => false,
|
isFocusFilterTouched: () => false,
|
||||||
});
|
});
|
||||||
@@ -203,6 +204,7 @@ describe("studioBootstrapOperation", () => {
|
|||||||
deskAssignments: {},
|
deskAssignments: {},
|
||||||
analytics: {},
|
analytics: {},
|
||||||
voiceReplies: {},
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
}),
|
}),
|
||||||
isFocusFilterTouched: () => true,
|
isFocusFilterTouched: () => true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ describe("studioBootstrapWorkflow", () => {
|
|||||||
deskAssignments: {},
|
deskAssignments: {},
|
||||||
analytics: {},
|
analytics: {},
|
||||||
voiceReplies: {},
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -197,6 +198,7 @@ describe("studioBootstrapWorkflow", () => {
|
|||||||
deskAssignments: {},
|
deskAssignments: {},
|
||||||
analytics: {},
|
analytics: {},
|
||||||
voiceReplies: {},
|
voiceReplies: {},
|
||||||
|
office: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
mergeStudioSettings,
|
mergeStudioSettings,
|
||||||
@@ -12,6 +13,7 @@ describe("studio settings normalization", () => {
|
|||||||
expect(normalized.gateway).toBeNull();
|
expect(normalized.gateway).toBeNull();
|
||||||
expect(normalized.focused).toEqual({});
|
expect(normalized.focused).toEqual({});
|
||||||
expect(normalized.avatars).toEqual({});
|
expect(normalized.avatars).toEqual({});
|
||||||
|
expect(normalized.office).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes gateway entries", () => {
|
it("normalizes gateway entries", () => {
|
||||||
@@ -114,16 +116,17 @@ describe("studio settings normalization", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(normalized.avatars["ws://localhost:18789"]).toEqual({
|
expect(normalized.avatars["ws://localhost:18789"]?.["agent-1"]?.seed).toBe("seed-1");
|
||||||
"agent-1": "seed-1",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("merges avatar patches", () => {
|
it("merges avatar patches", () => {
|
||||||
|
const firstProfile = createDefaultAgentAvatarProfile("seed-1");
|
||||||
|
const replacementProfile = createDefaultAgentAvatarProfile("seed-2");
|
||||||
|
const secondProfile = createDefaultAgentAvatarProfile("seed-3");
|
||||||
const current = normalizeStudioSettings({
|
const current = normalizeStudioSettings({
|
||||||
avatars: {
|
avatars: {
|
||||||
"ws://localhost:18789": {
|
"ws://localhost:18789": {
|
||||||
"agent-1": "seed-1",
|
"agent-1": firstProfile,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -131,15 +134,55 @@ describe("studio settings normalization", () => {
|
|||||||
const merged = mergeStudioSettings(current, {
|
const merged = mergeStudioSettings(current, {
|
||||||
avatars: {
|
avatars: {
|
||||||
"ws://localhost:18789": {
|
"ws://localhost:18789": {
|
||||||
"agent-1": "seed-2",
|
"agent-1": replacementProfile,
|
||||||
"agent-2": "seed-3",
|
"agent-2": secondProfile,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(merged.avatars["ws://localhost:18789"]).toEqual({
|
expect(merged.avatars["ws://localhost:18789"]?.["agent-1"]?.seed).toBe("seed-2");
|
||||||
"agent-1": "seed-2",
|
expect(merged.avatars["ws://localhost:18789"]?.["agent-2"]?.seed).toBe("seed-3");
|
||||||
"agent-2": "seed-3",
|
});
|
||||||
|
|
||||||
|
it("normalizes office title preferences per gateway", () => {
|
||||||
|
const normalized = normalizeStudioSettings({
|
||||||
|
office: {
|
||||||
|
" ws://localhost:18789 ": {
|
||||||
|
title: " Team Orbit ",
|
||||||
|
},
|
||||||
|
bad: {
|
||||||
|
title: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.office["ws://localhost:18789"]).toEqual({
|
||||||
|
title: "Team Orbit",
|
||||||
|
});
|
||||||
|
expect(normalized.office.bad).toEqual({
|
||||||
|
title: "Luke Headquarters",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges office title patches", () => {
|
||||||
|
const current = normalizeStudioSettings({
|
||||||
|
office: {
|
||||||
|
"ws://localhost:18789": {
|
||||||
|
title: "Luke Headquarters",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = mergeStudioSettings(current, {
|
||||||
|
office: {
|
||||||
|
"ws://localhost:18789": {
|
||||||
|
title: "Orbit Control",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(merged.office["ws://localhost:18789"]).toEqual({
|
||||||
|
title: "Orbit Control",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,18 +47,18 @@ describe("studio settings route", () => {
|
|||||||
|
|
||||||
const response = await GET();
|
const response = await GET();
|
||||||
const body = (await response.json()) as {
|
const body = (await response.json()) as {
|
||||||
settings?: { gateway?: { url?: string; token?: string } | null };
|
settings?: { gateway?: { url?: string; tokenConfigured?: boolean } | null };
|
||||||
localGatewayDefaults?: { url?: string; token?: string } | null;
|
localGatewayDefaults?: { url?: string; tokenConfigured?: boolean } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(body.localGatewayDefaults).toEqual({
|
expect(body.localGatewayDefaults).toEqual({
|
||||||
url: "ws://localhost:18791",
|
url: "ws://localhost:18791",
|
||||||
token: "local-token",
|
tokenConfigured: true,
|
||||||
});
|
});
|
||||||
expect(body.settings?.gateway).toEqual({
|
expect(body.settings?.gateway).toEqual({
|
||||||
url: "ws://localhost:18791",
|
url: "ws://localhost:18791",
|
||||||
token: "local-token",
|
tokenConfigured: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +82,11 @@ describe("studio settings route", () => {
|
|||||||
|
|
||||||
const patch = {
|
const patch = {
|
||||||
gateway: { url: "ws://example.test:1234", token: "t" },
|
gateway: { url: "ws://example.test:1234", token: "t" },
|
||||||
|
office: {
|
||||||
|
"ws://example.test:1234": {
|
||||||
|
title: "Orbit Control",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const putResponse = await PUT({
|
const putResponse = await PUT({
|
||||||
@@ -91,16 +96,31 @@ describe("studio settings route", () => {
|
|||||||
|
|
||||||
const getResponse = await GET();
|
const getResponse = await GET();
|
||||||
const body = (await getResponse.json()) as {
|
const body = (await getResponse.json()) as {
|
||||||
settings?: { gateway?: { url?: string; token?: string } | null };
|
settings?: {
|
||||||
|
gateway?: { url?: string; tokenConfigured?: boolean } | null;
|
||||||
|
office?: Record<string, { title?: string }>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(getResponse.status).toBe(200);
|
expect(getResponse.status).toBe(200);
|
||||||
expect(body.settings?.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
|
expect(body.settings?.gateway).toEqual({
|
||||||
|
url: "ws://example.test:1234",
|
||||||
|
tokenConfigured: true,
|
||||||
|
});
|
||||||
|
expect(body.settings?.office?.["ws://example.test:1234"]).toEqual({
|
||||||
|
title: "Orbit Control",
|
||||||
|
});
|
||||||
|
|
||||||
const settingsPath = path.join(tempDir, "claw3d", "settings.json");
|
const settingsPath = path.join(tempDir, "claw3d", "settings.json");
|
||||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||||
const raw = fs.readFileSync(settingsPath, "utf8");
|
const raw = fs.readFileSync(settingsPath, "utf8");
|
||||||
const parsed = JSON.parse(raw) as { gateway?: { url?: string; token?: string } | null };
|
const parsed = JSON.parse(raw) as {
|
||||||
|
gateway?: { url?: string; token?: string } | null;
|
||||||
|
office?: Record<string, { title?: string }>;
|
||||||
|
};
|
||||||
expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
|
expect(parsed.gateway).toEqual({ url: "ws://example.test:1234", token: "t" });
|
||||||
|
expect(parsed.office?.["ws://example.test:1234"]).toEqual({
|
||||||
|
title: "Orbit Control",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user