Compare commits

...

37 Commits

Author SHA1 Message Date
Arvin Xu 65cbfc8491 🐛 fix(desktop): swallow transient net errors in main process
Electron's net stack (SimpleURLLoaderWrapper, used internally by
electron-updater's net.request) emits transient connectivity errors
(ERR_NETWORK_CHANGED, ERR_NETWORK_IO_SUSPENDED) on Wi-Fi/VPN switch and
system sleep. With no global guard they bubble up as an uncaughtException
and pop the "A JavaScript error occurred in the main process" dialog.

Add a process-level handler that swallows known transient net-stack
errors and re-throws everything else, so genuine crashes stay visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:57:04 +08:00
Arvin Xu 2487407192 feat(cli): add lh update command to self-upgrade the CLI (#16052)
*  feat(cli): add `lh update` command to self-upgrade the CLI

Add a `lh update` command that checks the npm registry for the latest
published version of `@lobehub/cli`, compares it against the installed
version, and runs the matching global-install command. The package
manager is auto-detected (npm/pnpm/yarn/bun) from the running binary
path, with `--package-manager` to override. Supports `--check` for a
dry run and `--tag` to target a non-latest dist-tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): resolve update command package metadata from the bundled entry

`update.ts` loaded `require('../../package.json')`, a path relative to its
source location. tsdown bundles everything into `dist/index.js`, where that
runtime require resolves to `node_modules/@lobehub/package.json` instead of
`@lobehub/cli/package.json` — so a published build threw at startup the moment
the update module was registered, breaking every `lh` invocation.

Reuse the single package-metadata load already done by the bundled entry
(`program.ts`, at the depth where `../package.json` is correct both in source
and in dist). Export the package name alongside the version and consume both
in the update command. Verified against a real `tsdown` build: `lh --version`
starts cleanly and `lh update --check` resolves `@lobehub/cli` correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(cli): extract package metadata into a shared src/pkg.ts const

Importing `cliVersion`/`cliPackageName` from `program.ts` into `update.ts`
created a `program ↔ update` import cycle (program registers the update
command). Move the single `require('../package.json')` load into a dedicated
`src/pkg.ts` const module that both `program.ts` and `update.ts` import,
breaking the cycle. The module sits directly under `src/` — the same depth as
the bundled `dist/index.js` — so `../package.json` resolves to `@lobehub/cli`'s
own package.json in both source and bundled runs.

Verified against a real tsdown build: one `createRequire("../package.json")`
remains, `lh --version` starts cleanly, `lh update --check` resolves the
package, and `man:generate` still reads the version via the re-export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): use the semver package for update version comparison

The hand-rolled comparator ordered prerelease identifiers lexicographically,
so `1.0.0-beta.10` sorted below `1.0.0-beta.9` and `lh update --tag beta`
reported "already current" instead of offering the newer prerelease.

