Add persona and team import polish (#1203)

This commit is contained in:
klopez4212
2026-06-27 00:33:04 +01:00
committed by GitHub
parent 6c60cb59ab
commit f00c86e744
12 changed files with 246 additions and 41 deletions

View File

@@ -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) => {

View File

@@ -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"],
},
);
});

View 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,
};
}

View File

@@ -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", () => {

View File

@@ -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) {

View File

@@ -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");
});

View 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,
};
}

View File

@@ -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) {

View File

@@ -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(

View File

@@ -226,6 +226,7 @@ export function useTeamActions(
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
]);
setTeamDialogState(null);
} catch (err) {
actions.setActionErrorMessage(
err instanceof Error

View 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);
});

View File

@@ -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)
);
}