Skip to content

feat(ai-tts): per-block AI narration for articles - #2781

Merged
Innei merged 38 commits into
masterfrom
feat/ai-tts
Aug 7, 2026
Merged

feat(ai-tts): per-block AI narration for articles#2781
Innei merged 38 commits into
masterfrom
feat/ai-tts

Conversation

@Innei

@Innei Innei commented Aug 5, 2026

Copy link
Copy Markdown
Member

Adds AI text-to-speech narration for articles, generated per Lexical block so that editing one paragraph regenerates only that paragraph.

Design: docs/superpowers/specs/2026-08-05-ai-tts-design.md (see its Post-implementation deviations section — it is authoritative where it disagrees with the original prose).
Plan: docs/superpowers/plans/2026-08-05-ai-tts-implementation.md.

How it works

An admin enqueues a task. The handler splits the article's Lexical content into speakable root blocks, synthesizes each changed chunk through an OpenAI-compatible /audio/speech endpoint, uploads each result to a content-addressed object key, and commits one ai_tts_blocks row per chunk. A parent ai_tts row per (refId, lang) holds the playback order and the voice config locked at generation time. Readers fetch segments from a public endpoint; article detail responses advertise availability through meta.tts.

Translated languages are narrated too, from ai_translations.content, and only when that translation's stored hash is still current — a stale translation is never voiced.

Three properties the pipeline is built around

  • Each chunk commits immediately after its upload. Before that commit an uploaded object has no row pointing at it, so a crash there would leave audio nobody can find or reuse — paid for and wasted. Committing per chunk means an aborted or failed run resumes and pays only for what is still missing.
  • block_order publishes only at finalize. A partially generated language is simply not published yet; the previously published order keeps serving.
  • Generate-and-upsert first, delete the displaced object afterwards. A crash between the two leaves a recoverable orphan; the reverse order leaves a row pointing at a deleted file, which is not recoverable.

Object keys are content-addressed

{prefix}/tts/{refId}/{lang}/{blockId}-{chunkIndex}-{fingerprint12}.mp3, where the fingerprint covers the chunk text and the model/voice/speed triple. Audio is served with a one-year cache header and sits behind a CDN, so a regenerated chunk must never reuse its old URL. Nothing is ever overwritten in place; re-uploading an identical chunk lands on the same key with the same bytes, which makes retries idempotent.

Audio is an ordinary tracked file

TTS audio rides the existing isolated-file system rather than carrying its own sweep: ai_tts_blocks.url is registered as a usage source, so an object with a live row reads as used and one without reads as isolated, handled by the existing admin flow. Article deletion still cleans up explicitly, since ref_id is polymorphic and cascades nothing.

Surface

  • POST /ai/tts/task, GET /ai/tts/ref/:id, GET /ai/tts, DELETE /ai/tts/:id — admin
  • GET /ai/tts/article/:id?lang=&password= — public, entitlement-aware
  • meta.tts on post and note detail responses, carrying available / blockCount / stale
  • @mx-space/api-client: getTts({ articleId, lang })
  • Admin: a per-article panel in the editor, and a management page at /ai/tts
  • Config: a ttsOptions section (openrouter | openai | custom)

Access control

The public endpoint runs two guards before any repository read: article visibility, then a premium/entitlement check. A paying member, the owner, and every reader on a site without membership enabled can all hear narration of a premium article; an unentitled reader gets null. meta.tts and the endpoint now share one entitlement predicate so they cannot drift.

Incidental security fix, outside the TTS blast radius: isGlobalArticleVisible tested document.password, but NoteRepository projects that column away in favour of hasPassword — so the note-password gate never fired on a loaded row. Public summary, insights and translation of a password-protected note were reachable. Fixed for all four AI features; this is a live tightening of three already-shipped features.

Verification

  • core: 285 files / 2285 tests
  • api-client: 188 · admin: 345
  • lint:migrations clean; tsc --noEmit clean in both apps; migration chain verified against a live fresh-database apply

