Spaces:
Running
Running
| import { writable, derived, get } from "svelte/store"; | |
| import { virtualFileSystem } from "./virtual-fs"; | |
| import { websocketService } from "./websocket"; | |
| export interface ContentState { | |
| content: string; | |
| language: string; | |
| theme: string; | |
| lastModified: Date; | |
| version: number; | |
| isUISynced: boolean; | |
| isAgentSynced: boolean; | |
| lastSource: "ui" | "agent" | "init"; | |
| isConflicted: boolean; | |
| } | |
| export interface ContentChange { | |
| content: string; | |
| source: "ui" | "agent" | "init"; | |
| timestamp: Date; | |
| version: number; | |
| } | |
| /** | |
| * ContentManager: Single source of truth for editor content | |
| * Manages bidirectional sync between UI and agent with conflict resolution | |
| */ | |
| class ContentManager { | |
| private static instance: ContentManager | null = null; | |
| private readonly DEFAULT_CONTENT = `<world canvas="#game-canvas" sky="#87ceeb"> | |
| <!-- Ground --> | |
| <static-part pos="0 -0.5 0" shape="box" size="20 1 20" color="#90ee90"></static-part> | |
| <!-- Ball --> | |
| <dynamic-part pos="-2 4 -3" shape="sphere" size="1" color="#ff4500"></dynamic-part> | |
| </world> | |
| <script> | |
| console.log("Game script loaded!"); | |
| </script>`; | |
| private readonly contentStore = writable<ContentState>({ | |
| content: this.DEFAULT_CONTENT, | |
| language: "html", | |
| theme: "vs-dark", | |
| lastModified: new Date(), | |
| version: 1, | |
| isUISynced: true, | |
| isAgentSynced: true, | |
| lastSource: "init", | |
| isConflicted: false, | |
| }); | |
| private syncTimeout: number | null = null; | |
| private readonly DEBOUNCE_MS = 300; | |
| private isUpdating = false; | |
| private agentEditInProgress = false; | |
| private lastAgentVersion = 0; | |
| private constructor() { | |
| this.setupSyncSubscription(); | |
| } | |
| static getInstance(): ContentManager { | |
| if (!ContentManager.instance) { | |
| ContentManager.instance = new ContentManager(); | |
| } | |
| return ContentManager.instance; | |
| } | |
| /** | |
| * Public reactive store for UI components | |
| */ | |
| readonly content = derived(this.contentStore, ($state) => ({ | |
| content: $state.content, | |
| language: $state.language, | |
| theme: $state.theme, | |
| lastModified: $state.lastModified, | |
| version: $state.version, | |
| })); | |
| /** | |
| * Public store subscription method | |
| */ | |
| subscribe = this.content.subscribe; | |
| /** | |
| * Update content from UI (Monaco editor) | |
| * Debounced for smooth typing experience | |
| */ | |
| updateFromUI(content: string): void { | |
| if (this.isUpdating || this.agentEditInProgress) { | |
| // Agent is editing, mark as conflicted | |
| if (this.agentEditInProgress) { | |
| this.contentStore.update((state) => ({ | |
| ...state, | |
| isConflicted: true, | |
| })); | |
| } | |
| return; | |
| } | |
| this.updateContent(content, "ui"); | |
| this.debouncedAgentSync(); | |
| } | |
| /** | |
| * Update content from agent (MCP tools) | |
| * Now handles version conflicts more gracefully | |
| */ | |
| updateFromAgent(content: string): void { | |
| if (this.isUpdating) return; | |
| this.isUpdating = true; | |
| this.agentEditInProgress = true; | |
| const currentState = get(this.contentStore); | |
| // Check for version conflict | |
| if ( | |
| currentState.version > this.lastAgentVersion && | |
| currentState.lastSource === "ui" | |
| ) { | |
| // UI has made changes since agent started editing | |
| console.warn("Agent edit conflicted with UI changes - agent wins"); | |
| this.contentStore.update((state) => ({ | |
| ...state, | |
| isConflicted: true, | |
| })); | |
| } | |
| this.updateContent(content, "agent"); | |
| this.lastAgentVersion = get(this.contentStore).version; | |
| this.clearSyncTimeout(); | |
| // Allow UI to resume editing after a short delay | |
| setTimeout(() => { | |
| this.agentEditInProgress = false; | |
| this.contentStore.update((state) => ({ | |
| ...state, | |
| isConflicted: false, | |
| })); | |
| }, 500); | |
| this.isUpdating = false; | |
| } | |
| /** | |
| * Initialize content (on app start) | |
| */ | |
| initialize(content?: string): void { | |
| const initialContent = content || this.DEFAULT_CONTENT; | |
| this.updateContent(initialContent, "init"); | |
| // Sync to VFS immediately but not to WebSocket yet (may not be connected) | |
| virtualFileSystem.updateGameContent(initialContent); | |
| this.contentStore.update((state) => ({ | |
| ...state, | |
| isAgentSynced: true, | |
| isConflicted: false, | |
| })); | |
| // Initialize version tracking | |
| this.lastAgentVersion = 1; | |
| } | |
| /** | |
| * Get current content (synchronous) | |
| */ | |
| getCurrentContent(): string { | |
| return get(this.contentStore).content; | |
| } | |
| /** | |
| * Get current state (synchronous) | |
| */ | |
| getCurrentState(): ContentState { | |
| return get(this.contentStore); | |
| } | |
| /** | |
| * Check if agent is currently editing | |
| */ | |
| isAgentEditing(): boolean { | |
| return this.agentEditInProgress; | |
| } | |
| /** | |
| * Force full sync (for reconnection scenarios) | |
| */ | |
| forceFullSync(): void { | |
| this.immediateFullSync(); | |
| } | |
| /** | |
| * Update language setting | |
| */ | |
| setLanguage(language: string): void { | |
| this.contentStore.update((state) => ({ | |
| ...state, | |
| language, | |
| lastModified: new Date(), | |
| })); | |
| } | |
| /** | |
| * Update theme setting | |
| */ | |
| setTheme(theme: string): void { | |
| this.contentStore.update((state) => ({ | |
| ...state, | |
| theme, | |
| lastModified: new Date(), | |
| })); | |
| } | |
| /** | |
| * Reset to default content | |
| */ | |
| reset(): void { | |
| this.updateContent(this.DEFAULT_CONTENT, "init"); | |
| this.immediateFullSync(); | |
| } | |
| private updateContent( | |
| content: string, | |
| source: ContentChange["source"], | |
| ): void { | |
| this.contentStore.update((state) => { | |
| // Prevent unnecessary updates | |
| if (state.content === content) return state; | |
| return { | |
| ...state, | |
| content, | |
| lastModified: new Date(), | |
| version: state.version + 1, | |
| isUISynced: source === "ui" || source === "init", | |
| isAgentSynced: source === "agent" || source === "init", | |
| lastSource: source, | |
| isConflicted: false, | |
| }; | |
| }); | |
| } | |
| private setupSyncSubscription(): void { | |
| this.contentStore.subscribe((state) => { | |
| // Only sync if content actually changed and we're not in an update cycle | |
| if (!this.isUpdating) { | |
| if (!state.isAgentSynced) { | |
| this.syncToAgent(state.content); | |
| } | |
| } | |
| }); | |
| } | |
| private debouncedAgentSync(): void { | |
| this.clearSyncTimeout(); | |
| this.syncTimeout = window.setTimeout(() => { | |
| const state = get(this.contentStore); | |
| if (!state.isAgentSynced) { | |
| this.syncToAgent(state.content); | |
| } | |
| }, this.DEBOUNCE_MS); | |
| } | |
| private immediateFullSync(): void { | |
| this.clearSyncTimeout(); | |
| const content = get(this.contentStore).content; | |
| this.syncToAgent(content); | |
| } | |
| private syncToAgent(content: string): void { | |
| // Update virtual file system | |
| virtualFileSystem.updateGameContent(content); | |
| // Send to WebSocket if connected | |
| if (websocketService.isConnected()) { | |
| websocketService.send({ | |
| type: "editor_sync", | |
| payload: { content }, | |
| timestamp: Date.now(), | |
| }); | |
| } | |
| // Mark as synced | |
| this.contentStore.update((state) => ({ | |
| ...state, | |
| isAgentSynced: true, | |
| })); | |
| } | |
| private clearSyncTimeout(): void { | |
| if (this.syncTimeout !== null) { | |
| clearTimeout(this.syncTimeout); | |
| this.syncTimeout = null; | |
| } | |
| } | |
| } | |
| export const contentManager = ContentManager.getInstance(); | |