Skip to content

feat(ai): shared AI system - actions, budgets, AdminCP, automatic ALT, field assist, Quick Ask - #843

Draft
aXenDeveloper wants to merge 12 commits into
canaryfrom
claude/vitnode-ai-implementation-06dgpo
Draft

aXenDeveloper wants to merge 12 commits into
canaryfrom
claude/vitnode-ai-implementation-06dgpo

Conversation

@aXenDeveloper

@aXenDeveloper aXenDeveloper commented Oct 4, 2026 •

Copy link
Copy Markdown
Owner

Description

Stacked on #842 (refactor/edit_articles), which added article translation and excerpt generation. This PR extends that work into one shared AI system in VitNode Core.

What?

Core AI platform (packages/vitnode/src/api/lib/ai/)

  • defineAiAction + buildApiPlugin({ aiActions }):
    • canonical keys such as @vitnode/blog:excerpt.generate, with typed refs (aiActionRef);
    • validated input and output, a prompt version, default limits, a permission descriptor and authorize();
    • a required title and description and an optional Lucide icon, shown in the AdminCP;
    • validation within each plugin and across all plugins at boot.
  • Model capability metadata: text, image-input, structured-output, streaming.
    • It defaults to ["text"], so vision is never assumed.
    • Streamed runs require streaming.
    • model(), embeddingModel(), imageModel() and models() are unchanged.
  • One runner: c.get("ai").run(), runAsSystem(), stream() and estimate(). A run goes through:
    • authorization and model choice;
    • atomic reservations;
    • native Vercel AI SDK calls, outside any transaction;
    • crash-safe recording of each provider call, then output validation;
    • idempotent settlement;
    • stable error codes and ai.run.* events.
  • usageCost contract:
    • Money is decimal (bigint fixed point, numeric(24,12)).
    • Cost order: the provider-reported cost, then the model's pricing in vitnode.api.config.ts, then unknown. Unknown is never zero.
    • Prices live only in the config: there is no price editor in the AdminCP and no gateway price sync. Malformed pricing stops the boot.
    • Pricing covers cache reads and writes, long-context tiers, and per-request and per-image prices. Reasoning tokens are never billed twice.
    • AI Gateway and OpenRouter adapters. Estimates are reconciled with the billed cost through audited adjustments.
  • Budgets and points (1 point = 0.001 USD, conversion version 1):
    • a global USD budget and an optional system budget;
    • monthly user points: the largest role allowance wins and allowances are never summed; user overrides apply; root roles are unlimited;
    • daily per-permission counts, plus rate and concurrency limits.
    • Reservations lock rows in a fixed order. Users are charged only for the call that delivered a valid result; the site pays everything else.
  • Maintenance cron: settles runs whose lease expired as uncertain, reconciles costs, and prunes history. History retention is independent of the queue.

AdminCP & account

  • New AdminCP AI section, gated by ai:can_view and ai:can_manage:
    • Overview
    • Actions: a data table with icon, title, description, plugin, model, daily limit and status; search, a plugin filter and configure. There are no test runs.
    • History
    • Settings: global switch, budgets, limits, automatic ALT with progress and "find missing now"
  • AI access lives where roles and users are edited:
    • an AI tab on the create/edit role form: monthly points, unlimited, and allow/deny plus a daily limit per AI permission. It is saved right after the role.
    • an AI access card on the AdminCP user page for one member's exception: blocked, unlimited or their own allowance.
  • /settings/ai for members: used, available and reserved points, reset date, daily limits, their own history, and notices (80%, exhausted, no allowance, site paused).

Automatic multilingual ALT (Core Files)

  • core_files_alt stores ALT text per language, with its origin (human or AI) and the file fingerprint. There is one base analysis per fingerprint.
  • altPolicy per file: automatic, manual or disabled.
  • Resolver order: the occurrence's own override, then the file's ALT, then the default language, then empty. The blog cover image now renders it.
  • Uploads enqueue work with durable dedupe. An hourly sweep runs in bounded batches, with a cursor and back-off.
  • A dedicated ai queue worker, lease recovery, and budget waits that don't use up retries.
  • Image bytes are read server-side: Local, S3 and Supabase implement read, so no file is ever made public. Human text is never overwritten.
  • Off by default, and it can't be enabled without a site budget.
  • Per-file ALT editing in the AdminCP files list (files:can_edit_alt).