One pre-existing failure is unrelated and left alone: aggregate.controller.e2e-spec.ts's on-this-day test mixes host-local date math with Postgres EXTRACT (UTC), so it fails when run between 00:00–08:00 on a UTC+8 host. Zero diff in the implicated files versus base.

Known limitations

  • Reuse is coupled to storage config. The object key folds in imageStorageOptions.prefix, so changing that prefix — or flipping S3 on/off — invalidates every stored key and makes the next incremental run a full paid regeneration. A templated prefix such as blog/{Y}/{m}/{d} would churn daily.
  • speed is a real column interpolated into the object fingerprint. Key equality drives reuse, so a float round-trip difference would silently force full regeneration. Common values round-trip fine; unusual ones are untested.
  • A password-holding note reader must re-send ?password= to the narration endpoint, or meta.tts.available: true still yields null. Correct for a stateless endpoint, but it is a client contract Shiro/Yohaku need to know about.
  • Automatic regeneration on edit is deliberately out of scope — meta.tts.stale reports the consequence instead.
  • The upstream file inventory walks local storage only, so S3-hosted objects are not enumerated by it. Pre-existing, not a TTS regression.

Pre-existing bugs found along the way, not fixed here

Each deserves its own change:

  1. Translation freshness is self-contradictory. The hash is written with the LLM-detected translated.sourceLang but re-derived as getMetaLang(document) || translation.sourceLang, so an article with meta.lang: 'zh-CN' and a detected 'zh' reports stale forever (ai-translation.service.ts:606-618, translation-consistency.service.ts:150).
  2. 0025_snapshot.json carries a concurrently flag drift on two unrelated indexes, so a plain drizzle-kit generate emits an unsafe bare DROP INDEX on drafts_ref_uniq. This branch's own chain was verified clean; the drift belongs to whoever regenerates next.
  3. apps/admin's lint script is broken in any clean checkout — it runs oxlint, which is not a dependency anywhere. Every lint run on this branch had to fetch it live.
  4. packages/api-client's mockResponse resolves with an { error: 1, … } marker instead of throwing on a URL mismatch, so every .resolves.not.toThrowError() test there is blind to a wrong path.
  5. apps/admin runs eslint with react: false — there is no rules-of-hooks or hooks-deps lint, which is how a stranded-loading-state hook defect reached the final review.

Follow-ups worth filing

  • EntitlementService.isPremiumLocked has no direct test; the membership-disabled branch is verified only by reading.
  • use-cover-generation.ts has the same in-flight state-reset gap that was fixed here for use-tts-generation.ts.
  • The implementation-plan doc still describes skipReference as live guidance; the option no longer exists.
  • The repo now hand-rolls SET NX Redis locks in six places; a shared helper would fix them all.

Innei added 30 commits August 5, 2026 23:45
uploadBuffer gains optional objectKey (used verbatim, no template/prefix) and
skipReference (no FileReferenceService row), plus storageBackend/storageKey
on the return value. audio joins the S3 routing whitelist alongside
image|file|video. Both additions are needed by AI TTS: the audio filename
must stay stable across regenerations without overwriting the CDN-cached
object in place, and the ai_tts_blocks row is its own reference so
cleanupOrphanFiles must not sweep it after 60 minutes.
…urse list separator into nested lists

- splitIntoChunks(text, maxChars<=0) previously looped unboundedly,
  eventually surfacing an opaque V8 RangeError after wasted work; now
  rejects immediately with a clear message.
- extractSpeakableText only inserted the sentence separator between
  list items at the top level; a list nested inside a listitem fell
  through the generic no-separator join, reproducing the concatenation
  collision this module exists to prevent one level down. The
  separator logic now lives in collectInlineText itself, so it applies
  at every recursion depth.