Replace `compareSemver` with `isNewerVersion`, delegating to the `semver`
package (already a workspace dependency) so numeric prerelease parts compare
numerically. Add `semver` / `@types/semver` to the CLI package and a
regression test for the beta.9 → beta.10 case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:22:38 +08:00
AmAzing- 6de1e14a4d feat(subscription): add referral invite entry (#16073)
*  feat(subscription): add referral invite entry

* 💬 chore(subscription): refine referral code copy
2026-06-20 10:52:52 +08:00
Arvin Xu db2a62d704 🐛 fix(chat): treat parked runs as non-terminal in client run-lifecycle (#16072)
A parked run (`waiting_for_human` / `waiting_for_async_tool`) is not finished —
it is paused waiting out-of-band and resumes under a new operation. The client
lifecycle wrongly drove parked states through the terminal path: it emitted a
`client.runtime.complete` signal (mis-encoding `waiting_for_human` as a terminal
`cancelled`) and let `waiting_for_async_tool` fall through with an undefined
complete status.

Route parked states to `onRunParked` (no longer NOOP) instead of `completeRun`:
- `waiting_for_human`: complete the op so the approval spinner clears; a new op
  resumes the run.
- `waiting_for_async_tool`: keep the op running until the async result drives it.
- neither fires terminal side effects (title / queue drain / notification /
  markUnread) nor emits `client.runtime.complete`.

Drops the `waiting_for_human → cancelled` mis-encoding from the normalizer and
the parked branch from `completeRun` (parked never reaches it). This makes the
client lifecycle a correct shared implementation before gateway/hetero reuse it.

Characterization updated to lock the corrected behavior. LOBE-10382.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 10:38:07 +08:00
YuTengjing e914e98369 🐛 fix: avoid task template locale key flash (#16071) 2026-06-20 01:16:36 +08:00
Arvin Xu 825cfc2189 🐛 fix(device): tolerate CRLF line endings when editing files & surface real edit errors (#16061) 2026-06-20 01:15:22 +08:00
Arvin Xu 4386a42b92 🐛 fix(bot): recover MIME type for QQ c2c file attachments so audio reaches the model (#16063) 2026-06-20 01:00:14 +08:00
YuTengjing c316279606 🐛 fix: preserve gateway error event body (#16069) 2026-06-20 00:58:05 +08:00
YuTengjing c17dc415ed 🐛 fix: avoid flashing unauthorized state in tool auth alert (#16068) 2026-06-20 00:35:09 +08:00
YuTengjing 6b87a141b6 🐛 fix: resolve resource header i18n namespace fallback (#16066) 2026-06-20 00:09:28 +08:00
Arvin Xu b569b3e53b 🗃️ feat(database): combine workspace-device + ai_infra surrogate _id PK migrations (#16065)
* 🗃️ feat(database): combine workspace-device + ai_infra surrogate PK migrations (0111)

Merge the two staged workspace-scoped DB rollouts that both claimed 0111
into a single migration, since they touch disjoint tables:

- ai_infra (LOBE-10056 Phase 5): ai_providers / ai_models move from their
  composite PK to a surrogate `_id` uuid PK, with business uniqueness moved
  to workspace-scoped partial unique indexes (personal WHERE workspace_id IS
  NULL, workspace WHERE workspace_id IS NOT NULL).
- device + workspace (LOBE-10315): replace the full (user_id, device_id)
  unique with two partial uniques scoped by workspace_id; add workspace
  frozen / frozen_reason / frozen_at columns; DeviceModel gains an optional
  workspaceId plus registerWorkspaceDevice / queryWorkspaceDevices /
  findWorkspaceDeviceById / updateWorkspaceDevice / deleteWorkspaceDevice.

The combined 0111 SQL stays hand-written and idempotent: a NO-OP on cloud
production (ai_infra side was applied online via manual steps; device/frozen
adds are all guarded) and a full rebuild on fresh / self-hosted databases.
`bun run db:generate` reports no drift; device model tests pass.

Supersedes #15533 and #16004.

Co-Authored-By: rdmclin2 <rdmclin2@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🗃️ feat(database): add verify_evidence table (0112)

Captured artifacts backing a verify_check_result — screenshot / gif /
video / text / dom_snapshot / transcript — with storage key, media
metadata (mime/width/height/duration/size/checksum), provenance
(captured_by / captured_at) and a self-referencing parent_evidence_id
that forms an evidence tree (ON DELETE set null keeps children).

- types: add verifyEvidenceTypes + verifyEvidenceCaptureSources to the
  verify domain vocabulary in @lobechat/types.
- schema: verify_evidence table with check_result / user / workspace
  cascade FKs and FK-lookup indexes.
- 0112 SQL is idempotent (CREATE TABLE IF NOT EXISTS, DROP+ADD FK
  constraints, CREATE INDEX IF NOT EXISTS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Revert "🗃️ feat(database): add verify_evidence table (0112)"

This reverts commit 45a253b8f8.

---------

Co-authored-by: rdmclin2 <rdmclin2@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:03:59 +08:00
Arvin Xu ef09457e63 feat(topic): persist topic unread as a backend status (#16056)
*  feat(topic): persist topic unread as a backend status

Move the "unread completed generation" indicator from a client-only
Zustand Set to a persisted `topics.status = 'unread'`, so it survives
reload and syncs across devices.

- add 'unread' to the topic status enum / type / updateTopic zod / sort rank
- compute cross-agent unread counts server-side on the sidebar agent list
  (SidebarAgentItem.unreadCount) so the home badge stays accurate for
  agents whose topics aren't loaded on the client
- markTopicUnread (formerly markUnreadCompleted) writes status 'unread';
  markTopicRead (formerly clearUnreadCompletedTopic) flips it back to
  'active' when the user opens the topic
- partition the terminal status write by (viewing, succeeded) so the
  background completion's 'unread' write never races the 'active' write
- derive unread selectors + sidebar status bucketing from topic.status

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(topic): clear unread on route hydration; don't mark parked/cancelled runs unread

Address two review findings on the persisted unread status:

- Route hydration (reload / deep link / notification) sets activeTopicId
  directly instead of going through switchTopic, so the persisted
  'unread' was never cleared while the user was already reading. Add
  useClearActiveTopicUnread — once the active topic appears in a loaded
  bucket as 'unread', mark it read. Wired into agent + group ChatHydration.
- A gateway agent_runtime_end with reason 'interrupted' / 'waiting_for_async_tool'
  is a cancel / park, not a completion. Gate markTopicUnread on
  isCompletedRuntimeEnd(reason) and derive onSessionComplete's `succeeded`
  from the same predicate, so aborts/parks clear back to 'active' instead
  of persisting as an unread completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(topic): hoist topic status enum into @lobechat/types + add TopicModel tests

- extract the inline status z.enum in the topic router into a shared
  chatTopicStatusSchema / TOPIC_STATUSES source of truth in @lobechat/types
- derive ChatTopicStatus from the schema so the type and validator stay in sync
- add TopicModel unit tests covering CRUD, status filtering/ordering, triggers,
  metadata merge, duplicate, batchMoveToAgent and memory-extractor queries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 23:55:04 +08:00
YuTengjing 44ebb77365 🐛 fix: preserve gateway error semantics (#16058) 2026-06-19 23:15:24 +08:00
YuTengjing a97e331727 🐛 fix: handle generated asset metadata dedup (#16057) 2026-06-19 23:11:30 +08:00
Arvin Xu 63b52522d0 🐛 fix(trusted-client): skip Market trusted token for synthetic eval userIds (#15959)
Synthetic local agent-eval / smoke userIds (`eval_*`, `qstash_smoke_*`) are
never real Market accounts, so Market rejects any trusted-client token built
from them with `invalid_trust_token / Invalid userId format`. The rejected
round-trip is non-fatal (prep calls are best-effort) but noisy and adds
noticeable prep latency during evals.

generateTrustedClientToken now returns undefined for these ids before signing,
avoiding the doomed request. Real userIds are unaffected (no real id carries
these prefixes).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 22:44:18 +08:00
Arvin Xu ba5571cb4a 🐛 fix(hetero-agent): don't treat an inline tool's task_notification as a post-task summary (#16050)
A slow `git commit` (running a lint-staged hook) makes Claude Code track the Bash
call as a task and emit `task_started` + `task_notification` back-to-back, with no
out-of-band callback turn in between, immediately followed by the tool_result —
i.e. an ordinary inline synchronous tool, not a Monitor-style long-running task.

The adapter armed `pendingTaskCompletion` on every `task_notification`, so the
next normal turn (which simply consumes that commit's tool_result and continues
working) got stamped `signal.type = 'task-completion'`. Downstream, a signal-
tagged turn is anchored off the source tool and kept off the spine, while the
following turn re-anchors to the pre-task assistant — the continuation forks off
the main chain and disappears from the rendered topic. Observed on
tpc_joZS2mksoY5L (two `git commit`s, two dropped turns) and tpc_SZXNHtiNeqxv.

Fix at the source: only arm `pendingTaskCompletion` when the ending task actually
fired callback turns while alive (`callbackCount > 0`). That is the signature of
a genuine long-running task whose completion produces a post-task summary (the
summary is meant to render after the preceding callbacks). A task that starts and
ends with zero callbacks was an inline tool — leave the next turn untagged so it
stays a normal main-chain step. Erring this way is safe: a mis-classified genuine
summary merely renders inline instead of disappearing.

Regression test: `task_started` + `task_notification` with no intervening callback
→ the next turn carries no `externalSignal`. The existing Monitor flow (a callback
fires before `task_notification`, `callbackCount > 0`) still tags `task-completion`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:50:10 +08:00
Arvin Xu ce2e517be9 🐛 fix(model-runtime): converge 2nd residue wave + unmap payload-too-large from ECW (#16053)
🐛 fix(model-runtime): converge second residue wave + unmap payload-too-large from ECW

Second pass over the UpstreamHttpError fallback residue. Add ~40 message
patterns so the recognizable remainder gets a precise code instead of the
bare-HTTP bucket:

- ModelNotFound: invalid model identifier, incorrect model ID, integrator gating
- PermissionDenied: 403 Forbidden, Azure VNet/firewall, channel client restriction
- UserConfigError: routing 404 / "No route for that URI" / url.not_found (base-URL misconfig)
- InvalidRequestFormat: provider-side validation + custom-proxy schema bridges
  (reasoning/thinking type, Gemini INVALID_ARGUMENT, function_declarations, pydantic field errors)
- ExceededContextWindow: genuine "the model's context length" overflow
- CapabilityNotSupported: image inference unsupported, no-SSE
- InsufficientQuota: paid-only model, credits exhausted, CodingPlan
- ProviderNetworkError: undici "fetch failed"
- UpstreamGatewayError: bare HTML error bodies

Also unmap `Request body too large` from ExceededContextWindow: a payload / 413
size limit is not the model context window. It stays in the bare-HTTP bucket.

Tests: +second-round cases + a guard that payload-too-large is NOT ECW.
`bunx vitest run src/errors` → 103 passed.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:47:21 +08:00
Arvin Xu 9e9ab1f05d feat(device): auto-activate a device for bot triggers on a local target (#16032)
*  feat(device): auto-activate a device for bot triggers on a local target

A bot conversation has no UI to pick an execution device, and a stored
`local` target (in-process IPC) is unreachable from the cloud bot server,
so a bot-triggered run on a `local`-target agent would stay
`device-unrouted` and never touch the owner's machine.

Upgrade a stored `local` target to `auto` when `trigger === bot`, so the
run auto-activates an online device (single → use it; several → stay
unrouted and let the model pick via the remote-device tool). `none`
(explicit no-device) and `sandbox` (explicit cloud) are deliberate
opt-outs and stay; an explicit `requestedDeviceId` already pins a device;
chat mode still wins (plain chat). Only the `bot` trigger fires this — a
`chat`/`cron` run on a `local` target is unchanged.

Plumbs `trigger` into `resolveExecutionPlan` and both call sites (native
+ hetero dispatch) in execAgent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(device): rename misleading `isDesktop` to `clientExecutionAvailable`

The `isDesktop` option on `resolveExecutionTarget` / `resolveExecutionPlan`
never meant "the build is desktop" nor "the message came from a desktop
client". It gates whether the `local` target has somewhere to run — i.e.
whether tools can execute on the user's own client/device (`runtimeMode:
'local'`, the `'client'` executor), as opposed to a pure-cloud context that
only reaches the sandbox. It is the boolean form of `RuntimePlatform`
('desktop' vs 'web'); each layer feeds the value that means this for it:
`isDesktop` (build const) in the UI, `gatewayConfigured` on the server,
`hasDeviceProxy` in the tools engine.

`isDesktop` was misleading on two counts: it implied the desktop build, and
"local"/"desktop" are perspective-relative (the server is never "local" /
"desktop"). `clientExecutionAvailable` names the user-side capability
directly, so `!clientExecutionAvailable` reads unambiguously as "no client to
run on" regardless of where the resolver runs. It gates only `local`; an
explicit `device` binding is reachable from anywhere and is unaffected.

Pure rename + doc clarification, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(device): honour a bound local device for bot runs

The bot-trigger upgrade relabelled every `local` target to `auto`, but the
device switcher persists the desktop's own `deviceId` as `boundDeviceId` when
the user picks `local` ("this device"). `auto` deliberately ignores
`boundDeviceId`, so an owner's bot message for a pinned-local agent could
auto-grab whichever single device was online — or go ambiguous with several —
instead of the machine the user chose.

Upgrade a bot `local` target to `device` (route to the pinned machine) when it
carries a `boundDeviceId`; only an UNBOUND `local` falls through to `auto`. An
offline pinned device now stays `device-unrouted` (bound-device-offline)
rather than silently running elsewhere — same no-silent-fallback rule the
`device` target already follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:52:31 +08:00
YuTengjing 6c62349339 feat: inject model knowledge cutoff (#16048) 2026-06-19 20:50:20 +08:00
YuTengjing 3beafb20c6 🌐 feat(subscription): add contextual plans modal header i18n keys (#16025) 2026-06-19 20:45:48 +08:00
YuTengjing b2ae69ac11 ♻️ refactor: derive OpenAI model routing from IDs (#16046) 2026-06-19 20:36:39 +08:00
Arvin Xu ec40f7e405 📝 docs(hetero-agent): document live-trace capture & field-classifier debug flow (#16049)
* 📝 docs(hetero-agent): document live-trace capture & field-classifier debug flow

Capture the recurring "read the recorded trace, then check the hetero
implementation against it" workflow in the heterogeneous-agent skill so it
doesn't have to be rediscovered each time.

- references/debug-workflow.md §2: the in-app live-trace recorder
  (HeterogeneousAgentCtr.createCliTraceSession) — when it records, where it
  writes (cwd/.heerogeneous-tracing vs appStoragePath when the toggle is on),
  the per-session layout (meta/stdin/stdout/stderr/exit), and the
  .last-live-trace fast path. Prefer it over a hand-rolled `claude -p` repro.
- references/debug-workflow.md §8: verify a structured-field classifier
  against a real trace — grep the discriminator field across all event states,
  not just the failing one. Worked example: the CC usage-limit fix, where
  rate_limit_info rides on status:"allowed" events too.
- SKILL.md: point the debug order at the live trace and add the
  wrong-terminal-error-guide bug pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 📝 docs(hetero-agent): fix mangled reproduce-session sentence

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:21:05 +08:00
Arvin Xu 8b9fd761f6 🐛 fix(chat): allow audio upload in agent mode and render audio with a waveform player (#16022)
* 🐛 fix(chat): allow audio upload in agent mode and render audio with a waveform player

- Unify the upload UI media gate with the store's agent-mode bypass: in agent
  mode / heterogeneous agents the gate no longer drops audio/video/image the
  agent can still process (e.g. .m4a on a non-audio model).
- Backfill the audio mime from the file extension on upload, since .m4a shares
  the ISO-BMFF container with mp4 and the browser / byte-sniffing can report an
  empty or video/mp4 mime, which mis-classified it as a non-audio file.
- Replace the native <audio> in user messages with a self-contained waveform
  player (play/pause, click-to-seek, decoded peaks with a deterministic fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ️ a11y(chat): lazy-load audio waveform and label the play/pause control

- Defer waveform fetch/decode until first playback (or seek) instead of on mount,
  and skip decoding files over 20MB, so opening a conversation with several audio
  attachments no longer downloads/decodes every clip and spikes network/memory.
- Give the toggle a stateful accessible label (Play audio / Pause audio) with
  aria-pressed, keeping the filename as a title tooltip, so screen readers announce
  the action rather than the file name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:14:34 +08:00
Arvin Xu 5dea768397 🐛 fix(device): inject device-bound working directory into server local-system tool chain (#15887)
* 🐛 fix(device): inject bound working directory into server local-system tool chain

Server/gateway-mode device runs ignored the bound working directory
(workingDirByDevice): the local-system tool's systemRole prompt
({{workingDirectory}}) and the daemon both saw the device default cwd
(= `/` for a Finder/Dock-launched app), so the agent didn't know where
the repo was and ran commands from `/`.

Core fix: resolveWorkspaceInit already loads the persisted device row and
resolves the bound directory (resolveDeviceWorkingDirectory: topic override
> workingDirByDevice > device.defaultCwd) for its workspace scan. Return
that boundCwd and write it onto deviceSystemInfo.workingDirectory, which
fills the {{workingDirectory}} prompt placeholder — the channel the model
uses to know where it is and reach for absolute paths. Sourced from the DB
row (works offline), not a live queryDeviceSystemInfo() call (which only
reports the daemon's process.cwd = `/`); that field is dropped from the
template fetch. Resume-safe via the existing deviceSystemInfo plumbing.

The model can't pick cwd/scope, so two ops still need a runtime default
injected into the device-proxy args: runCommand (manifest hides `cwd`,
daemon spawns in process.cwd() when omitted) and the search ops
(searchFiles/globFiles/grepContent, whose `scope` daemon-side falls back
to process.cwd() despite the manifest claiming it defaults to the working
directory). File ops (read/write/edit/move/list) get absolute paths from
the prompt and need nothing injected.

LOBE-10440

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(device): resolve relative file-op paths against bound working directory

Server/gateway mode follow-up: local-system file ops (readFile / writeFile /
editFile / listFiles / moveFiles) now resolve a relative path against the
device-bound working directory instead of the daemon's process cwd (= `/` for a
Finder/Dock-launched app), so a model-supplied relative path lands in the bound
directory. `executeToolCall` only forwards `arguments`, so `cwd` rides in the
args: the server proxy injects it (WORKING_DIR_ARG) and the daemon resolves it
via a new shared `resolveAgainstCwd` in `@lobechat/local-file-shell` (absolute
paths and the no-cwd case are unchanged → no regression for desktop client-mode).

Also returns `resolveWorkspaceInit` as a dedicated `ResolvedWorkspaceInit`
({ boundCwd, workspace }) instead of an ad-hoc intersection, keeping `boundCwd`
out of the persisted/UI-shared `WorkspaceInitResult`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(device): drop unexposed listFiles from working-dir injection

listFiles was removed from LocalSystemManifest.api (99023811d8), so the
proxy never builds a listFiles function. Drop it from WORKING_DIR_ARG and
the test, swapping in moveFiles which is still an exposed file op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:13:31 +08:00
Arvin Xu deb4bd6a3c feat(devices): add recent-directory button to device detail panel (#16040)
*  feat(devices): add recent-directory button to device detail panel

Add an always-visible "+" action to the right of the "Recent directories"
title in the device settings detail panel. Previously the add affordance was
gated on canBrowse (current desktop device only), leaving remote / non-current
devices with no way to add a directory — which looked like a bug.

The button adapts per device: native folder dialog when browsable, otherwise
reuses the control bar's openAddWorkingDirModal with deviceService.statPath
validation on the target device. The redundant bottom button is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(devices): extract AddWorkingDirModal into shared WorkingDirectory feature

Move AddWorkingDirModal out of ChatInput/ControlBar into a neutral
src/features/WorkingDirectory domain feature, so the device settings panel no
longer reaches into the chat control bar to reuse it. Both the control bar
picker and the device detail panel now import it from @/features/WorkingDirectory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(devices): load the device i18n namespace for recent-dir errors

The detail panel's useTranslation only declared the `setting` namespace, so the
inline `{ ns: 'device' }` overrides for the add-directory validation errors
relied on `device` being loaded elsewhere — on the standalone settings page it
may not be, rendering the raw key. Declare both namespaces and reference the
device keys with the `device:` prefix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:09:53 +08:00
Arvin Xu 415fdd02eb 🐛 fix(hetero-agent): gate CC usage-limit guide on rejected status (#16042)
A later unrelated terminal failure (e.g. an ECONNRESET network drop) was
inheriting the last allowed rate_limit_event's rolling-window metadata and
rendering a bogus "usage limit reached, resets at X" guide.

CC stamps `rate_limit_info` onto its events even when the request goes
through (`status: 'allowed'`) — that block is the rolling-window metadata
(`resetsAt`, `rateLimitType`), not evidence the limit was hit. Now require
BOTH `status: 'rejected'` AND a concrete reset window before classifying a
failure as a user-side quota limit.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 20:09:41 +08:00
YuTengjing 815901efa0 ️ perf: persist model config cache (#16047) 2026-06-19 19:40:46 +08:00
YuTengjing a6f816f9bd 🐛 fix: provide Market auth to SPA modals (#16045) 2026-06-19 19:26:01 +08:00
YuTengjing 4e96552102 🐛 fix: suppress agent mode notice for CLI agents (#16044) 2026-06-19 19:00:25 +08:00
Arvin Xu 80cce10dd5 🐛 fix(cli): stop connect daemon on logout (#16038)
* 🐛 fix(cli): stop connect daemon on logout

`lh logout` only cleared the credentials file, leaving the `lh connect`
daemon running. It kept the device online on the gateway using the cached
token until expiry, so the machine stayed remotely driveable after the user
thought they had logged out, and left stale daemon.pid / daemon.status.json
behind.

Tear down the daemon via stopDaemon() before clearing credentials, which
SIGTERMs the daemon (disconnecting the gateway) and removes the pid/status
files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔒 fix(cli): verify daemon identity before SIGTERM

A bare isProcessAlive() check let logout/connect-stop SIGTERM an unrelated
process when the OS reused a dead daemon's PID after a crash or reboot.
getRunningDaemonPid() now also confirms the live PID is actually a LobeHub
daemon (matches the `connect … --daemon-child` command-line signature) and
treats reused PIDs as stale, cleaning up the metadata instead of killing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🔖 chore(cli): bump @lobehub/cli to 0.0.32

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(cli): add top-level `disconnect` command

`lh connect` was only stoppable via the nested `lh connect stop`, which is
hard to discover. Add `lh disconnect` as a top-level alias that runs the same
daemon-stop handler, so users have an obvious inverse of `lh connect`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:06:09 +08:00
Arvin Xu d28e976ac2 🐛 fix(device): distinguish local and gateway device targets (#15921)
* 💄 style(device): hide current machine's duplicate entry from remote device list on desktop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

*  feat(device): add explicit `auto` execution mode — never auto-activate a device unless opted in

Auto-activation used to be an implicit default: an unbound run on any
device-capable target (incl. the desktop `local` default) silently grabbed
the single online device. So an agent the user never pointed at a device
would still activate one — and a `none` selection was the only way to opt out.

Introduce `auto` as an explicit, opt-in mode and make it the ONLY mode that
auto-activates an unbound device:

- `none`    → plain chat, never a device
- `auto`    → one online device is activated automatically; with several, the
              run stays unrouted so the model picks one via the remote-device
              tool. Ignores any stale stored `boundDeviceId` (that's `device`
              mode) — only an explicit `requestedDeviceId` pins a device.
- `local`   → strictly this desktop machine
- `device`  → the explicitly bound device
- `sandbox` → cloud sandbox

`local` / `device` no longer auto-grab a device: an unbound run stays
`device-unrouted` until one is bound/requested. Platform defaults are
unchanged (desktop `local`, web `none`) so no migration is needed — existing
configs simply stop auto-activating; users opt into it by picking `auto` in
the execution-device switcher.

Adds the `auto` option + chip to HeteroDeviceSwitcher (non-hetero only) and
en/zh copy. Updates resolveExecutionPlan + tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(device): show this machine as a named device row with a local tag

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix: restore gateway device option

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:55:04 +08:00
Innei f22185716a ⬆️ chore(deps): upgrade react-router to v8 (#16029)
- Bump react-router-dom 7.13 → react-router 8.0
- Codemod 'react-router-dom' → 'react-router' across 275 sites
- Pin RouterProvider to 'react-router/dom' in 5 SPA entries
- Bump react/react-dom to 19.2.7 (v8 minimum)
- Update vite manualChunks + optimizeDeps include
- Update vi.mock string literals in test files
2026-06-19 16:31:01 +08:00
YuTengjing 702e2aa15d 🔨 chore: fix skill audit symlink inventory (#16039) 2026-06-19 16:12:44 +08:00
YuTengjing b917a81c77 🐛 fix: guide task template connector auth (#16036) 2026-06-19 15:48:52 +08:00
Arvin Xu 32eaab9537 chore: clean LOBE-XXX code comment markers (2026-06-19) (#16031) 2026-06-19 13:21:45 +08:00
AmAzing- a976cd52c9 🐛 fix(agent): hide Codex cloud config tab (#16037) 2026-06-19 13:21:16 +08:00
AmAzing- ce274593c2 🐛 fix(chat): clarify skill installation flow (#16034) 2026-06-19 11:41:01 +08:00
508 changed files with 30333 additions and 1727 deletions
+5 -1
View File
@@ -38,7 +38,7 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
## Default Debug Order
1. Prove whether the raw CLI output is correct before touching UI code.
1. Prove whether the raw CLI output is correct before touching UI code. The app records every real session — read the most recent one via `cat .heerogeneous-tracing/.last-live-trace` rather than hand-rolling a `claude -p` repro (see references/debug-workflow\.md §2).
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
4. Turn the repro into a focused test before fixing.
@@ -77,6 +77,10 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
- Subagent tools show up in the main bubble:
check for subagent chunks reaching the main gateway handler.
- Wrong terminal-error guide (e.g. "usage limit reached" shown for a network drop):
a classifier is branching on a structured field whose mere presence isn't its meaning.
Grep the field across all event states in a real trace before trusting it — see
references/debug-workflow\.md §8 (CC `rate_limit_info` rides on `status: "allowed"` too).
## References
@@ -3,12 +3,13 @@
## Contents
1. Pipeline map
2. Capture raw CLI traces first
2. Capture raw CLI traces first (incl. in-app live traces)
3. Compare raw and adapted events
4. Check step boundaries before persistence
5. Check tool persistence invariants
6. Focused tests
7. Repro-to-fix workflow
8. Verify a structured-field classifier against a real trace
## 1. Pipeline Map
@@ -27,6 +28,54 @@ Start at the leftmost broken layer. Do not jump straight to UI rendering unless
## 2. Capture Raw CLI Traces First
### In-app live traces (the faithful capture — prefer this)
The running app already records every CLI session it spawns. This is the most
faithful trace you can get, because it captures the **exact** spawn args, env
keys, cwd, `--resume`/`--mcp-config` flags, model, and stdin that the app used —
things a hand-rolled `claude -p` / `codex exec` repro will not reproduce. Reach
for this before reproducing manually. The recorder lives in
`apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
(`createCliTraceSession`, `shouldTraceCliOutput`, `resolveTraceRootDir`).
When it records:
- **Dev build** (`!app.isPackaged`): always.
- **Packaged build**: only when the user flips the Help-menu developer toggle
(`heteroTracingEnabled`). Off by default so normal runs aren't polluted.
- Never under `NODE_ENV=test`.
Where it writes:
- Toggle **off** (plain dev run): `<cwd>/.heerogeneous-tracing/` — i.e. inside
the repo you're running against. (Yes, the dir name is misspelled
`heerogeneous`; it is the real path.)
- Toggle **on**: `<appStoragePath>/heteroAgent/tracing/` — keeps traces out of
the user's project. This is the only path packaged builds ever use.
Layout per session — `.../<agentType>/<YYYYMMDD-HHMMSS>-<sessionId>/`:
- `meta.json` — spawn `args`, `command`, `cwd`, `envKeys`, `model`,
`resumeSessionId`/`agentSessionId`, attachment summaries. **Read this first**
to know exactly how the CLI was invoked.
- `stdin.txt` — the stream-json request fed to the CLI.
- `stdout.jsonl` — the raw provider NDJSON (the trace you actually read).
- `stderr.log` — CLI stderr.
- `exit.json``{ code, signal, finishedAt }`.
`.heerogeneous-tracing/.last-live-trace` always points at the most recent
session dir, so the fast path to "what just happened" is:
```bash
dir=$(cat .heerogeneous-tracing/.last-live-trace)
cat "$dir/meta.json" # how the CLI was spawned
wc -l "$dir/stdout.jsonl" # raw event count
```
Reproduce the same session yourself by reusing the recorded `meta.json` `args`
together with `stdin.txt` (the args already include `--resume <sessionId>`),
instead of guessing flags.
### Codex raw JSONL
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
@@ -244,3 +293,55 @@ When the bug comes from a real trace, distill it into the closest existing test
6. Only then do an Electron smoke test with the `agent-testing` skill if UI confirmation is still needed.
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
## 8. Verify A Structured-Field Classifier Against A Real Trace
Whenever the adapter **branches on a structured field** from the raw stream —
`status`, `usage`, `rateLimitType`, `stop_reason`, `parent_tool_use_id`,
`subtype`, etc. — do not trust your mental model of the wire format. The field
you key on almost always also appears on **benign / non-target** events, and a
classifier that ignores the surrounding state will misfire on those.
The procedure (recurring — run it every time):
1. Pull the most recent real session: `dir=$(cat .heerogeneous-tracing/.last-live-trace)`.
2. Grep the field across **every** event state, not just the failing one, and
count by co-occurring state. Example:
```bash
# Which event statuses carry a rate_limit_info block?
grep -o '"status":"[a-z]*"' "$dir/stdout.jsonl" | sort | uniq -c
grep -c 'rate_limit_info' "$dir/stdout.jsonl"
```
3. If the field rides on states you did not account for, the classifier needs an
extra gate. Add the trace as a fixture/assertion to the adapter test so the
regression can't come back.
### Worked example: CC usage-limit vs. transient throttle (`fix/cc-rate-limit-quota-misclassify`)
- **Symptom:** an unrelated terminal failure (e.g. an `ECONNRESET` network drop)
rendered a bogus "usage limit reached, resets at X" guide.
- **What the trace showed:** Anthropic stamps a `rate_limit_info` block —
carrying `resetsAt` and `rateLimitType` (e.g. `seven_day`) — onto events even
when the request **goes through** (`status: "allowed"`). In real traces those
reset-window fields appear on \~all `rate_limit_info` blocks, the vast majority
of which are `allowed`, not `rejected`. So the window is rolling-window
_metadata for an allowed call_, NOT evidence the limit was hit.
- **The bug:** `isUserQuotaRateLimit` keyed only on the presence of a reset
window (`info.resetsAt != null || info.rateLimitType != null`). A later
terminal error inherited the last allowed event's window → false positive.
- **The fix:** require `status === 'rejected'` **and** a concrete reset window.
A bare `rejected` with no window is the transient server throttle → leave it
to the overloaded (retry) classifier. Status codes (429 / 529) and message
text are deliberately not consulted — only this structured signal decides the
guide.
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` →
`isUserQuotaRateLimit`
- regression assertions in
`packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
The general lesson: a field's **presence** is not its **meaning**. Confirm which
event states a discriminator field co-occurs with in a real recorded trace
before branching on it.
+2 -2
View File
@@ -18,8 +18,8 @@ Periodic review of the project-local skill set under `.agents/skills/`. The goal
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
```bash
find .agents/skills -name SKILL.md | wc -l # total count
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
find -L .agents/skills -name SKILL.md | wc -l # total count, including symlinked skills
find -L .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length, including symlinked skills
```
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.31",
"version": "0.0.32",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -37,6 +37,7 @@
"@lobechat/tool-runtime": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^24.13.2",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"commander": "^13.1.0",
"dayjs": "^1.11.19",
@@ -45,6 +46,7 @@
"fast-glob": "^3.3.3",
"ignore": "^7.0.5",
"picocolors": "^1.1.1",
"semver": "^7.7.3",
"superjson": "^2.2.6",
"tsdown": "^0.21.4",
"typescript": "^6.0.3",
+19
View File
@@ -440,6 +440,25 @@ describe('connect command', () => {
});
});
describe('disconnect (alias for connect stop)', () => {
it('should stop running daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});
it('should warn if no daemon is running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
});
describe('connect status', () => {
it('should show no daemon running', async () => {
const program = createProgram();
+18 -11
View File
@@ -74,17 +74,7 @@ export function registerConnectCommand(program: Command) {
});
// Subcommands
connectCmd
.command('stop')
.description('Stop the background daemon process')
.action(() => {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
});
connectCmd.command('stop').description('Stop the background daemon process').action(handleStop);
connectCmd
.command('status')
@@ -148,10 +138,27 @@ export function registerConnectCommand(program: Command) {
}
handleDaemonStart({ ...options, daemon: true });
});
// Top-level alias for `connect stop`. Users who run `lh connect` naturally
// reach for `lh disconnect` to undo it; the nested `connect stop` is not
// discoverable enough on its own.
program
.command('disconnect')
.description('Disconnect from the device gateway (alias for `connect stop`)')
.action(handleStop);
}
// --- Internal helpers ---
function handleStop() {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
}
function handleDaemonStart(options: ConnectOptions) {
const existingPid = getRunningDaemonPid();
if (existingPid !== null) {
+31 -1
View File
@@ -1,7 +1,8 @@
import { Command } from 'commander';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
import { registerLogoutCommand } from './logout';
@@ -9,6 +10,10 @@ vi.mock('../auth/credentials', () => ({
clearCredentials: vi.fn(),
}));
vi.mock('../daemon/manager', () => ({
stopDaemon: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
@@ -19,6 +24,11 @@ vi.mock('../utils/logger', () => ({
}));
describe('logout command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(stopDaemon).mockReturnValue(false);
});
function createProgram() {
const program = new Command();
program.exitOverride();
@@ -44,4 +54,24 @@ describe('logout command', () => {
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
});
it('should stop the connect daemon before clearing credentials', async () => {
vi.mocked(stopDaemon).mockReturnValue(true);
vi.mocked(clearCredentials).mockReturnValue(true);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Disconnected device daemon'));
});
it('should still attempt daemon teardown when no credentials exist', async () => {
vi.mocked(clearCredentials).mockReturnValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
});
});
+9
View File
@@ -1,6 +1,7 @@
import type { Command } from 'commander';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
export function registerLogoutCommand(program: Command) {
@@ -8,6 +9,14 @@ export function registerLogoutCommand(program: Command) {
.command('logout')
.description('Log out and remove stored credentials')
.action(() => {
// Tear down the connect daemon first — otherwise it keeps the device
// online on the gateway with the cached token even after credentials are
// gone, leaving the machine remotely driveable past "logout".
const stopped = stopDaemon();
if (stopped) {
log.info('Disconnected device daemon.');
}
const removed = clearCredentials();
if (removed) {
log.info('Logged out. Credentials removed.');
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { buildInstallCommand, isNewerVersion } from './update';
describe('isNewerVersion', () => {
it('compares core versions', () => {
expect(isNewerVersion('1.2.3', '1.2.2')).toBe(true);
expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false);
expect(isNewerVersion('2.0.0', '1.9.9')).toBe(true);
});
it('tolerates a leading v and missing segments', () => {
expect(isNewerVersion('v1.2.0', '1.2.0')).toBe(false);
expect(isNewerVersion('1.2', '1.2.0')).toBe(false);
expect(isNewerVersion('1.3', '1.2.9')).toBe(true);
});
it('ranks a stable release above a prerelease of the same core', () => {
expect(isNewerVersion('1.2.3', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3-beta.2', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3-beta.1')).toBe(false);
});
it('orders numeric prerelease identifiers numerically, not lexicographically', () => {
// The bug a raw string compare gets wrong: beta.10 must outrank beta.9.
expect(isNewerVersion('1.0.0-beta.10', '1.0.0-beta.9')).toBe(true);
expect(isNewerVersion('1.0.0-beta.9', '1.0.0-beta.10')).toBe(false);
expect(isNewerVersion('1.0.0-beta.2', '1.0.0-beta.10')).toBe(false);
});
it('returns false for an unparseable latest version', () => {
expect(isNewerVersion('not-a-version', '1.0.0')).toBe(false);
});
});
describe('buildInstallCommand', () => {
it('builds the global install command per package manager', () => {
expect(buildInstallCommand('npm', '@lobehub/cli@1.0.0')).toEqual({
args: ['install', '-g', '@lobehub/cli@1.0.0'],
command: 'npm',
});
expect(buildInstallCommand('pnpm', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'pnpm',
});
expect(buildInstallCommand('bun', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'bun',
});
expect(buildInstallCommand('yarn', '@lobehub/cli@1.0.0')).toEqual({
args: ['global', 'add', '@lobehub/cli@1.0.0'],
command: 'yarn',
});
});
});
+179
View File
@@ -0,0 +1,179 @@
import { spawn } from 'node:child_process';
import { realpathSync } from 'node:fs';
import type { Command } from 'commander';
import pc from 'picocolors';
import semver from 'semver';
// Pull package metadata from the shared `src/pkg.ts` module (resolved at the
// bundled entry's depth) rather than a local `require('../../package.json')`,
// which would point outside the package once bundled into dist/index.js.
import { cliPackageName, cliVersion } from '../pkg';
import { log } from '../utils/logger';
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
interface UpdateOptions {
check?: boolean;
packageManager?: PackageManager;
tag?: string;
}
/**
* Detect which package manager installed the CLI so we run the matching global
* upgrade command. We first trust an explicit `npm_config_user_agent` (set when
* invoked through a package-manager script) and otherwise infer from the path of
* the running binary. Falls back to npm.
*/
export function detectPackageManager(): PackageManager {
const ua = process.env.npm_config_user_agent;
if (ua) {
if (ua.startsWith('pnpm')) return 'pnpm';
if (ua.startsWith('yarn')) return 'yarn';
if (ua.startsWith('bun')) return 'bun';
if (ua.startsWith('npm')) return 'npm';
}
try {
const binPath = realpathSync(process.argv[1] ?? '').replaceAll('\\', '/');
if (binPath.includes('/pnpm/')) return 'pnpm';
if (binPath.includes('/.bun/') || binPath.includes('/bun/')) return 'bun';
if (binPath.includes('/yarn/') || binPath.includes('/.yarn/')) return 'yarn';
} catch {
// ignore fall back to npm
}
return 'npm';
}
/** Build the global-install command for the detected package manager. */
export function buildInstallCommand(
pm: PackageManager,
spec: string,
): { args: string[]; command: string } {
switch (pm) {
case 'pnpm': {
return { args: ['add', '-g', spec], command: 'pnpm' };
}
case 'yarn': {
return { args: ['global', 'add', spec], command: 'yarn' };
}
case 'bun': {
return { args: ['add', '-g', spec], command: 'bun' };
}
default: {
return { args: ['install', '-g', spec], command: 'npm' };
}
}
}
/**
* Whether `latest` is a newer version than `current`. Delegates to `semver` so
* prerelease identifiers order correctly (e.g. `1.0.0-beta.10` > `1.0.0-beta.9`,
* which a lexicographic compare gets wrong). Tolerates a leading `v` and missing
* segments via coercion; an unparseable `latest` is treated as "not newer".
*/
export function isNewerVersion(latest: string, current: string): boolean {
const latestParsed = semver.coerce(latest, { includePrerelease: true }) ?? semver.parse(latest);
const currentParsed =
semver.coerce(current, { includePrerelease: true }) ?? semver.parse(current);
if (!latestParsed || !currentParsed) return false;
return semver.gt(latestParsed, currentParsed);
}
async function fetchLatestVersion(name: string, tag: string): Promise<string> {
const url = `https://registry.npmjs.org/${name}/${encodeURIComponent(tag)}`;
const res = await fetch(url, { headers: { accept: 'application/json' } });
if (!res.ok) {
throw new Error(`npm registry returned status ${res.status} for tag "${tag}"`);
}
const data = (await res.json()) as { version?: string };
if (!data.version) {
throw new Error('npm registry response is missing the "version" field');
}
return data.version;
}
function runInstall(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
shell: process.platform === 'win32',
stdio: 'inherit',
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code ?? 'null'}`));
});
});
}
export function registerUpdateCommand(program: Command) {
program
.command('update')
.description('Update the LobeHub CLI to the latest published version')
.option('--check', 'Only check for a newer version without installing')
.option('--tag <tag>', 'npm dist-tag to update to', 'latest')
.option(
'--package-manager <pm>',
`Force a package manager (${PACKAGE_MANAGERS.join(', ')}) instead of auto-detecting`,
)
.action(async (options: UpdateOptions) => {
if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
log.error(
`Unsupported package manager "${options.packageManager}". Use one of: ${PACKAGE_MANAGERS.join(', ')}.`,
);
process.exit(1);
return;
}
const current = cliVersion;
const tag = options.tag || 'latest';
log.info(`Current version: ${pc.bold(current)}`);
let latest: string;
try {
latest = await fetchLatestVersion(cliPackageName, tag);
} catch (error) {
log.error(`Unable to check for updates: ${(error as Error).message}`);
process.exit(1);
return;
}
log.info(`Latest version: ${pc.bold(latest)} ${pc.dim(`(${tag})`)}`);
if (!isNewerVersion(latest, current)) {
log.info(pc.green('Already on the latest version.'));
return;
}
if (options.check) {
log.info(
`Update available: ${current}${pc.green(latest)}. Run ${pc.cyan('lh update')} to upgrade.`,
);
return;
}
const pm = options.packageManager || detectPackageManager();
const spec = `${cliPackageName}@${latest}`;
const { args, command } = buildInstallCommand(pm, spec);
log.info(`Upgrading via ${pc.bold(pm)}: ${pc.dim([command, ...args].join(' '))}`);
try {
await runInstall(command, args);
log.info(pc.green(`Successfully updated to ${latest}. Restart any running sessions.`));
} catch (error) {
log.error(`Update failed: ${(error as Error).message}`);
log.error(`You can upgrade manually: ${[command, ...args].join(' ')}`);
process.exit(1);
}
});
}
+82
View File
@@ -19,11 +19,22 @@ vi.mock('node:os', async (importOriginal) => {
};
});
// Mock only `execFileSync` (used by isDaemonProcess to read a process command
// line); keep the real `spawn` so nothing else changes.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return { ...actual, execFileSync: vi.fn() };
});
// eslint-disable-next-line import-x/first
import { execFileSync } from 'node:child_process';
// eslint-disable-next-line import-x/first
import {
appendLog,
getLogPath,
getRunningDaemonPid,
isDaemonProcess,
isProcessAlive,
readPid,
readStatus,
@@ -35,9 +46,15 @@ import {
writeStatus,
} from './manager';
// A command line that matches the daemon signature (`connect … --daemon-child`).
const DAEMON_COMMAND = '/usr/local/bin/node /path/to/cli.js connect --daemon-child';
describe('daemon manager', () => {
beforeEach(async () => {
await mkdir(mockDir, { recursive: true });
// Default: any inspected PID looks like our daemon. Tests that need a
// reused / unrelated PID override this per-case.
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
});
afterEach(() => {
@@ -80,6 +97,36 @@ describe('daemon manager', () => {
});
});
describe('isDaemonProcess', () => {
it('should return true when the command line matches the daemon signature', () => {
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
expect(isDaemonProcess(12345)).toBe(true);
expect(execFileSync).toHaveBeenCalledWith(
'ps',
['-ww', '-p', '12345', '-o', 'command='],
expect.any(Object),
);
});
it('should return false for an unrelated process command line', () => {
vi.mocked(execFileSync).mockReturnValue('/usr/bin/vim notes.txt' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when the signature is only partially present', () => {
// `connect` without the internal `--daemon-child` flag is not our daemon.
vi.mocked(execFileSync).mockReturnValue('/usr/bin/node /path/cli connect' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when ps is unavailable / throws', () => {
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('ps: command not found');
});
expect(isDaemonProcess(12345)).toBe(false);
});
});
describe('getRunningDaemonPid', () => {
it('should return null when no PID file', () => {
expect(getRunningDaemonPid()).toBeNull();
@@ -110,6 +157,23 @@ describe('daemon manager', () => {
expect(readStatus()).toBeNull();
});
it('should treat a live but reused (non-daemon) PID as stale and clean up', () => {
// process.pid is alive, but the inspected command line is not our daemon —
// simulates the OS reusing a dead daemon's PID for an unrelated process.
writePid(process.pid);
writeStatus({
connectionStatus: 'connected',
gatewayUrl: 'https://test.com',
pid: process.pid,
startedAt: new Date().toISOString(),
});
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
expect(getRunningDaemonPid()).toBeNull();
expect(readPid()).toBeNull();
expect(readStatus()).toBeNull();
});
});
describe('status file', () => {
@@ -232,5 +296,23 @@ describe('daemon manager', () => {
killSpy.mockRestore();
});
it('should NOT SIGTERM a live PID that is not our daemon', () => {
// Stale daemon.pid whose PID was reused by an unrelated, living process.
writePid(process.pid);
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const result = stopDaemon();
expect(result).toBe(false);
// Only the liveness probe (signal 0) is allowed — never a real SIGTERM.
expect(killSpy).not.toHaveBeenCalledWith(process.pid, 'SIGTERM');
// Stale metadata is cleaned up so we don't keep re-checking it.
expect(readPid()).toBeNull();
killSpy.mockRestore();
});
});
});
+33 -3
View File
@@ -1,4 +1,4 @@
import { spawn } from 'node:child_process';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -70,6 +70,34 @@ export function isProcessAlive(pid: number): boolean {
}
}
/**
* Verify a live PID actually belongs to a LobeHub connect daemon.
*
* A bare `isProcessAlive` check is not enough: if a daemon dies without cleaning
* up `daemon.pid` (crash, `kill -9`, reboot), the OS can later reuse that PID
* for an unrelated process. Acting on the stale PID would let `lh logout` /
* `connect stop` SIGTERM a stranger. The daemon is always spawned as
* `<node> … connect … --daemon-child`, so we confirm that signature in the
* process command line before trusting the PID.
*
* Best-effort and deliberately conservative: if the command line can't be read
* (e.g. `ps` is unavailable), we return `false` so callers never kill a process
* we can't positively identify.
*/
export function isDaemonProcess(pid: number): boolean {
try {
// `-ww` disables column truncation so the trailing `--daemon-child` flag is
// never cut off; stderr is silenced so a dead PID just yields an empty match.
const command = execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return command.includes('--daemon-child') && command.includes('connect');
} catch {
return false;
}
}
/**
* Get the PID of a running daemon, cleaning up stale PID files.
* Returns null if no daemon is running.
@@ -78,9 +106,11 @@ export function getRunningDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
if (isProcessAlive(pid)) return pid;
// Require both liveness AND identity — a live-but-reused PID is treated as
// stale so we never act on a process that isn't ours.
if (isProcessAlive(pid) && isDaemonProcess(pid)) return pid;
// Stale PID file — process is dead
// Stale PID file — process is dead or the PID now belongs to someone else.
removePid();
removeStatus();
return null;
+16
View File
@@ -0,0 +1,16 @@
import { createRequire } from 'node:module';
/**
* Single source of truth for this package's own metadata.
*
* Must live directly under `src/` (depth 1), the same depth as the bundled
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
* package.json both when running from source (`bun src/index.ts`) and from the
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
* the path outside the package once everything is bundled into a single file.
*/
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { name: string; version: string };
export const cliPackageName = pkg.name;
export const cliVersion = pkg.version;
+5 -7
View File
@@ -1,5 +1,3 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
@@ -33,11 +31,10 @@ import { registerStatusCommand } from './commands/status';
import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUpdateCommand } from './commands/update';
import { registerUserCommand } from './commands/user';
import { registerVerifyCommand } from './commands/verify';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
import { cliVersion } from './pkg';
export function createProgram() {
const program = new Command();
@@ -45,7 +42,7 @@ export function createProgram() {
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version(version);
.version(cliVersion);
registerLoginCommand(program);
registerLogoutCommand(program);
@@ -80,8 +77,9 @@ export function createProgram() {
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
registerUpdateCommand(program);
return program;
}
export { version as cliVersion };
export { cliPackageName, cliVersion } from './pkg';
+2 -2
View File
@@ -127,8 +127,8 @@
],
"overrides": {
"node-gyp": "^12.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react": "19.2.7",
"react-dom": "19.2.7",
"vitest": "3.2.6"
}
}
@@ -366,14 +366,14 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
async readFiles({ paths, cwd }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
logger.debug('Starting batch file reading:', { count: paths.length });
const results: LocalReadFileResult[] = [];
for (const filePath of paths) {
logger.debug('Reading single file:', { filePath });
const result = await readLocalFile({ path: filePath });
const result = await readLocalFile({ cwd, path: filePath });
results.push(result);
}
@@ -400,9 +400,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
async handleMoveFiles({ items, cwd }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
logger.debug('Starting batch file move:', { itemsCount: items?.length });
return moveLocalFiles({ items });
return moveLocalFiles({ cwd, items });
}
@IpcMethod()
@@ -418,9 +418,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
async handleWriteFile({ path: filePath, content, cwd }: WriteLocalFileParams) {
logger.debug(`Writing file ${filePath}`, { contentLength: content?.length });
return writeLocalFile({ content, path: filePath });
return writeLocalFile({ content, cwd, path: filePath });
}
@IpcMethod()
+5
View File
@@ -3,6 +3,11 @@ import './pre-app-init';
import fixPath from 'fix-path';
import { App } from './core/App';
import { installProcessErrorHandlers } from './process-error-handlers';
// Guard the main process against transient network blips (Wi-Fi/VPN switch,
// system sleep) emitted by Electron's net stack as uncaught exceptions.
installProcessErrorHandlers();
const app = new App();
@@ -0,0 +1,77 @@
import { createLogger } from '@/utils/logger';
const logger = createLogger('main:process-error-handlers');
/**
* Transient Chromium network errors emitted by Electron's `net` stack
* (`SimpleURLLoaderWrapper`). These happen during normal operation — switching
* Wi-Fi / VPN, the machine sleeping, the network interface dropping — and are
* NOT application bugs. Electron emits them as an `error` event on the internal
* loader; when nothing is listening they bubble up as an `uncaughtException`
* and pop the "A JavaScript error occurred in the main process" dialog, even
* though the request layer already handles the failure via promise rejection.
*
* We swallow these specific cases so transient connectivity blips never crash
* the main process. Everything else is re-thrown to preserve normal crash
* visibility.
*
* @see https://github.com/electron/electron/issues/24948
*/
const TRANSIENT_NET_ERROR_CODES = new Set([
'ERR_NETWORK_CHANGED',
'ERR_NETWORK_IO_SUSPENDED',
'ERR_INTERNET_DISCONNECTED',
'ERR_NETWORK_ACCESS_DENIED',
'ERR_CONNECTION_RESET',
'ERR_CONNECTION_ABORTED',
'ERR_CONNECTION_CLOSED',
'ERR_NAME_NOT_RESOLVED',
'ERR_TIMED_OUT',
]);
const isTransientNetError = (error: unknown): boolean => {
if (!error) return false;
const message = error instanceof Error ? error.message : String(error);
// Electron net errors are formatted as `net::ERR_XXX`.
const match = message.match(/net::(ERR_[A-Z_]+)/);
if (match && TRANSIENT_NET_ERROR_CODES.has(match[1])) return true;
// Belt-and-suspenders: these only ever originate from the net loader.
const stack = error instanceof Error ? (error.stack ?? '') : '';
return /net::ERR_/.test(message) && stack.includes('SimpleURLLoaderWrapper');
};
/**
* Install global guards for the Electron main process. Must be called as early
* as possible (before the rest of the app boots) so it catches errors from any
* module's top-level / async work.
*/
export const installProcessErrorHandlers = () => {
process.on('uncaughtException', (error) => {
if (isTransientNetError(error)) {
logger.warn('Ignoring transient network error in main process:', error.message);
return;
}
// Re-throw so genuine bugs still surface as a crash instead of being
// silently swallowed by this handler.
logger.error('Uncaught exception in main process:', error);
throw error;
});
process.on('unhandledRejection', (reason) => {
if (isTransientNetError(reason)) {
logger.warn(
'Ignoring transient network rejection in main process:',
reason instanceof Error ? reason.message : String(reason),
);
return;
}
logger.error('Unhandled rejection in main process:', reason);
});
logger.info('Process error handlers installed');
};
@@ -72,7 +72,7 @@ import {
} from '@lobechat/types';
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
import debug from 'debug';
import type { ExtendParamsType } from 'model-bank';
import { type ExtendParamsType, ModelProvider } from 'model-bank';
import { composioEnv } from '@/config/composio';
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
@@ -911,6 +911,12 @@ export const createRuntimeExecutors = (
item.providerId === provider &&
(item.id === model || item.config?.deploymentName === model),
);
const canonicalModelCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
const modelKnowledgeCutoff =
modelCard?.knowledgeCutoff ??
(provider === ModelProvider.LobeHub ? canonicalModelCard?.knowledgeCutoff : undefined);
let modelExtendParams = readExtendParams(modelCard);
@@ -920,10 +926,7 @@ export const createRuntimeExecutors = (
// `thinkingLevel` still reach the model. Mirrors the client-side
// `transformToAiModelList` re-namespacing behavior.
if (!modelExtendParams || modelExtendParams.length === 0) {
const canonicalCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
modelExtendParams = readExtendParams(canonicalCard);
modelExtendParams = readExtendParams(canonicalModelCard);
}
const modelSupportsPreserveThinkingFromCard =
@@ -1300,6 +1303,7 @@ export const createRuntimeExecutors = (
},
messages: messagesForContext,
model,
modelKnowledgeCutoff,
provider,
systemRole: agentConfig.systemRole ?? undefined,
toolDiscoveryConfig,
@@ -2700,6 +2704,10 @@ export const createRuntimeExecutors = (
toolResultMaxLength,
topicId: ctx.topicId,
userId: ctx.userId,
// Device-bound cwd folded into deviceSystemInfo at operation
// creation; resume-safe via computeDeviceContext (recovers it
// from the prior tool message's pluginState.metadata).
workingDirectory: state.metadata?.deviceSystemInfo?.workingDirectory,
workspaceId: state.metadata?.workspaceId ?? ctx.workspaceId,
}),
{
@@ -14,6 +14,7 @@ const mockBuiltinModels = vi.hoisted(() => [
{
abilities: { functionCall: true, video: false, vision: true },
id: 'gpt-4',
knowledgeCutoff: '2024-06',
providerId: 'openai',
},
{
@@ -77,6 +78,9 @@ vi.mock('@/business/client/model-bank/loadModels', () => ({
// model-bank is a TypeScript source file that cannot be dynamically imported in vitest
vi.mock('model-bank', () => ({
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
ModelProvider: {
LobeHub: 'lobehub',
},
}));
// composioEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
@@ -1575,6 +1579,87 @@ describe('RuntimeExecutors', () => {
);
});
it('should pass model knowledge cutoff into serverMessagesEngine', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
const instruction = {
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'openai',
},
type: 'call_llm' as const,
};
await executors.call_llm!(instruction, state);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should resolve LobeHub routed model knowledge cutoff by model id fallback', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'lobehub',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should omit model knowledge cutoff for unknown non-LobeHub providers', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'custom-openai',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy.mock.calls[0][0]).toHaveProperty('modelKnowledgeCutoff', undefined);
});
it('should keep current turn when agent historyCount is 0', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
@@ -1,3 +1,4 @@
import { AgentRuntimeErrorType } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { formatErrorEventData } from '../formatErrorEventData';
@@ -62,6 +63,75 @@ describe('formatErrorEventData', () => {
});
describe('business-typed errors (must not be overridden)', () => {
it('preserves traceable runtime payload body for gateway error events', () => {
const out = formatErrorEventData(
{
error: { message: 'Upstream failed', traceId: 'trace-123' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: 'openai',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Upstream failed',
provider: 'openai',
traceId: 'trace-123',
},
error: 'Upstream failed',
errorType: AgentRuntimeErrorType.ProviderBizError,
phase: 'llm_execution',
});
});
it('uses the normalized runtime type for gateway error events', () => {
const out = formatErrorEventData(
{
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: 'lobehub',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Payment required',
provider: 'lobehub',
status: 402,
traceId: 'trace-402',
},
error: 'Payment required',
errorType: AgentRuntimeErrorType.InsufficientQuota,
phase: 'llm_execution',
});
});
it('uses the normalized runtime message when the payload message is a placeholder', () => {
const out = formatErrorEventData(
{
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'error',
provider: 'lobehub',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Payment required',
provider: 'lobehub',
status: 402,
traceId: 'trace-402',
},
error: 'Payment required',
errorType: AgentRuntimeErrorType.InsufficientQuota,
phase: 'llm_execution',
});
});
it('preserves ConversationParentMissing errorType and message even when .cause has PG info', () => {
// Mirrors createConversationParentMissingError from messagePersistErrors.ts:
// the user-facing errorType lives on the error object directly, and the
@@ -1,5 +1,11 @@
import { pickNonEmptyString, toRecord } from '@lobechat/utils/object';
import { formatErrorForState } from './formatErrorForState';
import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
const isErrorType = (value: unknown): value is string | number =>
typeof value === 'string' || typeof value === 'number';
/**
* Normalize an arbitrary thrown value into the shape the runtime stream-event
* protocol expects. Extracts a human-readable `error` string and a best-effort
@@ -23,55 +29,38 @@ import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
* DB failures by SQLSTATE.
*/
export const formatErrorEventData = (error: unknown, phase: string) => {
let errorMessage = 'Unknown error';
let errorType: string | undefined;
// True when `errorType` came from a business-typed field on the error
// payload (step 1 above). Driver class names assigned via `error.name`
// do NOT set this flag, so raw `PostgresError` / `DatabaseError` instances
// still fall through to the PG unwrap step.
let hasBusinessErrorType = false;
if (error && typeof error === 'object') {
const payload = error as { error?: unknown; errorType?: unknown; message?: unknown };
if (typeof payload.errorType === 'string') {
errorType = payload.errorType;
hasBusinessErrorType = true;
}
if (typeof payload.message === 'string' && payload.message.length > 0) {
errorMessage = payload.message;
} else if (typeof payload.error === 'string' && payload.error.length > 0) {
errorMessage = payload.error;
} else if (
payload.error &&
typeof payload.error === 'object' &&
'message' in payload.error &&
typeof payload.error.message === 'string'
) {
errorMessage = payload.error.message;
} else if (error instanceof Error && error.message.length > 0) {
errorMessage = error.message;
} else if (errorType) {
errorMessage = errorType;
}
} else if (error instanceof Error && error.message.length > 0) {
errorMessage = error.message;
errorType = error.name;
} else if (typeof error === 'string' && error.length > 0) {
errorMessage = error;
}
const payload = toRecord(error);
const rawPayloadErrorType = payload?.errorType ?? payload?.type;
const payloadErrorType = isErrorType(rawPayloadErrorType) ? rawPayloadErrorType : undefined;
const structuredError =
error instanceof Error || payloadErrorType === undefined
? undefined
: formatErrorForState(payload);
const body = structuredError?.body;
const hasPayloadErrorType = payloadErrorType !== undefined;
let errorType = hasPayloadErrorType
? String(structuredError?.type ?? payloadErrorType)
: undefined;
const payloadError = payload?.error;
let errorMessage =
pickNonEmptyString(structuredError?.message) ??
pickNonEmptyString(payload?.message) ??
pickNonEmptyString(payloadError) ??
pickNonEmptyString(toRecord(payloadError)?.message) ??
(error instanceof Error ? pickNonEmptyString(error.message) : pickNonEmptyString(error)) ??
errorType ??
'Unknown error';
if (!errorType && error instanceof Error && error.name) {
errorType = error.name;
}
// Enrichment: run PG unwrap whenever no *business-typed* errorType was
// Enrichment: run PG unwrap whenever no payload errorType was
// declared. This covers both Drizzle-wrapped errors (PG info under .cause)
// AND raw top-level driver errors like `PostgresError` / `DatabaseError`
// which carry a specific `name` but are still real PG errors deserving
// `pg_<sqlstate>` classification on the dashboard.
if (!hasBusinessErrorType) {
if (!hasPayloadErrorType) {
const pg = unwrapPgError(error);
if (pg) {
errorMessage = formatPgError(pg);
@@ -80,6 +69,7 @@ export const formatErrorEventData = (error: unknown, phase: string) => {
}
return {
...(body === undefined ? {} : { body }),
error: errorMessage,
errorType,
phase,
@@ -16,7 +16,35 @@ describe('formatErrorForState', () => {
expect(result.type).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
expect(result.message).toBe('Invalid API key');
expect(result.body).toEqual({ detail: 'Unauthorized' });
expect(result.body).toEqual({
detail: 'Unauthorized',
message: 'Invalid API key',
provider: 'openai',
});
});
it('preserves top-level context from ChatCompletionErrorPayload', () => {
const budget = { required: 12 };
const result = formatErrorForState({
budget,
error: { message: 'Budget exceeded' },
errorType: ChatErrorType.FreePlanLimit,
provider: 'lobehub',
});
expect(result).toMatchObject({
attribution: 'user',
body: {
budget,
message: 'Budget exceeded',
provider: 'lobehub',
},
category: 'quota',
httpStatus: 402,
message: 'Budget exceeded',
type: ChatErrorType.FreePlanLimit,
});
});
it('wraps standard Error as InternalServerError', () => {
@@ -180,6 +208,43 @@ describe('formatErrorForState', () => {
expect(result.category).toBe('quota');
});
it('keeps payload.error available when _responseBody is present', () => {
const result = formatErrorForState({
_responseBody: { provider: 'lobehub' },
error: { status: 402 },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'opaque upstream message',
});
expect(result).toMatchObject({
body: {
error: { status: 402 },
message: 'opaque upstream message',
provider: 'lobehub',
},
category: 'quota',
type: AgentRuntimeErrorType.InsufficientQuota,
});
});
it('merges payload status into an existing _responseBody error object', () => {
const result = formatErrorForState({
_responseBody: { error: { message: 'Payment required' }, provider: 'lobehub' },
error: { status: 402 },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'opaque upstream message',
});
expect(result).toMatchObject({
body: {
error: { message: 'Payment required', status: 402 },
provider: 'lobehub',
},
category: 'quota',
type: AgentRuntimeErrorType.InsufficientQuota,
});
});
it('keeps a genuine residual as ProviderBizError (E8002)', () => {
const result = formatErrorForState({
errorType: AgentRuntimeErrorType.ProviderBizError,
@@ -1,5 +1,6 @@
import { getErrorCodeSpec, refineErrorCode } from '@lobechat/model-runtime';
import { AgentRuntimeErrorType, ChatErrorType, type ChatMessageError } from '@lobechat/types';
import { isRecord } from '@lobechat/utils';
/** Pull a usable HTTP status out of the nested upstream error object. */
const extractHttpStatus = (body: unknown): number | undefined => {
@@ -19,6 +20,80 @@ const extractProvider = (body: unknown): string | undefined => {
return typeof p === 'string' ? p : undefined;
};
const extractMessage = (value: unknown): string | undefined => {
if (!isRecord(value)) return undefined;
const message = value.message;
if (typeof message === 'string' && message) return message;
const nestedError = value.error;
if (isRecord(nestedError)) {
const nestedMessage = nestedError.message;
if (typeof nestedMessage === 'string' && nestedMessage) return nestedMessage;
}
};
interface ChatCompletionErrorPayloadLike {
_responseBody?: unknown;
budget?: unknown;
error?: unknown;
errorType: ChatMessageError['type'];
message?: string;
provider?: unknown;
}
const mergePayloadError = (
sourceBody: Record<string, unknown>,
payload: ChatCompletionErrorPayloadLike,
): unknown | undefined => {
if (payload._responseBody === undefined || payload.error === undefined) return undefined;
if (!('error' in sourceBody)) return payload.error;
if (isRecord(sourceBody.error) && isRecord(payload.error)) {
return { ...payload.error, ...sourceBody.error };
}
};
const buildPayloadBody = (
payload: ChatCompletionErrorPayloadLike,
originalError: unknown,
message: string,
): unknown => {
// Runtime payloads often keep UI context (for example quota hints) next to
// `error`, while `error` itself only carries the display message. Merge both
// layers so normalizing `{ errorType, error }` does not drop the fields the
// chat error renderer needs later.
const sourceBody = payload._responseBody ?? payload.error ?? originalError;
const context: Record<string, unknown> = {};
if (payload.budget !== undefined) context.budget = payload.budget;
if (typeof payload.provider === 'string') context.provider = payload.provider;
if (isRecord(sourceBody)) {
const payloadError = mergePayloadError(sourceBody, payload);
return {
...sourceBody,
// `_responseBody` is the display-facing body, but gateway/model-runtime
// still carries status/provider details in `error` for some failures:
// `{ _responseBody: { error: { message } }, error: { status: 402 } }`.
...(payloadError === undefined ? {} : { error: payloadError }),
...(payload.budget !== undefined && !('budget' in sourceBody)
? { budget: payload.budget }
: {}),
...(typeof payload.provider === 'string' && !('provider' in sourceBody)
? { provider: payload.provider }
: {}),
...('message' in sourceBody ? {} : { message }),
};
}
return {
...context,
...(sourceBody === undefined ? {} : { error: sourceBody }),
message,
};
};
/**
* Merge classification metadata from `ERROR_CODE_SPECS` onto a normalized
* `ChatMessageError`. Codes that aren't in the spec table (fallbacks like
@@ -79,14 +154,16 @@ const enrichWithSpec = (formatted: ChatMessageError): ChatMessageError => {
*/
export const formatErrorForState = (error: unknown): ChatMessageError => {
if (error && typeof error === 'object' && 'errorType' in error) {
const payload = error as {
error?: unknown;
errorType: ChatMessageError['type'];
message?: string;
};
const payload = error as ChatCompletionErrorPayloadLike;
const message =
(payload.message && payload.message !== 'error' ? payload.message : undefined) ??
extractMessage(payload._responseBody) ??
extractMessage(payload.error) ??
String(payload.errorType);
return enrichWithSpec({
body: payload.error || error,
message: payload.message || String(payload.errorType),
body: buildPayloadBody(payload, error, message),
message,
type: payload.errorType,
});
}
@@ -161,7 +161,7 @@ export const createServerAgentToolsEngine = (
const executionTarget =
executionPlan?.target ??
resolveExecutionTarget(agentConfig.agencyConfig, {
isDesktop: platform === 'desktop',
clientExecutionAvailable: platform === 'desktop',
});
const runtimeMode: RuntimeEnvMode = executionTargetToRuntimeMode(executionTarget);
// Device tools (local-system, remote-device proxy) only exist for
@@ -70,6 +70,25 @@ describe('serverMessagesEngine', () => {
expect(result[0].content).toBe(systemRole + '\n\n' + getCurrentDateContent());
});
it('should inject model knowledge cutoff when provided', async () => {
const messages = createBasicMessages();
const result = await serverMessagesEngine({
messages,
model: 'gpt-4',
modelKnowledgeCutoff: '2024-06',
provider: 'openai',
systemRole: 'You are a helpful assistant',
});
expect(result[0].role).toBe('system');
expect(result[0].content).toBe(
'You are a helpful assistant\n\n' +
getCurrentDateContent() +
'\n\nModel knowledge cutoff: 2024-06',
);
});
it('should handle empty messages', async () => {
const result = await serverMessagesEngine({
messages: [],
@@ -51,6 +51,7 @@ const createServerVariableGenerators = (params: {
export const serverMessagesEngine = async ({
messages = [],
model,
modelKnowledgeCutoff,
provider,
systemRole,
inputTemplate,
@@ -121,6 +122,7 @@ export const serverMessagesEngine = async ({
// Model info
model,
modelKnowledgeCutoff,
provider,
systemRole,
@@ -132,6 +132,8 @@ export interface ServerMessagesEngineParams {
/** Model ID */
model: string;
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
modelKnowledgeCutoff?: string;
/** Page content context (optional, for document editing) */
pageContentContext?: PageContentContext;
+6 -8
View File
@@ -20,8 +20,8 @@ import { GenerationModel } from '@/database/models/generation';
import { asyncAuthedProcedure, asyncRouter as router } from '@/libs/trpc/async';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { VideoGenerationService } from '@/server/services/generation/video';
import { buildVideoGenerationFilePayload } from '@/server/services/generation/videoFile';
import { FileSource } from '@/types/files';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
const log = debug('lobe-video:async');
@@ -196,13 +196,11 @@ export const videoRouter = router({
url: processResult.videoKey,
width: processResult.width,
},
{
fileHash: processResult.fileHash,
fileType: processResult.mimeType,
name: `${sanitizeFileName(batch?.prompt ?? '', generationId)}.mp4`,
size: processResult.fileSize,
url: processResult.videoKey,
},
buildVideoGenerationFilePayload({
generationId,
processResult,
prompt: batch?.prompt,
}),
FileSource.VideoGeneration,
);
+2 -12
View File
@@ -1,4 +1,5 @@
import {
chatTopicStatusSchema,
type RecentTopic,
type RecentTopicGroup,
type RecentTopicGroupMember,
@@ -614,18 +615,7 @@ export const topicRouter = router({
})
.optional(),
sessionId: z.string().optional(),
status: z
.enum([
'active',
'running',
'paused',
'waitingForHuman',
'failed',
'completed',
'archived',
])
.nullable()
.optional(),
status: chatTopicStatusSchema.nullable().optional(),
title: z.string().optional(),
}),
}),
@@ -7,6 +7,7 @@ import {
} from '@/database/models/agentOperation';
import { MessageModel } from '@/database/models/message';
import { type LobeChatDatabase } from '@/database/type';
import { formatErrorForState } from '@/server/modules/AgentRuntime/formatErrorForState';
import { buildFinalSnapshotKey } from '@/server/modules/AgentTracing';
import { emitAgentSignalSourceEvent } from '@/server/services/agentSignal';
import { toAgentSignalTraceEvents } from '@/server/services/agentSignal/observability/traceEvents';
@@ -361,13 +362,20 @@ export class CompletionLifecycle {
const assistantMessageId = metadata?.assistantMessageId;
if (assistantMessageId && state?.error) {
const errorMessage = this.extractErrorMessage(state.error) || String(state.error);
// Preserve the semantic error type written by the runtime. Rebuilding
// this as a generic AgentRuntimeError would lose UI routing data such
// as quota context and force the client into the fallback card.
const messageError = formatErrorForState(state.error);
const errorMessage =
this.extractErrorMessage(messageError) ||
this.extractErrorMessage(state.error) ||
String(state.error);
try {
await this.messageModel.update(assistantMessageId, {
error: {
body: { message: errorMessage },
...messageError,
body: messageError.body ?? { message: errorMessage },
message: errorMessage,
type: 'AgentRuntimeError',
},
});
} catch (updateError) {
@@ -1,4 +1,5 @@
// @vitest-environment node
import { ChatErrorType } from '@lobechat/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CompletionLifecycle } from '../CompletionLifecycle';
@@ -197,6 +198,50 @@ describe('CompletionLifecycle.buildLifecycleEvent', () => {
});
});
describe('CompletionLifecycle.dispatchHooks — error persistence', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('persists budget errors without downgrading them to AgentRuntimeError', async () => {
const lifecycle = buildLifecycle();
const updateMessage = vi.fn().mockResolvedValue({ success: true });
const budget = { required: 12 };
(lifecycle as any).messageModel = { update: updateMessage };
vi.spyOn(lifecycle as any, 'persistCompletion').mockResolvedValue(undefined);
vi.spyOn(hookDispatcher, 'dispatch').mockResolvedValue(undefined as any);
vi.spyOn(hookDispatcher, 'unregister').mockImplementation(() => {});
await lifecycle.dispatchHooks(
'op-1',
{
error: {
budget,
error: { message: 'Budget exceeded' },
errorType: ChatErrorType.FreePlanLimit,
provider: 'lobehub',
},
metadata: { _hooks: [], assistantMessageId: 'msg-1' },
status: 'error',
},
'error',
);
expect(updateMessage).toHaveBeenCalledWith('msg-1', {
error: expect.objectContaining({
body: expect.objectContaining({
budget,
message: 'Budget exceeded',
provider: 'lobehub',
}),
message: 'Budget exceeded',
type: ChatErrorType.FreePlanLimit,
}),
});
});
});
describe('CompletionLifecycle.dispatchHooks — async-tool park', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -180,10 +180,35 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
platform: 'darwin' as const,
};
// Override the agent's agencyConfig and rebuild the service. Auto-activation
// is now exclusive to `executionTarget: 'auto'` — the default (`local`) never
// grabs a device — so the auto-activation specs opt in explicitly.
const useAgencyConfig = async (agencyConfig: Record<string, unknown>) => {
const { AgentService } = await import('@/server/services/agent');
vi.mocked(AgentService).mockImplementation(
() =>
({
getAgentConfig: vi.fn().mockResolvedValue({
agencyConfig,
chatConfig: {},
files: [],
id: 'agent-1',
knowledgeBases: [],
model: 'gpt-4',
plugins: [],
provider: 'openai',
systemRole: 'You are a helpful assistant',
}),
}) as any,
);
service = new AiAgentService(mockDb, userId);
};
describe('IM/Bot scenario with botContext', () => {
it('should auto-activate when exactly one device is online', async () => {
it('should auto-activate when exactly one device is online (executionTarget: auto)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -202,9 +227,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
expect(createOpArgs.activeDeviceId).toBe('device-001');
});
it('should NOT auto-activate when multiple devices are online', async () => {
it('should NOT auto-activate when multiple devices are online (executionTarget: auto)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice, onlineDevice2]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -223,9 +249,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
it('should NOT auto-activate when no devices are online', async () => {
it('should NOT auto-activate when no devices are online (executionTarget: auto)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -243,12 +270,35 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
const createOpArgs = mockCreateOperation.mock.calls[0][0];
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
it('should NOT auto-activate the single online device by default (executionTarget unset → local)', async () => {
// The default mode never grabs a device — only explicit `auto` does.
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({}); // unset executionTarget → default `local`
await service.execAgent({
agentId: 'agent-1',
botContext: {
applicationId: 'app-1',
isOwner: true,
platform: 'discord',
platformThreadId: 'discord:guild-1:channel-1',
senderExternalUserId: 'owner-id',
} as any,
prompt: 'List my files',
});
const createOpArgs = mockCreateOperation.mock.calls[0][0];
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
});
describe('IM/Bot scenario with discordContext', () => {
it('should auto-activate when exactly one device is online', async () => {
it('should auto-activate when exactly one device is online (executionTarget: auto)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -263,16 +313,15 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
});
describe('Web UI scenario (no botContext/discordContext)', () => {
// regular chat used to leave activeDeviceId undefined when no
// device was bound, which caused the local-system system prompt's
// {{workingDirectory}} / {{hostname}} placeholders to reach the LLM as
// literals. The model would then waste the first N steps groping for cwd.
// Now we auto-activate when exactly one device is online — multi-device
// users still need to bind explicitly, since picking one by recency
// would be a guess that could route tool calls to the wrong machine.
it('should auto-activate the only online device', async () => {
// In `auto` mode a single online device is activated up-front, so the
// local-system system prompt's {{workingDirectory}} / {{hostname}}
// placeholders resolve instead of reaching the LLM as literals. Multi-device
// users still pick explicitly (the model selects via the remote-device
// tool). The default mode never auto-activates.
it('should auto-activate the only online device (executionTarget: auto)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -284,9 +333,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
expect(createOpArgs.activeDeviceId).toBe('device-001');
});
it('should NOT auto-activate when multiple devices are online', async () => {
it('should NOT auto-activate when multiple devices are online (executionTarget: auto)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice, onlineDevice2]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -297,9 +347,24 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
it('should NOT auto-activate when no devices are online', async () => {
it('should NOT auto-activate when no devices are online (executionTarget: auto)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([]);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
prompt: 'List my files',
});
const createOpArgs = mockCreateOperation.mock.calls[0][0];
expect(createOpArgs.activeDeviceId).toBeUndefined();
});
it('should NOT auto-activate the single online device by default (unset → local)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
await useAgencyConfig({}); // unset executionTarget → default `local`
await service.execAgent({
agentId: 'agent-1',
@@ -482,33 +547,16 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
});
// Verifies topic-stored metadata.boundDeviceId is NOT silently reused as
// the runtime bound device. Setup: topic.metadata says device-002, but the
// only online device is device-001. If the topic metadata were reused as
// boundDeviceId, activeDeviceId would be undefined (device-002 is offline).
// After auto-activate, we instead pick the most-recent online
// the runtime bound device. Setup: `auto` mode, topic.metadata says
// device-002, but the only online device is device-001. If the topic
// metadata were reused as boundDeviceId, activeDeviceId would be undefined
// (device-002 is offline). Auto-activation instead picks the single online
// device (device-001) — proving the topic's stale metadata wasn't honored.
it('should not reuse topic boundDeviceId when no explicit deviceId is provided', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
topicMock.findById.mockResolvedValue({ metadata: { boundDeviceId: 'device-002' } });
const { AgentService } = await import('@/server/services/agent');
vi.mocked(AgentService).mockImplementation(
() =>
({
getAgentConfig: vi.fn().mockResolvedValue({
chatConfig: {},
files: [],
id: 'agent-1',
knowledgeBases: [],
model: 'gpt-4',
plugins: [],
provider: 'openai',
systemRole: 'You are a helpful assistant',
}),
}) as any,
);
service = new AiAgentService(mockDb, userId);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -585,11 +633,11 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
});
// Mirrors the "should not reuse topic boundDeviceId" test above with a
// different mock shape. Topic metadata stores device-002, but only
// device-001 is online; if topic metadata leaked into boundDeviceId,
// different mock shape. `auto` mode, topic metadata stores device-002, but
// only device-001 is online; if topic metadata leaked into boundDeviceId,
// activeDeviceId would be undefined (since device-002 is offline). The
// post-auto-activate picks device-001 instead, confirming the
// stale topic.metadata.boundDeviceId path is dead.
// auto-activation picks device-001 instead, confirming the stale
// topic.metadata.boundDeviceId path is dead.
it('should not reuse topic metadata bound device when no deviceId is supplied', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
@@ -597,6 +645,7 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
id: 'topic-1',
metadata: { boundDeviceId: 'device-002' },
});
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -635,27 +684,10 @@ describe('AiAgentService.execAgent - device auto-activation', () => {
});
describe('Remote Device tool injection when device is auto-activated', () => {
it('should mark autoActivated when single device is auto-activated (IM/Bot)', async () => {
it('should mark autoActivated when single device is auto-activated (IM/Bot, executionTarget: auto)', async () => {
mockDeviceProxy.isConfigured = true;
mockDeviceProxy.queryDeviceList.mockResolvedValue([onlineDevice]);
const { AgentService } = await import('@/server/services/agent');
vi.mocked(AgentService).mockImplementation(
() =>
({
getAgentConfig: vi.fn().mockResolvedValue({
chatConfig: {},
files: [],
id: 'agent-1',
knowledgeBases: [],
model: 'gpt-4',
plugins: [],
provider: 'openai',
systemRole: 'You are a helpful assistant',
}),
}) as any,
);
service = new AiAgentService(mockDb, userId);
await useAgencyConfig({ executionTarget: 'auto' });
await service.execAgent({
agentId: 'agent-1',
@@ -215,7 +215,9 @@ describe('AiAgentService.execAgent - device tool pipeline ()', () => {
{ deviceId: 'dev-1', deviceName: 'My PC', platform: 'win32' },
]);
mockGetAgentConfig.mockResolvedValue(createBaseAgentConfig());
mockGetAgentConfig.mockResolvedValue(
createBaseAgentConfig({ agencyConfig: { executionTarget: 'auto' } }),
);
await service.execAgent({ agentId: 'agent-1', prompt: 'Hello' });
+57 -21
View File
@@ -277,6 +277,22 @@ interface InternalExecAgentParams extends ExecAgentParams {
userInterventionConfig?: UserInterventionConfig;
}
/**
* Result of {@link AiAgentService.resolveWorkspaceInit}: the cacheable scan
* (`workspace`) plus the per-run resolved bound directory (`boundCwd`).
*
* `boundCwd` is deliberately kept OUT of {@link WorkspaceInitResult}: that type
* is persisted into `devices.workingDirs[].workspace` and read by the web UI,
* and its scanned root is always the enclosing `WorkingDirEntry.path` not a
* field on the scan. Surfacing it here lets the caller fill the system prompt's
* `{{workingDirectory}}` (and the tool cwd/scope downstream) without re-loading
* the device + topic the scan already read.
*/
interface ResolvedWorkspaceInit {
boundCwd?: string;
workspace: WorkspaceInitResult;
}
/**
* AI Agent Service
*
@@ -370,18 +386,22 @@ export class AiAgentService {
activeDeviceId: string | undefined;
agencyConfig?: LobeAgentAgencyConfig;
topicId: string;
}): Promise<WorkspaceInitResult> {
}): Promise<ResolvedWorkspaceInit> {
const empty: WorkspaceInitResult = { instructions: [], skills: [] };
const { activeDeviceId, agencyConfig, topicId } = params;
if (!activeDeviceId) return empty;
if (!activeDeviceId) return { workspace: empty };
try {
const deviceModel = new DeviceModel(this.db, this.userId);
const device = await deviceModel.findByDeviceId(activeDeviceId);
if (!device) return empty;
if (!device) return { workspace: empty };
// The bound project root we scan — resolved via the shared precedence
// helper so it cannot drift from hetero dispatch / topic backfill.
// helper so it cannot drift from hetero dispatch / topic backfill. Read
// from the persisted `device.defaultCwd` (not a live device query, which
// only reports the daemon's process.cwd = `/`); also returned to the
// caller so the system prompt's {{workingDirectory}} reflects the same
// bound directory the workspace scan used.
const topic = await this.topicModel.findById(topicId);
const boundCwd = resolveDeviceWorkingDirectory({
deviceDefaultCwd: device.defaultCwd,
@@ -389,14 +409,14 @@ export class AiAgentService {
topicWorkingDirectory: topic?.metadata?.workingDirectory,
workingDirByDevice: agencyConfig?.workingDirByDevice,
});
if (!boundCwd) return empty;
if (!boundCwd) return { workspace: empty };
const workingDirs = device.workingDirs ?? [];
const cached = workingDirs.find((dir) => dir.path === boundCwd);
if (isWorkspaceCacheFresh(cached, Date.now()) && cached?.workspace) {
log('execAgent: reusing cached workspace init for %s', boundCwd);
return cached.workspace;
return { boundCwd, workspace: cached.workspace };
}
const scanned = await deviceGateway.initWorkspace({
@@ -409,9 +429,9 @@ export class AiAgentService {
// cache rather than dropping the project's skills + instructions.
if (cached?.workspace) {
log('execAgent: workspace init scan failed, using stale cache for %s', boundCwd);
return cached.workspace;
return { boundCwd, workspace: cached.workspace };
}
return empty;
return { boundCwd, workspace: empty };
}
// Persist the fresh scan back onto `workingDirs` (update in place or prepend
@@ -420,10 +440,10 @@ export class AiAgentService {
await deviceModel.update(activeDeviceId, { workingDirs: updated });
log('execAgent: scanned and cached workspace init for %s', boundCwd);
return scanned;
return { boundCwd, workspace: scanned };
} catch (error) {
log('execAgent: resolveWorkspaceInit failed: %O', error);
return empty;
return { workspace: empty };
}
}
@@ -1450,9 +1470,10 @@ export class AiAgentService {
const heteroPlan = resolveExecutionPlan({
agencyConfig: agentConfig.agencyConfig,
canUseDevice,
isDesktop: false,
isHetero: true,
clientExecutionAvailable: false,
requestedDeviceId,
trigger: requestTriggerMetadata?.trigger,
});
if (heteroPlan.kind !== 'sandbox') {
@@ -1913,9 +1934,9 @@ export class AiAgentService {
// engine's enabledToolIds exclusion — resolving the plan here closes
// that bypass at the source.
//
// `isDesktop` uses `gatewayConfigured` as a proxy: a device-gateway
// deployment serves desktop-class users, so the unset-target default
// resolves to `local` there and `none` otherwise.
// `clientExecutionAvailable` is `gatewayConfigured` here: a server with a
// device gateway can tunnel a `local` target to the user's device, so the
// unset-target default resolves to `local` there and `none` otherwise.
//
// Chat mode is orthogonal to `executionTarget` (the UI toggle only writes
// `enableAgentMode`), so a default/stored `local` target would otherwise
@@ -1927,9 +1948,10 @@ export class AiAgentService {
agencyConfig: agentConfig.agencyConfig,
canUseDevice,
chatConfig: agentConfig.chatConfig ?? undefined,
isDesktop: gatewayConfigured,
clientExecutionAvailable: gatewayConfigured,
onlineDeviceIds: onlineDevices.map((device) => device.deviceId),
requestedDeviceId,
trigger: requestTriggerMetadata?.trigger,
});
// Device tools (local-system / remote-device proxy) only exist in a
// device-capable session — `none` and `sandbox` sessions must never see
@@ -2233,7 +2255,11 @@ export class AiAgentService {
platform: device?.platform ?? 'unknown',
userDataPath: systemInfo.userDataPath,
videosPath: systemInfo.videosPath,
workingDirectory: systemInfo.workingDirectory,
// `workingDirectory` is intentionally NOT taken from the live device
// query — it only reports the daemon's process.cwd() (= `/` for a
// Finder/Dock-launched app). The bound directory is resolved from the
// persisted device row in resolveWorkspaceInit and written onto
// deviceSystemInfo.workingDirectory at the call site below.
};
} catch (error) {
log('execAgent: failed to fetch device system info: %O', error);
@@ -2655,7 +2681,17 @@ export class AiAgentService {
topicId,
});
const projectMetas = workspaceInit.skills.map((s) => ({
// Feed the bound directory (resolved from the persisted device row) into
// the local-system tool's {{workingDirectory}} placeholder — the channel
// the model uses to know where it is and reach for absolute paths — and,
// downstream, the runCommand cwd / search scope (RuntimeExecutors reads
// state.metadata.deviceSystemInfo.workingDirectory). Resume-safe via the
// existing deviceSystemInfo plumbing (computeDeviceContext).
if (workspaceInit.boundCwd) {
deviceSystemInfo.workingDirectory = workspaceInit.boundCwd;
}
const projectMetas = workspaceInit.workspace.skills.map((s) => ({
description: s.description ?? '',
identifier: `project:${s.name}`,
location: s.path,
@@ -2675,8 +2711,8 @@ export class AiAgentService {
// trailing blocks on the system role — after the agent's persona and any
// page/task/additional instructions. `agentConfig` is read by
// `createOperation` below, so appending here still reaches the LLM.
if (workspaceInit.instructions.length) {
const block = workspaceInit.instructions
if (workspaceInit.workspace.instructions.length) {
const block = workspaceInit.workspace.instructions
.map(
({ content, source }) =>
`<project_instructions source="${source}">\n${content}\n</project_instructions>`,
@@ -2687,8 +2723,8 @@ export class AiAgentService {
: block;
log(
'execAgent: injected %d project instruction file(s): %s',
workspaceInit.instructions.length,
workspaceInit.instructions.map((i) => i.source).join(', '),
workspaceInit.workspace.instructions.length,
workspaceInit.workspace.instructions.map((i) => i.source).join(', '),
);
}
@@ -129,8 +129,14 @@ export async function ingestAttachment(
throw new Error('AttachmentSource must have either buffer or url');
}
// 2. MIME correction from filename
if (mimeType === 'application/octet-stream' && source.name) {
// 2. MIME correction from filename.
// Recover whenever we don't have a usable MIME type — both the generic
// `application/octet-stream` and bogus non-MIME values some platforms send
// (e.g. QQ labels c2c file attachments as `"file"`). Without the `includes('/')`
// guard, a value like `"file"` slips through and an `.m4a` never gets
// classified as audio, so it's parsed as a document instead of passed to
// audio-capable models.
if ((mimeType === 'application/octet-stream' || !mimeType.includes('/')) && source.name) {
const inferred = mime.getType(source.name);
if (inferred) {
log('ingestAttachment: inferred mimeType from filename: %s -> %s', source.name, inferred);
@@ -301,6 +301,34 @@ describe('FileService', () => {
expect(result).toBe(expectedResult);
});
describe('uploadBase64', () => {
beforeEach(() => {
mockFileModel.checkHash = vi.fn().mockResolvedValue({ isExist: false });
mockFileModel.create = vi.fn().mockResolvedValue({ id: 'new-file-id' });
vi.mocked(service['impl'].uploadMedia).mockResolvedValue({
key: 'assets/generations/2026-06-19/generated.png',
});
});
it('should write metadata compatible with UI upload path', async () => {
await service.uploadBase64(
Buffer.from('test content').toString('base64'),
'assets/generations/2026-06-19/generated.png',
);
expect(mockFileModel.create).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
dirname: 'assets/generations/2026-06-19',
filename: 'generated.png',
path: 'assets/generations/2026-06-19/generated.png',
}),
}),
expect.any(Boolean),
);
});
});
describe('uploadFromBuffer', () => {
beforeEach(() => {
mockFileModel.checkHash = vi.fn().mockResolvedValue({ isExist: false });
+8
View File
@@ -329,6 +329,7 @@ export class FileService {
// Extract filename from pathname
const name = pathname.split('/').pop() || 'unknown';
const dirname = pathname.split('/').slice(0, -1).join('/');
// Calculate file metadata
const size = buffer.length;
@@ -343,6 +344,13 @@ export class FileService {
fileHash: hash,
fileType,
id: fileId, // Use UUID instead of auto-generated ID
// Keep generated/base64 uploads compatible with UI hash-dedup, which reads metadata.path.
metadata: {
date: new Date().toISOString().slice(0, 10),
dirname,
filename: name,
path: key,
},
name,
size,
url: key, // Store original key (S3 key or desktop://)
@@ -123,6 +123,15 @@ describe('videoBackgroundPolling', () => {
expect.objectContaining({
fileHash: 'hash-abc',
fileType: 'video/mp4',
metadata: expect.objectContaining({
dirname: '',
duration: 10,
filename: 'test-prompt-gen-456.mp4',
generationId: 'gen-456',
height: 1080,
path: 'video-key-789',
width: 1920,
}),
name: 'test-prompt-gen-456.mp4',
size: 1024,
url: 'video-key-789',
@@ -8,10 +8,10 @@ import { GenerationModel } from '@/database/models/generation';
import type { LobeChatDatabase } from '@/database/type';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { VideoGenerationService } from '@/server/services/generation/video';
import { buildVideoGenerationFilePayload } from '@/server/services/generation/videoFile';
import { AsyncTaskError, AsyncTaskErrorType, AsyncTaskStatus } from '@/types/asyncTask';
import { FileSource } from '@/types/files';
import type { VideoGenerationAsset } from '@/types/generation';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
const log = debug('lobe-video:background-polling');
@@ -88,13 +88,11 @@ export async function processBackgroundVideoPolling(
await generationModel.createAssetAndFile(
generationId,
asset,
{
fileHash: processResult.fileHash,
fileType: processResult.mimeType,
name: `${sanitizeFileName(batch?.prompt ?? '', generationId)}.mp4`,
size: processResult.fileSize,
url: processResult.videoKey,
},
buildVideoGenerationFilePayload({
generationId,
processResult,
prompt: batch?.prompt,
}),
FileSource.VideoGeneration,
);
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildVideoGenerationFilePayload } from './videoFile';
describe('buildVideoGenerationFilePayload', () => {
afterEach(() => {
vi.useRealTimers();
});
it('should include upload metadata for generated video hash dedup', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-19T10:20:30Z'));
const payload = buildVideoGenerationFilePayload({
generationId: 'gen-456',
processResult: {
coverKey: 'generations/videos/video_cover.webp',
duration: 12,
fileHash: 'hash-abc',
fileSize: 1024,
height: 1080,
mimeType: 'video/mp4',
thumbnailKey: 'generations/videos/video_thumb.webp',
videoKey: 'generations/videos/video_raw.mp4',
width: 1920,
},
prompt: 'test prompt',
});
expect(payload).toEqual({
fileHash: 'hash-abc',
fileType: 'video/mp4',
metadata: {
date: '2026-06-19',
dirname: 'generations/videos',
duration: 12,
filename: 'test prompt.mp4',
generationId: 'gen-456',
height: 1080,
path: 'generations/videos/video_raw.mp4',
width: 1920,
},
name: 'test prompt.mp4',
size: 1024,
url: 'generations/videos/video_raw.mp4',
});
});
});
@@ -0,0 +1,38 @@
import type { VideoProcessResult } from '@/server/services/generation/video';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
interface BuildVideoGenerationFilePayloadParams {
generationId: string;
processResult: VideoProcessResult;
prompt?: string | null;
}
/**
* Keeps generated video files compatible with UI hash dedup, which reads metadata.path.
*/
export const buildVideoGenerationFilePayload = ({
generationId,
processResult,
prompt,
}: BuildVideoGenerationFilePayloadParams) => {
const name = `${sanitizeFileName(prompt ?? '', generationId)}.mp4`;
const dirname = processResult.videoKey.split('/').slice(0, -1).join('/');
return {
fileHash: processResult.fileHash,
fileType: processResult.mimeType,
metadata: {
date: new Date().toISOString().slice(0, 10),
dirname,
duration: processResult.duration,
filename: name,
generationId,
height: processResult.height,
path: processResult.videoKey,
width: processResult.width,
},
name,
size: processResult.fileSize,
url: processResult.videoKey,
};
};
@@ -529,7 +529,7 @@ export class HeterogeneousPersistenceHandler {
if (snapshot.model) state.main.turnModel = snapshot.model;
if (snapshot.provider) state.main.turnProvider = snapshot.provider;
// Recover the chain spine from the DB (LOBE-10445 phase 2). The next normal
// Recover the chain spine from the DB. The next normal
// turn parents off the run's latest NON-tool / NON-signal main-thread
// message; reading it straight from the DB (independent of
// `currentAssistantId`, which can regress to the seed placeholder on a cold
@@ -649,7 +649,7 @@ export class HeterogeneousPersistenceHandler {
if (!currentAssistant) return undefined;
const toolRows = messages.filter((m) => m.role === 'tool' && m.tool_call_id);
// Chain rule (LOBE-10445 phase 2): the next turn's assistant parents off the
// Chain rule: the next turn's assistant parents off the
// prior assistant (the spine), not its last child tool — recover the anchor
// as the current assistant itself (matches the subagent reducer, and is
// fork-resistant since it reads the thread's real latest assistant from DB).
@@ -18,7 +18,7 @@ import {
* chain parent were derived from that in-memory pointer, every later `newStep`
* would open off the seed orphan sibling forks.
*
* LOBE-10445 phase 2 anchors the chain to the run's latest NON-tool / NON-signal
* The phase 2 rewrite anchors the chain to the run's latest NON-tool / NON-signal
* main-thread message (`getLastMainThreadSpineMessageId`), read straight from the
* DB and ordered by createdAt independent of `currentAssistantId`. So step 2
* chains off step 1's assistant even though the in-memory pointer regressed.
@@ -1,4 +1,8 @@
import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import {
LocalSystemApiName,
LocalSystemIdentifier,
LocalSystemManifest,
} from '@lobechat/builtin-tool-local-system';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type ToolExecutionContext } from '../../types';
@@ -114,4 +118,77 @@ describe('localSystemRuntime', () => {
);
});
});
describe('working directory injection', () => {
const parseArgs = () => JSON.parse(mockExecuteToolCall.mock.calls[0][1].arguments);
const buildProxy = (workingDirectory?: string) => {
mockExecuteToolCall.mockResolvedValue({ content: '', success: true });
return localSystemRuntime.factory({
activeDeviceId: 'device-1',
toolManifestMap: {},
userId: 'user-1',
workingDirectory,
});
};
it('injects cwd into runCommand when the model omits it', async () => {
const proxy = buildProxy('/Users/me/repo');
await proxy[LocalSystemApiName.runCommand]({ command: 'git status' });
expect(parseArgs()).toEqual({ command: 'git status', cwd: '/Users/me/repo' });
});
it('injects scope into search ops that honor it', async () => {
const proxy = buildProxy('/Users/me/repo');
await proxy[LocalSystemApiName.grepContent]({ pattern: 'TODO' });
expect(parseArgs()).toEqual({ pattern: 'TODO', scope: '/Users/me/repo' });
});
it('does not override an explicit cwd/scope supplied by the model', async () => {
const proxy = buildProxy('/Users/me/repo');
await proxy[LocalSystemApiName.runCommand]({ command: 'ls', cwd: '/explicit' });
expect(parseArgs()).toEqual({ command: 'ls', cwd: '/explicit' });
});
it('injects cwd into file ops so the daemon can resolve a relative path', async () => {
const proxy = buildProxy('/Users/me/repo');
await proxy[LocalSystemApiName.readFile]({ path: 'src/index.ts' });
// The daemon's resolveAgainstCwd anchors the relative path to cwd; an
// absolute path the model supplies passes through unchanged there.
expect(parseArgs()).toEqual({ cwd: '/Users/me/repo', path: 'src/index.ts' });
});
it('injects cwd into writeFile / editFile / moveFiles', async () => {
for (const api of [
LocalSystemApiName.writeFile,
LocalSystemApiName.editFile,
LocalSystemApiName.moveFiles,
]) {
mockExecuteToolCall.mockClear();
const proxy = buildProxy('/Users/me/repo');
await proxy[api]({ path: 'x' });
expect(JSON.parse(mockExecuteToolCall.mock.calls[0][1].arguments).cwd).toBe(
'/Users/me/repo',
);
}
});
it('does not inject for command-id ops (getCommandOutput / killCommand)', async () => {
const proxy = buildProxy('/Users/me/repo');
await proxy[LocalSystemApiName.getCommandOutput]({ shell_id: 'cmd-1' });
expect(parseArgs()).toEqual({ shell_id: 'cmd-1' });
});
it('leaves args untouched when no working directory is bound', async () => {
const proxy = buildProxy(undefined);
await proxy[LocalSystemApiName.runCommand]({ command: 'pwd' });
expect(parseArgs()).toEqual({ command: 'pwd' });
});
});
});
@@ -1,9 +1,44 @@
import { LocalSystemIdentifier, LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import {
LocalSystemApiName,
LocalSystemIdentifier,
LocalSystemManifest,
} from '@lobechat/builtin-tool-local-system';
import { deviceGateway } from '@/server/services/deviceGateway';
import { type ServerRuntimeRegistration } from './types';
/**
* Which arg carries the working directory for the APIs that consume one. The
* model never picks the working directory the system prompt's
* `{{workingDirectory}}` tells it where it is so the runtime injects it as the
* tool call's cwd/scope. `executeToolCall` only forwards `arguments`, so it must
* ride in the args; the daemon otherwise falls back to `process.cwd()` (= `/`
* for a Finder/Dock-launched app):
*
* - `runCommand → cwd`: the manifest deliberately hides `cwd`, but the daemon
* spawns in `params.cwd`.
* - file ops (`readFile`/`writeFile`/`editFile`/`moveFiles`) `cwd`:
* the daemon resolves a relative `path`/`file_path`/move item against
* `params.cwd` (see `resolveAgainstCwd`), so a model-supplied relative path
* lands in the bound directory instead of `/`. Absolute paths ignore it.
* - search ops (`searchFiles`/`globFiles`/`grepContent`) `scope`: their
* manifest claims `scope` "defaults to the working directory", but the daemon
* falls back to `process.cwd()`. Inject `scope` so that promise holds.
*
* APIs that act on a command id (getCommandOutput / killCommand) take neither.
*/
const WORKING_DIR_ARG: Partial<Record<string, 'cwd' | 'scope'>> = {
[LocalSystemApiName.editFile]: 'cwd',
[LocalSystemApiName.globFiles]: 'scope',
[LocalSystemApiName.grepContent]: 'scope',
[LocalSystemApiName.moveFiles]: 'cwd',
[LocalSystemApiName.readFile]: 'cwd',
[LocalSystemApiName.runCommand]: 'cwd',
[LocalSystemApiName.searchFiles]: 'scope',
[LocalSystemApiName.writeFile]: 'cwd',
};
export const localSystemRuntime: ServerRuntimeRegistration = {
factory: (context) => {
if (!context.userId) {
@@ -16,7 +51,15 @@ export const localSystemRuntime: ServerRuntimeRegistration = {
const proxy: Record<string, (args: any) => Promise<any>> = {};
for (const api of LocalSystemManifest.api) {
const workingDirArg = WORKING_DIR_ARG[api.name];
proxy[api.name] = async (args: any) => {
// Inject the device-bound cwd/scope when the model didn't supply one.
// `??=` leaves an explicit per-call override possible for the future.
const finalArgs =
workingDirArg && context.workingDirectory && args?.[workingDirArg] == null
? { ...args, [workingDirArg]: context.workingDirectory }
: args;
return deviceGateway.executeToolCall(
{
deviceId: context.activeDeviceId!,
@@ -25,7 +68,7 @@ export const localSystemRuntime: ServerRuntimeRegistration = {
},
{
apiName: api.name,
arguments: JSON.stringify(args),
arguments: JSON.stringify(finalArgs),
identifier: LocalSystemIdentifier,
},
context.executionTimeoutMs,
@@ -185,6 +185,17 @@ export interface ToolExecutionContext {
/** Topic ID for sandbox session management */
topicId?: string;
userId?: string;
/**
* Device-bound working directory resolved when the operation was created
* (`resolveDeviceWorkingDirectory`: topic override > workingDirByDevice >
* device default). Injected by device-proxy runtimes as the tool call's
* cwd/scope so commands and file ops land in the bound directory instead of
* the daemon's `process.cwd()` (= `/` for a Finder/Dock-launched app).
*
* NOT the conversation `scope` above that is the operation's thread/group
* scope and is unrelated to the filesystem working directory.
*/
workingDirectory?: string;
/**
* Workspace ID that scopes ownership for any model/service the runtime
* instantiates. When unset the runtime falls back to personal mode
+6 -8
View File
@@ -132,7 +132,6 @@ table agent_cron_jobs {
enabled [name: 'agent_cron_jobs_enabled_idx']
remaining_executions [name: 'agent_cron_jobs_remaining_executions_idx']
last_executed_at [name: 'agent_cron_jobs_last_executed_at_idx']
workspace_id [name: 'agent_cron_jobs_workspace_id_idx']
}
}
@@ -390,7 +389,6 @@ table agent_operations {
status [name: 'agent_operations_status_idx']
(user_id, created_at) [name: 'agent_operations_user_id_created_at_idx']
metadata [name: 'agent_operations_metadata_idx']
workspace_id [name: 'agent_operations_workspace_id_idx']
}
}
@@ -440,7 +438,7 @@ table agent_skills {
table ai_models {
id varchar(150) [not null]
"_id" uuid [default: `gen_random_uuid()`]
"_id" uuid [pk, not null, default: `gen_random_uuid()`]
display_name varchar(200)
description text
organization varchar(100)
@@ -473,7 +471,7 @@ table ai_models {
table ai_providers {
id varchar(64) [not null]
name text
"_id" uuid [default: `gen_random_uuid()`]
"_id" uuid [pk, not null, default: `gen_random_uuid()`]
user_id text [not null]
sort integer
enabled boolean
@@ -765,6 +763,7 @@ table devices {
indexes {
(user_id, device_id) [name: 'devices_user_id_device_id_unique', unique]
(workspace_id, device_id) [name: 'devices_workspace_id_device_id_unique', unique]
user_id [name: 'devices_user_id_idx']
workspace_id [name: 'devices_workspace_id_idx']
}
@@ -784,7 +783,6 @@ table document_histories {
user_id [name: 'document_histories_user_id_idx']
workspace_id [name: 'document_histories_workspace_id_idx']
saved_at [name: 'document_histories_saved_at_idx']
workspace_id [name: 'document_histories_workspace_id_idx']
}
}
@@ -1040,7 +1038,6 @@ table llm_generation_tracing {
validation_failed [name: 'llm_generation_tracing_validation_failed_idx']
feedback_signal [name: 'llm_generation_tracing_feedback_signal_idx']
created_at [name: 'llm_generation_tracing_created_at_idx']
workspace_id [name: 'llm_generation_tracing_workspace_id_idx']
}
}
@@ -1802,7 +1799,6 @@ table file_chunks {
workspace_id [name: 'file_chunks_workspace_id_idx']
file_id [name: 'file_chunks_file_id_idx']
chunk_id [name: 'file_chunks_chunk_id_idx']
workspace_id [name: 'file_chunks_workspace_id_idx']
}
}
@@ -1818,7 +1814,6 @@ table files_to_sessions {
workspace_id [name: 'files_to_sessions_workspace_id_idx']
file_id [name: 'files_to_sessions_file_id_idx']
session_id [name: 'files_to_sessions_session_id_idx']
workspace_id [name: 'files_to_sessions_workspace_id_idx']
}
}
@@ -2609,6 +2604,9 @@ table workspaces {
avatar text
primary_owner_id text [not null]
settings jsonb [default: `{}`]
frozen boolean [default: false]
frozen_reason text
frozen_at "timestamp with time zone"
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
+13 -7
View File
@@ -41,6 +41,8 @@
"artifact.thinking": "Thinking",
"artifact.thought": "Thought process",
"artifact.unknownTitle": "Untitled Work",
"audioPlayer.pause": "Pause audio",
"audioPlayer.play": "Play audio",
"availableAgents": "Available Agents",
"backToBottom": "Jump to latest",
"beforeUnload.confirmLeave": "A request is still running. Leave anyway?",
@@ -122,15 +124,15 @@
"createModal.placeholder": "Describe what this Agent should do...",
"createModal.skillSuggestion.actions.createAnyway": "Create Agent Anyway",
"createModal.skillSuggestion.actions.createAnywayHint": "Skill not a fit?",
"createModal.skillSuggestion.actions.install": "Add Skill",
"createModal.skillSuggestion.actions.installing": "Adding…",
"createModal.skillSuggestion.actions.install": "Install Skill",
"createModal.skillSuggestion.actions.installing": "Installing…",
"createModal.skillSuggestion.actions.openSkills": "View in Skills",
"createModal.skillSuggestion.actions.tryInLobeAI": "Use in LobeAI",
"createModal.skillSuggestion.actions.tryInLobeAI": "Use in {{name}}",
"createModal.skillSuggestion.description": "This looks like a reusable workflow. Install the Skill once, then use it across Agents.",
"createModal.skillSuggestion.installError": "Skill wasn't added. Retry, or create an Agent anyway.",
"createModal.skillSuggestion.installed.description": "You can use this Skill in LobeAI or add it to any Agent.",
"createModal.skillSuggestion.installed.ready": "Ready in LobeAI",
"createModal.skillSuggestion.installed.title": "Skill added",
"createModal.skillSuggestion.installError": "Skill wasn't installed. Retry, or create an Agent anyway.",
"createModal.skillSuggestion.installed.description": "You can use this Skill in {{name}}, or enable it for any Agent.",
"createModal.skillSuggestion.installed.ready": "Ready in {{name}}",
"createModal.skillSuggestion.installed.title": "Skill installed",
"createModal.skillSuggestion.title": "A Skill may fit better",
"createModal.title": "What should this Agent do?",
"createTask.assignee": "Assignee",
@@ -233,9 +235,13 @@
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
"heteroAgent.cloudRepo.notSet": "No repo selected",
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
"heteroAgent.executionTarget.auto": "Auto",
"heteroAgent.executionTarget.autoDesc": "Use an online device automatically, picking one when several are available",
"heteroAgent.executionTarget.downloadDesktop": "Get Desktop App",
"heteroAgent.executionTarget.downloadDesktopDesc": "Run agents with access to your computer",
"heteroAgent.executionTarget.downloadDesktopTitle": "Get the desktop app",
"heteroAgent.executionTarget.gateway": "Gateway",
"heteroAgent.executionTarget.gatewayDesc": "Run through the device gateway so other clients can follow progress",
"heteroAgent.executionTarget.infoTooltip": "Pick a device and the agent uses it as its runtime environment — reading and writing files and operating the computer. Cloud sandbox is provided by LobeHub Marketplace.",
"heteroAgent.executionTarget.loading": "Loading devices…",
"heteroAgent.executionTarget.local": "This device",
+18
View File
@@ -444,6 +444,23 @@
"tab.setting": "Settings",
"tab.tasks": "Tasks",
"tab.video": "Video",
"taskTemplate.action.connect.button": "Connect {{provider}}",
"taskTemplate.action.connect.error": "Connection failed, please try again.",
"taskTemplate.action.connect.popupBlocked": "Connection popup blocked. Allow popups in your browser to continue.",
"taskTemplate.action.connect.short": "Connect",
"taskTemplate.action.connecting": "Waiting for authorization…",
"taskTemplate.action.create.error": "Failed to create task. Please try again.",
"taskTemplate.action.create.success": "Scheduled task added. Find it in Lobe AI.",
"taskTemplate.action.createButton": "Add task",
"taskTemplate.action.creating": "Creating...",
"taskTemplate.action.dismiss.error": "Failed to dismiss. Please try again.",
"taskTemplate.action.dismiss.tooltip": "Not interested",
"taskTemplate.action.refresh.button": "Refresh",
"taskTemplate.card.templateTag": "Template",
"taskTemplate.schedule.daily": "Every day at {{time}}",
"taskTemplate.schedule.editableAfterCreateTooltip": "You can adjust the schedule after creating the task.",
"taskTemplate.schedule.weekly": "Every {{weekday}} at {{time}}",
"taskTemplate.section.title": "Try these scheduled tasks",
"telemetry.allow": "Allow",
"telemetry.deny": "Deny",
"telemetry.desc": "We would like to anonymously collect usage information to help us improve {{appName}} and provide you with a better product experience. You can disable this at any time in Settings - About.",
@@ -474,6 +491,7 @@
"userPanel.email": "Email Support",
"userPanel.feedback": "Contact Us",
"userPanel.help": "Help Center",
"userPanel.inviteFriend": "Invite a friend",
"userPanel.moveGuide": "The settings button has been moved here",
"userPanel.plans": "Subscription Plans",
"userPanel.profile": "Account",
+4
View File
@@ -5,6 +5,10 @@
"authorize.footer.agreement": "By continuing, you confirm that you have read and agree to the <terms>Terms and Conditions</terms> and <privacy>Privacy Policy</privacy>.",
"authorize.footer.privacy": "Privacy Policy",
"authorize.footer.terms": "Terms of Service",
"authorize.scenes.connector.confirm": "Continue to Market",
"authorize.scenes.connector.description": "Market is only used to start this service authorization. Your {{appName}} account stays separate.",
"authorize.scenes.connector.subtitle": "Sign in to Market to connect and authorize this community service.",
"authorize.scenes.connector.title": "Connect Community Service",
"authorize.scenes.mcp.subtitle": "Create a community profile to install and run this skill from the community.",
"authorize.scenes.mcp.title": "Install Community Skill",
"authorize.scenes.sandbox.subtitle": "Create a community profile to run this tool in the community sandbox.",
+1
View File
@@ -1017,6 +1017,7 @@
"tools.activation.auto": "Auto",
"tools.activation.auto.desc": "Smart",
"tools.activation.fixed.hint": "Always on — managed by the app and cant be turned off",
"tools.activation.pin": "Pin",
"tools.activation.pinned": "Pinned",
"tools.activation.pinned.desc": "Always On",
"tools.add": "Add Skill",
+11 -1
View File
@@ -342,6 +342,14 @@
"plans.workspace.noSharedCredits": "No shared credits",
"plans.workspace.sharedCredits": "~{{count}} Credits / mo",
"plans.workspace.solo": "Solo (1 member)",
"plansModal.creditLimit.desc": "Upgrade your plan to unlock more monthly credits and keep working without interruption.",
"plansModal.creditLimit.title": "Youre out of credits",
"plansModal.default.desc": "Unlock more capacity and advanced features.",
"plansModal.default.title": "Upgrade your plan",
"plansModal.fileStorageLimit.desc": "Your file storage is full. Upgrade to keep uploading and managing files.",
"plansModal.fileStorageLimit.title": "Storage limit reached",
"plansModal.modelAccess.desc": "This model is available on paid plans. Upgrade to use the full model lineup.",
"plansModal.modelAccess.title": "Unlock all models",
"promoBanner.fableYearly": "Annual subscribers get {{percent}}% usage off for a limited time",
"qa.desc": "If your question is not answered, check <1>Product Documentation</1> for more FAQs, or contact us.",
"qa.detail": "View Details",
@@ -398,8 +406,10 @@
"referral.errors.invalidFormat": "Invalid referral code format, please enter 2-8 letters, numbers or underscores",
"referral.errors.selfReferral": "You cannot use your own invite code",
"referral.errors.updateFailed": "Update failed, please try again later",
"referral.hero.description": "Share your referral link below. After your friend makes their first payment, you each earn {{reward}}M credits.",
"referral.hero.title": "Invite friends, you both earn <0>{{reward}}M credits</0>",
"referral.inviteCode.description": "Share your exclusive referral code to invite friends to register",
"referral.inviteCode.title": "My Referral Code",
"referral.inviteCode.title": "My Exclusive Referral Code",
"referral.inviteLink.description": "Copy the link and share with friends. Both of you earn credits after your friend makes a payment",
"referral.inviteLink.title": "Referral Link",
"referral.rules.antiAbuse": "If fraudulent activity is detected (e.g., mass registration of disposable email accounts), the associated accounts will be permanently banned",
+13 -7
View File
@@ -41,6 +41,8 @@
"artifact.thinking": "思考中",
"artifact.thought": "思考过程",
"artifact.unknownTitle": "未命名作品",
"audioPlayer.pause": "暂停音频",
"audioPlayer.play": "播放音频",
"availableAgents": "可用助理",
"backToBottom": "跳转到最新",
"beforeUnload.confirmLeave": "你有正在生成中的请求,确定要离开吗?",
@@ -122,15 +124,15 @@
"createModal.placeholder": "描述这个助理要完成什么…",
"createModal.skillSuggestion.actions.createAnyway": "仍然创建助理",
"createModal.skillSuggestion.actions.createAnywayHint": "技能不合适?",
"createModal.skillSuggestion.actions.install": "添加技能",
"createModal.skillSuggestion.actions.installing": "添加中…",
"createModal.skillSuggestion.actions.install": "安装技能",
"createModal.skillSuggestion.actions.installing": "安装中…",
"createModal.skillSuggestion.actions.openSkills": "查看技能",
"createModal.skillSuggestion.actions.tryInLobeAI": "在 LobeAI 中使用",
"createModal.skillSuggestion.actions.tryInLobeAI": "在 {{name}} 中使用",
"createModal.skillSuggestion.description": "这更像一个可复用的工作流。安装一次技能后,可在多个助理中使用。",
"createModal.skillSuggestion.installError": "技能未添加成功。你可以重试,或仍然创建助理。",
"createModal.skillSuggestion.installed.description": "你可以在 LobeAI 中使用这个技能,也可以把它添加到任意助理。",
"createModal.skillSuggestion.installed.ready": "已可在 LobeAI 中使用",
"createModal.skillSuggestion.installed.title": "技能已添加",
"createModal.skillSuggestion.installError": "技能未安装成功。你可以重试,或仍然创建助理。",
"createModal.skillSuggestion.installed.description": "你可以在 {{name}} 中使用这个技能,也可以任意助理启用它。",
"createModal.skillSuggestion.installed.ready": "已可在 {{name}} 中使用",
"createModal.skillSuggestion.installed.title": "技能已安装",
"createModal.skillSuggestion.title": "这个需求适合用技能",
"createModal.title": "这个助理要做什么?",
"createTask.assignee": "负责人",
@@ -233,9 +235,13 @@
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
"heteroAgent.cloudRepo.notSet": "未选择仓库",
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
"heteroAgent.executionTarget.auto": "自动",
"heteroAgent.executionTarget.autoDesc": "自动使用在线设备,有多台时择一使用",
"heteroAgent.executionTarget.downloadDesktop": "下载桌面端",
"heteroAgent.executionTarget.downloadDesktopDesc": "让 Agent 直接连接你的电脑",
"heteroAgent.executionTarget.downloadDesktopTitle": "下载桌面端",
"heteroAgent.executionTarget.gateway": "网关",
"heteroAgent.executionTarget.gatewayDesc": "经由设备网关运行,其他客户端可跟踪进度",
"heteroAgent.executionTarget.infoTooltip": "选择某台设备后,Agent 将以该设备为运行环境,读写文件、操作电脑。云端沙箱由 LobeHub Marketplace 提供服务",
"heteroAgent.executionTarget.loading": "正在加载设备…",
"heteroAgent.executionTarget.local": "本机",
+18
View File
@@ -444,6 +444,23 @@
"tab.setting": "设置",
"tab.tasks": "任务",
"tab.video": "视频",
"taskTemplate.action.connect.button": "连接 {{provider}}",
"taskTemplate.action.connect.error": "连接失败,请重试。",
"taskTemplate.action.connect.popupBlocked": "连接弹出窗口被阻止。请在浏览器中允许弹出窗口以继续。",
"taskTemplate.action.connect.short": "授权",
"taskTemplate.action.connecting": "等待授权…",
"taskTemplate.action.create.error": "创建任务失败,请稍后再试",
"taskTemplate.action.create.success": "定时任务已创建,可在 Lobe AI 中查看",
"taskTemplate.action.createButton": "添加任务",
"taskTemplate.action.creating": "创建中…",
"taskTemplate.action.dismiss.error": "操作失败,请稍后再试",
"taskTemplate.action.dismiss.tooltip": "不感兴趣",
"taskTemplate.action.refresh.button": "换一批",
"taskTemplate.card.templateTag": "模板",
"taskTemplate.schedule.daily": "每天 {{time}}",
"taskTemplate.schedule.editableAfterCreateTooltip": "创建后可调整执行时间。",
"taskTemplate.schedule.weekly": "每{{weekday}} {{time}}",
"taskTemplate.section.title": "试试这些定时任务",
"telemetry.allow": "允许",
"telemetry.deny": "拒绝",
"telemetry.desc": "我们希望匿名获取你的使用信息,帮助我们改进 {{appName}},为你提供更好的产品体验。你可在「设置」-「关于」中随时关闭",
@@ -474,6 +491,7 @@
"userPanel.email": "邮件支持",
"userPanel.feedback": "联系我们",
"userPanel.help": "帮助中心",
"userPanel.inviteFriend": "邀请好友",
"userPanel.moveGuide": "设置按钮搬到这里啦",
"userPanel.plans": "订阅方案",
"userPanel.profile": "账户管理",
+4
View File
@@ -5,6 +5,10 @@
"authorize.footer.agreement": "继续操作即表示你确认已理解并同意<terms>条款和条件</terms>和<privacy>隐私政策</privacy>",
"authorize.footer.privacy": "隐私政策",
"authorize.footer.terms": "服务条款",
"authorize.scenes.connector.confirm": "前往 Market",
"authorize.scenes.connector.description": "Market 仅用于发起该服务授权,你的 {{appName}} 帐号仍保持独立。",
"authorize.scenes.connector.subtitle": "登录 Market 后即可连接并授权该社区服务。",
"authorize.scenes.connector.title": "连接社区服务",
"authorize.scenes.mcp.subtitle": "创建社区个人档案,即可安装并运行该社区技能。",
"authorize.scenes.mcp.title": "安装社区技能",
"authorize.scenes.sandbox.subtitle": "创建社区个人档案,即可在社区沙箱中运行该工具。",
+1
View File
@@ -1017,6 +1017,7 @@
"tools.activation.auto": "自动",
"tools.activation.auto.desc": "智能调用",
"tools.activation.fixed.hint": "由应用强制开启,始终可用,无法关闭",
"tools.activation.pin": "固定启用",
"tools.activation.pinned": "固定启用",
"tools.activation.pinned.desc": "始终注入",
"tools.add": "集成技能",
+11 -1
View File
@@ -342,6 +342,14 @@
"plans.workspace.noSharedCredits": "无共享积分",
"plans.workspace.sharedCredits": "~{{count}} 积分 / 月",
"plans.workspace.solo": "单人工作区 (1 名成员)",
"plansModal.creditLimit.desc": "升级方案以解锁更多每月积分,畅用不中断。",
"plansModal.creditLimit.title": "积分已用尽",
"plansModal.default.desc": "解锁更多容量与高级功能。",
"plansModal.default.title": "升级方案",
"plansModal.fileStorageLimit.desc": "文件存储已达上限,升级以继续上传和管理文件。",
"plansModal.fileStorageLimit.title": "存储空间不足",
"plansModal.modelAccess.desc": "该模型为付费方案专享,升级即可使用全部模型。",
"plansModal.modelAccess.title": "解锁全部模型",
"promoBanner.fableYearly": "年付订阅用户限时享 {{percent}}% 用量优惠",
"qa.desc": "如果您的问题未被解答,请查看 <1>产品文档</1> 获取更多常见问题,或联系我们。",
"qa.detail": "查看详情",
@@ -398,8 +406,10 @@
"referral.errors.invalidFormat": "推荐码格式无效,请输入 2-8 位字母、数字或下划线",
"referral.errors.selfReferral": "不能使用自己的邀请码",
"referral.errors.updateFailed": "更新失败,请稍后重试",
"referral.hero.description": "把下方的推荐链接分享给好友,好友完成首次付费后,你和好友将各自获得 {{reward}}M 积分。",
"referral.hero.title": "邀请好友,双方各得 <0>{{reward}}M 积分</0>",
"referral.inviteCode.description": "分享您的专属推荐码,邀请好友注册",
"referral.inviteCode.title": "我的推荐码",
"referral.inviteCode.title": "我的专属推荐码",
"referral.inviteLink.description": "复制链接并分享给好友,好友付费后双方均可获得奖励",
"referral.inviteLink.title": "推荐链接",
"referral.rules.antiAbuse": "如检测到通过不正当手段获取积分(如批量注册临时邮箱账号),相关账号将被永久封禁",
+5 -5
View File
@@ -409,9 +409,9 @@
"query-string": "^9.3.1",
"random-words": "^2.0.1",
"rc-util": "^5.44.4",
"react": "19.2.5",
"react": "19.2.7",
"react-confetti": "^6.4.0",
"react-dom": "19.2.5",
"react-dom": "19.2.7",
"react-fast-marquee": "^1.6.5",
"react-hotkeys-hook": "^5.2.3",
"react-i18next": "^16.5.3",
@@ -420,7 +420,7 @@
"react-pdf": "^10.3.0",
"react-responsive": "^10.0.1",
"react-rnd": "^10.5.2",
"react-router-dom": "^7.13.0",
"react-router": "^8.0.0",
"react-scan": "^0.5.3",
"react-virtuoso": "^4.18.1",
"react-wrap-balancer": "^1.1.1",
@@ -578,8 +578,8 @@
"lexical": "0.42.0",
"node-gyp": "^12.4.0",
"pdfjs-dist": "5.4.530",
"react": "19.2.5",
"react-dom": "19.2.5",
"react": "19.2.7",
"react-dom": "19.2.7",
"stylelint-config-clean-order": "7.0.0",
"typescript": "6.0.3",
"vitest": "3.2.6"
@@ -12,7 +12,7 @@
"@lobechat/agent-manager-runtime": "workspace:*",
"@lobechat/const": "workspace:*",
"lucide-react": "*",
"react-router-dom": "*"
"react-router": "*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
@@ -6,7 +6,7 @@ import { Avatar, Block, Flexbox, Markdown, Tag } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ArrowRight } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import type { CreateAgentParams, CreateAgentState } from '../../../types';
+2 -1
View File
@@ -16,7 +16,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"chat": "^4.23.0"
"chat": "^4.23.0",
"mime": "^4.1.0"
},
"devDependencies": {
"@types/node": "^24.13.2",
@@ -295,6 +295,27 @@ describe('QQAdapter', () => {
expect(message?.attachments[0].type).toBe('file');
});
it('should infer mime type from filename when content_type is the bare "file" label', async () => {
// QQ delivers c2c file attachments with content_type === 'file' (a coarse
// category, not a real MIME type). It must be recovered from the filename
// so an .m4a is classified as audio instead of an unreadable document.
const attachment = makeAttachment({
content_type: 'file',
filename: 'Broadstone Amelia 5.m4a',
});
const payload = makeWebhookPayload(QQ_EVENT_TYPES.GROUP_AT_MESSAGE_CREATE, {
attachments: [attachment],
content: 'audio file',
});
await adapter.handleWebhook(makeRequest(payload));
const factory = vi.mocked(mockChat.processMessage).mock.calls[0]?.[2];
const message = await factory?.();
expect(message?.attachments[0].mimeType).toBe('audio/mp4');
expect(message?.attachments[0].type).toBe('audio');
});
it('should map multiple attachments', async () => {
const attachments = [
makeAttachment({ content_type: 'image/png', filename: 'a.png' }),
+19 -3
View File
@@ -14,6 +14,7 @@ import type {
WebhookOptions,
} from 'chat';
import { Message, parseMarkdown } from 'chat';
import mime from 'mime';
import { QQApiClient } from './api';
import { signWebhookResponse } from './crypto';
@@ -395,20 +396,35 @@ export class QQAdapter implements Adapter<QQThreadId, QQRawMessage> {
if (!qqAttachments || qqAttachments.length === 0) return [];
return qqAttachments.map((a) => {
const type = this.resolveAttachmentType(a.content_type);
// QQ's `content_type` is not always a real MIME type — for c2c file
// attachments it comes back as the coarse category label `"file"`. Trusting
// it verbatim mislabels e.g. an `.m4a` as `"file"` instead of `audio/mp4`,
// which then defeats the filename-based MIME recovery in ingestAttachment
// (that only re-infers for `application/octet-stream`). Fall back to the
// filename when content_type isn't a usable MIME type.
const mimeType = this.resolveMimeType(a.content_type, a.filename);
return {
fetchData: () => this.fetchAttachmentData(a.url),
height: a.height,
mimeType: a.content_type,
mimeType,
name: a.filename,
size: a.size,
type,
type: this.resolveAttachmentType(mimeType),
url: a.url,
width: a.width,
} as Attachment;
});
}
/**
* Resolve a usable MIME type from QQ's `content_type`, falling back to
* filename-based inference when QQ sends a non-MIME value (e.g. `"file"`).
*/
private resolveMimeType(contentType: string | undefined, filename?: string): string {
if (contentType && contentType.includes('/')) return contentType;
return (filename && mime.getType(filename)) || 'application/octet-stream';
}
private resolveAttachmentType(contentType: string): 'image' | 'video' | 'audio' | 'file' {
if (contentType.startsWith('image/')) return 'image';
if (contentType.startsWith('video/')) return 'video';
@@ -41,6 +41,7 @@ import {
HistorySummaryProvider,
KnowledgeInjector,
LocalSystemToolSnapshotInjector,
ModelKnowledgeCutoffProvider,
OnboardingActionHintInjector,
OnboardingContextInjector,
OnboardingSyntheticStateInjector,
@@ -136,6 +137,7 @@ export class MessagesEngine {
private buildProcessors(): ContextProcessor[] {
const {
model,
modelKnowledgeCutoff,
provider,
systemRole,
inputTemplate,
@@ -244,6 +246,8 @@ export class MessagesEngine {
}),
// System date
new SystemDateProvider({ enabled: isSystemDateEnabled, timezone }),
// Model knowledge cutoff
new ModelKnowledgeCutoffProvider({ knowledgeCutoff: modelKnowledgeCutoff }),
// Skill context (available skills list + activated skill content).
// Disabled in chat mode — pairs with the tools-engine gate so the LLM
// sees neither the manifests nor the discovery prompt.
@@ -114,6 +114,35 @@ describe('MessagesEngine', () => {
expect(result.messages[0].content).toBe(systemRole);
});
it('should inject model knowledge cutoff when provided', async () => {
const params = createBasicParams({
modelKnowledgeCutoff: '2024-06',
systemRole: 'You are a helpful assistant',
});
const engine = new MessagesEngine(params);
const result = await engine.process();
expect(result.messages[0]).toEqual({
content: 'You are a helpful assistant\n\nModel knowledge cutoff: 2024-06',
role: 'system',
});
expect(result.metadata.modelKnowledgeCutoffInjected).toBe(true);
});
it('should skip model knowledge cutoff injection when unknown', async () => {
const params = createBasicParams({ systemRole: 'You are a helpful assistant' });
const engine = new MessagesEngine(params);
const result = await engine.process();
expect(result.messages[0]).toEqual({
content: 'You are a helpful assistant',
role: 'system',
});
expect(result.metadata.modelKnowledgeCutoffInjected).toBeUndefined();
});
it('should inject history summary when provided', async () => {
const historySummary = 'We discussed AI and machine learning';
const params = createBasicParams({ historySummary });
@@ -215,6 +215,8 @@ export interface MessagesEngineParams {
messages: UIChatMessage[];
/** Model ID */
model: string;
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
modelKnowledgeCutoff?: string;
/** Provider ID */
provider: string;
@@ -0,0 +1,48 @@
import debug from 'debug';
import { BaseSystemRoleProvider } from '../base/BaseSystemRoleProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
declare module '../types' {
interface PipelineContextMetadataOverrides {
modelKnowledgeCutoffInjected?: boolean;
}
}
const log = debug('context-engine:provider:ModelKnowledgeCutoffProvider');
export interface ModelKnowledgeCutoffProviderConfig {
enabled?: boolean;
knowledgeCutoff?: string;
}
export class ModelKnowledgeCutoffProvider extends BaseSystemRoleProvider {
readonly name = 'ModelKnowledgeCutoffProvider';
constructor(
private config: ModelKnowledgeCutoffProviderConfig = {},
options: ProcessorOptions = {},
) {
super(options);
}
protected buildSystemRoleContent(_context: PipelineContext): string | null {
if (this.config.enabled === false) {
log('Model knowledge cutoff injection disabled, skipping');
return null;
}
const knowledgeCutoff = this.config.knowledgeCutoff?.trim();
if (!knowledgeCutoff) {
log('No model knowledge cutoff configured, skipping injection');
return null;
}
return `Model knowledge cutoff: ${knowledgeCutoff}`;
}
protected onInjected(context: PipelineContext): void {
context.metadata.modelKnowledgeCutoffInjected = true;
}
}
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { ModelKnowledgeCutoffProvider } from '../ModelKnowledgeCutoffProvider';
const createContext = (messages: any[] = []) => ({
initialState: {
messages: [],
model: 'gpt-4',
provider: 'openai',
systemRole: '',
tools: [],
},
isAborted: false,
messages,
metadata: {
maxTokens: 4096,
model: 'gpt-4',
},
});
describe('ModelKnowledgeCutoffProvider', () => {
it('should inject model knowledge cutoff', async () => {
const provider = new ModelKnowledgeCutoffProvider({ knowledgeCutoff: '2024-06' });
const context = createContext([
{ content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].role).toBe('system');
expect(result.messages[0].content).toBe('Model knowledge cutoff: 2024-06');
expect(result.metadata.modelKnowledgeCutoffInjected).toBe(true);
});
it('should append cutoff to existing system message', async () => {
const provider = new ModelKnowledgeCutoffProvider({ knowledgeCutoff: '2024-06' });
const context = createContext([
{
content: 'You are a helpful assistant.',
createdAt: Date.now(),
id: 'sys',
role: 'system',
updatedAt: Date.now(),
},
{ content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(2);
expect(result.messages[0].content).toBe(
'You are a helpful assistant.\n\nModel knowledge cutoff: 2024-06',
);
expect(result.metadata.modelKnowledgeCutoffInjected).toBe(true);
});
it('should trim cutoff before injection', async () => {
const provider = new ModelKnowledgeCutoffProvider({ knowledgeCutoff: ' 2024-06 ' });
const context = createContext([]);
const result = await provider.process(context);
expect(result.messages[0].content).toBe('Model knowledge cutoff: 2024-06');
});
it('should skip injection when cutoff is missing', async () => {
const provider = new ModelKnowledgeCutoffProvider({});
const context = createContext([
{ content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(1);
expect(result.metadata.modelKnowledgeCutoffInjected).toBeUndefined();
});
it('should skip injection when disabled', async () => {
const provider = new ModelKnowledgeCutoffProvider({
enabled: false,
knowledgeCutoff: '2024-06',
});
const context = createContext([
{ content: 'Hello', createdAt: Date.now(), id: '1', role: 'user', updatedAt: Date.now() },
]);
const result = await provider.process(context);
expect(result.messages).toHaveLength(1);
expect(result.metadata.modelKnowledgeCutoffInjected).toBeUndefined();
});
});
@@ -19,6 +19,7 @@ export { GroupContextInjector } from './GroupContextInjector';
export { HistorySummaryProvider } from './HistorySummary';
export { KnowledgeInjector } from './KnowledgeInjector';
export { LocalSystemToolSnapshotInjector } from './LocalSystemToolSnapshotInjector';
export { ModelKnowledgeCutoffProvider } from './ModelKnowledgeCutoffProvider';
export { OnboardingActionHintInjector } from './OnboardingActionHintInjector';
export { OnboardingContextInjector } from './OnboardingContextInjector';
export { OnboardingSyntheticStateInjector } from './OnboardingSyntheticStateInjector';
@@ -90,6 +91,7 @@ export type {
export type { HistorySummaryConfig } from './HistorySummary';
export type { KnowledgeInjectorConfig } from './KnowledgeInjector';
export type { LocalSystemToolSnapshotInjectorConfig } from './LocalSystemToolSnapshotInjector';
export type { ModelKnowledgeCutoffProviderConfig } from './ModelKnowledgeCutoffProvider';
export type {
OnboardingContext,
OnboardingContextInjectorConfig,
@@ -0,0 +1,73 @@
-- Combined workspace-scoped DB rollout (formerly two separate 0111 migrations):
-- 1. ai_infra surrogate `_id` PK + workspace-scoped partial uniques (LOBE-10056)
-- 2. workspace-scoped device unique + workspace `frozen` columns (LOBE-10315)
--
-- The two parts touch disjoint tables (ai_providers / ai_models vs.
-- devices / workspaces). Every statement is guarded so the migration is a
-- NO-OP on databases that already have the shape (cloud production, where the
-- ai_infra side was applied online via manual steps) and a full rebuild on
-- fresh / self-hosted databases.
-- ===========================================================================
-- Part 1 — ai_infra surrogate `_id` PK + workspace-scoped partial uniques
-- (LOBE-10056 Phase 5)
--
-- On cloud production this whole part is a NO-OP: the manual steps [3]~[7]
-- (LOBE-10073 .. LOBE-10077) already performed the backfill, NOT NULL, PK swap
-- and partial indexes online / CONCURRENTLY. Every statement below is guarded
-- (UPDATE … WHERE _id IS NULL / IF EXISTS / catalog check / IF NOT EXISTS) so
-- it skips cleanly there, while still fully rebuilding the schema on a fresh or
-- self-hosted database (where [3]~[7] never ran).
-- ===========================================================================
-- 1) backfill rows still missing _id (no-op on prod; fills self-host history) --
UPDATE "ai_providers" SET "_id" = gen_random_uuid() WHERE "_id" IS NULL;--> statement-breakpoint
UPDATE "ai_models" SET "_id" = gen_random_uuid() WHERE "_id" IS NULL;--> statement-breakpoint
-- 2) enforce NOT NULL (no-op if already set) --
ALTER TABLE "ai_providers" ALTER COLUMN "_id" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "ai_models" ALTER COLUMN "_id" SET NOT NULL;--> statement-breakpoint
-- 3) drop old composite PKs (no-op on prod, already dropped in [7]) --
ALTER TABLE "ai_providers" DROP CONSTRAINT IF EXISTS "ai_providers_id_user_id_pk";--> statement-breakpoint
ALTER TABLE "ai_models" DROP CONSTRAINT IF EXISTS "ai_models_id_provider_id_user_id_pk";--> statement-breakpoint
-- 4) promote _id to PK only when the table has no PK yet
-- (Postgres has no `ADD PRIMARY KEY IF NOT EXISTS`; guard via pg_constraint) --
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conrelid = 'ai_providers'::regclass AND contype = 'p'
) THEN
ALTER TABLE "ai_providers" ADD CONSTRAINT "ai_providers_pkey" PRIMARY KEY ("_id");
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conrelid = 'ai_models'::regclass AND contype = 'p'
) THEN
ALTER TABLE "ai_models" ADD CONSTRAINT "ai_models_pkey" PRIMARY KEY ("_id");
END IF;
END $$;--> statement-breakpoint
-- 5) workspace-scoped partial unique indexes (no-op on prod, already built in [6]) --
CREATE UNIQUE INDEX IF NOT EXISTS "ai_providers_id_user_id_unique" ON "ai_providers" USING btree ("id","user_id") WHERE "workspace_id" IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "ai_providers_id_user_id_workspace_id_unique" ON "ai_providers" USING btree ("id","user_id","workspace_id") WHERE "workspace_id" IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "ai_models_id_provider_id_user_id_unique" ON "ai_models" USING btree ("id","provider_id","user_id") WHERE "workspace_id" IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "ai_models_id_provider_id_user_id_workspace_id_unique" ON "ai_models" USING btree ("id","provider_id","user_id","workspace_id") WHERE "workspace_id" IS NOT NULL;--> statement-breakpoint
-- ===========================================================================
-- Part 2 — workspace-scoped device unique + workspace `frozen` columns
-- (LOBE-10315)
--
-- Replace the full (user_id, device_id) unique with two partial uniques scoped
-- by workspace_id (null vs. not null), so personal and workspace-enrolled rows
-- live in independent identity spaces. Also add the workspace freeze trio
-- (mirrors users.banned) backing cloud workspace-freeze risk control.
-- ===========================================================================
DROP INDEX IF EXISTS "devices_user_id_device_id_unique";--> statement-breakpoint
ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "frozen" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "frozen_reason" text;--> statement-breakpoint
ALTER TABLE "workspaces" ADD COLUMN IF NOT EXISTS "frozen_at" timestamp with time zone;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "devices_workspace_id_device_id_unique" ON "devices" USING btree ("workspace_id","device_id") WHERE "devices"."workspace_id" IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "devices_user_id_device_id_unique" ON "devices" USING btree ("user_id","device_id") WHERE "devices"."workspace_id" IS NULL;
File diff suppressed because it is too large Load Diff
@@ -777,7 +777,14 @@
"when": 1780832120210,
"tag": "0110_add_verify_tables_and_ai_infra_id",
"breakpoints": true
},
{
"idx": 111,
"version": "7",
"when": 1781883177374,
"tag": "0111_workspace_device_and_ai_infra_surrogate_pk",
"breakpoints": true
}
],
"version": "6"
}
}
@@ -3,18 +3,20 @@ import { and, eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { devices, users } from '../../schemas';
import { devices, users, workspaces } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { DeviceModel } from '../device';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'device-model-test-user-id';
const otherUserId = 'device-model-other-user';
const wsId = 'device-model-ws-1';
const deviceModel = new DeviceModel(serverDB, userId);
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }, { id: 'device-model-other-user' }]);
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
});
afterEach(async () => {
@@ -113,6 +115,82 @@ describe('DeviceModel', () => {
});
});
describe('workspace devices', () => {
beforeEach(async () => {
await serverDB
.insert(workspaces)
.values({ id: wsId, name: 'WS 1', primaryOwnerId: userId, slug: 'device-model-ws-1-slug' });
});
it('queryPersonal excludes workspace-enrolled rows', async () => {
await deviceModel.register({ deviceId: 'p1', identitySource: 'machine-id' });
// an admin-enrolled workspace device owned by this user
await serverDB
.insert(devices)
.values({ deviceId: 'w1', identitySource: 'machine-id', userId, workspaceId: wsId });
const personal = await deviceModel.queryPersonal();
expect(personal.map((d) => d.deviceId)).toEqual(['p1']);
});
it('queryWorkspaceDevices returns every enrolled device (any owner), scoped to the workspace', async () => {
// enrolled by two different admins into the same workspace
await serverDB.insert(devices).values([
{ deviceId: 'w1', identitySource: 'machine-id', userId, workspaceId: wsId },
{ deviceId: 'w2', identitySource: 'machine-id', userId: otherUserId, workspaceId: wsId },
]);
// a personal device must not appear
await deviceModel.register({ deviceId: 'p1', identitySource: 'machine-id' });
const wsModel = new DeviceModel(serverDB, userId, wsId);
const ids = (await wsModel.queryWorkspaceDevices()).map((d) => d.deviceId).sort();
expect(ids).toEqual(['w1', 'w2']);
});
it('queryWorkspaceDevices returns [] without workspace context', async () => {
await serverDB
.insert(devices)
.values({ deviceId: 'w1', identitySource: 'machine-id', userId, workspaceId: wsId });
expect(await deviceModel.queryWorkspaceDevices()).toEqual([]);
});
it('dedupes a machine enrolled into one workspace by different admins to a single row', async () => {
// admin A enrolls machine "wdev" into the workspace
await new DeviceModel(serverDB, userId, wsId).registerWorkspaceDevice({
deviceId: 'wdev',
hostname: 'A-host',
identitySource: 'machine-id',
workspaceId: wsId,
});
// admin B enrolls the SAME machine (same deviceId) into the SAME workspace
await new DeviceModel(serverDB, otherUserId, wsId).registerWorkspaceDevice({
deviceId: 'wdev',
hostname: 'B-host',
identitySource: 'machine-id',
workspaceId: wsId,
});
const rows = (await new DeviceModel(serverDB, userId, wsId).queryWorkspaceDevices()).filter(
(d) => d.deviceId === 'wdev',
);
// one row, not two — (workspace_id, device_id) is unique
expect(rows).toHaveLength(1);
// the original enroller is preserved; only machine fields are refreshed
expect(rows[0].userId).toBe(userId);
expect(rows[0].hostname).toBe('B-host');
});
it('findWorkspaceDeviceById is scoped to the workspace', async () => {
await serverDB
.insert(devices)
.values({ deviceId: 'w1', identitySource: 'machine-id', userId, workspaceId: wsId });
const wsModel = new DeviceModel(serverDB, userId, wsId);
expect((await wsModel.findWorkspaceDeviceById('w1'))?.deviceId).toBe('w1');
// a non-workspace model never resolves it
expect(await deviceModel.findWorkspaceDeviceById('w1')).toBeUndefined();
});
});
describe('update', () => {
it('should update user-editable fields', async () => {
await deviceModel.register({ deviceId: 'dev-1', identitySource: 'machine-id' });
@@ -0,0 +1,641 @@
// @vitest-environment node
import { eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../core/getTestDB';
import { agents, chatGroups, messages, sessions, topics, users } from '../../schemas';
import type { LobeChatDatabase } from '../../type';
import { TopicModel } from '../topic';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'topic-model-test-user';
const otherUserId = 'topic-model-test-other-user';
const topicModel = new TopicModel(serverDB, userId);
const now = () => new Date();
const minutesAgo = (n: number) => new Date(Date.now() - n * 60 * 1000);
describe('TopicModel', () => {
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
});
afterEach(async () => {
await serverDB.delete(users);
});
describe('create', () => {
it('creates a topic owned by the calling user with null owner columns by default', async () => {
const topic = await topicModel.create({ title: 'Hello' });
expect(topic.title).toBe('Hello');
expect(topic.userId).toBe(userId);
expect(topic.agentId).toBeNull();
expect(topic.sessionId).toBeNull();
expect(topic.groupId).toBeNull();
// personal mode → workspaceId stays null
expect(topic.workspaceId).toBeNull();
});
it('coerces falsy owner ids to null', async () => {
const topic = await topicModel.create({
agentId: '',
groupId: '',
sessionId: '',
title: 'falsy owners',
});
expect(topic.agentId).toBeNull();
expect(topic.groupId).toBeNull();
expect(topic.sessionId).toBeNull();
});
it('attaches given messages to the new topic in a transaction', async () => {
await serverDB.insert(messages).values([
{ content: 'm1', id: 'msg-1', role: 'user', userId },
{ content: 'm2', id: 'msg-2', role: 'assistant', userId },
]);
const topic = await topicModel.create({ messages: ['msg-1', 'msg-2'], title: 'with msgs' });
const linked = await serverDB
.select({ id: messages.id, topicId: messages.topicId })
.from(messages)
.where(eq(messages.topicId, topic.id));
expect(linked.map((m) => m.id).sort()).toEqual(['msg-1', 'msg-2']);
});
});
describe('batchCreate', () => {
it('keeps a session topic session-scoped and a group topic group-scoped', async () => {
await serverDB.insert(agents).values({ id: 'agent-b', userId });
await serverDB.insert(sessions).values({ id: 'session-x', userId });
await serverDB.insert(chatGroups).values({ id: 'group-b', userId });
const created = await topicModel.batchCreate([
{ agentId: 'agent-b', sessionId: 'session-x', title: 'session topic' },
{ groupId: 'group-b', title: 'group topic' },
]);
const bySession = created.find((t) => t.title === 'session topic')!;
const byGroup = created.find((t) => t.title === 'group topic')!;
// sessionId given (no groupId) → sessionId kept, groupId stays null
expect(bySession.sessionId).toBe('session-x');
expect(bySession.groupId).toBeNull();
// groupId given (no sessionId) → groupId kept, sessionId stays null
expect(byGroup.groupId).toBe('group-b');
expect(byGroup.sessionId).toBeNull();
});
it('drops both owner ids when sessionId and groupId are passed together', async () => {
await serverDB.insert(sessions).values({ id: 'session-both', userId });
await serverDB.insert(chatGroups).values({ id: 'group-both', userId });
// Each field is nulled based on the *other* being present, so passing both
// detaches the topic from both — callers must pick exactly one.
const [created] = await topicModel.batchCreate([
{ groupId: 'group-both', sessionId: 'session-both', title: 'ambiguous' },
]);
expect(created.sessionId).toBeNull();
expect(created.groupId).toBeNull();
});
});
describe('findById', () => {
it('returns the topic for the owner', async () => {
const topic = await topicModel.create({ title: 'findable' });
const found = await topicModel.findById(topic.id);
expect(found?.id).toBe(topic.id);
});
it('does not return a topic owned by another user', async () => {
await serverDB
.insert(topics)
.values({ id: 'topic-foreign', title: 'nope', userId: otherUserId });
const found = await topicModel.findById('topic-foreign');
expect(found).toBeUndefined();
});
});
describe('query', () => {
it('orders favorites first then by recent activity', async () => {
await serverDB.insert(agents).values({ id: 'agent-q', userId });
await serverDB.insert(topics).values([
{
agentId: 'agent-q',
id: 't-fav',
title: 'fav',
favorite: true,
updatedAt: minutesAgo(60),
userId,
},
{ agentId: 'agent-q', id: 't-new', title: 'new', updatedAt: minutesAgo(1), userId },
{ agentId: 'agent-q', id: 't-old', title: 'old', updatedAt: minutesAgo(30), userId },
]);
const { items, total } = await topicModel.query({ agentId: 'agent-q' });
expect(total).toBe(3);
expect(items.map((t) => t.id)).toEqual(['t-fav', 't-new', 't-old']);
});
it('adopts orphan rows for the inbox agent only', async () => {
await serverDB.insert(agents).values({ id: 'agent-inbox', slug: 'inbox', userId });
await serverDB.insert(topics).values([
{ agentId: 'agent-inbox', id: 't-direct', title: 'direct', userId },
// legacy orphan: every owner column null
{ id: 't-orphan', title: 'orphan', userId },
]);
const inbox = await topicModel.query({ agentId: 'agent-inbox', isInbox: true });
expect(inbox.items.map((t) => t.id).sort()).toEqual(['t-direct', 't-orphan']);
const nonInbox = await topicModel.query({ agentId: 'agent-inbox' });
expect(nonInbox.items.map((t) => t.id)).toEqual(['t-direct']);
});
it('filters by groupId directly', async () => {
await serverDB.insert(chatGroups).values({ id: 'group-q', userId });
await serverDB.insert(topics).values([
{ groupId: 'group-q', id: 't-g1', title: 'g1', userId },
{ id: 't-no-group', title: 'no group', userId },
]);
const { items } = await topicModel.query({ groupId: 'group-q' });
expect(items.map((t) => t.id)).toEqual(['t-g1']);
});
describe('status filtering & ordering', () => {
it('excludes topics whose status is in excludeStatuses but keeps null status', async () => {
await serverDB.insert(agents).values({ id: 'agent-s', userId });
await serverDB.insert(topics).values([
{ agentId: 'agent-s', id: 't-active', status: 'active', title: 'active', userId },
{ agentId: 'agent-s', id: 't-done', status: 'completed', title: 'done', userId },
{ agentId: 'agent-s', id: 't-null', title: 'null status', userId },
]);
const { items } = await topicModel.query({
agentId: 'agent-s',
excludeStatuses: ['completed'],
});
expect(items.map((t) => t.id).sort()).toEqual(['t-active', 't-null']);
});
it('orders by status priority floating unread above active/completed', async () => {
await serverDB.insert(agents).values({ id: 'agent-rank', userId });
// all share the same activity time so only the status rank decides order
const at = minutesAgo(5);
await serverDB.insert(topics).values([
{
agentId: 'agent-rank',
id: 't-completed',
status: 'completed',
title: 'c',
updatedAt: at,
userId,
},
{
agentId: 'agent-rank',
id: 't-active',
status: 'active',
title: 'a',
updatedAt: at,
userId,
},
{
agentId: 'agent-rank',
id: 't-unread',
status: 'unread',
title: 'u',
updatedAt: at,
userId,
},
{
agentId: 'agent-rank',
id: 't-waiting',
status: 'waitingForHuman',
title: 'w',
updatedAt: at,
userId,
},
]);
const { items } = await topicModel.query({ agentId: 'agent-rank', sortBy: 'status' });
// waitingForHuman(0) < unread(2) < active(4) < completed(6)
expect(items.map((t) => t.id)).toEqual([
't-waiting',
't-unread',
't-active',
't-completed',
]);
});
});
describe('trigger filtering', () => {
beforeEach(async () => {
await serverDB.insert(agents).values({ id: 'agent-trig', userId });
await serverDB.insert(topics).values([
{ agentId: 'agent-trig', id: 't-chat', title: 'chat', trigger: 'chat', userId },
{ agentId: 'agent-trig', id: 't-cron', title: 'cron', trigger: 'cron', userId },
{ agentId: 'agent-trig', id: 't-none', title: 'none', userId },
]);
});
it('keeps only the requested triggers when `triggers` is set', async () => {
const { items } = await topicModel.query({ agentId: 'agent-trig', triggers: ['cron'] });
expect(items.map((t) => t.id)).toEqual(['t-cron']);
});
it('drops excluded triggers but keeps null-trigger topics', async () => {
const { items } = await topicModel.query({
agentId: 'agent-trig',
excludeTriggers: ['cron'],
});
expect(items.map((t) => t.id).sort()).toEqual(['t-chat', 't-none']);
});
it('includeTriggers takes precedence over excludeTriggers', async () => {
const { items } = await topicModel.query({
agentId: 'agent-trig',
excludeTriggers: ['cron'],
includeTriggers: ['cron'],
});
expect(items.map((t) => t.id)).toEqual(['t-cron']);
});
});
it('returns card-detail columns only when withDetails is set', async () => {
await serverDB.insert(agents).values({ id: 'agent-d', userId });
await serverDB.insert(topics).values({
agentId: 'agent-d',
description: 'desc',
id: 't-detail',
title: 'detail',
trigger: 'chat',
userId,
});
await serverDB.insert(messages).values([
{ content: 'first user message', id: 'dm-1', role: 'user', topicId: 't-detail', userId },
{ content: 'assistant reply', id: 'dm-2', role: 'assistant', topicId: 't-detail', userId },
]);
const lean = await topicModel.query({ agentId: 'agent-d' });
expect(lean.items[0]).not.toHaveProperty('firstUserMessage');
expect(lean.items[0]).not.toHaveProperty('messageCount');
const detailed = await topicModel.query({ agentId: 'agent-d', withDetails: true });
expect(detailed.items[0]).toMatchObject({
description: 'desc',
firstUserMessage: 'first user message',
messageCount: 2,
trigger: 'chat',
});
});
});
describe('queryTopics', () => {
it('filters by the given statuses and is scoped to the owner', async () => {
await serverDB.insert(topics).values([
{ id: 't-running', status: 'running', title: 'r', userId },
{ id: 't-active', status: 'active', title: 'a', userId },
{ id: 't-running-other', status: 'running', title: 'ro', userId: otherUserId },
]);
const result = await topicModel.queryTopics({ statuses: ['running'] });
expect(result.map((t) => t.id)).toEqual(['t-running']);
});
it('returns all owned topics when no statuses filter is given', async () => {
await serverDB.insert(topics).values([
{ id: 't1', status: 'running', title: '1', userId },
{ id: 't2', status: 'active', title: '2', userId },
]);
const result = await topicModel.queryTopics();
expect(result.map((t) => t.id).sort()).toEqual(['t1', 't2']);
});
});
describe('count', () => {
it('counts all owned topics and can scope to an agent', async () => {
await serverDB.insert(agents).values({ id: 'agent-c', userId });
await serverDB.insert(topics).values([
{ agentId: 'agent-c', id: 'c1', title: '1', userId },
{ id: 'c2', title: '2', userId },
{ id: 'c-other', title: 'x', userId: otherUserId },
]);
expect(await topicModel.count()).toBe(2);
expect(await topicModel.count({ agentId: 'agent-c' })).toBe(1);
});
});
describe('update', () => {
it('updates status and bumps updatedAt', async () => {
const topic = await topicModel.create({ title: 'to update' });
const before = topic.updatedAt.getTime();
const [updated] = await topicModel.update(topic.id, { status: 'unread' });
expect(updated.status).toBe('unread');
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(before);
const [cleared] = await topicModel.update(topic.id, { status: 'active' });
expect(cleared.status).toBe('active');
});
it('does not update a topic owned by another user', async () => {
await serverDB
.insert(topics)
.values({ id: 't-foreign-upd', status: 'active', title: 'foreign', userId: otherUserId });
const result = await topicModel.update('t-foreign-upd', { status: 'unread' });
expect(result).toHaveLength(0);
const [row] = await serverDB.select().from(topics).where(eq(topics.id, 't-foreign-upd'));
expect(row.status).toBe('active');
});
});
describe('updateMetadata', () => {
it('merges new metadata into existing metadata', async () => {
const topic = await topicModel.create({
metadata: { model: 'gpt-4', provider: 'openai' },
title: 'meta',
});
const [updated] = await topicModel.updateMetadata(topic.id, { workingDirectory: '/tmp' });
expect(updated.metadata).toMatchObject({
model: 'gpt-4',
provider: 'openai',
workingDirectory: '/tmp',
});
});
it('deep-merges the onboardingSession sub-object', async () => {
const topic = await topicModel.create({
metadata: {
onboardingSession: {
lastActiveAt: '2026-01-01',
phase: 'discovery',
startedAt: '2026-01-01',
version: 1,
},
},
title: 'onboarding',
});
const [updated] = await topicModel.updateMetadata(topic.id, {
onboardingSession: { phase: 'summary' },
});
expect(updated.metadata?.onboardingSession).toMatchObject({
lastActiveAt: '2026-01-01',
phase: 'summary',
startedAt: '2026-01-01',
version: 1,
});
});
});
describe('delete', () => {
it('deletes a single owned topic', async () => {
const topic = await topicModel.create({ title: 'del' });
await topicModel.delete(topic.id);
expect(await topicModel.findById(topic.id)).toBeUndefined();
});
it('batch deletes only the given ids', async () => {
await serverDB.insert(topics).values([
{ id: 'b1', title: '1', userId },
{ id: 'b2', title: '2', userId },
{ id: 'b3', title: '3', userId },
]);
await topicModel.batchDelete(['b1', 'b2']);
const remaining = await topicModel.queryTopics();
expect(remaining.map((t) => t.id)).toEqual(['b3']);
});
it('deleteAll removes only the calling user rows', async () => {
await serverDB.insert(topics).values([
{ id: 'mine-1', title: '1', userId },
{ id: 'theirs-1', title: '2', userId: otherUserId },
]);
await topicModel.deleteAll();
expect(await topicModel.queryTopics()).toHaveLength(0);
const theirs = await serverDB.select().from(topics).where(eq(topics.id, 'theirs-1'));
expect(theirs).toHaveLength(1);
});
it('batchDeleteByAgentId removes all topics under one agent', async () => {
await serverDB.insert(agents).values([
{ id: 'agent-del', userId },
{ id: 'agent-keep', userId },
]);
await serverDB.insert(topics).values([
{ agentId: 'agent-del', id: 'd1', title: '1', userId },
{ agentId: 'agent-del', id: 'd2', title: '2', userId },
{ agentId: 'agent-keep', id: 'k1', title: '3', userId },
]);
await topicModel.batchDeleteByAgentId('agent-del');
const remaining = await topicModel.queryTopics();
expect(remaining.map((t) => t.id)).toEqual(['k1']);
});
});
describe('duplicate', () => {
it('copies the topic and its messages under a new id', async () => {
const topic = await topicModel.create({ title: 'original' });
await serverDB.insert(messages).values([
{ content: 'hi', id: 'dup-m1', role: 'user', topicId: topic.id, userId },
{ content: 'yo', id: 'dup-m2', role: 'assistant', topicId: topic.id, userId },
]);
const { topic: cloned, messages: clonedMessages } = await topicModel.duplicate(
topic.id,
'copy',
);
expect(cloned.id).not.toBe(topic.id);
expect(cloned.title).toBe('copy');
expect(clonedMessages).toHaveLength(2);
expect(clonedMessages.every((m) => m.topicId === cloned.id)).toBe(true);
expect(clonedMessages.map((m) => m.id)).not.toContain('dup-m1');
});
it('throws when the source topic does not exist', async () => {
await expect(topicModel.duplicate('nope')).rejects.toThrow('not found');
});
});
describe('batchMoveToAgent', () => {
it('reassigns agentId, clears sessionId, and moves child messages', async () => {
await serverDB.insert(agents).values([
{ id: 'agent-src', userId },
{ id: 'agent-dst', userId },
]);
await serverDB.insert(topics).values({
agentId: 'agent-src',
id: 'move-1',
sessionId: null,
title: 'movable',
userId,
});
await serverDB.insert(messages).values({
agentId: 'agent-src',
content: 'm',
id: 'move-msg',
role: 'user',
topicId: 'move-1',
userId,
});
await topicModel.batchMoveToAgent(['move-1'], 'agent-dst');
const [topic] = await serverDB.select().from(topics).where(eq(topics.id, 'move-1'));
expect(topic.agentId).toBe('agent-dst');
expect(topic.sessionId).toBeNull();
const [msg] = await serverDB.select().from(messages).where(eq(messages.id, 'move-msg'));
expect(msg.agentId).toBe('agent-dst');
});
it('throws when the target agent is not accessible', async () => {
await serverDB.insert(agents).values({ id: 'agent-foreign', userId: otherUserId });
await serverDB.insert(topics).values({ id: 'move-x', title: 'x', userId });
await expect(topicModel.batchMoveToAgent(['move-x'], 'agent-foreign')).rejects.toThrow(
'not found or not accessible',
);
});
it('is a no-op for an empty id list', async () => {
await expect(topicModel.batchMoveToAgent([], 'whatever')).resolves.toBeUndefined();
});
});
describe('getCronTopicsGroupedByCronJob', () => {
it('groups cron-triggered topics by their cronJobId and skips topics without one', async () => {
await serverDB.insert(agents).values({ id: 'agent-cron', userId });
await serverDB.insert(topics).values([
{
agentId: 'agent-cron',
id: 'cron-a1',
metadata: { cronJobId: 'job-a' },
title: 'a1',
trigger: 'cron',
userId,
},
{
agentId: 'agent-cron',
id: 'cron-a2',
metadata: { cronJobId: 'job-a' },
title: 'a2',
trigger: 'cron',
userId,
},
{
agentId: 'agent-cron',
id: 'cron-b1',
metadata: { cronJobId: 'job-b' },
title: 'b1',
trigger: 'cron',
userId,
},
// cron trigger but no cronJobId → excluded by the SQL filter
{
agentId: 'agent-cron',
id: 'cron-nojob',
metadata: {},
title: 'nojob',
trigger: 'cron',
userId,
},
]);
const grouped = await topicModel.getCronTopicsGroupedByCronJob('agent-cron');
const byJob = Object.fromEntries(grouped.map((g) => [g.cronJobId, g.topics.length]));
expect(byJob).toEqual({ 'job-a': 2, 'job-b': 1 });
});
});
describe('queryRecent', () => {
it('orders recent topics by latest activity and tags type', async () => {
await serverDB.insert(agents).values({ id: 'agent-recent', slug: 'inbox', userId });
await serverDB.insert(chatGroups).values({ id: 'group-recent', userId });
await serverDB.insert(topics).values([
{
agentId: 'agent-recent',
id: 'r-agent',
title: 'agent',
updatedAt: minutesAgo(10),
userId,
},
{
groupId: 'group-recent',
id: 'r-group',
title: 'group',
updatedAt: minutesAgo(1),
userId,
},
]);
const result = await topicModel.queryRecent();
expect(result.map((t) => t.id)).toEqual(['r-group', 'r-agent']);
expect(result.find((t) => t.id === 'r-group')?.type).toBe('group');
expect(result.find((t) => t.id === 'r-agent')?.type).toBe('agent');
});
});
describe('listTopicsForMemoryExtractor', () => {
it('omits topics already marked completed unless ignoreExtracted is set', async () => {
await serverDB.insert(topics).values([
{ createdAt: minutesAgo(2), id: 'mem-pending', title: 'pending', userId },
{
createdAt: minutesAgo(1),
id: 'mem-done',
metadata: { userMemoryExtractStatus: 'completed' },
title: 'done',
userId,
},
]);
const pendingOnly = await topicModel.listTopicsForMemoryExtractor();
expect(pendingOnly.map((t) => t.id)).toEqual(['mem-pending']);
const all = await topicModel.listTopicsForMemoryExtractor({ ignoreExtracted: true });
expect(all.map((t) => t.id).sort()).toEqual(['mem-done', 'mem-pending']);
});
it('countTopicsForMemoryExtractor matches the list length', async () => {
await serverDB.insert(topics).values([
{ id: 'mem-1', title: '1', userId },
{
id: 'mem-2',
metadata: { userMemoryExtractStatus: 'completed' },
title: '2',
userId,
},
]);
expect(await topicModel.countTopicsForMemoryExtractor()).toBe(1);
});
});
});
+104 -14
View File
@@ -1,5 +1,5 @@
import type { WorkingDirEntry } from '@lobechat/types';
import { and, desc, eq } from 'drizzle-orm';
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import type { DeviceItem } from '../schemas';
import { devices } from '../schemas';
@@ -20,23 +20,31 @@ export interface UpdateDeviceParams {
}
/**
* Devices are intentionally USER-LEVEL, not workspace-scoped.
* Two distinct kinds of device live in this table, told apart by `workspace_id`:
*
* Even though the `devices` table carries a nullable `workspace_id` column, a
* physical machine belongs to the user across every workspace they're in (the
* unique key is `(userId, deviceId)`). This model therefore scopes all reads
* and writes by `userId` only and deliberately does NOT take a `workspaceId`
* argument or use `buildWorkspaceWhere` / `buildWorkspacePayload`. Switching it
* to workspace-scoped lookups would hide a user's own device inside their
* workspaces. See the matching note on `devices.workspaceId` in the schema.
* - **Personal devices** (`workspace_id IS NULL`): a user's own machine, keyed
* by `(userId, deviceId)`. The personal read/write path (`query` / `register`
* / `update` / `delete` / `findByDeviceId`) is scoped by `userId` and must
* stay that way a user's machine belongs to them across all their
* workspaces.
* - **Workspace devices** (`workspace_id = <ws>`): a machine enrolled into a
* workspace by an admin (e.g. a shared build server). Owned by the workspace,
* reachable by every member. `userId` records the enrolling admin. These are
* read via `queryWorkspaceDevices` / `findWorkspaceDeviceById` (scoped by
* `workspace_id`), never mixed into the personal `query`.
*
* `workspaceId` here is the caller's current workspace (for the workspace
* reads); the personal path ignores it.
*/
export class DeviceModel {
private userId: string;
private db: LobeChatDatabase;
private workspaceId?: string;
constructor(db: LobeChatDatabase, userId: string) {
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.userId = userId;
this.db = db;
this.workspaceId = workspaceId;
}
/**
@@ -65,6 +73,45 @@ export class DeviceModel {
platform: params.platform,
},
target: [devices.userId, devices.deviceId],
targetWhere: sql`${devices.workspaceId} IS NULL`,
})
.returning();
return result;
};
/**
* Enroll a machine as a WORKSPACE device (admin-driven). Upserts on
* `(userId, deviceId)` like {@link register}, but stamps `workspace_id` so the
* row belongs to the workspace and surfaces to all its members. `userId`
* records the enrolling admin.
*/
registerWorkspaceDevice = async (params: RegisterDeviceParams & { workspaceId: string }) => {
const now = new Date();
const [result] = await this.db
.insert(devices)
.values({
deviceId: params.deviceId,
hostname: params.hostname,
identitySource: params.identitySource,
lastSeenAt: now,
platform: params.platform,
userId: this.userId,
workspaceId: params.workspaceId,
})
// Dedupe on (workspaceId, deviceId): a machine enrolled into a workspace is
// ONE device no matter which admin (re-)runs the enrollment. `userId` is
// left untouched on conflict — it stays the original enroller. The partial
// unique index requires its predicate be repeated in `targetWhere`.
.onConflictDoUpdate({
set: {
hostname: params.hostname,
identitySource: params.identitySource,
lastSeenAt: now,
platform: params.platform,
},
target: [devices.workspaceId, devices.deviceId],
targetWhere: sql`${devices.workspaceId} IS NOT NULL`,
})
.returning();
@@ -73,14 +120,36 @@ export class DeviceModel {
query = async (): Promise<DeviceItem[]> => {
return this.db.query.devices.findMany({
// `lastSeenAt` is written from a JS `new Date()` (ms precision), so two
// rapid registers can tie on it and leave the order undefined. Break ties
// by `createdAt` (DB-side now(), µs precision) for a stable ordering.
orderBy: [desc(devices.lastSeenAt), desc(devices.createdAt)],
orderBy: [desc(devices.lastSeenAt)],
where: eq(devices.userId, this.userId),
});
};
/** The caller's PERSONAL devices only (excludes any workspace-enrolled rows). */
queryPersonal = async (): Promise<DeviceItem[]> => {
return this.db.query.devices.findMany({
orderBy: [desc(devices.lastSeenAt)],
where: and(eq(devices.userId, this.userId), isNull(devices.workspaceId)),
});
};
/** Every device enrolled into the current workspace (any enrolling admin). */
queryWorkspaceDevices = async (): Promise<DeviceItem[]> => {
if (!this.workspaceId) return [];
return this.db.query.devices.findMany({
orderBy: [desc(devices.lastSeenAt)],
where: eq(devices.workspaceId, this.workspaceId),
});
};
/** A single workspace device by id, scoped to the current workspace. */
findWorkspaceDeviceById = async (deviceId: string) => {
if (!this.workspaceId) return undefined;
return this.db.query.devices.findFirst({
where: and(eq(devices.workspaceId, this.workspaceId), eq(devices.deviceId, deviceId)),
});
};
findByDeviceId = async (deviceId: string) => {
return this.db.query.devices.findFirst({
where: and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)),
@@ -99,4 +168,25 @@ export class DeviceModel {
.delete(devices)
.where(and(eq(devices.userId, this.userId), eq(devices.deviceId, deviceId)));
};
/**
* Update a WORKSPACE device's user-editable fields, scoped by `workspace_id`
* (not the enrolling admin's userId), so any workspace owner can manage it.
* Caller must be a workspace owner enforced at the router (`wsOwnerProcedure`).
*/
updateWorkspaceDevice = async (deviceId: string, value: UpdateDeviceParams) => {
if (!this.workspaceId) return;
return this.db
.update(devices)
.set({ ...value, updatedAt: new Date() })
.where(and(eq(devices.workspaceId, this.workspaceId), eq(devices.deviceId, deviceId)));
};
/** Remove a WORKSPACE device, scoped by `workspace_id`. Owner-gated at the router. */
deleteWorkspaceDevice = async (deviceId: string) => {
if (!this.workspaceId) return;
return this.db
.delete(devices)
.where(and(eq(devices.workspaceId, this.workspaceId), eq(devices.deviceId, deviceId)));
};
}
+1 -1
View File
@@ -2363,7 +2363,7 @@ export class MessageModel {
* Id of the latest main-thread (`threadId IS NULL`) "spine" message in a
* topic: the most recent message that is NOT a tool and NOT a signal-tagged
* reactive turn (Monitor stdout callbacks etc.). This is the chain anchor for
* the heterogeneous-agent write side (LOBE-10445 phase 2): the next normal
* the heterogeneous-agent write side: the next normal
* turn parents off it, producing a `user → asst → asst …` spine with tools as
* inline children.
*
+11 -9
View File
@@ -135,18 +135,20 @@ export interface ListTopicsForMemoryExtractorCursor {
// higher in the list. A NULL / unknown status falls through to `active` (3),
// matching the client which treats a missing status as active. Keep this in
// sync with `STATUS_GROUP_ORDER` / `resolveStatusBucket` in `@lobechat/utils`
// (client-side bucketing): `waitingForHuman` and `failed` both collapse into the
// top `pending` bucket, so they must float to the top here too — otherwise a
// failed topic could fall off the first page and vanish from the pending group.
// (client-side bucketing): `waitingForHuman`, `failed` and `unread` all collapse
// into the top `pending` bucket, so they must float to the top here too —
// otherwise such a topic could fall off the first page and vanish from the
// pending group.
const STATUS_SORT_RANK = sql`CASE ${topics.status}
WHEN 'waitingForHuman' THEN 0
WHEN 'failed' THEN 1
WHEN 'running' THEN 2
WHEN 'active' THEN 3
WHEN 'paused' THEN 4
WHEN 'completed' THEN 5
WHEN 'archived' THEN 6
ELSE 3 END`;
WHEN 'unread' THEN 2
WHEN 'running' THEN 3
WHEN 'active' THEN 4
WHEN 'paused' THEN 5
WHEN 'completed' THEN 6
WHEN 'archived' THEN 7
ELSE 4 END`;
// Favorites always float to the top; the rest are ordered by the requested
// strategy. `status` adds the priority bucket before the recency tiebreaker.
@@ -4,10 +4,17 @@ import {
type SidebarGroup,
} from '@lobechat/types';
import { cleanObject } from '@lobechat/utils';
import { and, desc, eq, not, sql } from 'drizzle-orm';
import { and, count, desc, eq, not, sql } from 'drizzle-orm';
import { ChatGroupModel } from '../../models/chatGroup';
import { agents, agentsToSessions, chatGroups, sessionGroups, sessions } from '../../schemas';
import {
agents,
agentsToSessions,
chatGroups,
sessionGroups,
sessions,
topics,
} from '../../schemas';
import { type LobeChatDatabase } from '../../type';
import { sanitizeBm25Query } from '../../utils/bm25';
import { normalizeInboxAgentMeta } from '../../utils/inboxAgent';
@@ -85,6 +92,12 @@ export class HomeRepository {
// 2.1 Query member avatars for each chat group
const memberAvatarsMap = await this.getChatGroupMemberAvatars(chatGroupList.map((g) => g.id));
// 2.2 Unread completion counts per agent / group, derived from persisted
// `topics.status === 'unread'`. The list query covers all agents, so this is
// the source of truth for the sidebar badge even on agents the client hasn't
// loaded topics for.
const { agentUnread, groupUnread } = await this.getUnreadCounts();
// 3. Query all sessionGroups (user-defined folders)
const groupList = await this.db
.select({
@@ -97,7 +110,58 @@ export class HomeRepository {
.orderBy(sessionGroups.sort);
// 4. Process and categorize
return this.processAgentList(agentList, chatGroupList, groupList, memberAvatarsMap);
return this.processAgentList(
agentList,
chatGroupList,
groupList,
memberAvatarsMap,
agentUnread,
groupUnread,
);
}
/**
* Count topics with an unread completed generation, grouped by agent and by
* group. Returns plain maps keyed by agentId / groupId.
*/
private async getUnreadCounts(): Promise<{
agentUnread: Map<string, number>;
groupUnread: Map<string, number>;
}> {
const isUnread = eq(topics.status, 'unread');
const [byAgent, byGroup] = await Promise.all([
this.db
.select({ id: topics.agentId, value: count() })
.from(topics)
.where(
and(
buildWorkspaceWhere(this.scope, topics),
isUnread,
sql`${topics.agentId} is not null`,
),
)
.groupBy(topics.agentId),
this.db
.select({ id: topics.groupId, value: count() })
.from(topics)
.where(
and(
buildWorkspaceWhere(this.scope, topics),
isUnread,
sql`${topics.groupId} is not null`,
),
)
.groupBy(topics.groupId),
]);
const agentUnread = new Map<string, number>();
for (const row of byAgent) if (row.id) agentUnread.set(row.id, row.value);
const groupUnread = new Map<string, number>();
for (const row of byGroup) if (row.id) groupUnread.set(row.id, row.value);
return { agentUnread, groupUnread };
}
private processAgentList(
@@ -132,6 +196,8 @@ export class HomeRepository {
sort: number | null;
}>,
memberAvatarsMap: Map<string, Array<{ avatar: string; background?: string }>>,
agentUnread: Map<string, number> = new Map(),
groupUnread: Map<string, number> = new Map(),
): SidebarAgentListResponse {
// Convert to unified format
// For pinned status: agents.pinned takes priority, fallback to sessions.pinned for backward compatibility
@@ -154,6 +220,7 @@ export class HomeRepository {
sessionId: a.sessionId,
title: meta.title,
type: 'agent' as const,
unreadCount: agentUnread.get(a.id) ?? 0,
updatedAt: a.updatedAt,
};
}),
@@ -169,6 +236,7 @@ export class HomeRepository {
sessionId: null,
title: g.title,
type: 'group' as const,
unreadCount: groupUnread.get(g.id) ?? 0,
updatedAt: g.updatedAt,
})),
];
+22 -17
View File
@@ -1,12 +1,13 @@
import type { AiProviderConfig, AiProviderSettings } from '@lobechat/types';
import { isNotNull, isNull } from 'drizzle-orm';
import {
boolean,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
@@ -23,14 +24,11 @@ export const aiProviders = pgTable(
name: text('name'),
/**
* Surrogate primary key for the ai_providers workspace-scoped unique
* constraints migration. The original composite PK (id, user_id) was
* incompatible with workspace-scoped duplicates because workspace_id can
* be NULL for personal rows. Added nullable + DEFAULT to avoid a full
* table rewrite; a later manual step backfills history and adds NOT NULL
* before the unique index + PK swap.
* Surrogate primary key for the workspace-scoped rebuild (LOBE-10056). The
* business uniqueness now lives in the workspace-scoped partial unique
* indexes below, so the PK no longer carries it.
*/
_id: uuid('_id').defaultRandom(),
_id: uuid('_id').defaultRandom().notNull().primaryKey(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
@@ -59,7 +57,12 @@ export const aiProviders = pgTable(
...timestamps,
},
(table) => [
primaryKey({ columns: [table.id, table.userId] }),
uniqueIndex('ai_providers_id_user_id_unique')
.on(table.id, table.userId)
.where(isNull(table.workspaceId)),
uniqueIndex('ai_providers_id_user_id_workspace_id_unique')
.on(table.id, table.userId, table.workspaceId)
.where(isNotNull(table.workspaceId)),
index('ai_providers_user_id_idx').on(table.userId),
index('ai_providers_workspace_id_idx').on(table.workspaceId),
],
@@ -74,14 +77,11 @@ export const aiModels = pgTable(
id: varchar('id', { length: 150 }).notNull(),
/**
* Surrogate primary key for the ai_models workspace-scoped unique
* constraints migration. The original composite PK (id, provider_id,
* user_id) was incompatible with workspace-scoped duplicates because
* workspace_id can be NULL for personal rows. Added nullable + DEFAULT
* to avoid a full table rewrite (~4M rows); a later manual step
* backfills history and adds NOT NULL before the unique index + PK swap.
* Surrogate primary key for the workspace-scoped rebuild (LOBE-10056). The
* business uniqueness now lives in the workspace-scoped partial unique
* indexes below, so the PK no longer carries it.
*/
_id: uuid('_id').defaultRandom(),
_id: uuid('_id').defaultRandom().notNull().primaryKey(),
displayName: varchar('display_name', { length: 200 }),
description: text('description'),
@@ -108,7 +108,12 @@ export const aiModels = pgTable(
...timestamps,
},
(table) => [
primaryKey({ columns: [table.id, table.providerId, table.userId] }),
uniqueIndex('ai_models_id_provider_id_user_id_unique')
.on(table.id, table.providerId, table.userId)
.where(isNull(table.workspaceId)),
uniqueIndex('ai_models_id_provider_id_user_id_workspace_id_unique')
.on(table.id, table.providerId, table.userId, table.workspaceId)
.where(isNotNull(table.workspaceId)),
index('ai_models_user_id_idx').on(table.userId),
index('ai_models_workspace_id_idx').on(table.workspaceId),
],
+28 -10
View File
@@ -1,4 +1,5 @@
import type { WorkingDirEntry } from '@lobechat/types';
import { sql } from 'drizzle-orm';
import { index, jsonb, pgTable, text, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core';
import { timestamps, timestamptz } from './_helpers';
@@ -22,14 +23,15 @@ export const devices = pgTable(
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
// NOTE: devices are a USER-LEVEL identity, not workspace-scoped content. A
// physical machine belongs to the user across all of their workspaces (the
// unique key is (userId, deviceId), see below). `workspaceId` here only
// records which workspace the device was registered from — it is NOT used to
// filter device lookups. So `DeviceModel`/`deviceRouter` intentionally scope
// by userId only and do NOT use `buildWorkspaceWhere`. Do not "fix" them to
// workspace-scope reads, or a user's device would disappear inside their own
// workspaces.
// `workspace_id` distinguishes the two kinds of device row:
// - NULL → a PERSONAL device, identified by (userId, deviceId).
// - <workspaceId> → a device ENROLLED into that workspace (shared infra),
// identified by (workspaceId, deviceId). `userId` then only records the
// enrolling admin — it is NOT part of the identity, so two admins
// enrolling the same machine resolve to ONE row (see the partial unique
// below). The same physical machine produces a distinct `deviceId` per
// principal (the hash mixes in userId / `workspace:<id>`), so personal
// and workspace rows never collide.
workspaceId: text('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }),
/** Machine-derived id (sha256 truncated to 32 chars; 64 leaves room for fallback randomUUID) */
@@ -54,8 +56,24 @@ export const devices = pgTable(
...timestamps,
},
(t) => [
/** One row per (user, machine); register() upserts on this target */
uniqueIndex('devices_user_id_device_id_unique').on(t.userId, t.deviceId),
/**
* One row per (user, machine) for PERSONAL devices; register() upserts on
* this target (partial ON CONFLICT must repeat the
* `WHERE workspace_id IS NULL` predicate). Workspace rows are excluded so
* `user_id` is not part of their identity (see workspace partial below).
*/
uniqueIndex('devices_user_id_device_id_unique')
.on(t.userId, t.deviceId)
.where(sql`${t.workspaceId} IS NULL`),
/**
* One row per (workspace, machine) for enrolled devices, regardless of which
* admin ran the enrollment. registerWorkspaceDevice() upserts on this target
* (partial ON CONFLICT must repeat the `WHERE workspace_id IS NOT NULL`
* predicate).
*/
uniqueIndex('devices_workspace_id_device_id_unique')
.on(t.workspaceId, t.deviceId)
.where(sql`${t.workspaceId} IS NOT NULL`),
index('devices_user_id_idx').on(t.userId),
index('devices_workspace_id_idx').on(t.workspaceId),
],
+10 -1
View File
@@ -44,7 +44,16 @@ export const topics = pgTable(
trigger: text('trigger'), // 'cron' | 'chat' | 'api' | 'eval' | 'share' - topic creation trigger source
mode: text('mode'), // 'temp' | 'test' | 'default' - topic usage scenario
status: text('status', {
enum: ['active', 'running', 'paused', 'waitingForHuman', 'failed', 'completed', 'archived'],
enum: [
'active',
'running',
'paused',
'waitingForHuman',
'failed',
'completed',
'archived',
'unread',
],
}),
completedAt: timestamptz('completed_at'),
+16 -1
View File
@@ -1,4 +1,13 @@
import { index, jsonb, pgTable, primaryKey, text, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
import {
boolean,
index,
jsonb,
pgTable,
primaryKey,
text,
uniqueIndex,
varchar,
} from 'drizzle-orm/pg-core';
import { createNanoId } from '../utils/idGenerator';
import { createdAt, timestamptz, updatedAt } from './_helpers';
@@ -23,6 +32,12 @@ export const workspaces = pgTable(
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
settings: jsonb('settings').default({}),
// Freeze state, mirrors the `users.banned` / `banReason` / `banExpires`
// trio. Driven by cloud risk control (abnormal spend) and admin tooling;
// OSS column with no desktop/open-source behavior attached.
frozen: boolean('frozen').default(false),
frozenReason: text('frozen_reason'),
frozenAt: timestamptz('frozen_at'),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
@@ -21,6 +21,12 @@ export type ListLocalFileSortBy = 'name' | 'modifiedTime' | 'createdTime' | 'siz
export type ListLocalFileSortOrder = 'asc' | 'desc';
export interface ListLocalFileParams {
/**
* Working directory a relative `path` resolves against (the device-bound
* directory, injected by the server runtime not model-supplied). Absolute
* paths ignore it; absent the daemon's process cwd.
*/
cwd?: string;
/**
* Maximum number of files to return
* @default 100
@@ -59,6 +65,8 @@ export interface MoveLocalFileParams {
}
export interface MoveLocalFilesParams {
/** Working directory each item's relative paths resolve against. See {@link ListLocalFileParams.cwd}. */
cwd?: string;
items: MoveLocalFileParams[];
}
@@ -81,12 +89,16 @@ export interface RenameLocalFileResult {
}
export interface LocalReadFileParams {
/** Working directory a relative `path` resolves against. See {@link ListLocalFileParams.cwd}. */
cwd?: string;
fullContent?: boolean;
loc?: [number, number];
path: string;
}
export interface LocalReadFilesParams {
/** Working directory each relative path resolves against. See {@link ListLocalFileParams.cwd}. */
cwd?: string;
paths: string[];
}
@@ -95,6 +107,8 @@ export interface WriteLocalFileParams {
* Content to write
*/
content: string;
/** Working directory a relative `path` resolves against. See {@link ListLocalFileParams.cwd}. */
cwd?: string;
/**
* File path to write to
@@ -351,6 +365,8 @@ export interface GlobFilesResult {
// Edit types
export interface EditLocalFileParams {
/** Working directory a relative `file_path` resolves against. See {@link ListLocalFileParams.cwd}. */
cwd?: string;
file_path: string;
new_string: string;
old_string: string;
@@ -176,6 +176,38 @@ describe('ClaudeCodeAdapter', () => {
});
});
it('does not treat an allowed rate_limit_event window as a quota limit on a later network error', () => {
const adapter = new ClaudeCodeAdapter();
// CC stamps a rate_limit_info onto an *allowed* request — it carries the
// rolling-window metadata (resetsAt / rateLimitType) even though nothing
// was rejected. A later ECONNRESET must surface as a generic error, NOT
// inherit this window and render a bogus "usage limit reached" guide.
const rawError = 'API Error: Unable to connect to API (ECONNRESET)';
adapter.adapt({ subtype: 'init', type: 'system' });
adapter.adapt({
rate_limit_info: {
isUsingOverage: false,
rateLimitType: 'five_hour',
resetsAt: 1_781_853_000,
status: 'allowed',
},
type: 'rate_limit_event',
});
const events = adapter.adapt({
api_error_status: null,
is_error: true,
result: rawError,
type: 'result',
});
expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']);
expect(events[1].data).toMatchObject({ error: rawError, message: rawError });
expect(events[1].data).not.toHaveProperty('code', 'rate_limit');
expect(events[1].data).not.toHaveProperty('rateLimitInfo');
});
it('classifies rate-limit failures from paired rate_limit_event + result events', () => {
const adapter = new ClaudeCodeAdapter();
const rawError = "You've hit your limit · resets 9am (Asia/Shanghai)";
@@ -2558,6 +2590,51 @@ describe('ClaudeCodeAdapter', () => {
).toBeUndefined();
});
/**
* Real-world regression (recorded on tpc_joZS2mksoY5L): a slow `git commit`
* (running a lint-staged hook) makes CC track the Bash call as a task and
* emit `task_started` + `task_notification` back-to-back, with NO out-of-band
* callback turn in between, immediately followed by the tool_result. That is
* an inline synchronous tool, not a Monitor-style long-running task the next
* turn is the normal main-chain continuation and must NOT be tagged
* `task-completion` (doing so mis-anchors it and drops it from the rendered
* chain).
*/
it('does NOT tag the next turn when a task started and ended with no callbacks (inline tool)', () => {
const adapter = new ClaudeCodeAdapter();
init(adapter);
// A Bash `git commit` tool_use.
adapter.adapt({
message: {
content: [
{
id: 'toolu_commit',
input: { command: 'git commit' },
name: 'Bash',
type: 'tool_use',
},
],
id: 'msg_01',
},
type: 'assistant',
});
// CC tracks the slow commit as a task, then notifies completion
// back-to-back — NO callback turn opened while it was alive.
adapter.adapt(ccTaskStarted('task_1', 'toolu_commit'));
adapter.adapt(ccTaskNotification('task_1'));
// The commit's tool_result is consumed inline by the next turn.
adapter.adapt(ccUser('toolu_commit', 'committed'));
// Next turn is plain continuation — must carry NO externalSignal.
const next = adapter.adapt(ccMessageStart('msg_02'));
expect(
next.find((e) => e.type === 'stream_start' && e.data?.newStep)!.data.externalSignal,
).toBeUndefined();
});
it('clears unconsumed task-completion lineage on `result`', () => {
const adapter = new ClaudeCodeAdapter();
init(adapter);
@@ -2572,13 +2649,18 @@ describe('ClaudeCodeAdapter', () => {
adapter.adapt(ccTaskStarted('task_1', 'toolu_mon'));
adapter.adapt(ccUser('toolu_mon', 'Monitor started'));
adapter.adapt(ccMessageStart('msg_02'));
// A signal callback fires while the task is alive (callbackCount > 0), so
// `task_notification` genuinely arms pendingTaskCompletion — otherwise (an
// inline tool with no callbacks) nothing is armed and this test would pass
// vacuously, no longer guarding the `result` clear path.
adapter.adapt(ccMessageStart('msg_03'));
adapter.adapt(ccTaskNotification('task_1'));
// Run ends before the summary turn fires (unusual but possible).
adapter.adapt({ result: 'ok', type: 'result', usage: undefined });
// A later turn (e.g. follow-up user message) must NOT inherit
// the unconsumed task-completion lineage.
const next = adapter.adapt(ccMessageStart('msg_03'));
// the unconsumed task-completion lineage — `result` dropped it.
const next = adapter.adapt(ccMessageStart('msg_04'));
expect(
next.find((e) => e.type === 'stream_start' && e.data?.newStep)!.data.externalSignal,
).toBeUndefined();
@@ -218,18 +218,27 @@ const CLI_OVERLOADED_PATTERNS = [
] as const;
/**
* The one reliable discriminator between a user-side plan/quota limit and a
* transient server throttle: only the genuine user limit carries a concrete
* reset window in the structured `rate_limit_event` `resetsAt` (epoch
* seconds) and/or a named `rateLimitType` (e.g. `seven_day`). Anthropic's
* transient throttle emits a rate_limit_event too, but with just
* `status: 'rejected'` and no reset info. Status codes (429 / 529) alone are
* ambiguous, so this structured signal not the HTTP status, not the message
* text is what decides whether we show the "usage limit reached, resets at
* X" guide vs the "temporarily overloaded, retry" guide.
* Discriminates a user-side plan/quota limit from everything else.
*
* Two signals must BOTH hold:
* 1. The request was actually `status: 'rejected'`. Anthropic stamps a
* `rate_limit_info` onto its events even when the request goes through
* (`status: 'allowed'`) that block is just the rolling-window metadata
* (`resetsAt`, `rateLimitType`) for an *allowed* call, NOT evidence the
* limit was hit. Leaning on the presence of a reset window alone made a
* later unrelated terminal failure (e.g. an `ECONNRESET` network drop)
* inherit the last allowed event's window and render a bogus "usage limit
* reached, resets at X" guide. The `status` is the gate.
* 2. A concrete reset window (`resetsAt` epoch seconds and/or a named
* `rateLimitType` such as `seven_day`). A bare `rejected` with no window is
* Anthropic's transient server throttle left to the overloaded (retry)
* classifier, not the usage-limit guide.
*
* Status codes (429 / 529) and message text are deliberately not consulted
* here only this structured signal decides the "usage limit reached" guide.
*/
const isUserQuotaRateLimit = (info?: HeterogeneousRateLimitInfo): boolean =>
!!info && (info.resetsAt != null || info.rateLimitType != null);
!!info && info.status === 'rejected' && (info.resetsAt != null || info.rateLimitType != null);
const getCliResultMessage = (result: unknown): string | undefined => {
if (typeof result === 'string') return result;
@@ -663,8 +672,19 @@ export class ClaudeCodeAdapter implements AgentEventAdapter {
// task-ended notification) can be tagged with `task-completion`.
// Last-task-wins if multiple tasks end before a summary fires — in
// practice CC summarizes once per LLM call.
//
// Gate on `callbackCount > 0`: only a task that actually fired out-of-band
// callback turns while alive is a genuine long-running task whose ending
// produces a post-task summary (the summary "keeps it inside the same
// AssistantGroup as the preceding callbacks" — so there must BE preceding
// callbacks). A task that fires `task_started` and `task_notification`
// back-to-back with no intervening callback turn was an inline synchronous
// tool that CC merely tracked as a task (e.g. a slow `git commit` running a
// lint-staged hook); its `tool_result` is consumed by the next turn in the
// normal main chain. Tagging that turn `task-completion` mis-anchors it and
// drops it from the rendered chain — so leave it untagged.
const ending = this.activeTasks.get(raw.task_id);
if (ending) {
if (ending && ending.callbackCount > 0) {
this.pendingTaskCompletion = {
sourceToolCallId: ending.toolUseId,
sourceToolName: ending.sourceToolName,
@@ -100,7 +100,7 @@ const delegateSubagent = (
// ─── Chain rule ───
/**
* Parent for the NEXT turn's assistant (LOBE-10445 phase 2 write-side spine).
* Parent for the NEXT turn's assistant (write-side spine).
*
* Normal turns parent off the run's spine (`lastSpineMessageId`, the most recent
* non-tool / non-signal main message) so the persisted shape is
@@ -27,8 +27,8 @@ import type { ExternalSignalContext, ToolCallPayload } from '../types';
* by delegating subagent-scoped events to `reduceSubagentRuns`, so a single
* `reduce` call is the only entry point both engines need.
*
* The CHAIN RULE lives here and is authoritative for both engines (LOBE-10445
* phase 2): the next turn's assistant parents off the most recent NON-tool,
* The CHAIN RULE lives here and is authoritative for both engines:
* the next turn's assistant parents off the most recent NON-tool,
* NON-signal main-thread message the run's "spine" (`lastSpineMessageId`)
* so the persisted shape is `user → asst → asst …` with tools as inline
* children. The read side (`conversation-flow`) reconstructs the
@@ -82,7 +82,7 @@ export interface MainAgentRunState {
/** Set once a terminal event has been reduced (idempotent finalize). */
ended: boolean;
/**
* Chain rule (LOBE-10445 phase 2): the most recent NON-tool, NON-signal
* Chain rule: the most recent NON-tool, NON-signal
* main-thread message the run's spine. The next NORMAL turn's assistant
* parents off this (signal-tagged reactive turns parent off `lastToolMsgIdEver`
* instead). Advances on every normal turn; a signal turn does NOT advance it,
@@ -95,7 +95,7 @@ export interface MainAgentRunState {
lastTextSnapshotSeq: number;
/**
* Run-lifetime id of the most recent main-agent tool message. Since
* LOBE-10445 phase 2 this anchors ONLY signal-tagged reactive turns (Monitor
* this anchors ONLY signal-tagged reactive turns (Monitor
* stdout pushes) onto a tool, so the reader renders them as tool-child
* callbacks; normal turns parent off `lastSpineMessageId`. Only advances on
* tool batches; never reset across turns.
@@ -349,7 +349,7 @@ const reduceToolsChunk = (
})),
});
// Chain rule (LOBE-10445 phase 2): the next turn's assistant parents off the
// Chain rule: the next turn's assistant parents off the
// prior assistant (the spine), NOT this batch's last tool — so
// `lastChainParentId` stays at `currentAssistantId` here, tools become inline
// children, and the read side reconstructs the zigzag. (Subagent threads have
@@ -304,6 +304,39 @@ describe('file operations', () => {
expect(result.linesAdded).toBeGreaterThan(0);
expect(result.linesDeleted).toBeGreaterThan(0);
});
it('should match a multi-line LF old_string against a CRLF file (Windows)', async () => {
const filePath = path.join(tmpDir, 'crlf.txt');
await writeFile(filePath, 'line1\r\nline2\r\nline3\r\n');
// LLM emits `\n` even though the file on disk uses `\r\n`.
const result = await editLocalFile({
file_path: filePath,
new_string: 'lineA\nlineB',
old_string: 'line1\nline2',
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(1);
// Existing CRLF line-ending style is preserved.
expect(fs.readFileSync(filePath, 'utf8')).toBe('lineA\r\nlineB\r\nline3\r\n');
});
it('should replace_all with an LF old_string against a CRLF file', async () => {
const filePath = path.join(tmpDir, 'crlf-all.txt');
await writeFile(filePath, 'a\r\nb\r\na\r\nb\r\n');
const result = await editLocalFile({
file_path: filePath,
new_string: 'x\ny',
old_string: 'a\nb',
replace_all: true,
});
expect(result.success).toBe(true);
expect(result.replacements).toBe(2);
expect(fs.readFileSync(filePath, 'utf8')).toBe('x\r\ny\r\nx\r\ny\r\n');
});
});
// ─── listLocalFiles ───
@@ -0,0 +1,33 @@
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { resolveAgainstCwd } from '../expandTilde';
describe('resolveAgainstCwd', () => {
const cwd = '/Users/me/repo';
it('anchors a relative path to cwd', () => {
expect(resolveAgainstCwd('src/index.ts', cwd)).toBe(path.join(cwd, 'src/index.ts'));
expect(resolveAgainstCwd('./pkg/a.ts', cwd)).toBe(path.join(cwd, 'pkg/a.ts'));
});
it('leaves an absolute path untouched', () => {
expect(resolveAgainstCwd('/etc/hosts', cwd)).toBe('/etc/hosts');
});
it('expands ~ before considering cwd', () => {
expect(resolveAgainstCwd('~/notes.md', cwd)).toBe(path.join(os.homedir(), 'notes.md'));
});
it('falls back to expandTilde behavior when cwd is absent (no regression)', () => {
expect(resolveAgainstCwd('src/index.ts')).toBe('src/index.ts');
expect(resolveAgainstCwd('src/index.ts', undefined)).toBe('src/index.ts');
});
it('passes through empty / undefined input', () => {
expect(resolveAgainstCwd(undefined, cwd)).toBeUndefined();
expect(resolveAgainstCwd('', cwd)).toBe('');
});
});
+25 -7
View File
@@ -3,19 +3,37 @@ import { readFile, writeFile } from 'node:fs/promises';
import { createPatch } from 'diff';
import type { EditFileParams, EditFileResult } from '../types';
import { expandTilde } from './expandTilde';
import { resolveAgainstCwd } from './expandTilde';
export async function editLocalFile({
file_path: rawPath,
old_string,
new_string,
replace_all = false,
cwd,
}: EditFileParams): Promise<EditFileResult> {
const filePath = expandTilde(rawPath) ?? rawPath;
const filePath = resolveAgainstCwd(rawPath, cwd) ?? rawPath;
try {
const content = await readFile(filePath, 'utf8');
if (!content.includes(old_string)) {
// Resolve the search/replace strings against the file's actual line endings.
// LLMs almost always emit `\n` even when the on-disk file uses CRLF (the norm
// on Windows), so a literal match would fail and the edit appears broken. When
// the raw old_string isn't present but its CRLF-adjusted form is, edit against
// that — keeping the file's existing line-ending style and producing a minimal
// diff instead of rewriting every line.
let search = old_string;
let replace = new_string;
if (!content.includes(search) && content.includes('\r\n')) {
const toCRLF = (s: string) => s.replaceAll('\r\n', '\n').replaceAll('\n', '\r\n');
const crlfSearch = toCRLF(search);
if (content.includes(crlfSearch)) {
search = crlfSearch;
replace = toCRLF(replace);
}
}
if (!content.includes(search)) {
return {
error: 'The specified old_string was not found in the file',
replacements: 0,
@@ -27,16 +45,16 @@ export async function editLocalFile({
let replacements: number;
if (replace_all) {
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
const regex = new RegExp(search.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
const matches = content.match(regex);
replacements = matches ? matches.length : 0;
newContent = content.replaceAll(old_string, new_string);
newContent = content.replaceAll(search, replace);
} else {
const index = content.indexOf(old_string);
const index = content.indexOf(search);
if (index === -1) {
return { error: 'Old string not found', replacements: 0, success: false };
}
newContent = content.slice(0, index) + new_string + content.slice(index + old_string.length);
newContent = content.slice(0, index) + replace + content.slice(index + search.length);
replacements = 1;
}
@@ -15,3 +15,23 @@ export const expandTilde = (input: string | undefined): string | undefined => {
}
return input;
};
/**
* Resolve a filesystem path for Node fs APIs: first expand a leading `~`, then
* anchor a still-relative path to `cwd` the device-bound working directory.
*
* Absolute paths pass through untouched. When `cwd` is absent the behavior is
* identical to {@link expandTilde}, so callers that don't carry a working
* directory (e.g. desktop client-mode today) keep resolving relative paths
* against the process cwd and nothing regresses. Without this, a relative path
* supplied by the model resolves against the daemon's `process.cwd()` (= `/`
* for a Finder/Dock-launched app) instead of the user's bound directory.
*/
export const resolveAgainstCwd = (
input: string | undefined,
cwd?: string,
): string | undefined => {
const expanded = expandTilde(input);
if (!expanded || !cwd) return expanded;
return path.isAbsolute(expanded) ? expanded : path.join(cwd, expanded);
};
+3 -3
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import { SYSTEM_FILES_TO_IGNORE } from '@lobechat/file-loaders';
import type { FileEntry, ListFilesParams, ListFilesResult } from '../types';
import { expandTilde } from './expandTilde';
import { resolveAgainstCwd } from './expandTilde';
export interface ListFilesOptions {
/** Whether to filter out system files like .DS_Store, Thumbs.db, etc. */
@@ -12,11 +12,11 @@ export interface ListFilesOptions {
}
export async function listLocalFiles(
{ path: rawPath, sortBy = 'modifiedTime', sortOrder = 'desc', limit = 100 }: ListFilesParams,
{ path: rawPath, sortBy = 'modifiedTime', sortOrder = 'desc', limit = 100, cwd }: ListFilesParams,
options?: ListFilesOptions,
): Promise<ListFilesResult> {
const { ignoreSystemFiles = true } = options || {};
const dirPath = expandTilde(rawPath) ?? rawPath;
const dirPath = resolveAgainstCwd(rawPath, cwd) ?? rawPath;
try {
const entries = await readdir(dirPath);
+4 -4
View File
@@ -3,9 +3,9 @@ import { access, mkdir, rename } from 'node:fs/promises';
import path from 'node:path';
import type { MoveFileResultItem, MoveFilesParams } from '../types';
import { expandTilde } from './expandTilde';
import { resolveAgainstCwd } from './expandTilde';
export async function moveLocalFiles({ items }: MoveFilesParams): Promise<MoveFileResultItem[]> {
export async function moveLocalFiles({ items, cwd }: MoveFilesParams): Promise<MoveFileResultItem[]> {
const results: MoveFileResultItem[] = [];
if (!items || items.length === 0) {
@@ -13,8 +13,8 @@ export async function moveLocalFiles({ items }: MoveFilesParams): Promise<MoveFi
}
for (const item of items) {
const sourcePath = expandTilde(item.oldPath) ?? item.oldPath;
const newPath = expandTilde(item.newPath) ?? item.newPath;
const sourcePath = resolveAgainstCwd(item.oldPath, cwd) ?? item.oldPath;
const newPath = resolveAgainstCwd(item.newPath, cwd) ?? item.newPath;
const resultItem: MoveFileResultItem = {
newPath: undefined,
sourcePath,
+3 -2
View File
@@ -9,7 +9,7 @@ import {
} from '@lobechat/file-loaders';
import type { ReadFileParams, ReadFileResult } from '../types';
import { expandTilde } from './expandTilde';
import { resolveAgainstCwd } from './expandTilde';
/** Hard cap on file size we will read into memory at all (10MB). */
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
@@ -46,8 +46,9 @@ export async function readLocalFile({
path: rawPath,
loc,
fullContent,
cwd,
}: ReadFileParams): Promise<ReadFileResult> {
const filePath = expandTilde(rawPath) ?? rawPath;
const filePath = resolveAgainstCwd(rawPath, cwd) ?? rawPath;
const effectiveLoc = fullContent ? undefined : (loc ?? [0, 200]);
let stats;
+3 -2
View File
@@ -2,16 +2,17 @@ import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { WriteFileParams, WriteFileResult } from '../types';
import { expandTilde } from './expandTilde';
import { resolveAgainstCwd } from './expandTilde';
export async function writeLocalFile({
path: rawPath,
content,
cwd,
}: WriteFileParams): Promise<WriteFileResult> {
if (!rawPath) return { error: 'Path cannot be empty', success: false };
if (content === undefined) return { error: 'Content cannot be empty', success: false };
const filePath = expandTilde(rawPath) ?? rawPath;
const filePath = resolveAgainstCwd(rawPath, cwd) ?? rawPath;
try {
const dirname = path.dirname(filePath);
+17
View File
@@ -82,6 +82,12 @@ export interface KillCommandResult {
// ─── File Types ───
export interface ReadFileParams {
/**
* Working directory a relative `path` is resolved against (the device-bound
* directory, injected by the runtime). Absolute paths ignore it; absent the
* process cwd, as before.
*/
cwd?: string;
fullContent?: boolean;
loc?: [number, number];
path: string;
@@ -106,6 +112,8 @@ export interface ReadFileResult {
export interface WriteFileParams {
content: string;
/** Working directory a relative `path` resolves against. See {@link ReadFileParams.cwd}. */
cwd?: string;
path: string;
}
@@ -115,6 +123,8 @@ export interface WriteFileResult {
}
export interface EditFileParams {
/** Working directory a relative `file_path` resolves against. See {@link ReadFileParams.cwd}. */
cwd?: string;
file_path: string;
new_string: string;
old_string: string;
@@ -131,6 +141,8 @@ export interface EditFileResult {
}
export interface ListFilesParams {
/** Working directory a relative `path` resolves against. See {@link ReadFileParams.cwd}. */
cwd?: string;
limit?: number;
path: string;
sortBy?: 'createdTime' | 'modifiedTime' | 'name' | 'size';
@@ -232,6 +244,11 @@ export interface MoveFileItem {
}
export interface MoveFilesParams {
/**
* Working directory each item's relative `oldPath`/`newPath` resolves against.
* See {@link ReadFileParams.cwd}.
*/
cwd?: string;
items: MoveFileItem[];
}
+15 -7
View File
@@ -1,6 +1,8 @@
export default {
'ModelSwitch.title': 'Model',
'active': 'Active',
'audioPlayer.pause': 'Pause audio',
'audioPlayer.play': 'Play audio',
'agentBuilder.installPlugin.authRequired': 'Cloud MCP requires sign-in to continue',
'agentBuilder.installPlugin.cancel': 'Cancel',
'agentBuilder.installPlugin.clickApproveToConnect':
@@ -184,8 +186,14 @@ export default {
'heteroAgent.cloudRepo.notSet': 'No repo selected',
'heteroAgent.cloudRepo.noRepos': 'No repositories configured. Add them in agent settings.',
'heteroAgent.cloudRepo.multiSelected': '{{count}} repos selected',
'heteroAgent.executionTarget.auto': 'Auto',
'heteroAgent.executionTarget.autoDesc':
'Use an online device automatically, picking one when several are available',
'heteroAgent.executionTarget.infoTooltip':
'Pick a device and the agent uses it as its runtime environment — reading and writing files and operating the computer. Cloud sandbox is provided by LobeHub Marketplace.',
'heteroAgent.executionTarget.gateway': 'Gateway',
'heteroAgent.executionTarget.gatewayDesc':
'Run through the device gateway so other clients can follow progress',
'heteroAgent.executionTarget.loading': 'Loading devices…',
'heteroAgent.executionTarget.local': 'This device',
'heteroAgent.executionTarget.localDesc': 'Run as a local process on this desktop app',
@@ -332,18 +340,18 @@ export default {
'createModal.placeholder': 'Describe what this Agent should do...',
'createModal.skillSuggestion.actions.createAnyway': 'Create Agent Anyway',
'createModal.skillSuggestion.actions.createAnywayHint': 'Skill not a fit?',
'createModal.skillSuggestion.actions.install': 'Add Skill',
'createModal.skillSuggestion.actions.installing': 'Adding…',
'createModal.skillSuggestion.actions.install': 'Install Skill',
'createModal.skillSuggestion.actions.installing': 'Installing…',
'createModal.skillSuggestion.actions.openSkills': 'View in Skills',
'createModal.skillSuggestion.actions.tryInLobeAI': 'Use in LobeAI',
'createModal.skillSuggestion.actions.tryInLobeAI': 'Use in {{name}}',
'createModal.skillSuggestion.description':
'This looks like a reusable workflow. Install the Skill once, then use it across Agents.',
'createModal.skillSuggestion.installed.description':
'You can use this Skill in LobeAI or add it to any Agent.',
'createModal.skillSuggestion.installed.ready': 'Ready in LobeAI',
'createModal.skillSuggestion.installed.title': 'Skill added',
'You can use this Skill in {{name}}, or enable it for any Agent.',
'createModal.skillSuggestion.installed.ready': 'Ready in {{name}}',
'createModal.skillSuggestion.installed.title': 'Skill installed',
'createModal.skillSuggestion.installError':
"Skill wasn't added. Retry, or create an Agent anyway.",
"Skill wasn't installed. Retry, or create an Agent anyway.",
'createModal.skillSuggestion.title': 'A Skill may fit better',
'createModal.title': 'What should this Agent do?',
'claudeCodeInstallGuide.actions.openDocs': 'Open Install Guide',
+20
View File
@@ -504,6 +504,25 @@ export default {
'sync.title': 'Sync Status',
'sync.unconnected.tip':
'Signaling server connection failed, and peer-to-peer communication channel cannot be established. Please check the network and try again.',
'taskTemplate.action.connect.button': 'Connect {{provider}}',
'taskTemplate.action.connect.error': 'Connection failed, please try again.',
'taskTemplate.action.connect.popupBlocked':
'Connection popup blocked. Allow popups in your browser to continue.',
'taskTemplate.action.connect.short': 'Connect',
'taskTemplate.action.connecting': 'Waiting for authorization…',
'taskTemplate.action.create.error': 'Failed to create task. Please try again.',
'taskTemplate.action.create.success': 'Scheduled task added. Find it in Lobe AI.',
'taskTemplate.action.createButton': 'Add task',
'taskTemplate.action.creating': 'Creating...',
'taskTemplate.action.dismiss.error': 'Failed to dismiss. Please try again.',
'taskTemplate.action.dismiss.tooltip': 'Not interested',
'taskTemplate.action.refresh.button': 'Refresh',
'taskTemplate.card.templateTag': 'Template',
'taskTemplate.schedule.daily': 'Every day at {{time}}',
'taskTemplate.schedule.editableAfterCreateTooltip':
'You can adjust the schedule after creating the task.',
'taskTemplate.schedule.weekly': 'Every {{weekday}} at {{time}}',
'taskTemplate.section.title': 'Try these scheduled tasks',
'tab.image': 'Image',
'tab.audio': 'Audio',
'tab.chat': 'Chat',
@@ -556,6 +575,7 @@ export default {
'userPanel.email': 'Email Support',
'userPanel.feedback': 'Contact Us',
'userPanel.help': 'Help Center',
'userPanel.inviteFriend': 'Invite a friend',
'userPanel.moveGuide': 'The settings button has been moved here',
'userPanel.plans': 'Subscription Plans',
'userPanel.profile': 'Account',

Some files were not shown because too many files have changed in this diff Show More