Agents crud (#9084)
This commit is contained in:
@@ -6,6 +6,9 @@ export function getPersonaSource(persona: Persona): PersonaSource {
|
||||
if (persona.isBuiltin) {
|
||||
return "builtin";
|
||||
}
|
||||
if (persona.writable === true) {
|
||||
return "custom";
|
||||
}
|
||||
if (persona.isFromDisk) {
|
||||
return "file";
|
||||
}
|
||||
@@ -13,5 +16,5 @@ export function getPersonaSource(persona: Persona): PersonaSource {
|
||||
}
|
||||
|
||||
export function isPersonaReadOnly(persona: Persona): boolean {
|
||||
return getPersonaSource(persona) !== "custom";
|
||||
return persona.writable === false || getPersonaSource(persona) !== "custom";
|
||||
}
|
||||
|
||||
@@ -219,5 +219,6 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
|
||||
getBuiltinPersonas: () => get().personas.filter((p) => p.isBuiltin),
|
||||
|
||||
getCustomPersonas: () => get().personas.filter((p) => !p.isBuiltin),
|
||||
getCustomPersonas: () =>
|
||||
get().personas.filter((p) => !p.isBuiltin && p.writable !== false),
|
||||
}));
|
||||
|
||||
@@ -119,7 +119,8 @@ export function AgentsView() {
|
||||
);
|
||||
|
||||
const handleDeletePersona = useCallback((persona: Persona) => {
|
||||
if (getPersonaSource(persona) === "builtin") return;
|
||||
if (getPersonaSource(persona) === "builtin" || persona.writable === false)
|
||||
return;
|
||||
setDeletingPersona(persona);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -4,21 +4,27 @@ import { Camera, X } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||
import { savePersonaAvatar, savePersonaAvatarBytes } from "@/shared/api/agents";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Avatar } from "@/shared/types/agents";
|
||||
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg"];
|
||||
|
||||
function filePathToFileUrl(filePath: string): string {
|
||||
const normalizedPath = filePath.replaceAll("\\", "/");
|
||||
const url = new URL("file://");
|
||||
url.pathname = normalizedPath.startsWith("/")
|
||||
? normalizedPath
|
||||
: `/${normalizedPath}`;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
interface AvatarDropZoneProps {
|
||||
personaId: string;
|
||||
avatar: Avatar | null | undefined;
|
||||
onChange: (avatar: Avatar | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function AvatarDropZone({
|
||||
personaId,
|
||||
avatar,
|
||||
onChange,
|
||||
disabled = false,
|
||||
@@ -44,11 +50,20 @@ export function AvatarDropZone({
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(buffer));
|
||||
const filename = await savePersonaAvatarBytes(personaId, bytes, ext);
|
||||
const value = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("Avatar file could not be read as a data URL"));
|
||||
}
|
||||
});
|
||||
reader.addEventListener("error", () => reject(reader.error));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
onChange({ type: "local", value: filename });
|
||||
onChange({ type: "url", value });
|
||||
} catch (err) {
|
||||
console.error("Failed to save avatar:", err);
|
||||
setError(t("avatar.saveFailed"));
|
||||
@@ -56,7 +71,7 @@ export function AvatarDropZone({
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[personaId, onChange, t],
|
||||
[onChange, t],
|
||||
);
|
||||
|
||||
/** Save a file selected via the native file picker (has a path). */
|
||||
@@ -72,9 +87,7 @@ export function AvatarDropZone({
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const filename = await savePersonaAvatar(personaId, filePath);
|
||||
|
||||
onChange({ type: "local", value: filename });
|
||||
onChange({ type: "url", value: filePathToFileUrl(filePath) });
|
||||
} catch (err) {
|
||||
console.error("Failed to save avatar:", err);
|
||||
setError(t("avatar.saveFailed"));
|
||||
@@ -82,7 +95,7 @@ export function AvatarDropZone({
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[personaId, onChange, t],
|
||||
[onChange, t],
|
||||
);
|
||||
|
||||
// Standard HTML5 drag-and-drop (works when dragDropEnabled is false)
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||
import type { Persona } from "@/shared/types/agents";
|
||||
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
|
||||
import {
|
||||
getPersonaSource,
|
||||
isPersonaReadOnly,
|
||||
} from "@/features/agents/lib/personaPresentation";
|
||||
|
||||
interface PersonaCardProps {
|
||||
persona: Persona;
|
||||
@@ -40,8 +43,9 @@ export function PersonaCard({
|
||||
const initials = persona.displayName.charAt(0).toUpperCase();
|
||||
const avatarSrc = useAvatarSrc(persona.avatar);
|
||||
const personaSource = getPersonaSource(persona);
|
||||
const canEditPersona = personaSource === "custom";
|
||||
const canDeletePersona = personaSource !== "builtin";
|
||||
const canEditPersona = !isPersonaReadOnly(persona);
|
||||
const canDeletePersona =
|
||||
personaSource !== "builtin" && persona.writable !== false;
|
||||
const providerModelLabel = [persona.provider, persona.model]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
@@ -72,8 +72,9 @@ export function PersonaEditor({
|
||||
const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false;
|
||||
const isReadOnly = detailsMode || readOnlyBySource;
|
||||
const personaSource = persona ? getPersonaSource(persona) : "custom";
|
||||
const canEditPersona = personaSource === "custom";
|
||||
const canDeletePersona = personaSource !== "builtin";
|
||||
const canEditPersona = !readOnlyBySource;
|
||||
const canDeletePersona =
|
||||
personaSource !== "builtin" && persona?.writable !== false;
|
||||
const acpProviders = useAgentStore((s) => s.providers);
|
||||
const setProviders = useAgentStore((s) => s.setProviders);
|
||||
const mergeInventoryEntries = useProviderInventoryStore(
|
||||
@@ -187,9 +188,6 @@ export function PersonaEditor({
|
||||
|
||||
const initials = displayName.charAt(0).toUpperCase() || "?";
|
||||
|
||||
// For new personas, use a temporary ID for the avatar upload
|
||||
const avatarPersonaId = persona?.id ?? "new-persona";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
|
||||
@@ -236,7 +234,6 @@ export function PersonaEditor({
|
||||
</AvatarRoot>
|
||||
) : (
|
||||
<AvatarDropZone
|
||||
personaId={avatarPersonaId}
|
||||
avatar={avatar}
|
||||
onChange={setAvatar}
|
||||
disabled={isReadOnly}
|
||||
|
||||
@@ -60,10 +60,10 @@ export function PersonaGallery({
|
||||
});
|
||||
const sorted = useMemo(() => {
|
||||
const builtins = personas
|
||||
.filter((p) => p.isBuiltin)
|
||||
.filter((p) => p.isBuiltin || p.writable === false)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
const custom = personas
|
||||
.filter((p) => !p.isBuiltin)
|
||||
.filter((p) => !p.isBuiltin && p.writable !== false)
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
return [...builtins, ...custom];
|
||||
}, [personas]);
|
||||
|
||||
@@ -246,12 +246,12 @@ function MentionAvatar({ persona }: { persona: Persona }) {
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-full",
|
||||
persona.isBuiltin
|
||||
persona.isBuiltin || persona.writable === false
|
||||
? "bg-foreground/10 text-foreground"
|
||||
: "bg-brand/10 text-brand",
|
||||
)}
|
||||
>
|
||||
{persona.isBuiltin ? (
|
||||
{persona.isBuiltin || persona.writable === false ? (
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<User className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -43,11 +43,11 @@ export function PersonaPicker({
|
||||
);
|
||||
|
||||
const builtinPersonas = useMemo(
|
||||
() => personas.filter((p) => p.isBuiltin),
|
||||
() => personas.filter((p) => p.isBuiltin || p.writable === false),
|
||||
[personas],
|
||||
);
|
||||
const customPersonas = useMemo(
|
||||
() => personas.filter((p) => !p.isBuiltin),
|
||||
() => personas.filter((p) => !p.isBuiltin && p.writable !== false),
|
||||
[personas],
|
||||
);
|
||||
|
||||
@@ -211,7 +211,9 @@ function PersonaAvatar({
|
||||
);
|
||||
}
|
||||
|
||||
const isBuiltin = persona?.isBuiltin ?? true;
|
||||
const isBuiltin = persona
|
||||
? persona.isBuiltin || persona.writable === false
|
||||
: true;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,78 +1,188 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { exportPersona, importPersonas, refreshPersonas } from "../agents";
|
||||
import {
|
||||
createPersona,
|
||||
deletePersona,
|
||||
exportPersona,
|
||||
importPersonas,
|
||||
listPersonas,
|
||||
refreshPersonas,
|
||||
updatePersona,
|
||||
} from "../agents";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
const mockGooseSourcesCreate = vi.fn();
|
||||
const mockGooseSourcesDelete = vi.fn();
|
||||
const mockGooseSourcesExport = vi.fn();
|
||||
const mockGooseSourcesImport = vi.fn();
|
||||
const mockGooseSourcesList = vi.fn();
|
||||
const mockGooseSourcesUpdate = vi.fn();
|
||||
|
||||
vi.mock("@/shared/api/acpConnection", () => ({
|
||||
getClient: async () => ({
|
||||
goose: {
|
||||
GooseSourcesCreate: (...args: unknown[]) =>
|
||||
mockGooseSourcesCreate(...args),
|
||||
GooseSourcesDelete: (...args: unknown[]) =>
|
||||
mockGooseSourcesDelete(...args),
|
||||
GooseSourcesExport: (...args: unknown[]) =>
|
||||
mockGooseSourcesExport(...args),
|
||||
GooseSourcesImport: (...args: unknown[]) =>
|
||||
mockGooseSourcesImport(...args),
|
||||
GooseSourcesList: (...args: unknown[]) => mockGooseSourcesList(...args),
|
||||
GooseSourcesUpdate: (...args: unknown[]) =>
|
||||
mockGooseSourcesUpdate(...args),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockedInvoke = vi.mocked(invoke);
|
||||
const source = {
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
path: "/Users/test/.agents/agents/scout.md",
|
||||
global: true,
|
||||
writable: true,
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: "file:///Users/test/.goose/avatars/agents/scout.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("agents API", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── exportPersona ────────────────────────────────────────────────────
|
||||
it("listPersonas maps agent sources to personas", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
|
||||
it("exportPersona invokes correct Tauri command with ID", async () => {
|
||||
const mockResult = {
|
||||
json: '{"displayName":"Test"}',
|
||||
suggestedFilename: "test.json",
|
||||
};
|
||||
mockedInvoke.mockResolvedValue(mockResult);
|
||||
const result = await listPersonas();
|
||||
|
||||
const result = await exportPersona("persona-123");
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("export_persona", {
|
||||
id: "persona-123",
|
||||
});
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: source.path,
|
||||
displayName: "Scout",
|
||||
avatar: { type: "url", value: source.properties.avatar },
|
||||
systemPrompt: "Research carefully.",
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
isBuiltin: false,
|
||||
isFromDisk: true,
|
||||
writable: true,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// ── importPersonas ───────────────────────────────────────────────────
|
||||
it("createPersona creates a global agent source", async () => {
|
||||
mockGooseSourcesCreate.mockResolvedValue({ source });
|
||||
|
||||
it("importPersonas invokes correct Tauri command with bytes and filename", async () => {
|
||||
const mockPersonas = [
|
||||
{
|
||||
id: "imported-1",
|
||||
displayName: "Imported",
|
||||
systemPrompt: "Hello",
|
||||
isBuiltin: false,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
mockedInvoke.mockResolvedValue(mockPersonas);
|
||||
|
||||
const fileBytes = [0x7b, 0x7d]; // "{}"
|
||||
const result = await importPersonas(fileBytes, "personas.json");
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("import_personas", {
|
||||
fileBytes,
|
||||
fileName: "personas.json",
|
||||
const result = await createPersona({
|
||||
displayName: "Scout",
|
||||
avatar: { type: "url", value: source.properties.avatar },
|
||||
systemPrompt: "Research carefully.",
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
});
|
||||
expect(result).toEqual(mockPersonas);
|
||||
|
||||
expect(mockGooseSourcesCreate).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: source.properties.avatar,
|
||||
},
|
||||
global: true,
|
||||
});
|
||||
expect(result.displayName).toBe("Scout");
|
||||
});
|
||||
|
||||
// ── refreshPersonas ──────────────────────────────────────────────────
|
||||
it("updatePersona loads existing agent source and updates by path", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
mockGooseSourcesUpdate.mockResolvedValue({
|
||||
source: { ...source, name: "Scout 2" },
|
||||
});
|
||||
|
||||
it("refreshPersonas invokes correct Tauri command", async () => {
|
||||
const mockPersonas = [
|
||||
{
|
||||
id: "p1",
|
||||
displayName: "Refreshed",
|
||||
systemPrompt: "Prompt",
|
||||
isBuiltin: false,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
const result = await updatePersona(source.path, {
|
||||
displayName: "Scout 2",
|
||||
});
|
||||
|
||||
expect(mockGooseSourcesUpdate).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
name: "Scout 2",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
properties: {
|
||||
provider: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
avatar: source.properties.avatar,
|
||||
},
|
||||
];
|
||||
mockedInvoke.mockResolvedValue(mockPersonas);
|
||||
});
|
||||
expect(result.displayName).toBe("Scout 2");
|
||||
});
|
||||
|
||||
it("deletePersona deletes an agent source by path", async () => {
|
||||
mockGooseSourcesDelete.mockResolvedValue(undefined);
|
||||
|
||||
await deletePersona(source.path);
|
||||
|
||||
expect(mockGooseSourcesDelete).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
});
|
||||
});
|
||||
|
||||
it("exportPersona exports an agent source", async () => {
|
||||
mockGooseSourcesExport.mockResolvedValue({
|
||||
json: '{"type":"agent"}',
|
||||
filename: "scout.agent.json",
|
||||
});
|
||||
|
||||
const result = await exportPersona(source.path);
|
||||
|
||||
expect(mockGooseSourcesExport).toHaveBeenCalledWith({
|
||||
type: "agent",
|
||||
path: source.path,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
json: '{"type":"agent"}',
|
||||
suggestedFilename: "scout.agent.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("importPersonas imports agent source JSON", async () => {
|
||||
mockGooseSourcesImport.mockResolvedValue({ sources: [source] });
|
||||
const data = JSON.stringify({
|
||||
version: 1,
|
||||
type: "agent",
|
||||
name: "Scout",
|
||||
description: "Agent",
|
||||
content: "Research carefully.",
|
||||
});
|
||||
const fileBytes = Array.from(new TextEncoder().encode(data));
|
||||
|
||||
const result = await importPersonas(fileBytes, "scout.agent.json");
|
||||
|
||||
expect(mockGooseSourcesImport).toHaveBeenCalledWith({
|
||||
data,
|
||||
global: true,
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refreshPersonas lists personas", async () => {
|
||||
mockGooseSourcesList.mockResolvedValue({ sources: [source] });
|
||||
|
||||
const result = await refreshPersonas();
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("refresh_personas");
|
||||
expect(result).toEqual(mockPersonas);
|
||||
expect(mockGooseSourcesList).toHaveBeenCalledWith({ type: "agent" });
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,155 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SourceEntry } from "@aaif/goose-sdk";
|
||||
import { getClient } from "@/shared/api/acpConnection";
|
||||
import type {
|
||||
Persona,
|
||||
CreatePersonaRequest,
|
||||
UpdatePersonaRequest,
|
||||
Avatar,
|
||||
} from "@/shared/types/agents";
|
||||
|
||||
const AGENT_SOURCE_TYPE = "agent" as const;
|
||||
const AGENT_DESCRIPTION = "Agent";
|
||||
|
||||
type AgentSourceProperties = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
type AgentSourceEntry = SourceEntry & {
|
||||
type: typeof AGENT_SOURCE_TYPE;
|
||||
properties: AgentSourceProperties;
|
||||
};
|
||||
|
||||
function isAgentSource(source: SourceEntry): source is AgentSourceEntry {
|
||||
return source.type === AGENT_SOURCE_TYPE;
|
||||
}
|
||||
|
||||
function avatarToProperty(
|
||||
avatar: Avatar | null | undefined,
|
||||
): string | undefined {
|
||||
if (!avatar) return undefined;
|
||||
return avatar.value;
|
||||
}
|
||||
|
||||
function propertyToAvatar(value: string | undefined): Avatar | null {
|
||||
if (!value) return null;
|
||||
return { type: "url", value };
|
||||
}
|
||||
|
||||
function personaProperties(
|
||||
request: CreatePersonaRequest | UpdatePersonaRequest,
|
||||
): AgentSourceProperties | undefined {
|
||||
const properties: AgentSourceProperties = {};
|
||||
if (request.provider) properties.provider = request.provider;
|
||||
if (request.model) properties.model = request.model;
|
||||
const avatar = avatarToProperty(request.avatar);
|
||||
if (avatar) properties.avatar = avatar;
|
||||
return properties;
|
||||
}
|
||||
|
||||
function toPersona(source: AgentSourceEntry): Persona {
|
||||
const writable = source.writable !== false;
|
||||
return {
|
||||
id: source.path,
|
||||
displayName: source.name,
|
||||
avatar: propertyToAvatar(source.properties?.avatar),
|
||||
systemPrompt: source.content,
|
||||
provider: source.properties?.provider,
|
||||
model: source.properties?.model,
|
||||
isBuiltin: !writable,
|
||||
isFromDisk: writable,
|
||||
writable,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function listAgentSources(): Promise<AgentSourceEntry[]> {
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesList({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
});
|
||||
return response.sources.filter(isAgentSource);
|
||||
}
|
||||
|
||||
async function getAgentSource(id: string): Promise<AgentSourceEntry> {
|
||||
const source = (await listAgentSources()).find(
|
||||
(source) => source.path === id,
|
||||
);
|
||||
if (!source) {
|
||||
throw new Error(`Agent '${id}' not found`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function listPersonas(): Promise<Persona[]> {
|
||||
return invoke("list_personas");
|
||||
return (await listAgentSources()).map(toPersona);
|
||||
}
|
||||
|
||||
export async function createPersona(
|
||||
request: CreatePersonaRequest,
|
||||
): Promise<Persona> {
|
||||
return invoke("create_persona", { request });
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesCreate({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
name: request.displayName,
|
||||
description: AGENT_DESCRIPTION,
|
||||
content: request.systemPrompt,
|
||||
properties: personaProperties(request),
|
||||
global: true,
|
||||
});
|
||||
|
||||
if (!isAgentSource(response.source)) {
|
||||
throw new Error(`Unexpected source type returned: ${response.source.type}`);
|
||||
}
|
||||
|
||||
return toPersona(response.source);
|
||||
}
|
||||
|
||||
export async function updatePersona(
|
||||
id: string,
|
||||
request: UpdatePersonaRequest,
|
||||
): Promise<Persona> {
|
||||
return invoke("update_persona", { id, request });
|
||||
const existing = await getAgentSource(id);
|
||||
const client = await getClient();
|
||||
const merged: CreatePersonaRequest = {
|
||||
displayName: request.displayName ?? existing.name,
|
||||
avatar:
|
||||
request.avatar === undefined
|
||||
? propertyToAvatar(existing.properties?.avatar)
|
||||
: request.avatar,
|
||||
systemPrompt: request.systemPrompt ?? existing.content,
|
||||
provider: request.provider ?? existing.properties?.provider,
|
||||
model: request.model ?? existing.properties?.model,
|
||||
};
|
||||
const response = await client.goose.GooseSourcesUpdate({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
name: merged.displayName,
|
||||
description: existing.description || AGENT_DESCRIPTION,
|
||||
content: merged.systemPrompt,
|
||||
properties: personaProperties(merged),
|
||||
});
|
||||
|
||||
if (!isAgentSource(response.source)) {
|
||||
throw new Error(`Unexpected source type returned: ${response.source.type}`);
|
||||
}
|
||||
|
||||
return toPersona(response.source);
|
||||
}
|
||||
|
||||
export async function deletePersona(id: string): Promise<void> {
|
||||
return invoke("delete_persona", { id });
|
||||
const client = await getClient();
|
||||
await client.goose.GooseSourcesDelete({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshPersonas(): Promise<Persona[]> {
|
||||
return invoke("refresh_personas");
|
||||
return listPersonas();
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -36,14 +158,61 @@ export interface ExportResult {
|
||||
}
|
||||
|
||||
export async function exportPersona(id: string): Promise<ExportResult> {
|
||||
return invoke("export_persona", { id });
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesExport({
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
path: id,
|
||||
});
|
||||
return { json: response.json, suggestedFilename: response.filename };
|
||||
}
|
||||
|
||||
export async function importPersonas(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
): Promise<Persona[]> {
|
||||
return invoke("import_personas", { fileBytes, fileName });
|
||||
if (
|
||||
!fileName.endsWith(".agent.json") &&
|
||||
!fileName.endsWith(".persona.json") &&
|
||||
!fileName.endsWith(".json")
|
||||
) {
|
||||
throw new Error(
|
||||
"File must have a .agent.json, .persona.json, or .json extension",
|
||||
);
|
||||
}
|
||||
|
||||
const raw = new TextDecoder().decode(new Uint8Array(fileBytes));
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const data =
|
||||
parsed.type === AGENT_SOURCE_TYPE
|
||||
? raw
|
||||
: JSON.stringify({
|
||||
version: parsed.version ?? 1,
|
||||
type: AGENT_SOURCE_TYPE,
|
||||
name: parsed.displayName ?? parsed.name,
|
||||
description: AGENT_DESCRIPTION,
|
||||
content:
|
||||
parsed.systemPrompt ?? parsed.content ?? parsed.instructions ?? "",
|
||||
properties: {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
avatar:
|
||||
typeof parsed.avatar === "string"
|
||||
? parsed.avatar
|
||||
: typeof parsed.avatar === "object" &&
|
||||
parsed.avatar !== null &&
|
||||
"value" in parsed.avatar
|
||||
? (parsed.avatar as { value?: unknown }).value
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesImport({
|
||||
data,
|
||||
global: true,
|
||||
});
|
||||
|
||||
return response.sources.filter(isAgentSource).map(toPersona);
|
||||
}
|
||||
|
||||
export interface ImportFileReadResult {
|
||||
@@ -56,22 +225,3 @@ export async function readImportPersonaFile(
|
||||
): Promise<ImportFileReadResult> {
|
||||
return invoke("read_import_persona_file", { sourcePath });
|
||||
}
|
||||
|
||||
export async function savePersonaAvatar(
|
||||
personaId: string,
|
||||
sourcePath: string,
|
||||
): Promise<string> {
|
||||
return invoke("save_persona_avatar", { personaId, sourcePath });
|
||||
}
|
||||
|
||||
export async function savePersonaAvatarBytes(
|
||||
personaId: string,
|
||||
bytes: number[],
|
||||
extension: string,
|
||||
): Promise<string> {
|
||||
return invoke("save_persona_avatar_bytes", { personaId, bytes, extension });
|
||||
}
|
||||
|
||||
export async function getAvatarsDir(): Promise<string> {
|
||||
return invoke("get_avatars_dir");
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { getAvatarsDir } from "@/shared/api/agents";
|
||||
import type { Avatar } from "@/shared/types/agents";
|
||||
|
||||
let cachedAvatarsDir: string | null = null;
|
||||
|
||||
async function ensureAvatarsDir(): Promise<string> {
|
||||
if (!cachedAvatarsDir) {
|
||||
cachedAvatarsDir = await getAvatarsDir();
|
||||
function resolveFileUrl(value: string): string {
|
||||
try {
|
||||
return convertFileSrc(decodeURIComponent(new URL(value).pathname));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
return cachedAvatarsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an Avatar to a displayable image URL.
|
||||
* Lazily fetches the avatars directory on first call for a local avatar.
|
||||
*/
|
||||
export async function resolveAvatarSrc(
|
||||
avatar: Avatar | null | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!avatar) return undefined;
|
||||
if (avatar.type === "url") return avatar.value;
|
||||
if (avatar.type === "local") {
|
||||
const dir = await ensureAvatarsDir();
|
||||
return convertFileSrc(`${dir}/${avatar.value}`);
|
||||
if (avatar.type === "url") {
|
||||
return avatar.value.startsWith("file://")
|
||||
? resolveFileUrl(avatar.value)
|
||||
: avatar.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,26 @@
|
||||
// a narrow union.
|
||||
export type ProviderType = string;
|
||||
|
||||
// Avatar type — either a remote URL or a local file in ~/.goose/avatars/
|
||||
export type Avatar =
|
||||
| { type: "url"; value: string }
|
||||
| { type: "local"; value: string };
|
||||
export interface ProviderConfig {
|
||||
type: ProviderType;
|
||||
name: string;
|
||||
description?: string;
|
||||
models: ModelInfo[];
|
||||
requiresApiKey: boolean;
|
||||
apiKeyEnvVar?: string;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
contextWindow: number;
|
||||
supportsTools: boolean;
|
||||
supportsVision: boolean;
|
||||
supportsThinking: boolean;
|
||||
}
|
||||
|
||||
// Avatar type — remote, data, and file URLs are stored directly in source properties.
|
||||
export type Avatar = { type: "url"; value: string };
|
||||
|
||||
// Persona types (from sprout)
|
||||
export interface Persona {
|
||||
@@ -18,6 +34,7 @@ export interface Persona {
|
||||
model?: string;
|
||||
isBuiltin: boolean;
|
||||
isFromDisk?: boolean;
|
||||
writable?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user