The authorization panel had the illustration, the member list and the
three subject pickers had a bare grey line of text — so the same "there
is nothing here" read as two different states depending on which list
you were looking at, and the pickers' `py-4` left the message floating
near the top of a tall scroll area.
PermissionEmptyState holds the illustration + message and centers in
whatever height its parent gives it; all five lists consume it. Each
keeps its own copy — the pickers still distinguish no-results from
no-departments/no-groups.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The org / skill drill panels kept their pre-restyle back row — a
lucide chevron on slate greys, separated from the list by a hairline
that no other panel in this menu draws — and only the knowledge popup
had been switched to the list padding, so the "+" menu's drill panels
still doubled the gap under their last row.
The back row now uses Outlined.ArrowLeft on the menu's own greys with
a short divider before the title, drops the underline, and the list
padding rule keys off mobileTallPanel so every mobile list panel gets
it, not just the knowledge popup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AiMessageBubble carried its own extension → bisheng-icon table, a copy
of what FileIcon already knows. FileIcon exports getFileTypeIcon and the
bubble consumes it, so a new file type is registered once.
Also deletes FileListRow and the knowledge mock: neither has had a
call site for a while (the file list renders through FileTable /
FileCard). Suppressions shrink accordingly. The share dialog picks up
the icon import while it is here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "+" menu, knowledge pill, skill panel and the model / group selects
had each drifted: 8px vs 16px radius, per-menu padding, rows sized by
padding instead of height, and icon greys ranging over #999 / slate-600.
- Menus settle on rounded-2xl, 12px padding, 8px gap and 32px rows with
8px radius. Select grows a viewportClassName so its scroll viewport
can carry that padding instead of the outer panel (which would clip
the scrollbar).
- Resting icons all use #4E5969; active stays brand. The upload item
drops its baked-colour link.svg for Outlined.Attachment so it can
follow that tint.
- The "+" panel aligns to the input box's left edge rather than its own
trigger, which sits 13px inside it. The knowledge pill keeps trigger
alignment — mid-toolbar, where the input edge means nothing.
- Skill rows carry selection themselves (brand tint + trailing check)
now that the leading checkbox is gone; name and description each
clamp to one line and move the full text into a tooltip, but only
when measurement says they actually overflow. Empty search gets the
illustration instead of bare text.
- The knowledge panel takes its own 320px cap (skills/org sit at 440)
so it still opens downward more often, and the hand-rolled
useSubMenuLayout measuring goes away — align="center" plus Radix's
own collision handling already place these panels.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening a row's options menu or its rename input moves the pointer off
the row — into the portaled menu — so the hover fill dropped away and
the row you were acting on stopped looking involved.
Both sidebars now pin the hover fill for as long as the row owns that
UI. It stays the hover fill, never the active row's: this row is not
the open conversation. On coarse pointers it applies too, where it
reads as "this row owns the menu" rather than as a hover affordance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rename is entered from a Radix dropdown item, and Radix restores focus
to the menu trigger as it unmounts — after our synchronous focus() call.
The input therefore never held focus, never fired blur, and clicking
away left the field open with the edit unsaved.
Two halves: re-assert focus once that restore has run (in the existing
selection timeout), and commit from a capture-phase pointerdown listener
so an outside click saves even when blur never fires. Since an outside
click now reaches the commit twice — the listener, then the input's own
blur — a ref mirrors isRenaming so the second one is a no-op instead of
a duplicate onRename. Tests cover both paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The copy link was underlined on hover and swapped its own label to
"copied" for two seconds. Both read as emphasis the sentence does not
want, and a label that changes under the cursor is easy to miss on the
one screen a user is likely to be screenshotting rather than reading.
The link now never underlines and never changes text; ErrorPage fires a
new `onCopied` callback instead, and the client raises its usual toast
(existing `com_ui_copied_to_clipboard` key, all three languages). The
package still carries no i18n and no toast of its own. `labels.copied`
and the now-dead `com_error_page.copied` key are gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two hand-drawn knowledge glyphs (BookOpen / Books) predate the
matching bisheng-icons entries. Every call site — hub nav tab, the
attachment bar's KB card, the task-mode context chips — switches to
Outlined.Book / Outlined.Books, and the local SVG components are
deleted (ChatKnowledge only carried dead imports).
Sidebar section titles drop from bold to semibold, matching the weight
the rest of the sidebar chrome uses.
Suppressions: pruned the entries the deleted icons left behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The knowledge / skill submenus aligned their top edge with the parent
menu via a hand-computed alignOffset (measure the trigger, subtract
rects, re-measure on resize), and each panel repeated a heading that
its own trigger row already carried right next to it.
- DropdownMenuSubContent accepts align="center". Radix types SubContent
as 'start' | 'end' but hands the value straight to Popper, which does
support 'center', so the type is widened rather than the offset
hand-computed. The knowledge, org-knowledge and skill panels switch
to it and the alignOffset machinery goes away — useSubMenuLayout now
only clamps max height against the viewport.
- Panel headings drop where the trigger already names the list; the
mobile drill panels keep theirs, since there the heading IS the
back-navigation label. The knowledge pill's two variants collapse
into one branch with a mobile-only heading (the narrow toolbar can
render its trigger icon-only).
- Skill panels grow to 280×440 so the list isn't a letterbox.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rating an answer changed only the icon's highlight, which is easy to
miss and says nothing about what the rating is for. Every persisted
verdict — thumbs-up, or a dislike once its dialog is submitted — now
raises a "thanks, we'll use it to improve our answers" toast (new key,
all three languages).
Un-toggling stays silent: the icon losing its highlight is feedback
enough, and a toast there would read as if cancelling had itself been
recorded. onLike may now return the request promise, and the two call
sites do — so a failed request shows only the interceptor's error
toast, never both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- AccessModeSelector rows now behave like the options they are: hover
tint, brand-tinted background while checked, and the label kept on
one line so only the description truncates. Disabled rows opt out of
the hover tint so they still read as unavailable.
- That truncated description gets the same hover-only tooltip the
member list uses, so TruncatedTooltip moves out of PermissionListTab
into its own module. Its polymorphic ref keeps one `any` — now with
an inline reason — and the file's suppression entry shrinks.
- Both settings pages center their footer actions unconditionally; the
knowledge page had centered only in create mode, so the same footer
jumped between layouts depending on how you entered it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The member list, the create-page draft and the two "新增授权" dialogs had
each grown their own version of the same two things — so the level
control looked different depending on where you opened it, and the
draft picker was a look-alike of the share dialog rather than the same
dialog.
- PermissionLevelMenu extracts the list's dropdown (levels, separator,
destructive 移除) and the draft editor adopts it, dropping its
RelationSelect + separate delete icon button. Removal now always
lives inside the menu, never as a button beside it; a row with
neither permission renders the plain label at the same width, so the
column never jitters.
- permissionDialogStyles holds the share dialog's chrome verbatim —
shell sizing (80vh card / full-screen under 768px), subject tabs,
包含子部门 toggle, footer captions — and the draft picker consumes it
instead of re-approximating it.
- Empty authorization lists get their own copy (new key, three
languages) instead of borrowing the search-result empty text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Renaming a conversation looked like a raw text field dropped into the
sidebar — a white boxed input, two lucide glyphs, and a row that lost
its shape as the options slot emptied.
- The row itself becomes the active surface: transparent input, and a
1px #ECECEC hairline + soft drop shadow drawn as shadows (so the box
size doesn't shift), matching the chat input's chrome.
- Confirm is the only action now: cancel is Esc / blur, and an empty or
whitespace-only name silently restores the previous title, so the X
button had nothing left to do. Its glyph moves to bisheng-icons.
- Opening rename selects the whole title, so typing replaces it.
- The options slot unmounts while renaming instead of rendering empty —
an empty flex item still cost the row an 8px gap.
- Nav widens the scrollport by 1px on the left so the rename ring isn't
clipped against the rows' left edge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fastest support round trip for a crash is a screenshot, so the screen
is built for one: a bordered card carrying the identifiers, and a QR code
beside it holding the same ones plus the error's signature. An engineer
reads the incident straight out of the image instead of asking the user
to find and describe anything, and the full stack and environment are a
click away in the downloaded log.
Three identifiers do the work. The trace id names this occurrence and
ties the screenshot, the QR and the log file together. The error code
names the *fault* — derived from the message and the frame it was thrown
in, so two users hitting the same bug quote the same code and the reports
pile up instead of scattering; its alphabet drops I, L, O and U, the four
that come back wrong when a code is read down a phone line. The build
stamp comes from a define rather than /api/v1/env, whose version field is
hardcoded and names no build at all.
The payloads live in @bisheng/ui because they have to stay identical
wherever the screen is used — an engineer reading a QR should not have to
know which app produced it. Copy and artwork are passed in, so the
package still holds no i18n keys and no illustrations.
The QR is trimmed to stay scannable at the size it is printed: it points
and signs, it does not carry everything. Two things quietly ate the
budget and had to go — stack frames carried their full origin, and routes
carry a 32-character conversation id — and both were crowding out the
file and line, which is the one part worth scanning for.
Also fixes a fault in the screen it replaces: it read the signed-in user
through a hook that throws when its provider is missing, and the provider
IS missing here, because an errorElement renders in place of its route's
element rather than inside it. The crash screen crashed, and the user got
a white page and nothing to report. Everything it reads now survives the
provider being absent.
Two deliberate departures from the design: no backdrop blur on the
buttons (banned repo-wide for what it costs on 信创 machines), and brand
colours through theme tokens rather than the literal hex, so the screen
follows the blue/green switch. The diagnostics card also gains the route
and the signed-in user — a frontend crash is hard to place without them.
The admin app keeps its existing screen for now: consuming the shared one
there means adopting the design-token layer, which would restyle 103
blue-* usages across 29 files. That is worth doing, as its own change.
ETL4LM returns markdown tables whose rows can run past 10000 chars with no
\n / 。 / . inside them (one observed row: 7820 chars across 601 cells, the
result of a merged cell being expanded into every sub-cell). Such a fragment
survived ElemCharacterTextSplitter untouched — _split_text appends it verbatim
once new_separators is empty — and then tripped SplitterTransformer's
max_chunk_limit, failing the whole file with 10912. PaddleOCR and MinerU never
hit this because both rewrite tables to per-row markdown before splitting; ETL
is the only PDF loader without that step.
Add hard_split_limit to ElemCharacterTextSplitter, injected by
SplitterTransformer from its max chunk cap. _force_split kicks in only past
that limit, so documents that split fine today keep byte-identical chunk
boundaries and only files that would have been rejected change. This mirrors
what HierarchicalSplitterTransformer._split_long_text already does for the
hierarchical mode.
Also guard the separator_rule lookup against separators containing "".
Backport of 1ae4ca919. This branch predates KNOWLEDGE_MAX_CHUNK_CHARS, so the
cap stays the local 10000 literal and the test defines it inline; ruff
reformatted splitter.py while applying the change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The history endpoints return messages only, so useAiChat never learned an
existing conversation's name: `title` stayed empty and HeaderTitle fell back to
"New Chat" while the sidebar list showed the real name. Affects any conversation
opened by URL — a /c/<id> deep link as much as a share link (verified against a
session stored as "摩根大通" that rendered as "新对话" on both routes).
Fetch the name once via /api/v1/chat/info, carrying share-token so a share
recipient gets it too. Guarded so it cannot clobber a freshly generated title:
skipped while streaming (a brand-new conversation's row still reads "New Chat"
until gen_title lands) and never overwrites a title already held.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opening /workspace/share/<token> while signed out rendered an empty ChatView
(header stuck on "New Chat") on top of a burst of 401s. Not a regression: every
endpoint behind a share has been LoginUserDep-gated since it was introduced, so
the share_link check inside those handlers was unreachable without a session —
the middleware anonymous allowlist only ever covered /api/v1/share-link itself.
Share links are viewable by any logged-in user (owner or not); anonymous viewing
was never supported. So gate the route explicitly instead of failing blank.
client:
- RequireLogin guards share/:token/:vid? — the route hangs directly off
AuthLayout, so nothing used to stop it mounting content while unauthenticated.
Three states: startup spinner / handoff card + redirect / blocked. A
sessionStorage one-shot marker stops a redirect loop when the round trip comes
back still anonymous.
Authentication is read as `isAuthenticated || user`: isAuthenticated is state
written from an effect and trails the Recoil user by one render. Root.tsx
survives that frame by rendering null; a redirect cannot — it bounced
signed-in users to the login page.
- loginRedirect centralises "go to login and come back" for the guard and the
401 interceptor, which had drifted (the interceptor stored only
location.pathname, losing query and hash). Stores an absolute same-origin URL
so the platform app's return hop resolves against the right origin, is
idempotent across a burst of parallel 401s, and takes force for a
user-initiated retry.
- LoginHandoff is deliberately not an error screen: the link is fine, the viewer
just has not identified themselves and will be returned here. Hence no lock
illustration and no danger red; the one expressive element is a trace whose
dot runs the rule and comes back, encoding the round trip.
platform:
- consumeLoginReturnTo validates (same-origin + 10 min freshness) and consumes
the return target in one place. Also consumed on the authenticated landing in
App.tsx, which is the only path an SSO/gateway callback takes — the local
login form was the sole consumer before, so SSO users landed on the home page.
backend:
- bypass_tenant_filter_if widens share-token-authorized reads past tenant
scoping. message / session / linsight_session_version / linsight_execute_task
are all tenant-aware, so a recipient in another child tenant read nothing, the
endpoint skipped its authorization branch and answered 200 [] — a blank page
with no error. Only widens the read, only with a token; the owner /
share_link check still runs afterwards.
- header_share_token_parser swallows NotFoundError to honour its
Optional[ShareLink] contract. It started raising, which made ANY request
carrying a stale share-token header 404 — including the session owner's own.
- file_download now accepts a whole-conversation (workbench_chat) share too, not
just meta_data.versionId, so a task-mode share recipient can download the
files the task produced.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The empty-state rows sat inside the shared w-max wrapper, so "暂无数据"
centered on the widest folder name rather than on the visible sidebar —
off to the right, and drifting further as the tree scrolled sideways.
They now size to the scroll container's inline width (100cqi, the same
trick SectionHeader already uses) and pin with sticky left-0, so the
text stays centered on the sidebar while the tree scrolls under it.
-mx-3 cancels the list row's own padding so the centering is exact;
mobilePageMode rows already carry their own width and opt out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same tweak as cofco's 424fcac79 applied to the main line: the folder
name in the space header drops font-medium for font-normal, so it reads
as a location label rather than competing with the page's real actions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A customer on 信保安全浏览器 (Chromium 108, no GPU acceleration) had daily chat
stall on scroll and even on mouse movement. Forcing `backdrop-filter: none` from
the console fixed it outright, which pinned the cause: every such element gets
its own compositing layer and re-snapshots + re-blurs its backdrop each frame,
and that machine rasterizes it on the CPU.
The cost is per element, not per radius — a 4px blur on a 24px button costs the
same order as a full-screen one. What made it fatal was multiplication: the
per-message action buttons carry one each, so a long conversation ran dozens of
blur layers, each with a `hover:` transition that repaints them on mouse-over.
Removes all 40 occurrences (client 33, platform 7). Most were invisible anyway —
their backgrounds sit at 70-100% opacity, where the blur contributes nothing.
Two spellings were missed by a first pass that grepped only `backdrop-blur`:
the arbitrary-property form `[backdrop-filter:blur(8px)]` in the notification
dialog, and one inside an `@apply` in platform's applies.css.
The rule is recorded where each audience will meet it: the design spec gets the
reasoning plus the alternatives to reach for (translucent fill, noise texture,
pre-blurred asset for static backdrops), root AGENTS.md gets the hard constraint,
and the 总纲 hard-rules list gets a one-line pointer. Not duplicated into
packages/ui/AGENTS.md — the rule spans both apps and the library, so it belongs
at the root, and the library had no occurrences to begin with.
Both apps typecheck clean.
Typing pinyin and pressing Enter sent the raw pinyin as the message. The chat
input treated every non-shift Enter as "send" and called preventDefault on it,
so the keystroke that should have committed the IME candidate never reached the
box.
Enter now sends only when no composition is in flight, and the event is left
untouched while one is — so the first Enter commits the candidate and the second
sends, which is what every other IME-aware input in the app already does.
Composition is detected the same way useTextarea does it: a compositionstart /
compositionend ref plus `isComposing`, `key === 'Process'` and the legacy 229
keyCode, because no single signal is reliable across Safari and the various IMEs.
"新建任务" intermittently opened a daily chat, and clicking it again worked.
The button sets the task-mode atom and navigates to /c/new carrying
`state.taskMode`. But `newConversation` runs an async chain that fires its own
state-less `navigate('/c/new')` a tick later, and ChatView read that second
landing as "no nav state, therefore daily" — overwriting the mode the button had
just set. Whether that stray navigate happens depends on the current
conversation state, which is why it only showed up after clicking around.
On /c/new both sidebar entries already set the atom explicitly ("新建任务" true,
"新建对话" false), so the effect only needs to honour a navigation that actually
declares a mode; an absent state now leaves the toggle alone. The switch-
conversation paths — reset on entering an existing conversation, restore once
history proves it is a task conversation — are untouched.
Also types the nav state instead of `any`, which brings the file's
no-explicit-any count back under its suppression (149 -> 120 reported).
The input sat a few pixels right of the messages above it. `scrollbar-on-scroll`
reserves the scrollbar gutter with `scrollbar-gutter: stable`, which puts the
whole 8px on the end edge, so the scroller's content box is asymmetric and the
centered message column lands half a scrollbar to the left. The input is
centered in the full width, outside that scroller, so the two never matched.
Reserving the gutter on both edges makes the box symmetric again and the two
columns share a centre. Applied inline rather than as a utility class:
`.scrollbar-on-scroll` is plain CSS declared after `@tailwind utilities`, so at
equal specificity it wins on source order and the class form had no effect.
add_folder allowed creating an 11th layer (level 10): it checked
level > 10 while the move path and folder-upload pre-check both cap at
level 9 (UI layer 1 = level 0). Converge all three checks on a shared
module constant MAX_FOLDER_LEVEL = 9. Legacy over-deep folders stay
accessible and movable; they just can't gain children.
Client hardening: createFolderApi keeps status_code on thrown errors so
the create-folder flow can surface the depth-limit message (18011) when
the async breadcrumb pre-check races a navigation.
Tests: level-9 parent rejects a child with SpaceFolderDepthError
(18011); level-8 parent still creates a level-9 child.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The platform artifact carried "文件解析失败,并发过大,请稍后重试" while the
source in packages/locales said "文件解析失败,请稍后重试". Generated locale files
are checked against their source, so the drift failed `pnpm locales:check`.
Kept the wording and moved it to where it belongs — the source — then rebuilt,
so all three copies agree. en/ja keep their existing generic phrasing.
Raising the "link unrecognized" confirm over the add-source panel appeared to
let clicks pass through the mask to the panel behind it. Instrumenting the shared
confirm showed the opposite: the mask covers the whole viewport, sits on top, and
does receive the click. The panel was collapsing itself.
Its outside-click guard asks `target.closest('[role=dialog], [role=alertdialog]')`
to tell "clicked another dialog" from "clicked outside". A modal's mask is a
SIBLING of its dialog node inside the same portal layer, never a descendant, so
that lookup returned null and the mask read as an outside click — collapsing the
panel (and applying the selection) underneath the confirm.
Fall back to looking for a dialog in the target's own portal layer when `closest`
finds none. The sheet's own mask still counts as outside, so dismissing the drawer
by its backdrop keeps working.
Sending several images occasionally left one rendering as "图片已失效 无法查看".
It was not an expired link: on 105 the offending attachment was the only one of
six persisted without a storage path, so the backend had no object name to sign
and the URL endpoint 404'd. The model never saw that file either — the answer
silently covered five of the six images.
The client already held everything needed to catch this: its own record for that
file carried no filepath. Check it at sendMessage, the single choke point both
daily chat and task mode go through, and abort the send naming the file rather
than dropping it quietly — a partial send is what made the failure invisible.
Files still uploading have no path either, so the same guard covers them.
Why the upload result went missing in the first place is still open; Redis TTL
and eviction are ruled out (24h TTL, noeviction, zero evictions, 7 weeks uptime),
so the metadata was likely never written. To be confirmed on the next repro.
The busy notice offered a Retry button, but the turn is already lost by then —
the attachment never made it into the prompt, so retrying just re-runs against
the same throttled OCR service. Route `file_parse_busy` to the red failure card
like every other terminal outcome; it keeps its own "service is busy" wording
and the "view details" disclosure, and the user re-sends from the input box.
Model-side throttling (12046) is untouched and still gets the calm notice.
Sending images in daily mode could fail with an opaque `status_code: 500`
"Server error". The real cause — the OCR service returning 429 "请求频率过高"
— rode along in `data.exception`, but the frontend resolved `api_errors.500`
to "服务器错误" and dropped it, so the user had nothing to act on.
Attachment parsing now raises ChatFileParseError (12047) instead of falling
through to the generic handler, carrying a stable `error_type` and the raw
upstream text plus the offending filename. Every daily-mode failure now ships
the same classification, not just parse errors. The classifier also learns the
PaddleOCR/Baidu throttle wording, which previously matched no rate-limit
signature at all.
On the client, task mode's failure card moves out of the Linsight tree into a
shared ChatErrorCard and is reused by the chat bubble, so both surfaces render
the same title/explanation/hint plus a "view details" disclosure. Transient
failures (a throttled OCR service) get the calm busy notice with Retry;
terminal ones get the red card. When an envelope carries no classification —
an older backend, or a domain error that doesn't classify itself — the status
code still decides busy-vs-failed, so nothing regresses.
Note this improves the message only: the parse path still fires one OCR call
per attachment with no concurrency cap or retry, and one failure still aborts
the whole turn.
The stream, the DB and the grouped event array all carry a truthful
interleaving — the backend closes the open thinking segment before every tool
call precisely so each ReAct round can stand on its own. The final render then
threw that away: two filter() calls merged every thinking fragment into one
block at the top and dropped all tool rows below it, so reasoning that happened
after a search read as if it happened before.
Replace the filters with an in-order walk that folds only ADJACENT thinking
fragments (task mode's DeepStepGroup algorithm), leaving each tool call where
it occurred; the rail connector now follows "is there a next row" regardless of
kind. Adjacent rounds join with a paragraph break — they are complete passages,
not per-frame chunks. A just-opened live segment with no text yet is skipped so
the connector never points at a row that renders nothing. In-place completion
updates and collapse/reopen were already order-safe; backend untouched.
The 7% brand-alpha tint let the white below bleed through, reading a
touch dimmer than the design's selection fill. blue-50 is the same hue
as an opaque swatch — still a brand token, so it keeps following the
blue⇄green theme. Same change as cofco's fbcd7adc8, applied to the
main line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The attachment row orders chips by when each one first appeared, newest
leftmost. Knowledge selections are stored newest-first (the picker prepends)
while the file arrays are oldest-first, so when a whole set first appeared in
one frame — restoring a conversation from localStorage — the newest space was
handed the lowest sequence and the row came back reversed. Picking spaces one
at a time had always been right, which is why the two disagreed.
Feeding the knowledge array in oldest-first makes the sequence mean the same
thing for every source. File and skill chips are untouched.
One-off cleanup for rows the previous commit's bug left behind: media
knowledgefile rows marked SUCCESS whose stored transcript is empty or
the raw ASR response envelope.
The script scans media-extension SUCCESS rows, downloads each file's
MinIO transcript preview (preview/{id}.md) to judge the ingested text,
and converges hits to the same terminal state as every other
unrecognizable-audio case: Milvus/ES vectors deleted (MinIO source file
kept for re-parse/download), status=FAILED, remark=10956 error json —
the client then shows the 失败 badge with reason and stops the row
being clickable. Dry-run by default, --apply to write, scopable via
--knowledge-id / --file-id; idempotent since converged rows drop out of
the SUCCESS selection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>