Content Engine & editor

  • New field option ai: { action, sourceFields, mode: "suggestion" } on field.text and field.textarea.
    • The editor reviews a suggestion before accepting it, with stale-source and newer-edit detection.
    • The blog excerpt uses it. So does the example plugin's article, which needed only an action definition and the field metadata.
  • Rich-text translations are validated tag by tag (html-structure.ts).
  • Quick Ask in Tiptap:
    • shorten, correct, simplify, change tone, continue, or a custom request;
    • NDJSON streaming; cancelling charges no points; the upper-bound cost is shown first;
    • the answer is inserted as plain-text nodes in one transaction, so one Undo reverts it;
    • a selection that changed meanwhile is detected;
    • it writes in the language of the field being edited.
  • Translation freshness: per-field source and target fingerprints, recorded only after a save. The editor tells fresh, edited, outdated, untracked and missing fields apart.
  • Optional pre-publication AI review with structured output. It never claims to verify facts and never gates publishing.

Migrations: 20261003234938_ai_core, 20261004002941_ai_alt_queue, 20261004011620_ai_translation_sources, 20261006170949_ai_config_pricing (drops core_ai_pricing and joins the AI and notification migration heads).

Docs: apps/web/content/docs/dev/ai/ covers setup, actions, costs and limits, AdminCP and account usage, automatic ALT (including how the host must run the cron), fields and the editor, and future ideas. The built-in events page and the cron adapter example are corrected.

CI: the test job gets a PostgreSQL service, and VITNODE_TEST_POSTGRES_URL is passed through turbo, so the real-database tests run instead of being skipped.

Why?

Each AI feature was choosing its own model and spending without limits or history. Now plugins only declare what they need. Core decides who may run it, on which model and within which budget, and records what it cost.

Verification

Run locally with AI SDK mock models; no paid provider calls.

  • Core (packages/vitnode), after the rebase onto canary: the full suite with PostgreSQL passes, 8,880 tests in 554 files.
  • Real-PostgreSQL integration tests: concurrent reservations, idempotent settlement, idempotency races, policies, route permissions, history isolation, role and user AI access, maintenance, ALT end to end, and queue dedupe and recovery. Removing FOR UPDATE made the concurrency tests fail (mutation-checked).
  • Typecheck and lint: tsc is clean for core, blog and example; eslint is clean on the changed files.
  • Blog plugin tests: 22 passed.
  • Browser: Playwright on Chromium:
    • the AdminCP AI pages and /settings/ai render at 1280px and 390px without horizontal scroll;
    • the Actions table lists all 8 actions, and search filters it;
    • the role form's AI tab saves a value and shows it again on reopen;
    • the user page shows the AI access card.

Known limitations

  • Streams don't retry or fall back to another model.
  • Prices change only by editing vitnode.api.config.ts and deploying.
  • ALT base text is written in English first, then translated.
  • After a crash, one call may be repeated; exactly-once execution is not promised.
  • Some admin routes have no ai:* staff permission of their own: the assist routes rely on each action's permission and authorize(), and the translation-source routes on the content type's can_edit. This is documented in the code.
  • The future ideas (related content, cited answers, discussion summaries, moderation, bulk workflows, glossary) are documented as not implemented.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR

@github-actions github-actions Bot added the 💡 Feature A new feature label Oct 4, 2026
@github-actions

github-actions Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

React Doctor found 26 new issues in 20 files · 26 warnings · score 81 / 100 (Needs work) · 0 fixed · vs canary

26 warnings

src/api/ai/actions.ts

  • ⚠️ L10 Intl formatter rebuilt each call js-hoist-intl

src/api/lib/ai/admin-stats.ts

  • ⚠️ L163 Array lookup inside a loop js-set-map-lookups

src/api/lib/ai/alt-actions.ts

  • ⚠️ L27 Intl formatter rebuilt each call js-hoist-intl

src/api/lib/ai/alt.ts

  • ⚠️ L76 Array lookup inside a loop js-set-map-lookups
  • ⚠️ L236 await inside a loop async-await-in-loop
  • ⚠️ L399 Sequential independent awaits server-sequential-independent-await

src/api/lib/ai/available-actions.ts

  • ⚠️ L36 await inside a loop async-await-in-loop

src/api/lib/ai/capabilities.ts

  • ⚠️ L35 Array lookup inside a loop js-set-map-lookups

src/api/lib/ai/editor-actions.ts

  • ⚠️ L30 Intl formatter rebuilt each call js-hoist-intl

src/api/lib/ai/maintenance.ts

  • ⚠️ L58 await inside a loop async-await-in-loop
  • ⚠️ L165 await inside a loop async-await-in-loop

src/api/lib/ai/postgres-ledger.ts

  • ⚠️ L163 await inside a loop async-await-in-loop
  • ⚠️ L251 await inside a loop async-await-in-loop
  • ⚠️ L554 await inside a loop async-await-in-loop

