Merge remote-tracking branch 'origin/main' into claude/phase3-media
# Conflicts: # apps/web/src/locales/de/messages.po # apps/web/src/locales/en/messages.po # apps/web/src/locales/es/messages.po # apps/web/src/locales/hi/messages.po # apps/web/src/locales/ko/messages.po # apps/web/src/locales/pt-BR/messages.po # apps/web/src/locales/ru/messages.po # apps/web/src/locales/tr/messages.po # apps/web/src/locales/zh-CN/messages.po
This commit is contained in:
commit
8148e78c6c
74
apps/api/src/bot-import.test.ts
Normal file
74
apps/api/src/bot-import.test.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
||||||
|
|
||||||
|
describe("safeImportPath", () => {
|
||||||
|
it("normalizes separators and dot segments", () => {
|
||||||
|
expect(safeImportPath("notes\\./result.txt")).toBe("notes/result.txt");
|
||||||
|
expect(safeImportPath("MEMORY.md")).toBe("MEMORY.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects paths that could leave the bot home", () => {
|
||||||
|
expect(safeImportPath("../secrets")).toBeNull();
|
||||||
|
expect(safeImportPath("notes/../../x")).toBeNull();
|
||||||
|
expect(safeImportPath("/etc/passwd")).toBeNull();
|
||||||
|
expect(safeImportPath("C:\\Windows")).toBeNull();
|
||||||
|
expect(safeImportPath("./")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("relativeImportedFiles", () => {
|
||||||
|
it("drops the old Team Computer workspace prefix", () => {
|
||||||
|
expect(
|
||||||
|
relativeImportedFiles([
|
||||||
|
{ path: "bots/old-id/notes/result.txt", content: "a" },
|
||||||
|
{ path: "bots/old-id/README.md", content: "b" },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ path: "notes/result.txt", content: "a" },
|
||||||
|
{ path: "README.md", content: "b" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Private Computer paths as they are", () => {
|
||||||
|
const files = [
|
||||||
|
{ path: "notes/result.txt", content: "a" },
|
||||||
|
{ path: "bots/other/file.txt", content: "b" },
|
||||||
|
];
|
||||||
|
expect(relativeImportedFiles(files)).toEqual(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps paths when files span several workspaces", () => {
|
||||||
|
const files = [
|
||||||
|
{ path: "bots/a/x.txt", content: "a" },
|
||||||
|
{ path: "bots/b/y.txt", content: "b" },
|
||||||
|
];
|
||||||
|
expect(relativeImportedFiles(files)).toEqual(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the whole set when one path is unsafe", () => {
|
||||||
|
expect(
|
||||||
|
relativeImportedFiles([
|
||||||
|
{ path: "ok.txt", content: "a" },
|
||||||
|
{ path: "../escape.txt", content: "b" },
|
||||||
|
]),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an export without files", () => {
|
||||||
|
expect(relativeImportedFiles([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("importableRoutines", () => {
|
||||||
|
it("leaves out one-shot schedules", () => {
|
||||||
|
const routines = [
|
||||||
|
{ name: "daily", crons: ["0 9 * * *"] },
|
||||||
|
{ name: "once", crons: ["@once"] },
|
||||||
|
{ name: "webhook", crons: [] },
|
||||||
|
];
|
||||||
|
expect(importableRoutines(routines).map((routine) => routine.name)).toEqual([
|
||||||
|
"daily",
|
||||||
|
"webhook",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/api/src/bot-import.ts
Normal file
34
apps/api/src/bot-import.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { isOneShotRoutineCrons } from "@rakazo/core";
|
||||||
|
|
||||||
|
/** A relative, forward-slash path without `.`/`..` segments, or null when it could escape. */
|
||||||
|
export function safeImportPath(raw: string): string | null {
|
||||||
|
const normalized = raw.replace(/\\/g, "/");
|
||||||
|
if (normalized.startsWith("/") || /^[a-z]:/i.test(normalized)) return null;
|
||||||
|
const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== ".");
|
||||||
|
if (segments.length === 0 || segments.includes("..")) return null;
|
||||||
|
return segments.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports of bots on a Team Computer carry their workspace prefix (`bots/<old id>/…`).
|
||||||
|
* The prefix names the old bot, so it is dropped and the importer writes under the new one.
|
||||||
|
*/
|
||||||
|
export function relativeImportedFiles<T extends { path: string }>(
|
||||||
|
files: T[],
|
||||||
|
): Array<T & { path: string }> | null {
|
||||||
|
const safe: Array<T & { path: string }> = [];
|
||||||
|
for (const file of files) {
|
||||||
|
const path = safeImportPath(file.path);
|
||||||
|
if (!path) return null;
|
||||||
|
safe.push({ ...file, path });
|
||||||
|
}
|
||||||
|
const first = safe[0]?.path.split("/");
|
||||||
|
const workspace = first && first.length > 2 && first[0] === "bots" ? `bots/${first[1]}/` : null;
|
||||||
|
if (!workspace || !safe.every((file) => file.path.startsWith(workspace))) return safe;
|
||||||
|
return safe.map((file) => ({ ...file, path: file.path.slice(workspace.length) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot schedules belong to the chat that set them, so they are not carried over. */
|
||||||
|
export function importableRoutines<T extends { crons: string[] }>(routines: T[]): T[] {
|
||||||
|
return routines.filter((routine) => !isOneShotRoutineCrons(routine.crons));
|
||||||
|
}
|
||||||
@ -169,6 +169,7 @@ import { deleteAgentSecret, listAgentSecrets, putAgentSecret } from "./agent-sec
|
|||||||
import { createAgentSkillsService } from "./agent-skills.js";
|
import { createAgentSkillsService } from "./agent-skills.js";
|
||||||
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
import { aiConsentStatus, allowAiConsent } from "./ai-consent.js";
|
||||||
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js";
|
||||||
|
import { importableRoutines, relativeImportedFiles, safeImportPath } from "./bot-import.js";
|
||||||
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js";
|
||||||
import { createCoachService } from "./coach.js";
|
import { createCoachService } from "./coach.js";
|
||||||
import {
|
import {
|
||||||
@ -1264,6 +1265,87 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}
|
}
|
||||||
return duplicate;
|
return duplicate;
|
||||||
}),
|
}),
|
||||||
|
import: authed.bots.import.handler(async ({ context, input }) => {
|
||||||
|
const files = relativeImportedFiles(input.files);
|
||||||
|
const memory = input.memory.flatMap((doc) => {
|
||||||
|
const path = safeImportPath(doc.path);
|
||||||
|
return path ? [{ path, content: doc.content }] : [];
|
||||||
|
});
|
||||||
|
if (!files || memory.length !== input.memory.length) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", { message: "This export has an unsafe file path." });
|
||||||
|
}
|
||||||
|
const routines = importableRoutines(input.routines);
|
||||||
|
for (const routine of routines) {
|
||||||
|
if (hasMixedOneShotSchedule(routine.crons)) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "A one-time schedule can't be combined with other schedules.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (routine.crons.length > 0) nextRoutineDate(routine.crons, routine.timezone);
|
||||||
|
}
|
||||||
|
const created = await repos
|
||||||
|
.createBot(context.actor, { ...input.bot, notifyOnFinish: true })
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
throw mapSpaceLifecycleError(error);
|
||||||
|
});
|
||||||
|
const bot = await repos.getBot(context.actor, created.id);
|
||||||
|
const importContext = {
|
||||||
|
operationId: "import",
|
||||||
|
traceId: "import",
|
||||||
|
spaceId: context.actor.spaceId,
|
||||||
|
userId: context.actor.userId,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (!bot.computer) throw new IsolationError();
|
||||||
|
for (const doc of memory) {
|
||||||
|
await deps.memory.commit(
|
||||||
|
{ scope: "bot", botId: bot.id, path: doc.path, content: doc.content },
|
||||||
|
importContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const prefix = bot.computer.scope === "team" ? teamBotWorkspaceDirectory(bot.id) : "";
|
||||||
|
for (const file of files) {
|
||||||
|
await deps.home.writeFile(
|
||||||
|
bot.computer.homeKey,
|
||||||
|
prefix ? `${prefix}/${file.path}` : file.path,
|
||||||
|
file.content,
|
||||||
|
importContext,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Imported schedules start paused: the user decides when this bot may act on its own.
|
||||||
|
if (routines.length) {
|
||||||
|
await deps.prisma.routine.createMany({
|
||||||
|
data: routines.map((routine) => ({
|
||||||
|
spaceId: context.actor.spaceId,
|
||||||
|
botId: bot.id,
|
||||||
|
userId: context.actor.userId,
|
||||||
|
name: routine.name,
|
||||||
|
prompt: routine.prompt,
|
||||||
|
crons: routine.crons,
|
||||||
|
timezone: routine.timezone,
|
||||||
|
active: false,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await destroyBot(
|
||||||
|
{
|
||||||
|
prisma: deps.prisma,
|
||||||
|
sandbox: deps.sandbox,
|
||||||
|
home: deps.home,
|
||||||
|
jobs: deps.jobs,
|
||||||
|
artifacts: deps.artifacts,
|
||||||
|
dataDir: deps.dataDir,
|
||||||
|
},
|
||||||
|
bot,
|
||||||
|
{ ...importContext, operationId: "destroy", traceId: "destroy" },
|
||||||
|
{ deleteMemories: true },
|
||||||
|
).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}),
|
||||||
reorder: authed.bots.reorder.handler(async ({ context, input }) => {
|
reorder: authed.bots.reorder.handler(async ({ context, input }) => {
|
||||||
await repos.reorderBots(context.actor, input.botIds);
|
await repos.reorderBots(context.actor, input.botIds);
|
||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
|
|||||||
@ -179,3 +179,66 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) =
|
|||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||||||
await captureScreenshot(page, testInfo, "31-deleted-bot-fallback");
|
await captureScreenshot(page, testInfo, "31-deleted-bot-fallback");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a bot export restores as a new bot", async ({ page }, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `bot-import-${stamp}@rakazo.test`, "password12", "Bot Import");
|
||||||
|
await completeOnboarding(page);
|
||||||
|
await page.goto("/app");
|
||||||
|
await page.waitForURL(/\/app\/[^/]+$/);
|
||||||
|
|
||||||
|
await openNewBot(page);
|
||||||
|
const form = page.getByTestId("create-bot-form");
|
||||||
|
await expect(form.getByRole("button", { name: "Import bot" })).toBeVisible();
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
await page.emulateMedia({ colorScheme });
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
|
||||||
|
await captureScreenshot(page, testInfo, `27d-create-bot-import-${colorScheme}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInput = form.getByTestId("import-bot-file");
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: "broken.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from("{"),
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("create-bot-error")).toHaveText("Could not import this file");
|
||||||
|
|
||||||
|
await fileInput.setInputFiles({
|
||||||
|
name: "archivist-export.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
bot: {
|
||||||
|
name: "Archivist",
|
||||||
|
title: "Keeps the records",
|
||||||
|
description: "Files and finds documents.",
|
||||||
|
instructions: "Name every file by date.",
|
||||||
|
},
|
||||||
|
memory: [{ path: "MEMORY.md", content: "# Archive\n\nPrefers ISO dates." }],
|
||||||
|
routines: [
|
||||||
|
{
|
||||||
|
name: "Weekly sweep",
|
||||||
|
prompt: "Tidy the archive",
|
||||||
|
crons: ["0 8 * * 1"],
|
||||||
|
timezone: "UTC",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
files: [{ path: "notes/index.md", content: "imported" }],
|
||||||
|
history: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed");
|
||||||
|
const botList = page.locator("aside").first();
|
||||||
|
await expect(botList.getByRole("button", { name: /^Archivist/ })).toBeVisible();
|
||||||
|
await page.locator("main").getByRole("button", { name: "Archivist", exact: true }).click();
|
||||||
|
await expect(page.locator("label:has-text('Title') input")).toHaveValue("Keeps the records");
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
await page.emulateMedia({ colorScheme });
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-theme", colorScheme);
|
||||||
|
await captureScreenshot(page, testInfo, `27e-imported-bot-${colorScheme}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr "Konnte nicht ausgeblendet werden"
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr "Vorlesen konnte nicht aktiviert werden"
|
msgstr "Vorlesen konnte nicht aktiviert werden"
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr "Diese Datei konnte nicht importiert werden"
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "Connector konnte nicht installiert werden"
|
msgstr "Connector konnte nicht installiert werden"
|
||||||
@ -2556,6 +2560,10 @@ msgstr "Bild-Upload-Bereich"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr "Bild-Workflow"
|
msgstr "Bild-Workflow"
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr "Bot importieren"
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "OpenAPI-JSON importieren"
|
msgstr "OpenAPI-JSON importieren"
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr "Could not dismiss"
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr "Could not enable speaking"
|
msgstr "Could not enable speaking"
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr "Could not import this file"
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "Could not install connector"
|
msgstr "Could not install connector"
|
||||||
@ -2556,6 +2560,10 @@ msgstr "Image upload area"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr "Image workflow"
|
msgstr "Image workflow"
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr "Import bot"
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "Import OpenAPI JSON"
|
msgstr "Import OpenAPI JSON"
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr ""
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "No se pudo instalar el conector"
|
msgstr "No se pudo instalar el conector"
|
||||||
@ -2556,6 +2560,10 @@ msgstr "Área de carga de imagen"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "Importar JSON de OpenAPI"
|
msgstr "Importar JSON de OpenAPI"
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr ""
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "कनेक्टर इंस्टॉल नहीं हो सका"
|
msgstr "कनेक्टर इंस्टॉल नहीं हो सका"
|
||||||
@ -2556,6 +2560,10 @@ msgstr "छवि अपलोड क्षेत्र"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "OpenAPI JSON इम्पोर्ट करें"
|
msgstr "OpenAPI JSON इम्पोर्ट करें"
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr ""
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "도구 연결을 추가하지 못했습니다."
|
msgstr "도구 연결을 추가하지 못했습니다."
|
||||||
@ -2556,6 +2560,10 @@ msgstr "이미지 업로드 영역"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "OpenAPI JSON 가져오기"
|
msgstr "OpenAPI JSON 가져오기"
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr ""
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "Não foi possível instalar o conector"
|
msgstr "Não foi possível instalar o conector"
|
||||||
@ -2556,6 +2560,10 @@ msgstr "Área de envio de imagem"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "Importar OpenAPI JSON"
|
msgstr "Importar OpenAPI JSON"
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr "Не удалось отклонить"
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "Не удалось установить коннектор"
|
msgstr "Не удалось установить коннектор"
|
||||||
@ -2556,6 +2560,10 @@ msgstr "Область загрузки изображения"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "Импортировать OpenAPI JSON"
|
msgstr "Импортировать OpenAPI JSON"
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr ""
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "Bağlayıcı kurulamadı"
|
msgstr "Bağlayıcı kurulamadı"
|
||||||
@ -2556,6 +2560,10 @@ msgstr "Görüntü yükleme alanı"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "OpenAPI JSON içe aktar"
|
msgstr "OpenAPI JSON içe aktar"
|
||||||
|
|||||||
@ -1508,6 +1508,10 @@ msgstr ""
|
|||||||
msgid "Could not enable speaking"
|
msgid "Could not enable speaking"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Could not import this file"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Could not install connector"
|
msgid "Could not install connector"
|
||||||
msgstr "无法安装连接器"
|
msgstr "无法安装连接器"
|
||||||
@ -2556,6 +2560,10 @@ msgstr "图片上传区域"
|
|||||||
msgid "Image workflow"
|
msgid "Image workflow"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/shell/bot-panel.tsx
|
||||||
|
msgid "Import bot"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/PluginsOverlay.tsx
|
#: src/pages/PluginsOverlay.tsx
|
||||||
msgid "Import OpenAPI JSON"
|
msgid "Import OpenAPI JSON"
|
||||||
msgstr "导入 OpenAPI JSON"
|
msgstr "导入 OpenAPI JSON"
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import type {
|
|||||||
Connection,
|
Connection,
|
||||||
ConnectionCatalogItem,
|
ConnectionCatalogItem,
|
||||||
Group,
|
Group,
|
||||||
|
ImportBotInput,
|
||||||
MainConversationRef,
|
MainConversationRef,
|
||||||
Me,
|
Me,
|
||||||
ProductEvent,
|
ProductEvent,
|
||||||
@ -3107,6 +3108,16 @@ export function ShellPage() {
|
|||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function importBot(manifest: unknown) {
|
||||||
|
const bot = await rpc.bots.import(manifest as ImportBotInput);
|
||||||
|
setBots((current) =>
|
||||||
|
current.some((item) => item.id === bot.id) ? current : [bot, ...current],
|
||||||
|
);
|
||||||
|
navigate(`/app/${bot.id}`);
|
||||||
|
setPanel(null);
|
||||||
|
await refreshBots().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
async function createBot(input: {
|
async function createBot(input: {
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -4961,6 +4972,7 @@ export function ShellPage() {
|
|||||||
<CreateBotForm
|
<CreateBotForm
|
||||||
onCancel={() => setPanel(null)}
|
onCancel={() => setPanel(null)}
|
||||||
onCreate={(input) => createBot(input)}
|
onCreate={(input) => createBot(input)}
|
||||||
|
onImport={(manifest) => importBot(manifest)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{/* Centered Create Bot Modal */}
|
{/* Centered Create Bot Modal */}
|
||||||
@ -4992,6 +5004,10 @@ export function ShellPage() {
|
|||||||
onCreate={async (input) => {
|
onCreate={async (input) => {
|
||||||
await createBot(input);
|
await createBot(input);
|
||||||
}}
|
}}
|
||||||
|
onImport={async (manifest) => {
|
||||||
|
await importBot(manifest);
|
||||||
|
setShowCreateBotModal(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@ -30,7 +30,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
Toggle,
|
Toggle,
|
||||||
} from "@rakazo/ui-web";
|
} from "@rakazo/ui-web";
|
||||||
import { X } from "lucide-react";
|
import { Upload, X } from "lucide-react";
|
||||||
import { Suspense, useEffect, useId, useRef, useState } from "react";
|
import { Suspense, useEffect, useId, useRef, useState } from "react";
|
||||||
import { connectedModelOptions } from "../../lib/connected-models";
|
import { connectedModelOptions } from "../../lib/connected-models";
|
||||||
import { lazyWithRecovery } from "../../lib/lazy-recovery";
|
import { lazyWithRecovery } from "../../lib/lazy-recovery";
|
||||||
@ -87,6 +87,7 @@ function ComputerModePicker({
|
|||||||
|
|
||||||
export function CreateBotForm({
|
export function CreateBotForm({
|
||||||
onCreate,
|
onCreate,
|
||||||
|
onImport,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
onCreate: (input: {
|
onCreate: (input: {
|
||||||
@ -95,10 +96,13 @@ export function CreateBotForm({
|
|||||||
description: string;
|
description: string;
|
||||||
computerMode: ComputerMode;
|
computerMode: ComputerMode;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
/** Restores a bot from the JSON that the bot settings' Export downloads. */
|
||||||
|
onImport: (manifest: unknown) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const ids = useId();
|
const ids = useId();
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
@ -124,16 +128,52 @@ export function CreateBotForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImport(file: File) {
|
||||||
|
if (submitting) return;
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onImport(JSON.parse(await file.text()));
|
||||||
|
} catch {
|
||||||
|
setError(t`Could not import this file`);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="create-bot-form">
|
<div data-testid="create-bot-form">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<span className="text-ui-body text-muted-foreground">
|
<span className="text-ui-body text-muted-foreground">
|
||||||
<Trans>New bot</Trans>
|
<Trans>New bot</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t`Import bot`}
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload strokeWidth={1.8} className="size-icon-md" />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
hidden
|
||||||
|
data-testid="import-bot-file"
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (file) void handleImport(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
|
<Button variant="ghost" size="icon-sm" aria-label={t`Cancel new bot`} onClick={onCancel}>
|
||||||
<X strokeWidth={1.8} className="size-icon-md" />
|
<X strokeWidth={1.8} className="size-icon-md" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@ -1309,6 +1309,30 @@ export const ExportManifestSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ExportManifest = z.infer<typeof ExportManifestSchema>;
|
export type ExportManifest = z.infer<typeof ExportManifestSchema>;
|
||||||
|
|
||||||
|
const ImportPath = z.string().trim().min(1).max(1024);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An export manifest restored as a new bot. History is not replayed: the bot starts
|
||||||
|
* its own conversation, so extra keys such as `history` are accepted and dropped.
|
||||||
|
*/
|
||||||
|
export const ImportBotInput = z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
bot: CreateBotInput.pick({ name: true, title: true, description: true, instructions: true }),
|
||||||
|
memory: z.array(z.object({ path: ImportPath, content: z.string() })).max(500),
|
||||||
|
routines: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1).max(80),
|
||||||
|
prompt: z.string().min(1),
|
||||||
|
crons: z.array(z.string().min(1)).max(20),
|
||||||
|
timezone: z.string().min(1).max(100),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.max(100),
|
||||||
|
files: z.array(z.object({ path: ImportPath, content: z.string() })).max(5000),
|
||||||
|
});
|
||||||
|
export type ImportBotInput = z.infer<typeof ImportBotInput>;
|
||||||
|
|
||||||
/** Categories of durable knowledge about the user. */
|
/** Categories of durable knowledge about the user. */
|
||||||
export const USER_FACT_CATEGORIES = [
|
export const USER_FACT_CATEGORIES = [
|
||||||
"identity",
|
"identity",
|
||||||
|
|||||||
@ -50,6 +50,7 @@ import {
|
|||||||
ExternalConversationPolicySchema,
|
ExternalConversationPolicySchema,
|
||||||
GroupDetailSchema,
|
GroupDetailSchema,
|
||||||
GroupSchema,
|
GroupSchema,
|
||||||
|
ImportBotInput,
|
||||||
IntegrationCatalogResultSchema,
|
IntegrationCatalogResultSchema,
|
||||||
MainConversationRefSchema,
|
MainConversationRefSchema,
|
||||||
McpServerConfigInput,
|
McpServerConfigInput,
|
||||||
@ -340,6 +341,8 @@ export const appContract = {
|
|||||||
/** The user's main bot, created with its main conversation in the home space when missing. */
|
/** The user's main bot, created with its main conversation in the home space when missing. */
|
||||||
ensureMain: oc.output(BotSchema),
|
ensureMain: oc.output(BotSchema),
|
||||||
duplicate: oc.input(botId).output(BotSchema),
|
duplicate: oc.input(botId).output(BotSchema),
|
||||||
|
/** A new bot restored from an `export.bot` manifest. */
|
||||||
|
import: oc.input(ImportBotInput).output(BotSchema),
|
||||||
reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })),
|
reorder: oc.input(ReorderBotsInput).output(z.object({ ok: z.literal(true) })),
|
||||||
update: oc.input(UpdateBotInput).output(BotSchema),
|
update: oc.input(UpdateBotInput).output(BotSchema),
|
||||||
/** Builtin tool names this bot's allowlist can choose from. */
|
/** Builtin tool names this bot's allowlist can choose from. */
|
||||||
|
|||||||
@ -1613,6 +1613,68 @@ describeJourneys("required product journeys", () => {
|
|||||||
expect(rawJson).not.toMatch(/browserProfile|ciphertext|sessionCookie/i);
|
expect(rawJson).not.toMatch(/browserProfile|ciphertext|sessionCookie/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("9b: an export restores as a new bot with memory, files and paused routines", async () => {
|
||||||
|
const cookie = await signup(app, `import-j-${stamp}@rakazo.test`, "Import");
|
||||||
|
const source = await rpc<Bot>(app, cookie, "bots/create", {
|
||||||
|
name: "Scout",
|
||||||
|
title: "Researcher",
|
||||||
|
description: "Finds sources",
|
||||||
|
instructions: "Cite everything",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
});
|
||||||
|
await sendAndWait(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
source.id,
|
||||||
|
"write a file in your home called notes/result.txt that says import-ok",
|
||||||
|
);
|
||||||
|
await rpc(app, cookie, "routines/create", {
|
||||||
|
botId: source.id,
|
||||||
|
name: "Morning brief",
|
||||||
|
prompt: "Summarize the news",
|
||||||
|
crons: ["0 9 * * *"],
|
||||||
|
timezone: "UTC",
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
const manifest = await rpc<{
|
||||||
|
memory: Array<{ path: string; content: string }>;
|
||||||
|
files: Array<{ path: string; content: string }>;
|
||||||
|
}>(app, cookie, "export/bot", { botId: source.id });
|
||||||
|
expect(manifest.files.some((file) => file.content.includes("import-ok"))).toBe(true);
|
||||||
|
|
||||||
|
const imported = await rpc<Bot>(app, cookie, "bots/import", manifest);
|
||||||
|
expect(imported.id).not.toBe(source.id);
|
||||||
|
expect(imported).toMatchObject({
|
||||||
|
name: "Scout",
|
||||||
|
title: "Researcher",
|
||||||
|
description: "Finds sources",
|
||||||
|
instructions: "Cite everything",
|
||||||
|
});
|
||||||
|
const roundTrip = await rpc<{
|
||||||
|
memory: Array<{ path: string; content: string }>;
|
||||||
|
files: Array<{ path: string; content: string }>;
|
||||||
|
}>(app, cookie, "export/bot", { botId: imported.id });
|
||||||
|
expect(roundTrip.files.map((file) => file.content)).toContain(
|
||||||
|
manifest.files.find((file) => file.content.includes("import-ok"))?.content,
|
||||||
|
);
|
||||||
|
expect(roundTrip.files.every((file) => !file.path.includes(source.id))).toBe(true);
|
||||||
|
for (const doc of manifest.memory) {
|
||||||
|
expect(roundTrip.memory).toContainEqual(doc);
|
||||||
|
}
|
||||||
|
const routines = await prisma.routine.findMany({ where: { botId: imported.id } });
|
||||||
|
expect(routines).toHaveLength(1);
|
||||||
|
expect(routines[0]).toMatchObject({ name: "Morning brief", active: false, nextRunAt: null });
|
||||||
|
|
||||||
|
const other = await signup(app, `import-other-j-${stamp}@rakazo.test`, "Other");
|
||||||
|
const unsafe = await raw(app, other, "bots/import", {
|
||||||
|
...manifest,
|
||||||
|
files: [{ path: "../escape.txt", content: "x" }],
|
||||||
|
});
|
||||||
|
expect(unsafe.status).toBe(400);
|
||||||
|
const otherBots = await rpc<Bot[]>(app, other, "bots/list");
|
||||||
|
expect(otherBots.some((bot) => bot.name === "Scout")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("10: bots can be archived safely and deleted with or without their memories", async () => {
|
it("10: bots can be archived safely and deleted with or without their memories", async () => {
|
||||||
const ada = await signup(app, `delete-j-${stamp}@rakazo.test`, "Delete Ada");
|
const ada = await signup(app, `delete-j-${stamp}@rakazo.test`, "Delete Ada");
|
||||||
const bob = await signup(app, `delete-bob-j-${stamp}@rakazo.test`, "Delete Bob");
|
const bob = await signup(app, `delete-bob-j-${stamp}@rakazo.test`, "Delete Bob");
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user