diff --git a/specs/action_patterns.allium b/specs/action_patterns.allium index 4ce9d00..d216e1e 100644 --- a/specs/action_patterns.allium +++ b/specs/action_patterns.allium @@ -20,11 +20,11 @@ surface UserAction { -- ─── AI operation gating ─────────────────────────────── invariant AIOperationGating { - -- All AI operations check airplane/offline mode before executing. - -- If offline mode enabled: - -- If local provider configured (Ollama/LM Studio): route to local model - -- Else: show toast "AI unavailable in offline mode", abort operation - -- If online: use configured provider endpoint + -- All AI operations route through the active endpoint for the current mode. + -- See ai.allium AirplaneModeGating for endpoint selection logic. + -- If airplane_mode: use airplane endpoint (local model, e.g. Ollama/LM Studio) + -- If online: use online endpoint (cloud provider) + -- If active endpoint not configured: show toast, abort operation -- Two model categories: -- Title model: text analysis (suggestions, translation, language detect) -- Image model: vision-capable (image analysis only) diff --git a/specs/ai.allium b/specs/ai.allium index 182f708..4dfcdf1 100644 --- a/specs/ai.allium +++ b/specs/ai.allium @@ -46,49 +46,47 @@ entity ChatMessage { -- One-shot AI tasks (core scope, no streaming) -- All use OpenAI Chat Completions wire format. --- When airplane_mode: use airplane endpoint. When online: use online endpoint. +-- Endpoint routing: see AirplaneModeGating invariant below. -- When no endpoint configured for current mode: disable AI, show toast. rule AnalyzeTaxonomy { when: AnalyzeTaxonomyRequested(post) - requires: not airplane_mode - -- All AI activities gated by offline mode + requires: active_endpoint_configured -- Suggests tags and categories for a post ensures: TaxonomySuggestion(tags, categories) } rule AnalyzeImage { when: AnalyzeImageRequested(media) - requires: not airplane_mode + requires: active_endpoint_configured requires: is_image(media.mime_type) - -- Checks mime type is an image type -- Vision model generates alt text and caption ensures: ImageAnalysisResult(alt, caption) } rule AnalyzePost { when: AnalyzePostRequested(post) - requires: not airplane_mode + requires: active_endpoint_configured -- Generates title, excerpt, slug suggestions ensures: PostAnalysisResult(title, excerpt, slug) } rule DetectLanguage { when: DetectLanguageRequested(text) - requires: not airplane_mode + requires: active_endpoint_configured ensures: LanguageDetectionResult(language_code) } rule TranslatePost { when: TranslatePostRequested(post, target_language) - requires: not airplane_mode + requires: active_endpoint_configured -- Translates title, excerpt, content to target language ensures: TranslationResult(title, excerpt, content) } rule TranslateMedia { when: TranslateMediaRequested(media, target_language) - requires: not airplane_mode + requires: active_endpoint_configured -- Translates title, alt, caption to target language ensures: MediaTranslationResult(title, alt, caption) } @@ -102,7 +100,7 @@ rule StartChat { rule SendChatMessage { when: SendChatMessageRequested(conversation, content) - requires: not airplane_mode + requires: active_endpoint_configured ensures: ChatMessage.created(conversation: conversation, role: user, content: content) ensures: AiStreamingResponse(conversation) -- AI SDK v6 streamText() with tool-call loop (max 10 rounds) @@ -120,11 +118,13 @@ rule RefreshModelCatalog { } invariant AirplaneModeGating { - -- All AI activities must be gated by airplane (offline) mode - -- When airplane_mode = true: only the airplane endpoint is used - -- When airplane_mode = false: the online endpoint is used - -- If the active endpoint is not configured: AI is unavailable, - -- show toast notification to user + -- Endpoint routing based on airplane (offline) mode: + -- airplane_mode = true -> use airplane endpoint (local model) + -- airplane_mode = false -> use online endpoint (cloud provider) + -- active_endpoint_configured = true iff the endpoint for the + -- current mode has a non-empty url (and api_key for online). + -- When active endpoint is not configured: AI is unavailable, + -- show toast "AI unavailable — configure {online|airplane} endpoint in Settings" } invariant TwoEndpointModel { diff --git a/specs/bds.allium b/specs/bds.allium index 61064e6..4a5f70c 100644 --- a/specs/bds.allium +++ b/specs/bds.allium @@ -63,7 +63,7 @@ use "./metadata_diff.allium" as metadata_diff -- DB/filesystem diff and rebuil -- -- MAY change intentionally: -- Implementation language (TS -> Rust), editor (WYSIWYG -> plain text + preview), --- scripting runtime (Python -> Lua), process model (Electron -> native), +-- scripting runtime (Lua), process model (Electron -> native), -- UI framework (React -> Iced) -- Resolved questions: diff --git a/specs/editor_settings.allium b/specs/editor_settings.allium index e1cbe6f..6bc2acc 100644 --- a/specs/editor_settings.allium +++ b/specs/editor_settings.allium @@ -37,18 +37,18 @@ surface User { -- Add Category: text input + Add button. Reset to Defaults button -- 4. AI Assistant Settings: --- API keys: OpenCode, Mistral (masked display + Change, or password input + Save) --- Local providers: Ollama enable checkbox + capabilities table, --- LM Studio enable checkbox + capabilities table --- Offline mode: enable checkbox (disabled if no local providers), --- selectors for chat/title/image models (local only) --- Default Model (select with optgroup by provider + refresh), --- shows: max output tokens, context window, pricing --- Title Model (select), Image Analysis Model (vision-capable only) +-- Two-endpoint configuration (see ai.allium TwoEndpointModel): +-- Online endpoint: URL (text), API Key (masked + Change/Save), Model (select) +-- Airplane endpoint: URL (text), Model (select) +-- Both use OpenAI Chat Completions wire format. +-- Refresh Models button per endpoint: fetches model list from endpoint URL +-- Title Model (select from active endpoint models) +-- Image Analysis Model (select, vision-capable only) +-- Offline mode toggle: switches between online and airplane endpoint +-- Disabled if airplane endpoint URL is not configured -- System Prompt (textarea 12 rows + Save + Reset to Default) -- 5. Technology Settings: --- Python Runtime Mode (select: Web Worker/Main Thread) -- Semantic Similarity (checkbox) -- 6. Publishing Settings (SSH): @@ -69,20 +69,15 @@ surface User { -- ─── Settings view actions ────────────────────────────────── -- API key validation: --- On Save: test API call to provider endpoint before persisting +-- On Save: test API call to online endpoint before persisting -- On validation failure: toast error message, key not saved --- On success: key saved, masked display shown +-- On success: key encrypted via SecureKeyStore, masked display shown --- Local provider enable/disable: --- Enabling Ollama/LM Studio: fetches model list, shows capabilities table --- Capabilities table columns: model name, chat, title, image (checkmarks) --- Models auto-fetched on enable; manual refresh via Refresh Models button - --- Model catalog refresh: --- Refresh button next to Default Model selector --- Fetches available models from all enabled providers --- Updates optgroup dropdown with provider groupings --- Per-model info: max output tokens, context window, pricing +-- Endpoint configuration: +-- URL field required for both endpoints +-- Refresh Models button fetches model list from endpoint URL +-- Model selector populated from fetched model list +-- Per-model info: max output tokens, context window (when available) -- Rebuild operations (Data Maintenance section): -- 6 buttons, each executes immediately (no confirmation dialog) diff --git a/specs/frontmatter.allium b/specs/frontmatter.allium index 96259d2..bc7544a 100644 --- a/specs/frontmatter.allium +++ b/specs/frontmatter.allium @@ -100,7 +100,7 @@ value TemplateFrontmatter { id: String -- UUID v4 slug: String title: String - kind: post | list | not-found | partial + kind: post | list | not_found | partial enabled: Boolean version: Integer created_at: Timestamp @@ -122,9 +122,8 @@ rule WriteTemplateFile { -- ============================================================================ value ScriptFrontmatter { - -- File path: scripts/{slug}.lua (Rust) / {slug}.py (TypeScript legacy) - -- Note: TypeScript uses Python with triple-quote docstring delimiters - -- Rust uses Lua with standard YAML frontmatter + -- File path: scripts/{slug}.lua + -- YAML frontmatter delimited by --- markers id: String -- UUID v4 slug: String title: String @@ -261,9 +260,10 @@ invariant MenuOpmlFormat { -- ============================================================================ invariant TimestampFormat { - -- All timestamps are Unix milliseconds (number in JavaScript) - -- Stored as ISO 8601 strings in YAML frontmatter - -- Example: 2024-03-15T14:30:00.000Z + -- Database: Unix milliseconds stored as INTEGER columns + -- YAML frontmatter: ISO 8601 strings (e.g. 2024-03-15T14:30:00.000Z) + -- Conversion on read: parse ISO 8601 → Unix ms + -- Conversion on write: Unix ms → ISO 8601 } invariant YamlFormatting { diff --git a/specs/layout.allium b/specs/layout.allium index f6a5a92..8243a52 100644 --- a/specs/layout.allium +++ b/specs/layout.allium @@ -88,7 +88,8 @@ rule ToggleAssistantSidebar { value WindowTitleBar { -- Platform-adaptive - -- menu_bar: rendered only on non-Mac platforms (5 groups: File, Edit, View, Blog, Help) + -- menu_bar: rendered only on non-Mac platforms (6 groups: App, File, Edit, View, Window, Help) + -- macOS: native menu bar via muda crate (same 6 groups) -- Keyboard: Alt opens mnemonics, Alt+letter opens group -- Arrow keys navigate groups and items, Enter/Space activates -- View group hides devTools toggle when not in dev mode diff --git a/specs/schema.allium b/specs/schema.allium index bbbda5e..97f1818 100644 --- a/specs/schema.allium +++ b/specs/schema.allium @@ -134,7 +134,7 @@ entity Script { entrypoint: String -- Default: "render" for macros enabled: Boolean version: Integer -- Incremented on each update - file_path: String -- scripts/{slug}.lua (Rust) / {slug}.py (TS) + file_path: String -- scripts/{slug}.lua status: draft | published content: String? -- Draft body (null when published) created_at: Timestamp @@ -224,52 +224,19 @@ entity ChatMessage { created_at: Timestamp } -entity AiProvider { - id: String -- Provider key (e.g., "opencode", "mistral") - name: String -- Display name - env: String? -- JSON array of env var names - npm: String? -- Primary npm package - api: String? -- API base URL - doc: String? -- Documentation URL - updated_at: Timestamp -} - -entity AiModel { - provider: String -- FK → ai_providers.id +entity AiCachedModel { + -- Cached model list fetched from endpoint /v1/models API. + -- Keyed by endpoint kind so online and airplane caches are separate. + endpoint_kind: String -- "online" | "airplane" model_id: String name: String -- Display name - family: String? -- Model family (e.g., "claude-sonnet") - attachment: Boolean -- Supports attachments - reasoning: Boolean -- Supports reasoning - tool_call: Boolean -- Supports tool calls - structured_output: Boolean - temperature: Boolean -- Temperature control supported - knowledge: String? -- Knowledge cutoff date - release_date: String? - last_updated_date: String? - open_weights: Boolean - input_price: Integer? -- USD per 1M input tokens (in cents) - output_price: Integer? -- USD per 1M output tokens (in cents) - cache_read_price: Integer? - cache_write_price: Integer? - context_window: Integer -- Max context tokens - max_input_tokens: Integer - max_output_tokens: Integer - interleaved: String? -- JSON object for interleaved fields - status: String? -- e.g., "deprecated" - provider_npm: String? -- Per-model npm override + context_window: Integer? + max_output_tokens: Integer? updated_at: Timestamp } -entity AiModelModality { - provider: String - model_id: String - direction: String -- "input" | "output" - modality: String -- "text" | "image" | "pdf" | "audio" | "video" -} - entity AiCatalogMeta { - key: String -- "etag" | "lastFetchedAt" + key: String -- "{endpoint_kind}_etag" | "{endpoint_kind}_lastFetchedAt" value: String } diff --git a/specs/script.allium b/specs/script.allium index d4aeec5..963e6c3 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -2,8 +2,7 @@ -- bDS Scripting System -- Scope: core (Wave 6 — Lua runtime and scripting docs) -- Distilled from: src/main/engine/ScriptEngine.ts, schema.ts --- Note: TypeScript app uses Python/Pyodide. Rust rewrite uses Lua. --- Behavioural contract is identical; only runtime changes. +-- Scripting runtime is Lua (mlua crate). Behavioural contract preserved. entity Script { slug: String @@ -37,8 +36,7 @@ invariant ScriptFileLayout { for s in Scripts where file_path != "": s.file_path = format("scripts/{slug}.lua", slug: s.slug) } --- TypeScript app uses .py with triple-quote docstring frontmatter --- Rust app uses .lua with standard --- frontmatter +-- Script files use .lua with standard --- YAML frontmatter rule CreateScript { when: CreateScriptRequested(title, kind, content, entrypoint) diff --git a/specs/template_context.allium b/specs/template_context.allium index 0cc13b7..11fcbb6 100644 --- a/specs/template_context.allium +++ b/specs/template_context.allium @@ -227,7 +227,7 @@ invariant TemplateLookupPriority { -- 4. Default "post" template -- -- List pages use "list" template - -- 404 pages use "not-found" template + -- 404 pages use "not_found" template -- Partials are referenced via {% render 'partial' %} } @@ -257,7 +257,7 @@ rule RenderListPage { rule RenderNotFoundPage { when: RenderNotFoundPageRequested() - let template = first (t in Templates where t.kind = "not-found" and t.enabled) + let template = first (t in Templates where t.kind = "not_found" and t.enabled) let context = BuildNotFoundContext ensures: RenderedHtml = liquid_render(template.content, context) }