main
js 301 lines 9.67 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { fetchApi } from "/js/api.js";
3 import { closeModal } from "/js/modals.js";
4 import { setContext } from "/index.js";
5 import {
6 toastFrontendError,
7 toastFrontendInfo,
8 toastFrontendSuccess,
9 toastFrontendWarning,
10 } from "/components/notifications/notification-store.js";
11
12 const PREVIEW_API = "/api/plugins/_migrate_agents/migration_preview";
13 const IMPORT_API = "/api/plugins/_migrate_agents/migration_import";
14
15 export const sources = [
16 {
17 id: "openclaw",
18 name: "OpenClaw",
19 logo: "/plugins/_migrate_agents/webui/assets/openclaw.svg",
20 accent: "coral",
21 hint: "Chats, projects, memory, instructions and skills",
22 guideTitle: "Create an OpenClaw backup",
23 guideLabel: "Run in a terminal",
24 command: "openclaw backup create --verify",
25 guideNote: "Choose the generated .tar.gz file below. Credentials inside the backup are detected and excluded.",
26 },
27 {
28 id: "hermes",
29 name: "Hermes Agent",
30 logo: "/plugins/_migrate_agents/webui/assets/hermes.svg",
31 accent: "gold",
32 hint: "Chats, projects, memory, instructions and skills",
33 guideTitle: "Export your Hermes sessions",
34 guideLabel: "Run in a terminal",
35 command: "hermes sessions export backup.jsonl --redact",
36 guideNote: "To include memory and skills, also select the relevant files or folders from your Hermes data directory.",
37 },
38 {
39 id: "opencode",
40 name: "OpenCode",
41 logo: "/plugins/_migrate_agents/webui/assets/opencode.svg",
42 accent: "mint",
43 hint: "Chats, projects, AGENTS.md and skills",
44 guideTitle: "Export an OpenCode session",
45 guideLabel: "Run in a terminal",
46 command: "opencode export <session-id> > session.json",
47 guideNote: "Repeat for other sessions. You can also add AGENTS.md and skill folders when selecting files.",
48 },
49 {
50 id: "claude",
51 name: "Claude Code",
52 logo: "/plugins/_migrate_agents/webui/assets/claude.svg",
53 accent: "violet",
54 hint: "Chats, projects, CLAUDE.md, memory and skills",
55 guideTitle: "Find your Claude Code data",
56 guideLabel: "Folder to select",
57 command: "~/.claude/projects",
58 guideNote: "Add any CLAUDE.md, memory files, and skill folders you want to bring over.",
59 },
60 {
61 id: "codex",
62 name: "Codex",
63 logo: "/plugins/_migrate_agents/webui/assets/codex.svg",
64 accent: "sky",
65 hint: "Chats, projects, AGENTS.md, memory and skills",
66 guideTitle: "Find your Codex data",
67 guideLabel: "Folders to select",
68 command: "$CODEX_HOME/sessions and $CODEX_HOME/archived_sessions",
69 guideNote: "CODEX_HOME is usually ~/.codex. Add AGENTS.md, memory files, and skill folders separately if needed.",
70 },
71 ];
72
73 async function responseJson(response) {
74 const text = await response.text();
75 if (!response.ok) throw new Error(text || `Request failed (${response.status})`);
76 return text ? JSON.parse(text) : {};
77 }
78
79 export const store = createStore("migrationParty", {
80 sources,
81 source: "openclaw",
82 files: [],
83 preview: null,
84 busy: false,
85 includeChats: true,
86 includeProjects: true,
87 includeMemories: true,
88 includeInstructions: true,
89 includeSkills: true,
90 reviewed: false,
91 dragActive: false,
92 copied: false,
93 copyTimer: null,
94
95 get selectedSource() {
96 return sources.find((item) => item.id === this.source) || sources[0];
97 },
98
99 get totalBytes() {
100 return this.files.reduce((total, file) => total + (file.size || 0), 0);
101 },
102
103 get canImport() {
104 return Boolean(
105 this.preview &&
106 this.reviewed &&
107 !this.busy &&
108 this.selectedItemCount > 0,
109 );
110 },
111
112 get selectedItemCount() {
113 const summary = this.preview?.summary || {};
114 return (
115 (this.includeChats ? summary.chats || 0 : 0) +
116 (this.includeProjects ? summary.projects || 0 : 0) +
117 (this.includeMemories ? summary.memories || 0 : 0) +
118 (this.includeInstructions ? summary.instructions || 0 : 0) +
119 (this.includeSkills ? summary.skills || 0 : 0)
120 );
121 },
122
123 get primaryDisabled() {
124 return this.busy || (this.preview ? !this.canImport : !this.files.length);
125 },
126
127 get primaryLabel() {
128 if (this.busy) return this.preview ? "Importing…" : "Checking export…";
129 if (this.preview) return "Import selected data";
130 return this.files.length ? "Review selected data" : "Select an export to continue";
131 },
132
133 onOpen() {
134 this.reset();
135 },
136
137 cleanup() {
138 this.dragActive = false;
139 if (this.copyTimer) clearTimeout(this.copyTimer);
140 },
141
142 reset() {
143 this.files = [];
144 this.preview = null;
145 this.reviewed = false;
146 this.busy = false;
147 this.dragActive = false;
148 this.copied = false;
149 this.includeChats = true;
150 this.includeProjects = true;
151 this.includeMemories = true;
152 this.includeInstructions = true;
153 this.includeSkills = true;
154 },
155
156 chooseSource(source) {
157 if (source === this.source) return;
158 this.source = source;
159 this.files = [];
160 this.preview = null;
161 this.reviewed = false;
162 this.copied = false;
163 },
164
165 acceptFiles(fileList) {
166 const incoming = Array.from(fileList || []);
167 if (!incoming.length) return;
168 const byKey = new Map(this.files.map((file) => [`${file.webkitRelativePath || file.name}:${file.size}`, file]));
169 for (const file of incoming) byKey.set(`${file.webkitRelativePath || file.name}:${file.size}`, file);
170 this.files = [...byKey.values()];
171 this.preview = null;
172 this.reviewed = false;
173 },
174
175 handleFiles(event) {
176 this.acceptFiles(event?.target?.files);
177 if (event?.target) event.target.value = "";
178 },
179
180 onDrop(event) {
181 this.dragActive = false;
182 this.acceptFiles(event?.dataTransfer?.files);
183 },
184
185 removeFile(index) {
186 this.files = this.files.filter((_, position) => position !== index);
187 this.preview = null;
188 this.reviewed = false;
189 },
190
191 clearFiles() {
192 this.files = [];
193 this.preview = null;
194 this.reviewed = false;
195 },
196
197 formatBytes(value) {
198 if (!value) return "0 B";
199 const units = ["B", "KiB", "MiB", "GiB"];
200 const power = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
201 return `${(value / 1024 ** power).toFixed(power ? 1 : 0)} ${units[power]}`;
202 },
203
204 async copyExportValue() {
205 try {
206 await navigator.clipboard.writeText(this.selectedSource.command);
207 this.copied = true;
208 if (this.copyTimer) clearTimeout(this.copyTimer);
209 this.copyTimer = setTimeout(() => { this.copied = false; }, 1600);
210 void toastFrontendInfo("Copied to the clipboard.", "Migrate Agents");
211 } catch {
212 void toastFrontendError("Could not copy. Select the text and copy it manually.", "Migrate Agents");
213 }
214 },
215
216 formData(includeOptions = false) {
217 const data = new FormData();
218 data.append("source", this.source);
219 for (const file of this.files) {
220 data.append("files[]", file, file.webkitRelativePath || file.name);
221 }
222 if (includeOptions) {
223 data.append("include_chats", String(this.includeChats));
224 data.append("include_projects", String(this.includeProjects));
225 data.append("include_memories", String(this.includeMemories));
226 data.append("include_instructions", String(this.includeInstructions));
227 data.append("include_skills", String(this.includeSkills));
228 }
229 return data;
230 },
231
232 async inspect() {
233 if (!this.files.length) {
234 void toastFrontendWarning("Choose an export file, archive, or folder first.", "Migrate Agents");
235 return;
236 }
237 this.busy = true;
238 this.preview = null;
239 this.reviewed = false;
240 try {
241 const response = await fetchApi(PREVIEW_API, {
242 method: "POST",
243 credentials: "same-origin",
244 body: this.formData(),
245 });
246 this.preview = await responseJson(response);
247 const found = this.preview?.summary || {};
248 this.includeChats = Boolean(found.chats);
249 this.includeProjects = Boolean(found.projects);
250 this.includeMemories = Boolean(found.memories);
251 this.includeInstructions = Boolean(found.instructions);
252 this.includeSkills = Boolean(found.skills);
253 void toastFrontendInfo(
254 `Found ${found.chats || 0} chats, ${found.projects || 0} projects, ${found.memories || 0} memories, ${found.instructions || 0} instructions, and ${found.skills || 0} skills.`,
255 "Migrate Agents",
256 );
257 if (this.preview?.warnings?.length) {
258 void toastFrontendWarning(`${this.preview.warnings.length} item(s) need review.`, "Migrate Agents");
259 }
260 } catch (error) {
261 void toastFrontendError(error instanceof Error ? error.message : String(error), "Migrate Agents");
262 } finally {
263 this.busy = false;
264 }
265 },
266
267 async migrate() {
268 if (!this.canImport) return;
269 this.busy = true;
270 try {
271 const response = await fetchApi(IMPORT_API, {
272 method: "POST",
273 credentials: "same-origin",
274 body: this.formData(true),
275 });
276 const result = await responseJson(response);
277 const summary = result.summary || {};
278 void toastFrontendSuccess(
279 `Imported ${summary.chats || 0} chats, ${summary.projects || 0} projects, ${summary.memories || 0} memories, ${summary.instructions || 0} instructions, and ${summary.skills || 0} skills.`,
280 "Migrate Agents",
281 );
282 if (result.warnings?.length) {
283 void toastFrontendWarning(`${result.warnings.length} item(s) were skipped with warnings.`, "Migrate Agents");
284 }
285 if (result.ctxids?.[0]) setContext(result.ctxids[0]);
286 closeModal("/plugins/_migrate_agents/webui/main.html");
287 } catch (error) {
288 void toastFrontendError(error instanceof Error ? error.message : String(error), "Migrate Agents");
289 } finally {
290 this.busy = false;
291 }
292 },
293
294 primaryAction() {
295 return this.preview ? this.migrate() : this.inspect();
296 },
297
298 close() {
299 closeModal("/plugins/_migrate_agents/webui/main.html");
300 },
301 });