An AbortError from fetch was never an HttpStatusError, so isRetryable
treated it as retryable and burned the full backoff/retry budget before
surfacing TTS_GENERATION_FAILED. Check opts.signal.aborted in the retry
loop before continuing, matching the openrouter-images-api.ts convention.
Add AiTtsRepository wrapping ai_tts / ai_tts_blocks: findByRefAndLang,
findAllByRef, findBlocks, upsertParent/upsertBlock (ON CONFLICT
replace-in-place keyed on the unique indexes), deleteBlocksByIds,
deleteById/deleteByRefId (return removed blocks for object cleanup),
listPaginated with an optional search filter, and findMeta (block
count via jsonb_array_length, no block rows loaded).

Register the aiTts token in POSTGRES_REPOSITORY_TOKENS.
deleteById/deleteByRefId ran an un-transacted SELECT of block rows
followed by a separate parent DELETE relying on ON DELETE CASCADE. A
concurrent upsertBlock landing between the two would be cascaded away
without ever appearing in the returned rows, orphaning its stored
object. Both now run inside a transaction that takes a `for('update')`
lock on the parent row(s) first, so a concurrent block insert blocks
on the FK check until the transaction resolves, and delete the block
rows via `.returning()` directly instead of a separate select.

Removed listPaginated's `search` parameter (and the now-unused
buildSearchFilter/ilike/or/tryParseEntityId imports) per YAGNI — no
planned caller reaches it, and the design doc's search intent for this
endpoint is an article-title join at the service layer, not a
substring filter over the TTS row's own columns.

Test coverage: the parent-delete test now upserts a second, distinct
block before deleting so the removed-block assertion covers every row,
not just one; deleteByRefId's test now also re-queries findBlocks for
both deleted parents to confirm the block rows are gone from the
database, not just the parent rows; the listPaginated test drops the
search assertions and instead asserts page/size/total/hasNextPage/
hasPrevPage precisely across two pages.
- AiTtsService.handleArticleDeleted deletes ai_tts/ai_tts_blocks rows via
  the existing AiTtsRepository.deleteByRefId and best-effort deletes their
  storage objects; wired to POST_DELETE/NOTE_DELETE/PAGE_DELETE via
  @onevent, mirroring AiSummaryService's handleDeleteArticle.
- AiTtsService.reconcileOrphans lists objects under the tts/ prefix
  (S3, via a new S3Uploader.listObjects ListObjectsV2 call) or walks
  STATIC_FILE_DIR/audio/tts (local, via FileService.listObjectsUnderPrefix),
  diffs against every storage_key in ai_tts_blocks, and deletes anything
  unreferenced whose own storage mtime/LastModified is older than 60
  minutes. The diff/delete/count orchestration lives in the new pure
  tts-orphan-reconciliation.ts module, kept separate to hold
  ai-tts.service.ts under the 500-line cap.
- Wired to a new hourly cron (CronTaskType.CleanupTtsOrphans) through the
  existing CronTaskScheduler/CronBusinessService/TaskQueueProcessor
  pipeline. Note: cleanupOrphanFiles itself has had no cron since
  2b4e946 (5 months ago) - only a manual admin endpoint - so this adds
  a fresh cron task rather than piggybacking on a call site that no
  longer exists, following the same removed CleanupOrphanImages job's
  EVERY_HOUR cadence.
…of a parallel orphan sweep

Reworked after rebase onto 65736d7 (feat: reconcile isolated file
references), which generalizes orphan-file handling with file_usages,
FileReferenceUsageRepository, FileReferenceReconciliationService, and a
local-storage inventory that already walks the `audio` FileType.

- Register `ai_tts_blocks` (keyed on its `url` column) as a usage source in
  FileReferenceUsageRepository.findReferencedUrls and findUsageMatches,
  identically shaped to the existing ai_summary/ai_insight/ai_translation
  registrations. A TTS audio object with a live block row is now recognized
  as referenced, not isolated, by both the live cleanupOrphanFiles/
  batchDeleteOrphans path and the new reconcile() usage bookkeeping.
- Stop passing `skipReference: true` on TTS chunk uploads. A real
  file_reference row (created via FileService.writeTrackedOwnerFile, same
  as any other owner upload) plus the ai_tts_blocks usage row above is what
  makes the audio show as used instead of isolated in the admin's orphan
  review UI and in storage accounting. `uploadBuffer`'s `skipReference`
  option itself is unchanged - only the TTS caller stopped opting out.
