mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-11-03 08:12:53 +08:00
* feat(document): dirty-set incremental saves + operation-level flush + unload protection Every store mutation used to trigger a debounced FULL aggregate rewrite — editing one word rewrote the whole document, scene switches during playback rewrote it for nothing, and concurrent editors clobbered each other at document granularity. Mutations now mark precise dirt (scene:<id> / structure / stage / outline / currentScene / chats): scene edits flush as version-gated putScene, stage metadata as putStage, structure and outline changes keep the full-save path, and currentScene/chats no longer touch the document at all. Dirt entries are revisioned so a mutation landing during a write is never accidentally cleared, and failed writes retain their dirt for retry. flushStageSave() drains the debounce single-flight; agent apply operations await it so each AI edit is durable before its tool result returns; hidden-page/unload kick a best-effort immediate flush. The PendingChange type stays neutral about the change unit so a future operation-log backend can replace the flush internals at one seam. Co-authored-by: Codex <codex@openai.com> * fix(document): incremental-persistence review round — bypass-proofing, true drain, no stranded dirt Structural mutations carry the cursor they changed (and cursor resolution validates membership on load, so a stale device cursor can never resurrect a deleted scene). The Stage API now wraps the real store in a guard that diffs pre/post state and centrally classifies every raw write — whiteboard, element, canvas, and action-engine mutations can no longer bypass dirty tracking, and new API namespaces inherit the guard by construction (with a lint-like inventory test). flushStageSave is a bounded true drain: an awaiter whose mutation landed mid-flight loops until its own dirt is durable. setStage hands the old document's dirt to a departing flush instead of discarding it, and a failed flush always reschedules. Mixed dirty batches (scene+stage/outline) use the atomic full save — the incremental fast path is homogeneous-only. Chat-half failures keep the chats dirt for retry without changing the tolerated-split outer semantics. Co-authored-by: Codex <codex@openai.com> * fix(document): reviewer round — drain covers caller dirt, full-path chat retry, stamping unification flushStageSave's early-return now only fires when the failing round's snapshot actually covered the caller's dirt — an agent edit landing beside an in-flight failing round is written before the promise resolves, keeping the tool-result durability guarantee unconditional. The full-save path reports chat failures the same way the incremental path does (chats dirt survives and reschedules; the tolerated-split semantics are unchanged). The departing-stage flush retries once and its doc-comment states the best-effort semantics honestly. Stage and scene stamping are extracted and shared — fixing the incremental putScene branch silently dropping the order ?? index normalization the full path applies — and the dead outline mark in setGenerationComplete is gone. Co-authored-by: Codex <codex@openai.com> --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
140 lines
4.4 KiB
TypeScript
140 lines
4.4 KiB
TypeScript
/**
|
|
* Stage API - AI Agent Toolkit
|
|
*
|
|
* Provides a complete Stage operation interface for AI Agents to create and manage course content
|
|
*
|
|
* Design Principles:
|
|
* 1. Type Safety: Fully leverage TypeScript's type system
|
|
* 2. Ease of Use: Provide high-level abstractions with clear, intuitive API naming
|
|
* 3. Extensibility: Support adding new scene types in the future
|
|
* 4. Idempotency: Multiple calls with the same parameters produce the same result
|
|
* 5. Error Handling: Return explicit success/failure status and error messages
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const api = createStageAPI(stageStore);
|
|
*
|
|
* // Create a new scene
|
|
* const sceneId = api.scene.create({
|
|
* type: 'slide',
|
|
* title: 'Introduction',
|
|
* // speech is now in actions
|
|
* });
|
|
*
|
|
* // Add an element
|
|
* const elementId = api.element.add(sceneId, {
|
|
* type: 'text',
|
|
* content: 'Hello World',
|
|
* left: 100,
|
|
* top: 100
|
|
* });
|
|
*
|
|
* // Highlight an element (teaching feature)
|
|
* api.canvas.highlight(sceneId, elementId, 3000);
|
|
* ```
|
|
*/
|
|
|
|
// Re-export all types
|
|
export type {
|
|
APIResult,
|
|
CreateSceneParams,
|
|
CreateElementParams,
|
|
HighlightOptions,
|
|
SpotlightOptions,
|
|
StageStore,
|
|
} from './stage-api-types';
|
|
|
|
// Re-export utility functions that were previously accessible
|
|
export {
|
|
generateId,
|
|
validateSceneId,
|
|
getScene,
|
|
createDefaultContent,
|
|
createDefaultSlideContent,
|
|
createDefaultQuizContent,
|
|
createDefaultInteractiveContent,
|
|
createDefaultPBLContent,
|
|
} from './stage-api-defaults';
|
|
|
|
// Import sub-API factories
|
|
import { createSceneAPI } from './stage-api-scene';
|
|
import { createElementAPI } from './stage-api-element';
|
|
import { createCanvasAPI } from './stage-api-canvas';
|
|
import { createNavigationAPI } from './stage-api-navigation';
|
|
import { createWhiteboardAPI } from './stage-api-whiteboard';
|
|
import { createModeAPI, createStageMetaAPI } from './stage-api-mode';
|
|
import type { StageStore } from './stage-api-types';
|
|
import { markStagePersistenceDirty, useStageStore } from '@/lib/store/stage';
|
|
import type { PendingChange } from '@/lib/utils/stage-storage';
|
|
|
|
function persistenceChangesForSetState(
|
|
before: ReturnType<StageStore['getState']>,
|
|
after: ReturnType<StageStore['getState']>,
|
|
): PendingChange[] {
|
|
const changes: PendingChange[] = [];
|
|
|
|
if (before.stage !== after.stage) changes.push({ kind: 'stage' });
|
|
if (before.currentSceneId !== after.currentSceneId) changes.push({ kind: 'currentScene' });
|
|
|
|
if (before.scenes !== after.scenes) {
|
|
const beforeStructure = before.scenes.map(({ id, order }) => [id, order] as const);
|
|
const afterStructure = after.scenes.map(({ id, order }) => [id, order] as const);
|
|
const structureChanged =
|
|
beforeStructure.length !== afterStructure.length ||
|
|
beforeStructure.some(
|
|
([id, order], index) =>
|
|
afterStructure[index]?.[0] !== id || afterStructure[index]?.[1] !== order,
|
|
);
|
|
|
|
if (structureChanged) {
|
|
changes.push({ kind: 'structure' });
|
|
} else {
|
|
before.scenes.forEach((scene, index) => {
|
|
if (scene !== after.scenes[index]) changes.push({ kind: 'scene', sceneId: scene.id });
|
|
});
|
|
}
|
|
}
|
|
|
|
return changes;
|
|
}
|
|
|
|
function withProductionPersistence(store: StageStore): StageStore {
|
|
if (store !== useStageStore) return store;
|
|
return {
|
|
...store,
|
|
setState(partial) {
|
|
const before = store.getState();
|
|
store.setState(partial);
|
|
const changes = persistenceChangesForSetState(before, store.getState());
|
|
if (changes.length > 0) markStagePersistenceDirty(changes);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ==================== Stage API Implementation ====================
|
|
|
|
/**
|
|
* Create a Stage API instance
|
|
*
|
|
* @param store - Zustand store instance
|
|
* @returns Stage API object
|
|
*/
|
|
export function createStageAPI(store: StageStore) {
|
|
// All namespaces receive the same guarded injection boundary. New API
|
|
// modules cannot bypass persistence by adding another raw setState call.
|
|
const persistenceStore = withProductionPersistence(store);
|
|
return {
|
|
scene: createSceneAPI(persistenceStore),
|
|
navigation: createNavigationAPI(persistenceStore),
|
|
element: createElementAPI(persistenceStore),
|
|
canvas: createCanvasAPI(persistenceStore),
|
|
whiteboard: createWhiteboardAPI(persistenceStore),
|
|
mode: createModeAPI(persistenceStore),
|
|
stage: createStageMetaAPI(persistenceStore),
|
|
};
|
|
}
|
|
|
|
// ==================== Type Exports ====================
|
|
|
|
export type StageAPI = ReturnType<typeof createStageAPI>;
|