src/api/lib/ai/registry.test.ts

  • ⚠️ L179 JSON parse/stringify deep clone no-json-parse-stringify-clone

src/api/lib/ai/runner.ts

  • ⚠️ L279 await inside a loop async-await-in-loop

src/api/lib/ai/usage-summary.ts

  • ⚠️ L88 await inside a loop async-await-in-loop
  • ⚠️ L129 await inside a loop async-await-in-loop

src/api/modules/admin/ai/routes/access.route.ts

  • ⚠️ L267 await inside a loop async-await-in-loop

src/api/modules/admin/ai/routes/translation-sources.route.ts

  • ⚠️ L149 await inside a loop async-await-in-loop

src/api/modules/admin/ai/routes/update-action.route.ts

  • ⚠️ L49 array.find() inside a loop js-index-maps

src/api/modules/queue/helpers/process-queue-tasks.ts

  • ⚠️ L94 await inside a loop async-await-in-loop

src/components/ai/ai-field-assist.tsx

  • ⚠️ L115 Loading flag reset outside finally no-loading-flag-reset-outside-finally

src/views/admin/views/content/form/translation-freshness.ts

  • ⚠️ L80 await inside a loop async-await-in-loop

src/views/admin/views/core/ai/access/user-override-card-content.tsx

  • ⚠️ L181 Loading flag reset outside finally no-loading-flag-reset-outside-finally

src/views/admin/views/core/ai/actions/actions-content.tsx

  • ⚠️ L160 Array lookup inside a loop js-set-map-lookups

Reviewed by React Doctor for commit 73b9c6e. See inline comments for fixes.

@aXenDeveloper
aXenDeveloper changed the base branch from refactor/edit_articles to canary October 6, 2026 15:51
@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Oct 6, 2026
claude and others added 11 commits October 6, 2026 17:54
Plugins declare AI actions with defineAiAction; Core resolves models by
declared capabilities, reserves global/system/user budgets atomically in
PostgreSQL, records every provider call with normalized usage and cost,
and settles idempotently. Blog translate/excerpt routes now run through
the shared runner with unchanged responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
Adds admin routes for overview, settings, models and pricing overrides,
gateway price sync, action configuration and test runs, role/user access,
and run history; user routes for own usage, history and suggestion
feedback; a generic run route; and an ai-maintenance cron that settles
expired leases, reconciles billed costs with audited adjustments and
prunes history. Users are charged only for the call that delivered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
Images in core_files get per-language default ALT (core_files_alt) with
human/AI origin, a file fingerprint, and an automatic/manual/disabled
policy. A system action describes each image once with a vision model and
translates the base description into missing site languages; writes are
conditional and never overwrite human text. Uploads enqueue work; an
hourly bounded sweep repairs misses and new languages. The queue gains
durable dedupe keys, lease recovery for abandoned tasks, budget-aware
deferral that does not burn attempts, and a dedicated AI queue worker.
File descriptors carry their ALT map and the blog renders it through the
shared resolver, after the article's own override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
…ick Ask API

AdminCP gets an AI section (overview, models and pricing, actions,
access and limits, history, settings) and members get /settings/ai with
their points, reservations, reset date, daily limits, notices and own
history. Content Engine text fields accept an `ai` option naming a
registered action and its source fields; the AdminCP form shows a
review-before-accept suggestion that flags stale sources and newer edits.
The blog excerpt and an example plugin field use it through one shared
assist route. Core registers editor rewrite and quick-ask actions with
NDJSON streaming and an upper-bound estimate endpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
A toolbar action streams shorten/correct/simplify/tone rewrites, continue
writing and custom requests through the shared runner, shows an upper
bound of the cost before running, flags results whose source text changed
meanwhile, and applies them as plain-text nodes in one transaction so a
single Undo reverts them. Stopping or closing cancels the provider call
and charges no points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
Translated fields record the fingerprint of the source they were made
from, saved only together with the article, so the editor shows exactly
which fields went out of date - no timestamp guessing. Changed fields can
be re-translated with AI, except ones a person edited since, and a person
can mark a translation as up to date. An optional structured AI review
suggests clarity and completeness improvements beside the deterministic
checks; it never verifies facts, blocks or publishes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
… docs

- ALT: emit files.alt.updated per written language and on removal; stop
  retrying configuration errors; back off failed/unreadable files in the
  sweep; validate the language of manual ALT text.
- S3 and Supabase storage adapters read objects with their own
  credentials, so private buckets work for ALT without public URLs.