- Deleted the parallel machinery this replaces: CronTaskType.CleanupTtsOrphans
  and its scheduler/business-service wiring, tts-orphan-reconciliation.ts,
  FileService.listObjectsUnderPrefix, S3Uploader.listObjects (and the
  signedRequest bucket-root signing tweak it needed), AiTtsRepository
  .findAllStorageKeys, and their tests.
- Kept AiTtsService.handleArticleDeleted / handleDeleteArticle: deleting
  rows and objects immediately on POST/NOTE/PAGE_DELETE is strictly better
  than waiting on a reconcile pass, and ai_tts's polymorphic ref_id still
  has no FK to cascade on.
- Added coverage in file-reference-reconciliation.pg.e2e.spec.ts proving a
  TTS audio object is classified referenced while its ai_tts_blocks row
  exists, and isolated once that row is deleted.

Known limitation, not a regression: the upstream local-storage inventory
does not enumerate S3-hosted objects; this is a pre-existing property of
the whole file-reference system, out of scope here.
…ai_tts_blocks row

The "becomes isolated" reconciliation test never checked that the reference
actually reached Active before the block row was deleted, so it passed
identically whether or not the ai_tts_blocks usage-source registration
existed - it only proved "unreferenced stays unreferenced". Added
`expect(referencedBefore?.status).toBe(FileReferenceStatus.Active)`
immediately after the first `reconcile({ apply: true })` call, before the
row is deleted.

Verified by temporarily reverting the ai_tts_blocks registration in
file-reference-usage.repository.ts: both TTS tests now fail, and the
"becomes isolated" test fails specifically on the new assertion
(expected "active", received "pending") rather than later in the test -
confirming it now discriminates. Restored the registration afterward
(git checkout, zero diff).
Adds the AI TTS HTTP surface: task creation, admin ref/list/delete
endpoints, and a public per-article narration endpoint gated by both
the shared article-visibility guard and a premium/paywall check
copied from AiInsightsService.

AiTtsService gains only deleteById (reuses the existing deleteObjects
cleanup helper). The other three owned methods -- getPublicNarration,
getDetailsByRefId, list -- live in a new AiTtsQueryService: adding them
to ai-tts.service.ts would have pushed it past the 500-line cap, and
they are a natural read-model seam distinct from the write/lifecycle
concerns already there.
- TtsMetaSchema on PostResponseMeta/NoteResponseMeta, .tts() on both meta builders
- AiTtsQueryService.getMetaForArticle: single existence-only lookup, stale
  derived from article.modifiedAt vs the stored sourceModifiedAt (which
  already carries the translation's own vintage for translated rows)
- Wired into post detail, note detail (shared assembly), and note /latest
  alongside their existing .insights() calls; failure-safe via .catch()
- Locked-paywall posts report tts.available:false, mirroring summary
  suppression
- Update every e2e/contract spec that DI-constructs PostController/
  NoteController with the new AiTtsQueryService dependency
…ange

- Delete-by-row belongs to Task 16's management page per the plan; remove
  the trash button, its confirm wiring, the delete mutation, and the four
  now-unused i18n keys from the editor drawer. deleteTts stays exported in
  api/ai.ts for Task 16.
- The write route keeps the article id in useSearchParams rather than a
  route param, so switching articles reuses the same hook instance. Reset
  open/activeLang/pendingTaskId/runStatus/runError whenever refId changes
  so an in-flight task's completion effect can no longer attribute its
  banner or cache invalidation to a different article.
GET /ai/tts previously returned pagination-only meta, leaving the admin
fleet view with no way to resolve a row's article title from its refId.
Mirror ai-summary's flat-list precedent: AiTtsQueryService.list() now
also resolves an ArticleRefMap via DatabaseService.getRefArticleMap, and
the controller builds meta with PostMetaBuilder().articles(...) alongside
pagination.
Fleet view over every generated narration, mirroring
AiTranslationEntriesRouteView's flat paginated table (useUrlListState +
CompactPagination + GroupedResourceStates) rather than the grouped
article view, since GET /ai/tts is a flat list, not article-grouped.

