mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
Add persona and team import polish (#1203)
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { buildBatchImportPersonaInput } from "./batchImportPersonaInput";
|
||||
|
||||
type BatchImportDialogProps = {
|
||||
fileName: string;
|
||||
@@ -91,13 +92,7 @@ export function BatchImportDialog({
|
||||
});
|
||||
|
||||
try {
|
||||
await createPersona({
|
||||
displayName: persona.displayName,
|
||||
avatarUrl: persona.avatarDataUrl ?? undefined,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
runtime: persona.runtime ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
});
|
||||
await createPersona(buildBatchImportPersonaInput(persona));
|
||||
completed += 1;
|
||||
setImportedCount(completed);
|
||||
setItemStatuses((prev) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildBatchImportPersonaInput } from "./batchImportPersonaInput.ts";
|
||||
|
||||
function persona(overrides = {}) {
|
||||
return {
|
||||
displayName: "Imported Agent",
|
||||
avatarDataUrl: null,
|
||||
avatarRef: null,
|
||||
systemPrompt: "Use the imported provider.",
|
||||
runtime: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
provider: "anthropic",
|
||||
namePool: [],
|
||||
sourceFile: "agent.persona.md",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildBatchImportPersonaInput preserves provider from parsed personas", () => {
|
||||
assert.deepEqual(buildBatchImportPersonaInput(persona()), {
|
||||
displayName: "Imported Agent",
|
||||
avatarUrl: undefined,
|
||||
systemPrompt: "Use the imported provider.",
|
||||
runtime: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
provider: "anthropic",
|
||||
namePool: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("buildBatchImportPersonaInput carries imported name pools", () => {
|
||||
assert.deepEqual(
|
||||
buildBatchImportPersonaInput(persona({ namePool: ["fizz", "buzz"] })),
|
||||
{
|
||||
displayName: "Imported Agent",
|
||||
avatarUrl: undefined,
|
||||
systemPrompt: "Use the imported provider.",
|
||||
runtime: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
provider: "anthropic",
|
||||
namePool: ["fizz", "buzz"],
|
||||
},
|
||||
);
|
||||
});
|
||||
16
desktop/src/features/agents/ui/batchImportPersonaInput.ts
Normal file
16
desktop/src/features/agents/ui/batchImportPersonaInput.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
|
||||
import type { CreatePersonaInput } from "@/shared/api/types";
|
||||
|
||||
export function buildBatchImportPersonaInput(
|
||||
persona: ParsedPersonaPreview,
|
||||
): CreatePersonaInput {
|
||||
return {
|
||||
displayName: persona.displayName,
|
||||
avatarUrl: persona.avatarDataUrl ?? undefined,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
runtime: persona.runtime ?? undefined,
|
||||
model: persona.model ?? undefined,
|
||||
provider: persona.provider ?? undefined,
|
||||
namePool: persona.namePool.length > 0 ? persona.namePool : undefined,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ function createPersona(overrides = {}) {
|
||||
systemPrompt: "Be helpful.",
|
||||
runtime: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
namePool: [],
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
@@ -28,8 +29,10 @@ function createPreview(overrides = {}) {
|
||||
displayName: "Alice",
|
||||
systemPrompt: "Be helpful.",
|
||||
avatarDataUrl: null,
|
||||
avatarRef: null,
|
||||
runtime: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
namePool: [],
|
||||
sourceFile: "alice.persona.json",
|
||||
...overrides,
|
||||
@@ -106,6 +109,19 @@ test("buildPersonaImportPlan detects model change", () => {
|
||||
assert.equal(plan.fields[0]?.label, "Preferred model");
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects provider change", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({ provider: "anthropic" }),
|
||||
preview: createPreview({ provider: "databricks" }),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 1);
|
||||
assert.equal(plan.fields[0]?.field, "provider");
|
||||
assert.equal(plan.fields[0]?.label, "LLM provider");
|
||||
assert.equal(plan.fields[0]?.existingValue, "anthropic");
|
||||
assert.equal(plan.fields[0]?.importedValue, "databricks");
|
||||
});
|
||||
|
||||
test("buildPersonaImportPlan detects name pool change", () => {
|
||||
const plan = buildPersonaImportPlan({
|
||||
persona: createPersona({ namePool: ["Birch", "Compass"] }),
|
||||
@@ -123,17 +139,24 @@ test("buildPersonaImportPlan detects multiple field changes", () => {
|
||||
displayName: "Alice",
|
||||
systemPrompt: "Old prompt",
|
||||
model: "gpt-4o",
|
||||
provider: "openai",
|
||||
}),
|
||||
preview: createPreview({
|
||||
displayName: "Alicia",
|
||||
systemPrompt: "New prompt",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
provider: "anthropic",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(plan.fields.length, 3);
|
||||
assert.equal(plan.fields.length, 4);
|
||||
const fieldNames = plan.fields.map((f) => f.field);
|
||||
assert.deepEqual(fieldNames, ["displayName", "systemPrompt", "model"]);
|
||||
assert.deepEqual(fieldNames, [
|
||||
"displayName",
|
||||
"systemPrompt",
|
||||
"model",
|
||||
"provider",
|
||||
]);
|
||||
});
|
||||
|
||||
test("hasAnyPersonaImportChanges returns false for empty plan", () => {
|
||||
|
||||
@@ -174,6 +174,18 @@ export function buildPersonaImportPlan({
|
||||
});
|
||||
}
|
||||
|
||||
const existingProvider = normalizeOptionalText(persona.provider);
|
||||
const importedProvider = normalizeOptionalText(preview.provider);
|
||||
if (existingProvider !== importedProvider) {
|
||||
fields.push({
|
||||
field: "provider",
|
||||
label: "LLM provider",
|
||||
existingValue: existingProvider,
|
||||
importedValue: importedProvider,
|
||||
...singleLineChanges(existingProvider, importedProvider),
|
||||
});
|
||||
}
|
||||
|
||||
const existingNamePool = namePoolToString(persona.namePool);
|
||||
const importedNamePool = namePoolToString(preview.namePool);
|
||||
if (existingNamePool !== importedNamePool) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildPersonaImportUpdateInput } from "./personaImportUpdateInput.ts";
|
||||
|
||||
function createPersona(overrides = {}) {
|
||||
return {
|
||||
id: "persona-1",
|
||||
displayName: "Alice",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Be helpful.",
|
||||
runtime: "goose",
|
||||
model: "claude-sonnet-4",
|
||||
provider: "anthropic",
|
||||
namePool: [],
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
envVars: {},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPreview(overrides = {}) {
|
||||
return {
|
||||
displayName: "Alice",
|
||||
systemPrompt: "Be helpful.",
|
||||
avatarDataUrl: null,
|
||||
avatarRef: null,
|
||||
runtime: "goose",
|
||||
model: "gpt-5",
|
||||
provider: "databricks",
|
||||
namePool: [],
|
||||
sourceFile: "alice.md",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildPersonaImportUpdateInput applies selected model and provider updates", () => {
|
||||
const input = buildPersonaImportUpdateInput({
|
||||
existing: createPersona(),
|
||||
preview: createPreview(),
|
||||
selectedFields: ["model", "provider"],
|
||||
});
|
||||
|
||||
assert.equal(input.model, "gpt-5");
|
||||
assert.equal(input.provider, "databricks");
|
||||
});
|
||||
|
||||
test("buildPersonaImportUpdateInput preserves provider when provider is not selected", () => {
|
||||
const input = buildPersonaImportUpdateInput({
|
||||
existing: createPersona(),
|
||||
preview: createPreview(),
|
||||
selectedFields: ["model"],
|
||||
});
|
||||
|
||||
assert.equal(input.model, "gpt-5");
|
||||
assert.equal(input.provider, "anthropic");
|
||||
});
|
||||
45
desktop/src/features/agents/ui/personaImportUpdateInput.ts
Normal file
45
desktop/src/features/agents/ui/personaImportUpdateInput.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ParsedPersonaPreview } from "@/shared/api/tauriPersonas";
|
||||
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
|
||||
|
||||
type BuildPersonaImportUpdateInputArgs = {
|
||||
existing: AgentPersona;
|
||||
preview: ParsedPersonaPreview;
|
||||
selectedFields: Iterable<string>;
|
||||
};
|
||||
|
||||
export function buildPersonaImportUpdateInput({
|
||||
existing,
|
||||
preview,
|
||||
selectedFields,
|
||||
}: BuildPersonaImportUpdateInputArgs): UpdatePersonaInput {
|
||||
const selectedFieldSet = new Set(selectedFields);
|
||||
|
||||
return {
|
||||
id: existing.id,
|
||||
displayName: selectedFieldSet.has("displayName")
|
||||
? preview.displayName
|
||||
: existing.displayName,
|
||||
systemPrompt: selectedFieldSet.has("systemPrompt")
|
||||
? preview.systemPrompt
|
||||
: existing.systemPrompt,
|
||||
avatarUrl: selectedFieldSet.has("avatarUrl")
|
||||
? (preview.avatarDataUrl ?? undefined)
|
||||
: (existing.avatarUrl ?? undefined),
|
||||
runtime: selectedFieldSet.has("runtime")
|
||||
? (preview.runtime ?? undefined)
|
||||
: (existing.runtime ?? undefined),
|
||||
model: selectedFieldSet.has("model")
|
||||
? (preview.model ?? undefined)
|
||||
: (existing.model ?? undefined),
|
||||
provider: selectedFieldSet.has("provider")
|
||||
? (preview.provider ?? undefined)
|
||||
: (existing.provider ?? undefined),
|
||||
namePool: selectedFieldSet.has("namePool")
|
||||
? preview.namePool.length > 0
|
||||
? preview.namePool
|
||||
: undefined
|
||||
: existing.namePool.length > 0
|
||||
? [...existing.namePool]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -144,7 +144,10 @@ export function usePersonaActions() {
|
||||
clearFeedback("library");
|
||||
try {
|
||||
const result = await parsePersonaFiles(fileBytes, fileName);
|
||||
if (isSingleItemFile(fileBytes) && result.personas.length === 1) {
|
||||
if (
|
||||
isSingleItemFile(fileBytes, fileName) &&
|
||||
result.personas.length === 1
|
||||
) {
|
||||
setShouldLoadAcpRuntimes(true);
|
||||
setPersonaDialogState(importPersonaDialogState(result.personas[0]));
|
||||
} else if (result.personas.length > 0) {
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
updatePersona as updatePersonaApi,
|
||||
type ParsedPersonaPreview,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import { buildPersonaImportPlan } from "./personaImportPlan";
|
||||
import { buildPersonaImportUpdateInput } from "./personaImportUpdateInput";
|
||||
import {
|
||||
editPersonaDialogState,
|
||||
type PersonaDialogState,
|
||||
@@ -105,41 +106,20 @@ export function usePersonaImportActions(
|
||||
preview: personaImportTargetPreview.preview,
|
||||
});
|
||||
|
||||
const selectedFieldSet = new Set(selectedFields);
|
||||
const preview = personaImportTargetPreview.preview;
|
||||
const existing = personaImportTarget;
|
||||
|
||||
try {
|
||||
const updateInput: UpdatePersonaInput = {
|
||||
id: existing.id,
|
||||
displayName: selectedFieldSet.has("displayName")
|
||||
? preview.displayName
|
||||
: existing.displayName,
|
||||
systemPrompt: selectedFieldSet.has("systemPrompt")
|
||||
? preview.systemPrompt
|
||||
: existing.systemPrompt,
|
||||
avatarUrl: selectedFieldSet.has("avatarUrl")
|
||||
? (preview.avatarDataUrl ?? undefined)
|
||||
: (existing.avatarUrl ?? undefined),
|
||||
runtime: selectedFieldSet.has("runtime")
|
||||
? (preview.runtime ?? undefined)
|
||||
: (existing.runtime ?? undefined),
|
||||
model: selectedFieldSet.has("model")
|
||||
? (preview.model ?? undefined)
|
||||
: (existing.model ?? undefined),
|
||||
namePool: selectedFieldSet.has("namePool")
|
||||
? preview.namePool.length > 0
|
||||
? preview.namePool
|
||||
: undefined
|
||||
: existing.namePool.length > 0
|
||||
? [...existing.namePool]
|
||||
: undefined,
|
||||
};
|
||||
const updateInput = buildPersonaImportUpdateInput({
|
||||
existing,
|
||||
preview,
|
||||
selectedFields,
|
||||
});
|
||||
|
||||
await updatePersonaApi(updateInput);
|
||||
|
||||
const updatedFieldCount = plan.fields.filter((field) =>
|
||||
selectedFieldSet.has(field.field),
|
||||
selectedFields.includes(field.field),
|
||||
).length;
|
||||
|
||||
feedback.setPersonaNoticeMessage(
|
||||
|
||||
@@ -226,6 +226,7 @@ export function useTeamActions(
|
||||
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
|
||||
]);
|
||||
setTeamDialogState(null);
|
||||
} catch (err) {
|
||||
actions.setActionErrorMessage(
|
||||
err instanceof Error
|
||||
|
||||
16
desktop/src/shared/lib/fileMagic.test.mjs
Normal file
16
desktop/src/shared/lib/fileMagic.test.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isSingleItemFile } from "./fileMagic.ts";
|
||||
|
||||
test("isSingleItemFile treats markdown persona files as single imports", () => {
|
||||
assert.equal(isSingleItemFile([45, 45, 45], "fizz.md"), true);
|
||||
});
|
||||
|
||||
test("isSingleItemFile treats legacy persona markdown files as single imports", () => {
|
||||
assert.equal(isSingleItemFile([45, 45, 45], "fizz.persona.md"), true);
|
||||
});
|
||||
|
||||
test("isSingleItemFile does not classify arbitrary text files as single imports", () => {
|
||||
assert.equal(isSingleItemFile([45, 45, 45], "notes.txt"), false);
|
||||
});
|
||||
@@ -11,10 +11,18 @@ function matchesMagic(
|
||||
return magic.every((b, i) => bytes[i] === b);
|
||||
}
|
||||
|
||||
/** Return true when `bytes` looks like a single-item file (PNG or JSON). */
|
||||
export function isSingleItemFile(bytes: number[] | readonly number[]): boolean {
|
||||
function isMarkdownFileName(fileName: string | undefined): boolean {
|
||||
return fileName?.toLowerCase().endsWith(".md") ?? false;
|
||||
}
|
||||
|
||||
/** Return true when the file format carries one persona instead of a bundle. */
|
||||
export function isSingleItemFile(
|
||||
bytes: number[] | readonly number[],
|
||||
fileName?: string,
|
||||
): boolean {
|
||||
return (
|
||||
matchesMagic(bytes, PNG_MAGIC) ||
|
||||
(bytes.length > 0 && bytes[0] === JSON_FIRST_BYTE)
|
||||
(bytes.length > 0 && bytes[0] === JSON_FIRST_BYTE) ||
|
||||
isMarkdownFileName(fileName)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user