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