- Columns: article title (resolved from response meta via refId),
  language, block count, character count, updated at.
- Row actions: Regenerate (force: true, per-row lang) and Delete
  (confirmDialog-gated, consuming the previously-unused deleteTts).
- Header action: batch enqueue (no force) over checkbox-selected rows,
  using the shared useListSelection hook; selection resets on page
  change since classic pagination replaces the row set wholesale.
- api/http.ts: requestJson's meta-unwrap only ever surfaced
  meta.pagination, discarding sibling meta fields. Generalized it to
  spread the rest of meta (e.g. articles) alongside data/pagination -
  needed for any endpoint using withMeta() beyond just pagination.
- Nav entry is automatic via the file-based route scanner
  (vite-plugins/admin-routes) reading page.tsx's metadata export; no
  manual nav list exists to edit.
- Fixed an adjacent gap: AITaskType enum and taskTypeLabelKeys were
  missing Tts entirely, so TTS jobs rendered unlabeled (falling back to
  a raw refId) on the generic Tasks page.

New i18n keys added to both en-US.ts and zh-CN.ts; reused existing
generic ai.action.regenerate/delete, ai.confirm.deleteRecord, and
ai.toast.* keys where they already covered the message.
…um-blind

The meta surface and the public endpoint applied different guards, so three
populations were told narration existed and then handed null: active members,
the site owner, and every reader on a site that has not enabled membership.
Password-holding note readers had the same problem.

getPublicNarration now takes reader identity (@HasAdminAccess, @CurrentReaderId
and an optional ?password= for notes) and judges access through the same
machinery PostController.applyPaywall uses. EntitlementService gains
isEntitledToPremium / isPremiumLocked as the single membership rule, and
applyPaywall delegates to it so the two sides cannot drift again.

isGlobalArticleVisible is generalized to isArticleVisibleToViewer(article,
viewer); the anonymous call keeps its exact previous behaviour. Its note-password
branch was reading document.password, which NoteRepository projects away in
favour of hasPassword, so it never fired on a loaded row — it now accepts either
field, which also tightens summary, insights and translation.

Note detail suppresses meta.tts for an anonymous reader of a future-dated secret
note, whose text is blanked in the same response.

Caching needs no change: HttpCacheInterceptor returns before any Redis access
whenever the request carries identity, guards run before interceptors, and this
route sets no force cache option — so an entitled reader's narration is never
served from or written into the shared cache.
…egments deterministically

getMetaForArticle tested only row existence, but the generation pipeline creates
the parent with an empty block_order before any synthesis. A run that died
mid-flight therefore advertised available: true, blockCount: 0 forever. Both the
meta and the public endpoint now treat an empty block_order as not published.

findBlocks ordered by chunkIndex alone, which is 0 for every row in the common
single-chunk case, so Postgres returned segments in scan order. It now orders by
(blockId, chunkIndex), and toSegments sorts by the parent's blockOrder so the
array's natural reading is article order.
Innei added 5 commits August 6, 2026 03:59
…ingerprint

The stored object is addressed by text AND the voice triple, but planTts keyed
reuse on the text fingerprint alone. A force run that died after rewriting some
rows left the parent pointing at the old voice while some rows held the new one,
and the next incremental run reused every row because the text matched — a
permanently mixed-voice article the system could not detect.

PlanTtsInput now carries objectKeyFor(chunk). The service builds that closure
once from the resolved voice and passes the same one to planTts and synthesize,
so the key the planner compares against is provably the key the uploader writes.
A row whose storageKey is not what this run would produce regenerates.
…lerless skipReference

The design doc still prescribed the skipReference opt-out model, a voice-agnostic
object key, and an orphan-reconciliation cron — all reversed or deleted by human
rulings during implementation. Rather than rewrite the original prose, a
'Post-implementation deviations' section records what changed and why, so the
reasoning behind each reversal survives; it is authoritative where the two
disagree. It also corrects the premium paragraph to describe the entitlement-aware
endpoint, and records the caching verification.