- Streamed runs require models that declare the streaming capability.
- Quick Ask writes in the content language and sends feedback to the
  session it ran in; members with no allowance get their own notice.
- Docs: AdminCP & account usage, automatic ALT (incl. running cron),
  fields & editor, future ideas; corrected cron adapter example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
…point)

Adds the ALT progress card with a sweep-now button, a per-file ALT text
dialog in the files list, the no-allowance member notice, and updates the
implementation progress file. Work in progress; follow-up commit finalizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
Unify the real-PostgreSQL test helper on canary's describePostgres and
VITNODE_TEST_POSTGRES_URL, guard the nullable queue id now that dispatch
can deduplicate, and add notifications to the settings nav expectations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@aXenDeveloper
aXenDeveloper force-pushed the claude/vitnode-ai-implementation-06dgpo branch from b3f224d to a8c2a15 Compare October 6, 2026 16:08
…ons table

- Model prices live only in `vitnode.api.config.ts`: drop the AdminCP
  Models & pricing page, manual overrides and the AI Gateway price sync
  (`core_ai_pricing` dropped by `20261006164800_ai_config_pricing`).
  Malformed pricing stops the boot.
- AI access moves to an AI tab on the create/edit role form and an AI
  access card on the AdminCP user page; the standalone page is gone.
- AI actions require a title and description and may set a Lucide icon.
  The Actions page is a data table (icon, title, description, plugin,
  model, daily limit, status) with search and a plugin filter. Test runs
  are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAKdLyvtWcPvxpkF7orYsR
const elapsedDays = Math.max(1, Math.ceil(elapsedMs / 86_400_000));
const sourceCount = (names: string[]) =>
sources
.filter(row => row.source !== null && names.includes(row.source))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs


const languageName = (code: string) => {
try {
return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-hoist-intl (warning)

This is slow because new Intl.DisplayNames() rebuilds on every call inside a function, so move it to the top of the file, or wrap it in useMemo

Fix → Move new Intl.NumberFormat(...) to the top of the file or wrap it in useMemo. Building one is slow, so don't redo it on every call

Docs

const codes = rows.map(row => row.code);

return settings.altLanguages
? codes.filter(code => settings.altLanguages?.includes(code))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs

file.fingerprint,
);
if (missing.length === 0) continue;
if (await enqueueAltGeneration(c, file.id)) enqueued += 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/async-await-in-loop (warning)

This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.

Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.

Docs

}

const languages = await altLanguages(db, settings);
const existing = await db

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/server-sequential-independent-await (warning)

This awaited initializer does not read the previous result. If the operations are independent asynchronous work, they may overlap; await syntax alone does not establish a speedup.

Fix → Consider Promise.all([...]) only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics.

Docs

const exhausted = task.attempts >= task.maxAttempts;
// Conditional on the reservation we saw: a task its worker finished in
// the meantime is left alone.
const updated = await db

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/async-await-in-loop (warning)

This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.

Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.

Docs

description: t(`error.${aiErrorCodeOf(error)}`),
});
} finally {
if (controllerRef.current === abort) setPending(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-loading-flag-reset-outside-finally (warning)

This resets a loading/busy flag only on the success path: if the awaited call rejects the reset never runs and the flag stays stuck truthy (a spinner that never stops, a button disabled forever). Move the reset into a finally block, or mirror it on every catch, so it clears on rejection too.

Fix → A trailing setLoading(false) after an await never runs if the awaited call rejects, so the flag stays stuck truthy; reset it in a finally block (or mirror the reset on every catch) so it clears on both paths.

Docs

pendingRef.current.clear();
const byLocale = Map.groupBy(entries, entry => entry.locale);
for (const [locale, group] of byLocale) {
await fetcher({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/async-await-in-loop (warning)

This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.

Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.

Docs

onClick={async () => {
setIsDeleting(true);
const result = await onDelete(userId);
setIsDeleting(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-loading-flag-reset-outside-finally (warning)

This resets a loading/busy flag only on the success path: if the awaited call rejects the reset never runs and the flag stays stuck truthy (a spinner that never stops, a button disabled forever). Move the reset into a finally block, or mirror it on every catch, so it clears on rejection too.

Fix → A trailing setLoading(false) after an await never runs if the awaited call rejects, so the flag stays stuck truthy; reset it in a finally block (or mirror the reset on every catch) so it clears on both paths.

Docs

action =>
matchesSearch(action, search) &&
(selectedPlugins.length === 0 ||
selectedPlugins.includes(action.pluginId)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs

@github-actions github-actions Bot added 💡 Feature A new feature and removed 💡 Feature A new feature labels Oct 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💡 Feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants