Task-mode results claimed a deliverable (e.g. a generated PDF) that never
appeared in the workspace panel. Root cause: the model wrote the file to an
ABSOLUTE path (/output/report.pdf, /scratch/*.png) which resolves to the
container filesystem root -- outside the per-task working dir the LocalExecutor
harvests. The file was never uploaded (file_list=[]) nor synced, so
get_final_result_file found no deliverable and the panel fell back to a
synthesized fallback report. The shared LocalExecutor cannot safely rescue
container-root files (cross-task leak), so the fix steers the model to relative
paths:
- Harden the code-interpreter tool description (local + e2b) to require the
RELATIVE output/ (scratch/) dir and forbid absolute /output|/scratch, noting
files outside the working dir are discarded.
- Add a deterministic, non-blocking corrective notice: when a run's code wrote
to an absolute /output|/scratch path, append a system notice to the tool
result so the model self-corrects on the next step
(BaseExecutor.absolute_path_advisory + wiring in LocalExecutor.run).
- Tests: test/linsight/test_code_interpreter_output_path.py (18 cases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the None≡[] skill gate: with skills now strictly opt-in, the
daily task-mode path (ChatView → useAiChat → unified /chat/completions →
_to_linsight_submit) exposed a latent gap — it never carried the picked
skills, so the stored SV had skills=None and NOTHING was materialized. The
picker looked broken ("workspace has no such skill file"). Previously this
was masked because None loaded every enabled skill.
Thread the selection end-to-end:
- APIChatCompletion gains `skills` (Track H); _to_linsight_submit maps it
onto the linsight submit schema (None/[] = no skills, opt-in list = those).
- useAiChat sends the picked skill names (taskModeSkillsState('new')) on
task-mode turns only; the daily chain ignores the field.
- tests: _to_linsight_submit forwards a selection / stays None when absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live case (session b28d0dc6, model deepseek-v4-flash, input "skill test"):
the task-mode ask_user clarify card rendered a raw-JSON blob as its single
question title with zero options. Root cause is model-side — deepseek-v4-flash
did NOT escape the inner quotes in a question text (你想要的"skill test"是指什么?),
so its OpenAI-compatible function-call serializer corrupted the whole `questions`
array. The arg parser then produced a WELL-FORMED outer list/dict but crammed the
entire 3-question array into the FIRST dict's `question` VALUE (dropping the
opening `[{"question"` while keeping the `: "` separator). The trigger is
intermittent: it only fires when a question's text itself contains quotes (here
echoed from the literal user input) — which is why prior DeepSeek clarifications
without quotes rendered fine.
The existing recovery (854d45974) only re-parsed malformed STRING list-elements,
so this dict-value shape fell through unchanged. Extend the recovery to:
- re-expand a dict whose `question` value is itself a serialized questions array
(gated by a quoted-JSON-key signature so ordinary prose is never mangled);
- reconstruct a blob that RETAINED the `: "` separator via `[{"question"` so the
first question comes back clean (no leading `: "` noise);
- run the same crammed-array recovery on a malformed TOP-LEVEL string (still
degrades to [] for arbitrary prose — reason-only park).
Frontend needs no change (one clarify tool_call per recovered question already
renders the multi-page card). 22 unit tests green, incl. the exact live blob as
a fixture and regression guards for prose/placeholder questions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task mode was running skills the user never picked. Root cause: the submit
persisted `skills=NULL` (the field was absent — a stale/cached client or a
non-UI caller), and `materialize_session_skills` treated `None` as
"copy every governance-enabled skill" — the exact opposite of an empty
selection. Every enabled skill was then materialized into the workspace
`/skills/` subtree, advertised to the model, and executed, silently
defeating the picker.
- skill_provisioning: `None ≡ [] ≡ "no skills this run"` (guard on `not
selected`); skills are strictly opt-in via an explicit non-empty list,
still intersected with the tenant's governance-enabled set.
- useSubmitMessage (client): the daily-chat task-mode entry now sends an
explicit `skills: []` instead of omitting the field, so the contract no
longer depends on the downstream `|| []` guard.
- test + docstrings updated; `test_none_selection_copies_nothing` is a
regression guard against the footgun.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Howl instance lived in a Recoil atom read through hook closures; with
the slow TTS-synthesis fetch in between, a conversation-switch race left a
stale reference: the old audio kept playing with no pause control, and two
sounds could play at once (the older one orphaned and unstoppable).
- Hold the Howl instance in a module-level singleton so pause/resume/stop
always operate on the real current sound.
- Starting a new message's playback stops the current audio immediately,
before the synthesis fetch, not after.
- Version every playback request with a token; a fetch resolving after a
newer request started is discarded, and superseded Howl callbacks cannot
clobber the new playback's state.
Workflow/assistant conversations keep messages in the chatsState cache and
do not refetch on conversation switch, so a like/dislike that only hit the
API lost its highlight when switching away and back. Write the new verdict
back to the cached message on click. Also correct ChatMessageType.liked
from boolean to number (0/1/2, mirrors chatmessage.liked).
sync_information_article iterated channel_info_source (a tenant-aware table)
with no tenant context set, so on a multi-tenant deploy the first SELECT raised
NoTenantContextError and the daily article sync never ran — subscribed sources'
content never updated.
Mirror reconcile_all_tenants: enumerate active tenants (root + active children,
or the default tenant when multi-tenancy is off) and run the sync under each
tenant's context, isolating per-tenant failures. Add a sync counterpart
TenantDao.get_children_ids_active for the sync worker path, and fix a latent
missing `import asyncio` in the knowledge-space dispatch hook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to 1208fa158: instead of only relaxing the pattern to allow the
empty string, remove it entirely to align with feat/2.6.0 (72fd1e8f0
"fix: unused pattern"). The whitelist could not stop XSS (it allowed <>/"'&)
yet rejected the empty default plus emoji / non-CJK text; real escaping is
done by the frontend (React text nodes, no dangerouslySetInnerHTML). Keeping
all three branches identical avoids a future merge conflict on these lines.
Update regression tests accordingly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
applicationCenterWelcomeMessage / applicationCenterDescription were declared
with default="" but a pattern requiring >=1 char (^[...]+$). Any tenant whose
stored workstation config left these fields empty or unset (the default) made
WorkstationConfig(**raw) raise a pydantic ValidationError, so GET
/api/v1/workstation/config returned 500 and the client rendered its full-screen
"system maintenance" overlay — /workspace/c/new appeared broken.
Relax both patterns from + to * so the empty string is accepted while keeping
the character whitelist for non-empty input. Add regression tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The task result panel reads its liked verdict from the linsight store, which is
seeded once on hydration. An optimistic like updated only the button's local
state + backend, not the store — so switching away and back re-mounted the panel
with the stale store value and the highlight vanished (a full page reload fixed
it, since that re-hydrates the store from the server).
Sync the verdict back to the store on click via a new onLikedChange callback
(TaskTurnPanel + ExecutionFlow pass updateLinsight), so the store stays current
and the highlight survives switch-away/switch-back.
In task mode the result panel's like/dislike showed as soon as the run hit
'completed', but the task turn still carried its streaming placeholder id
(userMessageId + '_') — the real persisted category="task" ChatMessage id only
arrived on a page reload. A like clicked the moment the panel appeared wrote to
a non-existent row and silently vanished (occasional, timing-dependent).
Source the feedback id from the linsight store instead of the conversation
message, and populate the store's real message_id at completion by reusing the
existing session-version-list enrichment (no new endpoint/field):
- Websocket final_result: fetch the version list, copy this version's real
message_id + liked into the store
- TaskTurnPanel: ResultPanel reads linsight.message_id (history hydration
already seeds it via the same endpoint); add allowFeedback prop for the
share-view opt-out
- AiMessageBubble: pass allowFeedback instead of encoding it via messageId
The button now appears only once the real id is in the store, so show-time and
id-availability are aligned and the placeholder can never reach /liked.
update_workbench_llm persisted the incoming WorkbenchModelConfig wholesale, so a
body without `models` (Pydantic defaults it to None) nulled the entire configured
dialogue-model list — the way a stale admin page wiped the Root config in prod.
Treat a missing/None `models` as "no change" and keep the stored list; an explicit
`models: []` still clears. asr/tts/chat_title keep their None-clears semantics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
update_workbench_llm persisted the incoming WorkbenchModelConfig wholesale, so a
body without `models` (Pydantic defaults it to None) nulled the entire configured
dialogue-model list — the way a stale admin page wiped the Root config in prod.
Treat a missing/None `models` as "no change" and keep the stored list; an explicit
`models: []` still clears. asr/tts/chat_title keep their None-clears semantics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A blank dialogue-model row ({id:''}) saved from the workbench model config
flows into the workspace model pickers as <SelectItem value="">, which Radix
forbids — it throws and crashes the whole /workspace/c/new page.
- client: drop empty/invalid-id options in AiModelSelect and the Linsight
ModelSelector so a stale/blank model never renders an empty-string
SelectItem value (defense that doesn't rely on backend sanitizing).
- platform: reject saving a blank model row in WorkbenchModel — validation
only checked models.length, letting {id:''} through and overwriting the
configured dialogue models with an empty row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A blank dialogue-model row ({id:''}) saved from the workbench model config
flows into the workspace model pickers as <SelectItem value="">, which Radix
forbids — it throws and crashes the whole /workspace/c/new page.
- client: drop empty/invalid-id options in AiModelSelect and the Linsight
ModelSelector so a stale/blank model never renders an empty-string
SelectItem value (defense that doesn't rely on backend sanitizing).
- platform: reject saving a blank model row in WorkbenchModel — validation
only checked models.length, letting {id:''} through and overwriting the
configured dialogue models with an empty row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Channel article chat yielded the 'end' event BEFORE persisting the answer
ChatMessage, so the client never got the real id — the streamed answer kept its
temporary placeholder id and a like clicked before switching away wrote to a
non-existent row and vanished (same class of bug as the knowledge-space fix).
Persist the answer first, then emit the end event with its message_id so the
client can swap the placeholder out immediately (frontend useChannelChat already
consumes it).
A TTS provider failure (e.g. empty audio) raised a bare exception that
propagated as HTTP 500, which the client's global interceptor treats as a
service outage and redirects to the maintenance overlay — a poor experience
for what is just 'this one utterance failed to synthesize'.
- backend: wrap the synthesis call, raise a dedicated TtsSynthesisFailedError
(code 10026) instead of letting it bubble up as 500
- frontend: textToSpeech opts into the interceptor's translate-and-toast path
(skip403Redirect) instead of the 500 escape hatch; TextToSpeechButton no
longer masks the real error or double-toasts once the interceptor already
showed the localized message
- add api_errors.10026 to zh-Hans/en/ja
Knowledge-space chat (single-file / folder) rendered the streaming answer under
a temporary client-side placeholder id (userMessageId + '_') and never swapped
it for the real persisted ChatMessage id, so a like clicked before the first
page reload wrote to a row that never existed — it silently vanished. Reload
loaded the real id from history and worked fine, masking the bug as 'only
fails right after sending, fixed by refresh'.
- backend: the 'end' stream event now carries the persisted answer message_id
- frontend: useStreamChatSSE forwards it through onFinal; useFolderChat /
useFileChat / useChannelChat swap their placeholder messageId for it so
like/dislike (and any other per-message action) targets the right row
immediately, not just after a reload
Add a shared CommentDialog (optional-comment / reason dialog) + gallery FeedbackSection; migrate the message 点赞/点踩 feedback (dropping the bespoke MessageFeedbackForm) and the menu-unavailable apply-reason dialog onto it; feedback comment is now optional (com_feedback_placeholder, en/zh/ja).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The linsight task result is already persisted as a category="task" ChatMessage
in the unified daily conversation, so rate it through the shared chatmessage
feedback (/liked + /chat/comment) instead of a linsight_session_version-specific
column/endpoint. All four AI Q&A surfaces now share one storage + rollup path.
Backend:
- chat_helpers: agent history formatter returns liked + remark (fixes daily +
in-conversation task-turn highlight on reload)
- workstation_schema: WorkstationMessage carries liked + remark (channel surface)
- linsight utils: get_task_feedback_by_version maps session_version -> its task
ChatMessage {message_id, liked}
- linsight endpoint: session-version-list enriches each version with message_id +
liked for the standalone linsight page
Frontend:
- ResultPanel: rate by messageId via likeChatApi / disLikeCommentApi
- TaskTurnPanel forwards messageId + liked; AiMessageBubble passes the task row's
message.messageId (hidden on the read-only share view); ExecutionFlow reads them
from the enriched session-version store field
- drop the now-unused likeLinsightVersion / commentLinsightVersion
The PRD doc is no longer needed; the 点赞/点踩 feature itself (98b48c93a) is
unaffected — only its now-redundant design doc is removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A model rate limit is the vendor's availability blip, not a BiSheng fault, so it
no longer renders as the red danger card. Transient/retryable errors (rate_limit /
network_timeout / service_unavailable) now show a calm neutral ServiceBusyNotice
(grey, role=status) with a one-tap Retry; terminal errors (quota exhausted / auth /
content filter / unknown) keep the red failure card -- red means act, grey means
just a hiccup.
- ServiceBusyNotice: shared neutral notice (optional title, detail disclosure,
Retry button), styled after the existing task-terminated banner.
- Task mode: TaskErrorCard branches transient->notice and regains an onRetry prop,
wired only on the /linsight ExecutionFlow via continueConversation (same SV).
/c inline tasks get the calm visual but no button (re-send via the main input).
- Daily mode: thread the SSE error status_code through onError onto the message
(ChatMessage.errorCode); AiMessageBubble renders the notice + Retry (reusing the
already-wired regenerate) for transient codes {12046,429,503,10540,12045} and
suppresses the copy/feedback toolbar on it.
- i18n: add com_error_retry (重试 / Retry / 再試行).
Pure frontend; backend classification from the prior commit already routes the
rate limit here. vite build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MaaS gateways (Aliyun Bailian / DashScope and other OpenAI-compatible
endpoints) reuse OpenAI's `insufficient_quota` code + "exceeded your
current quota" message for TPM/TPS throttling (429-Throttling.AllocationQuota),
which recovers on its own. The classifier treated it as billing exhaustion
(FAIL_FAST / quota_exhausted), so a transient rate limit surfaced as a scary
"quota used up / top up" failure instead of a friendly "service busy, retry".
Narrow _QUOTA_SIGNATURES to unambiguous money wording only (arrearage /
insufficient balance / 余额不足 / 欠费) and move the generic quota/额度/配额
family -- including insufficient_quota -- into _RATE_LIMIT_SIGNATURES. Quota is
still checked first, so a genuine arrears signal keeps winning over a co-occurring
throttle word. Net: insufficient_quota + 429 now auto-retries (middleware) and, if
exhausted, renders the friendly rate_limit copy in both task and daily chat mode;
genuine balance exhaustion still fails fast with the top-up guidance.
Update the classifier / resilience / workstation unit tests and add R4-R6 to the
mock-model E2E runner (retry-then-recover, exhausted->rate_limit, billing->fail-fast).
53 unit tests + 9 E2E checks green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>