With the doc no longer instructing readers to use it, uploadBuffer's skipReference
option is deleted — no production caller passed it after the Task 11 rework.
file-upload-audio.spec.ts is retargeted accordingly: the tests that defended
skipReference now assert the pending reference the orphan system depends on, and
the S3 path every TTS upload actually takes gains coverage, as does
deleteObject's S3 branch.
… action real

use-tts-generation cleared pendingTaskId only from the completion effect, which
early-returns without task data. A 404 or a network drop therefore left the panel
disabled and the running banner up until the user reloaded. An error effect now
surfaces the failure and clears the pending task.

The TTS management page's bulk action had four compounding defects: an
unreachable onError behind Promise.allSettled, dedup hits counted as successes,
no force flag (so on a page where every row already has narration, planTts reused
everything and the headline action enqueued nothing), and discarded failure
reasons. summarizeTaskBatch now reads the created flag, the batch forces, and
reasons reach the toast.
isArticleVisibleToViewer returned early from the password branch, so a note that
is both password-protected and future-dated became visible to an anonymous
reader supplying the correct password — while note detail blanks that note's
text for that same reader and reports meta.tts unavailable. The endpoint was
looser than the meta.

At base this was blocked only accidentally: the dead document.password check
never matched, so control fell through to isNoteSecret. Repairing the field name
activated the branch and exposed the early return. Password and secret are
independent gates; both must pass. The owner short-circuit above them is
unchanged.
… key comment

The batch action now passes force, so one click on a full page re-synthesizes 20
narrations — from a button labelled 'Enqueue selected', while the strictly
cheaper delete already confirmed. It is relabelled to regenerate wording in both
locales and routed through the existing confirmDialog with a cost hint.

buildTtsRegeneratePayload is extracted so the row action and the batch build the
same payload, making the force flag structurally impossible to drop from one
without the other, and testable on its own.

tts-object-key.ts's comment still asserted that reuse keys on the speech
fingerprint alone — the invariant the object-key reuse fix removed.
@safedep

safedep Bot commented Aug 5, 2026

Copy link
Copy Markdown

SafeDep Report Summary

Green Malicious Packages Badge Green Vulnerable Packages Badge Green Risky License Badge

No dependency changes detected. Nothing to scan.

View complete scan results →

This report is generated by SafeDep Github App

@gitguardian

gitguardian Bot commented Aug 5, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
35820980 Triggered Generic Password 3da0ba9 apps/core/test/src/modules/ai/ai-tts/ai-tts-query.service.spec.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b74d5a911a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

})
}

