-- allium: 1 -- bDS Action Patterns and Chains -- Scope: cross-cutting (all waves) -- Distilled from: PostEditor.tsx, MediaEditor.tsx, appStore.ts -- Cross-cutting patterns for AI operations, auto-translation, -- drag-and-drop chains, and confirmation dialogs. use "./post.allium" as post use "./media.allium" as media -- ─── External surfaces ────────────────────────────────────── surface UserAction { provides: AISuggestionRequested(entity_type, entity_id) provides: PostSaved(post_id) provides: PostAutoTranslateCompleted(post_id, language) provides: ImageFileDroppedOnPostEditor(post_id, file) provides: AISuggestionsModalConfirmed(entity_type, entity_id, fields) provides: AISuggestionsModalCancelled(entity_type, entity_id) } surface AiEngine { facing _: AiRuntime provides: AiFieldSuggestionsResponded(entity_type, entity_id, suggestions) -- The active endpoint's response, parsed into per-field suggestions } -- ─── AI operation gating ──────────────────────────────────── -- -- All AI operations route through the active endpoint for the current -- mode; endpoint selection and the active_endpoint_configured predicate -- are specified in ai.allium (AirplaneModeGating, TwoEndpointModel). -- Rules below gate on active_endpoint_configured and refuse with a -- toast when it is false. Two model categories are used: -- title model: text analysis (suggestions, translation, language detect) -- image model: vision-capable (image analysis only) -- Model selection: per-operation default from settings, overrideable -- per-conversation in the chat panel only. rule AiOperationRefusedWithoutEndpoint { when: AISuggestionRequested(entity_type, entity_id) requires: not active_endpoint_configured ensures: ToastShown(message_key: "ai.unavailable_configure_endpoint") -- "AI unavailable — configure {online|airplane} endpoint in Settings" ensures: AiOperationAborted(entity_type, entity_id) } -- ─── AI suggestions pattern ───────────────────────────────── -- -- Shared flow used by PostAIAnalysis (editor_post.allium) and -- MediaAIImageAnalysis (editor_media.allium). Modal layout: -- see modals.allium AISuggestionsModal / AISuggestionField. config { post_suggestion_sample_length: Integer = 2000 } rule PostAISuggestionFlow { when: AISuggestionRequested(entity_type, entity_id) requires: entity_type = "post" requires: active_endpoint_configured let target = post/Post{id: entity_id} ensures: AiFieldSuggestionsRequested(entity_id, model: "title", input: post_suggestion_input(target)) -- black box: title + excerpt + first -- config.post_suggestion_sample_length chars of content } rule MediaAISuggestionFlow { when: AISuggestionRequested(entity_type, entity_id) requires: entity_type = "media" requires: active_endpoint_configured let target = media/Media{id: entity_id} ensures: AiFieldSuggestionsRequested(entity_id, model: "image", input: ai_thumbnail(target)) -- black box: the 448x448 AI thumbnail JPEG } rule PresentAiSuggestions { when: AiFieldSuggestionsResponded(entity_type, entity_id, suggestions) -- Response parsed into per-field suggestions; each field row shows -- label, current value, suggested value, and an accept checkbox -- defaulting to true. The post slug field is locked (no checkbox) -- if the post was ever published. ensures: AISuggestionsModalOpened(entity_type, entity_id, suggestions) } rule ApplyAcceptedSuggestions { when: AISuggestionsModalConfirmed(entity_type, entity_id, fields) let accepted = filter(fields, f => f.accepted and not f.locked) for field in accepted: ensures: SuggestionFieldApplied(entity_type, entity_id, field) ensures: if entity_type = "post": PostAutoSaveTimerReset(entity_id) -- Posts: applied fields go through the 3s auto-save timer else: MediaSaveRequested(entity_id) -- Media: applied fields trigger an explicit save } rule CancelAiSuggestions { when: AISuggestionsModalCancelled(entity_type, entity_id) ensures: AiSuggestionsDiscarded(entity_type, entity_id) -- All suggestions discarded, no entity changes } -- ─── Auto-translation chain ───────────────────────────────── rule AutoTranslationChain { when: PostSaved(post_id) -- Triggered only by explicit manual save, never by publish, auto-save, -- unmount/tab-switch persistence, post creation, import, or scripting. let saved_post = post/Post{id: post_id} requires: active_endpoint_configured requires: not saved_post.do_not_translate let missing = saved_post.project.blog_languages - saved_post.available_languages for language in missing: ensures: PostTranslationTaskEnqueued(post_id, language) -- One background task per missing language. Each task: -- 1. translate metadata (title, excerpt) via title model -- 2. translate content (full markdown) via title model -- 3. create/update the translation record in the DB -- Sequential within a language, parallel across languages; -- progress visible in the Tasks panel. A save with no missing -- languages enqueues nothing (empty set). } rule MediaMetadataTranslationCascade { when: PostAutoTranslateCompleted(post_id, language) let saved_post = post/Post{id: post_id} for link in saved_post.linked_media: let item = media/Media{id: link.media_id} if item.language != null and not exists media/MediaTranslation{media: item, language: language}: ensures: MediaMetadataTranslationTaskEnqueued(link.media_id, language) -- Translates title, alt, caption via the title model and -- writes the translated sidecar {path}.{lang}.meta } -- ─── Drag-and-drop image chain ────────────────────────────── -- -- Full chain when an image file is dropped on the post editor body. -- See editor_post.allium PostDragDropImage for the trigger rule. rule PostDragDropImageChain { when: ImageFileDroppedOnPostEditor(post_id, file) let target = post/Post{id: post_id} -- Synchronous steps (user waits): let imported = import_media(target.project, file) -- black box: new media record + file copy + base sidecar -- (media.allium ImportMedia) ensures: ThumbnailGenerationStarted(imported) -- async start: small/medium/large/ai variants ensures: MediaLinkedToPost(imported, target) -- updates sidecar linkedPostIds ensures: MarkdownImageInserted(target, imported) -- inserts ![](/media/YYYY/MM/file) at the cursor position ensures: DragDropImportCompleted(target, imported) } rule DragDropAiAutoApply { when: DragDropImportCompleted(target, imported) requires: active_endpoint_configured -- Background, non-blocking: image model on the 448x448 AI thumbnail. -- Results are auto-applied WITHOUT the AISuggestionsModal (unlike -- the manual Quick Action). ensures: MediaMetadataUpdated(imported, ai_image_analysis(imported)) ensures: MediaSidecarRewritten(imported) } rule DragDropTranslationCascade { when: DragDropImportCompleted(target, imported) requires: active_endpoint_configured requires: not target.do_not_translate for language in target.project.blog_languages: ensures: MediaMetadataTranslationTaskEnqueued(imported.id, language) -- creates translated sidecar files } -- ─── Confirmation dialog patterns ─────────────────────────── -- -- Four distinct patterns used across the application. The assignment -- table below is the authoritative mapping from destructive or -- irreversible actions to their confirmation UI. enum ConfirmationPattern { system_confirm -- Simple yes/no system dialog, no custom UI confirm_delete_modal -- Custom modal with entity name, reference counts, linked -- entity list; buttons Cancel + Delete (destructive red style) confirm_dialog -- Custom modal describing the action and its consequences; -- buttons Cancel + Confirm none -- Immediate execution on click, no dialog } value ConfirmationAssignment { action: String pattern: ConfirmationPattern } config { confirmation_assignments: Set = { ConfirmationAssignment(action: "post_delete", pattern: system_confirm), ConfirmationAssignment(action: "post_discard", pattern: system_confirm), ConfirmationAssignment(action: "template_delete", pattern: confirm_delete_modal), -- shows template title, no reference list ConfirmationAssignment(action: "template_delete_with_references", pattern: system_confirm), ConfirmationAssignment(action: "media_delete", pattern: confirm_delete_modal), -- shows linked posts ConfirmationAssignment(action: "tag_delete", pattern: confirm_delete_modal), -- shows post count ConfirmationAssignment(action: "tag_merge", pattern: confirm_dialog), -- "Merge N tags into {target}? Cannot be undone." ConfirmationAssignment(action: "rebuild_operations", pattern: none), ConfirmationAssignment(action: "script_delete", pattern: confirm_delete_modal), -- shows script title, no reference list ConfirmationAssignment(action: "menu_item_delete", pattern: none), ConfirmationAssignment(action: "metadata_diff_field_sync", pattern: none), ConfirmationAssignment(action: "import_execute", pattern: none), ConfirmationAssignment(action: "media_unlink", pattern: none) } }