await this.repository.upsertBlock({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stage regenerated chunks before touching live rows

When regenerating a narration that already has a published parent and the article is edited while synthesis is running, this upsert mutates the live ai_tts_blocks rows before the later modifiedAt guard skips finalizing. Because the public query still loads all blocks for the existing parent, the old blockOrder can now serve overwritten or extra chunks from an unpublished partial run; keep regenerated chunks staged (or otherwise invisible) until the source-stability check passes.

Useful? React with 👍 / 👎.

Comment on lines +315 to +316
const current = await this.loadDocument(refId)
if (!sameInstant(current.modifiedAt, document.modifiedAt)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate translation source before publishing TTS

For translated narration, the synthesized content comes from the ai_translations row, but this final guard only reloads the article and compares the article modifiedAt. If the translation is edited or regenerated while TTS is running, the article timestamp can stay unchanged, so stale audio for the old translation is published as current; capture and re-check the translation row/hash before upserting the parent for translated languages.

Useful? React with 👍 / 👎.

…, inline regenerate

- TtsSegmentPlayer: timeline-style segments with vertical rail and node buttons; add optional onRegenerate prop rendered before PlayAll
- TtsPlaybackBody: simplify top to lang badge + inline count, drop stat box and chip row; pass regenerate mutation to player
- ArticleDetailPane: remove duplicate section title, keep only item count

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5eaf1aa873

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +435 to +439
if ((error as { code?: string })?.code !== AppErrorCode.FILE_EXISTS) {
throw error
}
return {
url: await this.fileService.resolveFileUrl('audio', objectKey),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist audio produced by forced regeneration

When force=true but the text and voice settings are unchanged, the content-derived object key is also unchanged; on local storage this FILE_EXISTS path discards the newly synthesized buffer and returns the old file. Consequently Regenerate incurs provider work and reports success while the audible result never changes. Give forced runs a fresh object key/version or explicitly replace the existing local object.

Useful? React with 👍 / 👎.

Comment on lines +131 to +135
const runTask = (force: boolean) => {
if (!refId || isRunning) return
setRunStatus('running')
setRunError(undefined)
createTaskMutation.mutate({ force, refId })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Regenerate the selected narration language

When the drawer contains multiple language rows and the user selects a translated narration, Regenerate still submits only { force, refId }. The backend interprets an omitted langs field as the article's source language, so the selected translation is left unchanged while an unrelated source-language narration is regenerated; include activeLang in langs for this action.

Useful? React with 👍 / 👎.

Comment on lines +253 to +256
async getTts({ articleId, lang }: { articleId: string; lang?: string }) {
return this.proxy.tts.article(articleId).get<AITtsModel | null>({
params: { lang },
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Forward note passwords through the API client

For a password-protected note, the server's public TTS endpoint requires the verified password in query.password, but this official client method exposes and forwards only lang. Consumers using the client therefore always receive null for narration even after unlocking the note; accept an optional password and include it in params.

Useful? React with 👍 / 👎.

Comment on lines 350 to 351
Readable.from(buffer),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid tracking directly owned local narration files

Local TTS uploads now go through writeTrackedOwnerFile, creating a pending file_references row for every chunk even though narration cleanup deletes storage objects directly. After a narration block is replaced or deleted, that database row becomes isolated, and the orphan cleanup cannot remove it because resolveLocalFileType does not recognize /objects/audio/; repeated article edits therefore accumulate stale reference rows. Either opt these explicit audio uploads out of reference tracking or remove their references alongside the objects.

Useful? React with 👍 / 👎.

Innei added 2 commits August 8, 2026 02:00
Add a shared useAiQuickActions(refId) hook that returns context menu items
for generating summary, insights, translation, and TTS tasks. Wire it into
both NoteRow and PostRow so the AI submenu appears in their context menus,
alongside the existing edit/publish/copy/delete actions.
@Innei
Innei merged commit c4c4648 into master Aug 7, 2026
7 of 8 checks passed
@Innei
Innei deleted the feat/ai-tts branch August 7, 2026 18:02

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d387a430d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +215 to +222
const s3Uploader = new S3Uploader({
endpoint: config.endpoint,
accessKey: config.secretId,
secretKey: config.secretKey,
bucket: config.bucket,
region: config.region || 'auto',
})
await s3Uploader.deleteObject(key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route S3 deletes through the endpoint strategy

When narration audio is stored on a virtual-hosted endpoint such as https://<bucket>.s3... or Tencent COS, uploads use S3Uploader.resolveEndpoint() and address the object as /<key>, but this call reaches S3Uploader.deleteObject(), which hard-codes /<bucket>/<key> against the same host. Regenerating or deleting a narration therefore fails to remove its S3 objects (the caller merely logs the failure), leaving every displaced chunk behind; make deletion use the same endpoint strategy as upload.

Useful? React with 👍 / 👎.

Comment on lines 661 to +664
const metaBuilder = new NoteMetaBuilder()
.view('detail')
.insights({ hasInLocale: hasInsightsInLocale })
.tts(ttsMeta)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Hide narration metadata on secret latest notes

For an anonymous GET /notes/latest request whose latest published note has a future publicAt, the controller blanks the note text at line 627 but still publishes ttsMeta here. The public narration endpoint rejects that same secret note, so clients are told narration is available and then receive null when they attempt playback; apply the same secret-note visibility guard used by buildPublicNoteResponse before adding this metadata.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant