Compare commits

...

14 Commits

Author SHA1 Message Date
ONLY-yours d2f60c078c feat(cli): add binary release workflow and curl install script
- Add .github/workflows/release-cli.yml: builds standalone binaries via
  bun build --compile on ubuntu/macos runners and uploads to GitHub Release
- Add apps/cli/install.sh: POSIX-compatible curl installer that detects
  OS/arch, installs to /usr/local/bin (or ~/.local/bin fallback), and
  creates lobe + lobehub symlinks pointing to lh
2026-06-09 14:43:02 +08:00
ONLY-yours 96d19fe403 🐛 fix(skill): consolidate add-skill button into header dropdown
Move the standalone 'AddSkillButton' from SkillList sidebar into the
header '+' dropdown, providing a unified entry point for all add-skill
actions (import from URL/GitHub, upload zip, custom connector).
Replace legacy 'Add Custom MCP' with the new Connector flow.
2026-06-09 14:28:52 +08:00
LiJian 5dd0f0c0c9 feat: specialize Market auth modal copy per capability scene (#15569)
Introduce a MarketAuthScene ('default' | 'sandbox' | 'mcp' | 'publish') so the
Market authorization modal can show capability-specific copy instead of the
generic "Create Community Profile" wording, while falling back to the generic
copy for unknown scenes.

- Reactive (401) path: infer scene from the tRPC procedure path in the error
  link and carry it on the market-unauthorized event.
- Proactive path: callers pass the scene to signIn() (publish buttons, MCP/skill
  install, in-chat market tool auth).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:39:33 +08:00
LiJian dfb70c1e87 🐛 fix(skills): inject pinned skill content into the system prompt (#15568)
* 🐛 fix(skills): inject pinned skill content into the system prompt

Pinned skills (ids in agentConfig.plugins) were marked activated by
SkillResolver but never carried their content, because resolveClientSkills
dropped the `content` field when mapping store skills to metas. As a result
SkillContextProvider's `s.activated && s.content` filter skipped them, so the
agent had to call activateSkill to use a pinned skill instead of it being
force-injected.

- builtin skill content is already in the store: carry it through.
- pinned DB skill content is fetched on demand (store cache first), only for
  pinned ids to avoid bulk network calls when auto mode exposes every skill;
  a failed fetch degrades gracefully to a content-less listing.
- resolveClientSkills becomes async; contextEngineering awaits it.
- add skillEngineering tests covering both paths.

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

* 🐛 fix(skills): mark pinned skills activated and fix test types

The MessagesEngine path passes skillsConfig.enabledSkills straight to
SkillContextProvider without running SkillResolver, so the metas must carry
`activated` themselves — content alone is not enough (the provider only injects
`s.activated && s.content`). Mark pinned skills activated in resolveClientSkills,
guarded by content presence so a content-less pinned skill still falls back to
the <available_skills> list instead of disappearing.

Also widen the test helper's param type so `content`/`activated` are accessible
(fixes TS2339 in CI).

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

* 🐛 fix(skills): don't pre-activate ZIP-bundled pinned skills

Server-side bundle mounting for execScript / readReference is keyed off
stepContext.activatedSkills, which is populated only by the activateSkill tool
call — operation-level pinning never seeds it. So pre-injecting the content of a
ZIP-bundled DB skill would tell the model to run scripts from an unmounted bundle.

Gate the content pre-injection on the absence of a zipFileHash: bundled skills
stay in <available_skills> and are activated via the tool (which mounts the
bundle), while pure-content skills (builtin Artifacts, bundle-free DB skills)
are still force-injected when pinned.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:38:59 +08:00
Arvin Xu 7ad6e2aa25 🐛 fix(agent): make working-directory Clear actually clear legacy / default-sourced cwd (#15571)
* 🐛 fix(agent): make working-directory Clear actually clear legacy / default-sourced cwd

The "Clear" action in the working-directory picker was a no-op whenever the
shown directory came from a precedence level that clear() never touched:

- clear() only removed the topic override and the agent's per-device choice
  (workingDirByDevice), but the button's visibility was gated on selectedDir,
  which also resolves from legacyAgentWorkingDirectory (pre-migration
  localStorage pick) and deviceDefaultCwd (device-wide default). When the cwd
  came from either, clear() deleted an already-empty higher level → nothing
  changed.

Fixes:
- useCommitWorkingDirectory: when clearing at the agent-default scope, also drop
  the legacy per-agent value (localStorage-only, no network round-trip).
- WorkingDirectoryPicker: gate the Clear button on hasClearableSelection
  (topic / agent choice / legacy) instead of selectedDir, so it no longer
  renders as a dead button when the cwd comes solely from the device default
  (which isn't clearable from the agent picker).

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

* 💄 style(claude-code): slow token count-up animation to 2000ms

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:22:40 +08:00
Arvin Xu 3986223b25 🐛 fix(heterogeneous-agents): show real CLI model on remote-spawned Claude Code (#15572)
Remote/device-spawned CC runs persist via the server-side
HeterogeneousPersistenceHandler (the executing device is not the viewing
client), and the assistant placeholder was created with the agent's
configured chat model/provider (e.g. deepseek-v4-pro). That value leaked
into the model tag and was re-applied at terminal, so the model tag showed
the wrong model instead of the real Claude Code model.

- Create the hetero placeholder with `provider: heteroType` for ALL hetero
  agents (not just remote openclaw/hermes) and no model, mirroring the
  client path. The real model is reported by the CLI and backfilled.
- Capture the CLI's authoritative model/provider from the first
  `stream_start` (CC system/init) and backfill the placeholder, so the real
  model lands from the first turn even without usage-bearing turn_metadata.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:08:00 +08:00
Arvin Xu ea246d6e17 feat(agent): list project skills over device RPC in the sidebar (#15566)
*  feat(agent): list project skills over device RPC in the sidebar

The right-sidebar 技能 (project skills) tab only read skills over local
Electron IPC, so in device mode (working dir on a bound remote device, or
the web client) the list was always empty — unlike the Files / Review tabs
which already branch on `deviceId`.

Add a `listProjectSkills` device RPC mirroring `getProjectFileIndex`:
- types: `DeviceProjectSkillItem` / `DeviceListProjectSkillsResult`
- `deviceGateway.listProjectSkills` via the generic `invokeRpc` relay
- TRPC `device.listProjectSkills` + `GatewayConnectionCtr` dispatch to
  `WorkspaceCtr.listProjectSkills`
- renderer chokepoint `projectSkillService` branches on `deviceId`
- `useProjectSkills(dir, deviceId?)`; remote mode lists but doesn't open
  previews (parity with the Files tab)
- thread `remoteDeviceId` through `SkillsGroup`

No device-gateway repo change needed — the RPC relay is method-agnostic.

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

*  feat(agent): list project skills over device RPC for homogeneous agents too

Thread `deviceId` through the homogeneous resources path
(`AgentDocumentsGroup` → `ProjectLevelSkills`) so a device-bound homogeneous
agent's 技能 tab populates over RPC, matching the heterogeneous `SkillsGroup`.
`useProjectSkills` already accepts `deviceId`; this just wires it in and
OR-s `deviceId` into the `showProjectSkills` gate.

(The large AgentDocumentsGroup diff is prettier re-indentation from wrapping
the outer memo() once the param list crossed the print width.)

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

* 🐛 fix(agent): resolve per-device cwd in ResourcesSection so device-mode skills load

ResourcesSection computed its working directory with the legacy
`topicCwd || agentCwd` selector, which misses `workingDirByDevice[deviceId]`
and `device.defaultCwd`. For a device-bound agent the cwd lives in that
per-device map, so it resolved to `undefined` — the project-skills SWR key
was null and the fetch never fired even though `deviceId` was set (the 技能
tab showed "暂无可用技能"). Switch to `useEffectiveWorkingDirectory`, the
same resolver the runtime bar / WorkingSidebar use. Fixes both the hetero
SkillsGroup and the homogeneous AgentDocumentsGroup paths.

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

* 💄 feat(agent): show loading state for project skills while switching path

On a working-directory switch the project-skills SWR key changes, so items
go empty while the new scan is in flight. The homogeneous skills panel was
flashing the empty placeholder instead of a loader. Surface
`useProjectSkills().isLoading` and render NeuralNetworkLoading when project
skills are the only source and still loading. (The hetero SkillsGroup already
shows it via SkillSection's isLoading.)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:58:55 +08:00
Arvin Xu f5458e1ad9 ♻️ chore: replace LOBE-XXX markers with inline migration context (#15567)
♻️ chore: replace LOBE-XXX markers with inline migration context in 0110 SQL
2026-06-09 10:54:22 +08:00
LiJian 251e2ede5e feat(sandbox): sync user-uploaded files into the cloud sandbox (#15550)
*  feat(sandbox): sync user-uploaded files into the cloud sandbox

Pre-load the files a user attached in a conversation (topic message files +
session files) into the cloud sandbox the first time it is used, and tell the
agent they are available.

- FileModel.findFilesToInitInSandbox: merge messages_files (by topic) and
  files_to_sessions (by the topic's session), de-duped by file id
- SandboxMiddlewareService.ensureFilesInitialized: on first tool call, presign
  download URLs and run an idempotent curl bootstrap into /mnt/data; guarded by
  an in-sandbox marker and a short-lived Redis hint, best-effort so it never
  blocks the actual tool call (caps: 50 files / 100MB / 120s)
- Agent awareness via {{sandbox_uploaded_files}} in the cloud-sandbox systemRole,
  populated by both the server (RuntimeExecutors) and client (contextEngineering)
  placeholder generators

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

* 🐛 fix(sandbox): make file sync work on all server runtimes & keep prompt consistent

Address review feedback on the uploaded-files sync:

1. (high) The sync was a no-op on the cloudSandbox server runtime and the skills
   runtime because createSandboxService() was called without serverDB, so
   ensureFilesInitialized() returned early. Thread serverDB through both.
   (heterogeneous sandboxRunner is intentionally left out: it runs a coding agent
   in /workspace and does not use the cloud-sandbox systemRole.)

2. (medium) Drop the Redis "already initialized" hint. The in-sandbox marker is
   now the single source of truth for idempotency, so a recycled sandbox always
   re-syncs instead of being skipped by a stale 5-min Redis key.

3. (medium) Apply the 50-file / 100MB caps inside formatUploadedFilesPrompt (via
   the shared selectSandboxInitFiles), so the files the prompt advertises match
   exactly what the bootstrap downloads.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:40:34 +08:00
Innei 337e7f244c 🐛 fix(market-auth): skip auth flow when LobeChat session is missing (#15532)
Guard `signIn()` and the market.* 401 handlers on `isSignedIn` so the
Create Community Profile modal no longer pops up for unauthenticated
users. Routing the user back to LobeChat sign-in is not MarketAuth's
responsibility — callers handle that.
2026-06-09 10:16:44 +08:00
Arvin Xu eae47f527c feat(markdown): render GitHub / Linear / external links as rich chips (#15561)
*  feat(heterogeneous-agents): default Codex exec to bypass approvals/sandbox

Switch the default Codex execution mode from --full-auto to
--dangerously-bypass-approvals-and-sandbox, and share the execution-mode
constants from @lobechat/heterogeneous-agents/spawn so the desktop driver
and spawnAgent stay in sync. An explicit execution flag in extraArgs still
wins. Also fix the Codex adapter step tracking so consecutive agent_message
items stay in one step, stale tool completions don't start a new step, and
turn completion drains pending tools before emitting stream_end +
agent_runtime_end.

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

*  feat(shared-tool-ui): unwrap shell-wrapper commands in RunCommand UI

Codex execs commands wrapped as `/bin/zsh -lc '...'`; surface the inner
command in the RunCommand inspector and render. Also switch Unix glob
fallback from `find` to `fast-glob` to preserve globstar semantics.

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

*  feat(markdown): render GitHub / Linear / external links as rich chips

Add a markdown Link plugin that rewrites anchor elements into rich inline
chips: GitHub repo/PR/issue/commit/user, Linear issues, npm packages, Figma
files, mailto, and any other external link (favicon + full URL). Citation,
footnote, anchor and relative links keep the default renderer.

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

* ⬆️ chore(deps): bump @lobehub/editor to 4.17.0 and @lobehub/ui to 5.15.10

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:22:35 +08:00
Arvin Xu dfdf844761 🐛 fix(desktop): bump node-gyp to 12.x so Windows build finds Visual Studio 2026 (#15562)
GitHub redirects the `windows-2025` runner to the new `windows-2025-vs2026`
image, which ships Visual Studio 2026. node-gyp 11.5.0 only recognizes VS
2019/2022, so `electron-builder install-app-deps` fails to rebuild the native
`get-windows` module with "Could not find any Visual Studio installation".
node-gyp 12.x adds VS 2026 detection. Override it in both the root workspace
and the isolated apps/desktop install.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 02:36:10 +08:00
Arvin Xu cca01451f9 feat(heterogeneous-agents): default Codex exec to bypass approvals/sandbox (#15557)
*  feat(heterogeneous-agents): default Codex exec to bypass approvals/sandbox

Switch the default Codex execution mode from --full-auto to
--dangerously-bypass-approvals-and-sandbox, and share the execution-mode
constants from @lobechat/heterogeneous-agents/spawn so the desktop driver
and spawnAgent stay in sync. An explicit execution flag in extraArgs still
wins. Also fix the Codex adapter step tracking so consecutive agent_message
items stay in one step, stale tool completions don't start a new step, and
turn completion drains pending tools before emitting stream_end +
agent_runtime_end.

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

*  feat(shared-tool-ui): unwrap shell-wrapper commands in RunCommand UI

Codex execs commands wrapped as `/bin/zsh -lc '...'`; surface the inner
command in the RunCommand inspector and render. Also switch Unix glob
fallback from `find` to `fast-glob` to preserve globstar semantics.

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-09 02:03:35 +08:00
Innei d2cd9ef023 feat(page-editor): enable block plugin with shared inline padding (#15556)
*  feat(page-editor): enable block plugin with shared inline padding

Mount `ReactBlockPlugin` on the page editor with `anchorPadding={0}` so
the editor root no longer reserves its default 54 px gutters, and apply
`DEFAULT_BLOCK_ANCHOR_PADDING` as `paddingInline` on the `Flexbox`
wrapping `TitleSection` + `EditorCanvas`. This keeps the title and
editor content aligned while leaving the same 54 px of room for the
floating block menu / drag handle to render in.

Requires `@lobehub/editor` with `anchorPadding` support and the
exported `DEFAULT_BLOCK_ANCHOR_PADDING` constant.

* 🐛 fix(page-editor): drop redundant overflowY on editor content wrapper

`editorContent` previously declared `overflowY: 'auto'`, which created
a second scroll container nested inside `.contentWrapper` (already
`overflowY: 'auto'`). With the new inline padding from
`DEFAULT_BLOCK_ANCHOR_PADDING`, the nested scroller clipped the
floating block menu / drag handle that the editor renders in the
inline-padding gutter. Let the outer wrapper own scrolling so the
gutter overflow stays visible.
2026-06-09 01:04:10 +08:00
89 changed files with 2620 additions and 411 deletions
+75
View File
@@ -0,0 +1,75 @@
name: Release CLI
permissions:
contents: write
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Tag name for the release (e.g. v0.1.0)'
required: true
default: 'v0.0.0'
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
# skip pre-release tags (containing '-') on auto-trigger; always run on workflow_dispatch
if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref_name, '-') }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: lobe-linux-x64
- os: macos-latest
target: lobe-macos-arm64
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build binary
run: |
mkdir -p dist
bun build ./apps/cli/src/index.ts --compile --minify --outfile ./dist/${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: ./dist/${{ matrix.target }}
release:
name: Upload to Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: ./dist
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
files: |
./dist/lobe-linux-x64/lobe-linux-x64
./dist/lobe-macos-arm64/lobe-macos-arm64
./apps/cli/install.sh
+78
View File
@@ -0,0 +1,78 @@
#!/bin/sh
set -e
REPO="lobehub/lobe-chat"
BIN_NAME="lh"
# Detect OS
case "$(uname -s)" in
Linux) OS="linux" ;;
Darwin) OS="macos" ;;
*)
printf 'Error: Unsupported OS: %s\n' "$(uname -s)" >&2
exit 1
;;
esac
# Detect architecture
case "$(uname -m)" in
x86_64) ARCH="x64" ;;
aarch64|arm64) ARCH="arm64" ;;
*)
printf 'Error: Unsupported architecture: %s\n' "$(uname -m)" >&2
exit 1
;;
esac
BINARY="lobe-${OS}-${ARCH}"
URL="https://github.com/${REPO}/releases/latest/download/${BINARY}"
printf 'Detected: %s/%s\n' "$OS" "$ARCH"
printf 'Downloading %s...\n' "$BINARY"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$URL" -o "$TMP"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$TMP" "$URL"
else
printf 'Error: curl or wget is required\n' >&2
exit 1
fi
chmod +x "$TMP"
# Choose install directory: prefer /usr/local/bin, fall back to ~/.local/bin
USE_SUDO=0
if [ -w "/usr/local/bin" ]; then
INSTALL_DIR="/usr/local/bin"
elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
INSTALL_DIR="/usr/local/bin"
USE_SUDO=1
else
INSTALL_DIR="${HOME}/.local/bin"
mkdir -p "$INSTALL_DIR"
printf 'Note: No sudo access. Installing to %s\n' "$INSTALL_DIR"
printf 'Add the following to your shell profile if needed:\n'
printf ' export PATH="%s:$PATH"\n' "$INSTALL_DIR"
fi
# Install binary and create symlinks
if [ "$USE_SUDO" = "1" ]; then
sudo cp "$TMP" "${INSTALL_DIR}/${BIN_NAME}"
sudo chmod +x "${INSTALL_DIR}/${BIN_NAME}"
sudo ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobe"
sudo ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobehub"
else
cp "$TMP" "${INSTALL_DIR}/${BIN_NAME}"
chmod +x "${INSTALL_DIR}/${BIN_NAME}"
ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobe"
ln -sf "${INSTALL_DIR}/${BIN_NAME}" "${INSTALL_DIR}/lobehub"
fi
printf '\nInstalled successfully!\n'
printf ' Binary: %s/%s\n' "$INSTALL_DIR" "$BIN_NAME"
printf ' Symlinks: lobe, lobehub -> lh\n\n'
"${INSTALL_DIR}/${BIN_NAME}" --version
+1
View File
@@ -125,6 +125,7 @@
"node-mac-permissions"
],
"overrides": {
"node-gyp": "^12.4.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"vitest": "3.2.4"
@@ -16,6 +16,7 @@ import type {
InitWorkspaceParams,
KillCommandParams,
ListLocalFileParams,
ListProjectSkillsParams,
LocalReadFileParams,
LocalReadFilesParams,
LocalSearchFilesParams,
@@ -407,6 +408,10 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
}
case 'listProjectSkills': {
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
}
case 'getGitBranchDiff': {
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
}
@@ -440,8 +440,14 @@ describe('HeterogeneousAgentCtr', () => {
expect(command).toBe('codex');
expect(cliArgs).not.toContain(prompt);
expect(cliArgs).toEqual(
expect.arrayContaining(['exec', '--json', '--skip-git-repo-check', '--full-auto']),
expect.arrayContaining([
'exec',
'--json',
'--skip-git-repo-check',
'--dangerously-bypass-approvals-and-sandbox',
]),
);
expect(cliArgs).not.toContain('--full-auto');
expect(cliArgs).not.toContain('-');
expect(writes).toEqual([prompt]);
});
@@ -1,12 +1,10 @@
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
import {
CODEX_DEFAULT_EXECUTION_ARGS,
CODEX_EXECUTION_MODE_FLAGS,
CODEX_REQUIRED_ARGS,
} from '@lobechat/heterogeneous-agents/spawn';
const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check'] as const;
const CODEX_AUTO_EXECUTION_FLAGS = [
'--full-auto',
'--dangerously-bypass-approvals-and-sandbox',
'--sandbox',
'-s',
] as const;
import type { HeterogeneousAgentBuildPlanParams, HeterogeneousAgentDriver } from '../types';
const hasAnyFlag = (args: string[], flags: readonly string[]) =>
args.some((arg) => flags.includes(arg as (typeof flags)[number]));
@@ -18,9 +16,11 @@ const buildCodexOptionArgs = async ({
}: Pick<HeterogeneousAgentBuildPlanParams, 'args' | 'helpers' | 'imageList'>) => {
const imagePaths = await helpers.resolveCliImagePaths(imageList);
const imageArgs = imagePaths.flatMap((filePath) => ['--image', filePath]);
const autoExecutionArgs = hasAnyFlag(args, CODEX_AUTO_EXECUTION_FLAGS) ? [] : ['--full-auto'];
const executionModeArgs = hasAnyFlag(args, CODEX_EXECUTION_MODE_FLAGS)
? []
: [...CODEX_DEFAULT_EXECUTION_ARGS];
return [...CODEX_REQUIRED_ARGS, ...autoExecutionArgs, ...args, ...imageArgs];
return [...CODEX_REQUIRED_ARGS, ...executionModeArgs, ...args, ...imageArgs];
};
export const codexDriver: HeterogeneousAgentDriver = {
+6
View File
@@ -5,6 +5,12 @@
"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.mcp.subtitle": "Create a community profile to install and run this skill from the community.",
"authorize.scenes.mcp.title": "Install Community Skill",
"authorize.scenes.publish.subtitle": "Create a community profile to publish and manage your listing within the community.",
"authorize.scenes.publish.title": "Publish to the Community",
"authorize.scenes.sandbox.subtitle": "Create a community profile to run this tool in the community sandbox.",
"authorize.scenes.sandbox.title": "Try the Community Sandbox",
"authorize.subtitle": "Create a community profile to submit and manage listings within the community.",
"authorize.title": "Create Community Profile",
"callback.buttons.close": "Close Window",
+6
View File
@@ -5,6 +5,12 @@
"authorize.footer.agreement": "继续操作即表示你确认已理解并同意<terms>条款和条件</terms>和<privacy>隐私政策</privacy>",
"authorize.footer.privacy": "隐私政策",
"authorize.footer.terms": "服务条款",
"authorize.scenes.mcp.subtitle": "创建社区个人档案,即可安装并运行该社区技能。",
"authorize.scenes.mcp.title": "安装社区技能",
"authorize.scenes.publish.subtitle": "创建社区个人档案,以便在社区发布和管理你的上架内容。",
"authorize.scenes.publish.title": "发布到社区",
"authorize.scenes.sandbox.subtitle": "创建社区个人档案,即可在社区沙箱中运行该工具。",
"authorize.scenes.sandbox.title": "试用社区沙箱",
"authorize.subtitle": "创建社区个人档案,以便在社区上提交和管理上架信息。",
"authorize.title": "创建社区档案",
"callback.buttons.close": "关闭窗口",
+4 -3
View File
@@ -285,11 +285,11 @@
"@lobehub/analytics": "^1.6.2",
"@lobehub/charts": "^5.0.0",
"@lobehub/desktop-ipc-typings": "workspace:*",
"@lobehub/editor": "^4.16.1",
"@lobehub/editor": "^4.17.0",
"@lobehub/icons": "^5.0.0",
"@lobehub/market-sdk": "0.33.3",
"@lobehub/tts": "^5.1.2",
"@lobehub/ui": "^5.15.5",
"@lobehub/ui": "^5.15.10",
"@modelcontextprotocol/sdk": "^1.26.0",
"@napi-rs/canvas": "^0.1.88",
"@neondatabase/serverless": "^1.0.2",
@@ -528,7 +528,7 @@
"mcp-hello-world": "^1.1.2",
"mime": "^4.1.0",
"node-fetch": "^3.3.2",
"node-gyp": "^11.5.0",
"node-gyp": "^12.4.0",
"openapi-typescript": "^7.10.1",
"p-map": "^7.0.4",
"prettier": "^3.8.1",
@@ -571,6 +571,7 @@
"drizzle-orm": "^0.45.1",
"fast-xml-parser": "5.4.2",
"lexical": "0.42.0",
"node-gyp": "^12.4.0",
"pdfjs-dist": "5.4.530",
"react": "19.2.5",
"react-dom": "19.2.5",
@@ -178,7 +178,11 @@ export const AgentInspector = memo<BuiltinInspectorProps<AgentArgs>>(
)}
{metrics.totalTokens > 0 && (
<span>
<AnimatedNumber formatter={formatTokens} value={metrics.totalTokens} />
<AnimatedNumber
duration={2000}
formatter={formatTokens}
value={metrics.totalTokens}
/>
</span>
)}
</span>
@@ -2,3 +2,4 @@ export * from './ExecutionRuntime';
export * from './manifest';
export * from './systemRole';
export * from './types';
export * from './uploadedFiles';
@@ -1,3 +1,5 @@
import { SANDBOX_UPLOADED_FILES_DIR } from './uploadedFiles';
export const systemPrompt = `You have access to a Cloud Sandbox that provides a secure, isolated environment for executing code and file operations. This sandbox runs on AWS Bedrock AgentCore and is completely separate from the user's local system.
@@ -16,6 +18,12 @@ export const systemPrompt = `You have access to a Cloud Sandbox that provides a
</sandbox_environment>
<uploaded_files>
Files the user uploaded in this conversation (attachments and session files) are automatically synced into \`${SANDBOX_UPLOADED_FILES_DIR}\` when your sandbox session starts. If the user refers to a file they shared, look there first — do NOT ask them to re-upload. Run \`listFiles\` on \`${SANDBOX_UPLOADED_FILES_DIR}\` to see everything that is available.
{{sandbox_uploaded_files}}
</uploaded_files>
<preinstalled_software>
**IMPORTANT: Prefer Pre-installed Software**
The sandbox comes with pre-installed software and libraries. **Always prioritize using these pre-installed tools** when they can solve the user's problem, rather than installing additional packages.
@@ -0,0 +1,89 @@
/**
* Directory inside the cloud sandbox where user-uploaded files (attached to the
* conversation topic / session) are synced when the sandbox session starts.
*/
export const SANDBOX_UPLOADED_FILES_DIR = '/mnt/data';
/** Skip individual files larger than this when syncing into the sandbox. */
export const SANDBOX_INIT_MAX_FILE_SIZE = 100 * 1024 * 1024;
/** Hard cap on how many uploaded files are synced into the sandbox. */
export const SANDBOX_INIT_MAX_FILES = 50;
export interface SandboxUploadedFileMeta {
name: string;
size?: number;
}
/**
* Select the files that the sandbox bootstrap will actually sync, applying the
* per-file size cap and the total count cap. Shared by the bootstrap (what gets
* downloaded) and the prompt (what the agent is told exists) so the two never
* drift apart. Items with an unknown size are kept (we cannot rule them out).
*/
export const selectSandboxInitFiles = <T extends { size?: number }>(files: T[]): T[] =>
files
.filter((file) => file.size == null || file.size <= SANDBOX_INIT_MAX_FILE_SIZE)
.slice(0, SANDBOX_INIT_MAX_FILES);
const formatBytes = (size?: number): string => {
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const rounded = unit === 0 ? value : Math.round(value * 10) / 10;
return ` (${rounded}${units[unit]})`;
};
/**
* Reduce an uploaded file name to a safe, flat basename so it cannot escape the
* sandbox upload directory (no path traversal) or carry control characters.
*/
export const sanitizeSandboxFileName = (name: string): string => {
const base = name.split(/[/\\]/).pop() ?? '';
const cleaned = [...base]
.filter((char) => {
const code = char.codePointAt(0) ?? 0;
return code > 0x1f && code !== 0x7f;
})
.join('')
.trim();
return cleaned.length > 0 ? cleaned : 'file';
};
/**
* Build the absolute sandbox path for an uploaded file.
*/
export const sandboxUploadedFilePath = (name: string): string =>
`${SANDBOX_UPLOADED_FILES_DIR}/${sanitizeSandboxFileName(name)}`;
/**
* Render the dynamic `{{sandbox_uploaded_files}}` section listing the files that
* are pre-loaded into the sandbox. Returns an empty string when there are no
* files so the surrounding system prompt renders cleanly.
*
* Applies the same size/count caps as the bootstrap and de-dupes by resolved
* sandbox path, so the listed files match exactly what is written to disk.
*/
export const formatUploadedFilesPrompt = (files: SandboxUploadedFileMeta[]): string => {
if (!files || files.length === 0) return '';
const seen = new Set<string>();
const lines: string[] = [];
for (const file of selectSandboxInitFiles(files)) {
if (!file?.name) continue;
const path = sandboxUploadedFilePath(file.name);
if (seen.has(path)) continue;
seen.add(path);
lines.push(`- ${path}${formatBytes(file.size)}`);
}
if (lines.length === 0) return '';
return ['These user-uploaded files are pre-loaded and ready to use:', ...lines].join('\n');
};
@@ -167,9 +167,11 @@ CREATE INDEX IF NOT EXISTS "verify_rubrics_user_id_idx" ON "verify_rubrics" USIN
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "verify_rubrics_workspace_id_idx" ON "verify_rubrics" USING btree ("workspace_id");
--> statement-breakpoint
-- LOBE-10072: nullable surrogate `_id` for the online workspace-scoped rebuild
-- (LOBE-10056). Two-step (ADD nullable, then SET DEFAULT) so it stays catalog-only
-- — a combined volatile-DEFAULT ADD COLUMN would rewrite the whole table under lock.
-- Phase 5 ai_infra workspace-scoped migration: add nullable surrogate `_id` columns
-- for ai_providers and ai_models. Two-step approach (ADD nullable first, then SET DEFAULT)
-- keeps the operation catalog-only. A combined ADD COLUMN ... DEFAULT gen_random_uuid()
-- NOT NULL would trigger a full table rewrite under ACCESS EXCLUSIVE lock (ai_models has
-- ~4M rows), which would block all chat reads that depend on model resolution.
ALTER TABLE "ai_providers" ADD COLUMN IF NOT EXISTS "_id" uuid;
--> statement-breakpoint
ALTER TABLE "ai_providers" ALTER COLUMN "_id" SET DEFAULT gen_random_uuid();
@@ -11,9 +11,14 @@ import {
embeddings,
fileChunks,
files,
filesToSessions,
globalFiles,
knowledgeBaseFiles,
knowledgeBases,
messages,
messagesFiles,
sessions,
topics,
users,
} from '../../schemas';
import type { LobeChatDatabase } from '../../type';
@@ -1602,4 +1607,87 @@ describe('FileModel', () => {
expect(result).toHaveLength(0);
});
});
describe('findFilesToInitInSandbox', () => {
const sessionId = 'sandbox-session-1';
const topicId = 'sandbox-topic-1';
beforeEach(async () => {
await serverDB.insert(sessions).values({ id: sessionId, userId });
await serverDB.insert(topics).values([
{ id: topicId, sessionId, userId },
{ id: 'sandbox-topic-2', sessionId, userId },
]);
await serverDB.insert(messages).values([
{ id: 'sandbox-msg-1', role: 'user', topicId, userId },
{ id: 'sandbox-msg-2', role: 'user', topicId: 'sandbox-topic-2', userId },
]);
await serverDB.insert(files).values([
{ fileType: 'text/csv', id: 'sf-msg', name: 'msg.csv', size: 1, url: 'k-msg', userId },
{
fileType: 'application/pdf',
id: 'sf-sess',
name: 's.pdf',
size: 2,
url: 'k-sess',
userId,
},
{ fileType: 'text/plain', id: 'sf-both', name: 'both.txt', size: 3, url: 'k-both', userId },
{ fileType: 'text/plain', id: 'sf-other', name: 'o.txt', size: 4, url: 'k-other', userId },
]);
});
it('merges topic message files and session files, de-duped by id', async () => {
await serverDB.insert(messagesFiles).values([
{ fileId: 'sf-msg', messageId: 'sandbox-msg-1', userId },
{ fileId: 'sf-both', messageId: 'sandbox-msg-1', userId },
// attached to a different topic → must be excluded
{ fileId: 'sf-other', messageId: 'sandbox-msg-2', userId },
]);
await serverDB.insert(filesToSessions).values([
{ fileId: 'sf-sess', sessionId, userId },
// also referenced via message → must be de-duped
{ fileId: 'sf-both', sessionId, userId },
]);
const result = await fileModel.findFilesToInitInSandbox(topicId);
expect(result.map((file) => file.id).sort()).toEqual(['sf-both', 'sf-msg', 'sf-sess']);
expect(result.find((file) => file.id === 'sf-both')).toEqual({
fileType: 'text/plain',
id: 'sf-both',
name: 'both.txt',
size: 3,
url: 'k-both',
});
});
it('returns an empty array when the topic has no associated files', async () => {
const result = await fileModel.findFilesToInitInSandbox(topicId);
expect(result).toEqual([]);
});
it('does not return files belonging to another user', async () => {
await serverDB.insert(messages).values({
id: 'sandbox-msg-other',
role: 'user',
topicId,
userId: 'user2',
});
await serverDB.insert(files).values({
fileType: 'text/plain',
id: 'sf-user2',
name: 'u2.txt',
size: 5,
url: 'k-u2',
userId: 'user2',
});
await serverDB
.insert(messagesFiles)
.values({ fileId: 'sf-user2', messageId: 'sandbox-msg-other', userId: 'user2' });
const result = await fileModel.findFilesToInitInSandbox(topicId);
expect(result).toEqual([]);
});
});
});
+54
View File
@@ -12,11 +12,27 @@ import {
embeddings,
fileChunks,
files,
filesToSessions,
globalFiles,
knowledgeBaseFiles,
messages,
messagesFiles,
topics,
} from '../schemas';
import type { LobeChatDatabase, Transaction } from '../type';
/**
* Minimal file descriptor used to bootstrap user-uploaded files into a sandbox.
*/
export interface SandboxInitFileItem {
fileType: string;
id: string;
name: string;
size: number;
/** S3 key / storage url, needs to be turned into a download url before use */
url: string;
}
export class FileModel {
private readonly userId: string;
private db: LobeChatDatabase;
@@ -360,6 +376,44 @@ export class FileModel {
});
};
/**
* Collect the user-uploaded files that should be pre-loaded into a sandbox for
* the given topic. Combines two associations and de-duplicates by file id:
* - files attached to messages inside the topic (`messages_files`)
* - files attached to the session that owns the topic (`files_to_sessions`)
*/
findFilesToInitInSandbox = async (topicId: string): Promise<SandboxInitFileItem[]> => {
const columns = {
fileType: files.fileType,
id: files.id,
name: files.name,
size: files.size,
url: files.url,
};
const [messageFiles, sessionFiles] = await Promise.all([
this.db
.select(columns)
.from(messagesFiles)
.innerJoin(messages, eq(messagesFiles.messageId, messages.id))
.innerJoin(files, eq(messagesFiles.fileId, files.id))
.where(and(eq(messages.topicId, topicId), eq(messagesFiles.userId, this.userId))),
this.db
.select(columns)
.from(filesToSessions)
.innerJoin(topics, eq(topics.sessionId, filesToSessions.sessionId))
.innerJoin(files, eq(filesToSessions.fileId, files.id))
.where(and(eq(topics.id, topicId), eq(filesToSessions.userId, this.userId))),
]);
const deduped = new Map<string, SandboxInitFileItem>();
for (const file of [...messageFiles, ...sessionFiles]) {
if (!deduped.has(file.id)) deduped.set(file.id, file);
}
return [...deduped.values()];
};
countFilesByHash = async (hash: string) => {
const result = await this.db
.select({
@@ -176,6 +176,94 @@ describe('CodexAdapter', () => {
});
});
it('keeps consecutive agent_message items in the same Codex step', () => {
const adapter = new CodexAdapter();
adapter.adapt({ type: 'turn.started' });
adapter.adapt({
item: {
id: 'item_0',
text: 'First status update.',
type: 'agent_message',
},
type: 'item.completed',
});
const secondMessage = adapter.adapt({
item: {
id: 'item_1',
text: 'Second status update.',
type: 'agent_message',
},
type: 'item.completed',
});
expect(secondMessage).toHaveLength(1);
expect(secondMessage[0]).toMatchObject({
data: { chunkType: 'text', content: '\n\nSecond status update.' },
stepIndex: 0,
type: 'stream_chunk',
});
});
it('does not start a new step for an old pending tool completion', () => {
const adapter = new CodexAdapter();
adapter.adapt({ type: 'turn.started' });
adapter.adapt({
item: {
id: 'item_0',
text: 'Starting a long search.',
type: 'agent_message',
},
type: 'item.completed',
});
adapter.adapt({
item: {
command: '/bin/zsh -lc find .',
id: 'item_1',
status: 'in_progress',
type: 'command_execution',
},
type: 'item.started',
});
adapter.adapt({
item: {
id: 'item_2',
text: 'Continuing with narrower checks.',
type: 'agent_message',
},
type: 'item.completed',
});
adapter.adapt({
item: {
aggregated_output: '',
command: '/bin/zsh -lc find .',
exit_code: 0,
id: 'item_1',
status: 'completed',
type: 'command_execution',
},
type: 'item.completed',
});
const nextMessage = adapter.adapt({
item: {
id: 'item_3',
text: 'The broad search is done; continuing.',
type: 'agent_message',
},
type: 'item.completed',
});
expect(nextMessage).toHaveLength(1);
expect(nextMessage[0]).toMatchObject({
data: { chunkType: 'text', content: '\n\nThe broad search is done; continuing.' },
stepIndex: 1,
type: 'stream_chunk',
});
});
it('maps command execution items into tool lifecycle events', () => {
const adapter = new CodexAdapter();
@@ -469,7 +557,7 @@ describe('CodexAdapter', () => {
});
});
it('keeps a real collab_tool_call stream fixture readable and flushes unfinished attempts', async () => {
it('keeps a real collab_tool_call stream fixture readable and drains unfinished attempts', async () => {
const adapter = new CodexAdapter();
const rawEvents = await loadFixture('collab_tool_call.spawn_wait.jsonl');
@@ -496,7 +584,58 @@ describe('CodexAdapter', () => {
}),
]),
);
expect(flushed).toEqual([
expect(adapted).toEqual(
expect.arrayContaining([
expect.objectContaining({
data: {
isSuccess: false,
toolCallId: 'item_1',
},
type: 'tool_end',
}),
]),
);
expect(flushed).toEqual([]);
});
it('emits stream_end + agent_runtime_end on successful turn completion', () => {
const adapter = new CodexAdapter();
adapter.adapt({ type: 'turn.started' });
const events = adapter.adapt({
type: 'turn.completed',
usage: {
input_tokens: 10,
output_tokens: 3,
},
});
expect(events.map((event) => event.type)).toEqual([
'step_complete',
'stream_end',
'agent_runtime_end',
]);
});
it('drains unfinished Codex tools before successful turn completion', () => {
const adapter = new CodexAdapter();
adapter.adapt({ type: 'turn.started' });
adapter.adapt({
item: {
command: '/bin/zsh -lc sleep',
id: 'item_1',
status: 'in_progress',
type: 'command_execution',
},
type: 'item.started',
});
const events = adapter.adapt({
type: 'turn.completed',
});
expect(events).toEqual([
expect.objectContaining({
data: {
isSuccess: false,
@@ -504,7 +643,14 @@ describe('CodexAdapter', () => {
},
type: 'tool_end',
}),
expect.objectContaining({
type: 'stream_end',
}),
expect.objectContaining({
type: 'agent_runtime_end',
}),
]);
expect(adapter.flush()).toEqual([]);
});
it('emits cumulative tools_calling within the same Codex step', () => {
@@ -386,12 +386,15 @@ export class CodexAdapter implements AgentEventAdapter {
private currentModel?: string;
sessionId?: string;
private hasStepActivity = false;
private hasTextInCurrentStep = false;
private hasToolActivitySinceAgentMessage = false;
private pendingToolCalls = new Set<string>();
private pendingToolCallStepIndex = new Map<string, number>();
private stepToolCalls: ToolCallPayload[] = [];
private stepToolCallIds = new Set<string>();
private started = false;
private stepIndex = 0;
private terminalEndEmitted = false;
private terminalErrorEmitted = false;
adapt(raw: any): HeterogeneousAgentEvent[] {
@@ -429,36 +432,38 @@ export class CodexAdapter implements AgentEventAdapter {
}
flush(): HeterogeneousAgentEvent[] {
const events = [...this.pendingToolCalls].map((toolCallId) =>
this.makeEvent('tool_end', {
isSuccess: false,
toolCallId,
}),
);
this.pendingToolCalls.clear();
return events;
return this.drainPendingToolEndEvents();
}
private handleTurnCompleted(raw: any): HeterogeneousAgentEvent[] {
if (this.terminalEndEmitted || this.terminalErrorEmitted) return [];
this.terminalEndEmitted = true;
const model = getEventModel(raw) || this.currentModel;
if (model) this.currentModel = model;
const usage = toUsageData(raw.usage);
if (!usage && !model) return [];
const events = this.drainPendingToolEndEvents();
const data: StepCompleteData = {
...(model ? { model } : {}),
phase: 'turn_metadata',
provider: CODEX_IDENTIFIER,
...(usage ? { usage } : {}),
};
if (usage || model) {
const data: StepCompleteData = {
...(model ? { model } : {}),
phase: 'turn_metadata',
provider: CODEX_IDENTIFIER,
...(usage ? { usage } : {}),
};
return [this.makeEvent('step_complete', data)];
events.push(this.makeEvent('step_complete', data));
}
if (this.started) events.push(this.makeEvent('stream_end', {}));
events.push(this.makeEvent('agent_runtime_end', {}));
return events;
}
private handleTerminalError(raw: any): HeterogeneousAgentEvent[] {
if (this.terminalErrorEmitted) return [];
if (this.terminalErrorEmitted || this.terminalEndEmitted) return [];
this.terminalErrorEmitted = true;
const data: HeterogeneousTerminalErrorData = {
@@ -485,7 +490,8 @@ export class CodexAdapter implements AgentEventAdapter {
private handleTurnStarted(): HeterogeneousAgentEvent[] {
this.currentAgentMessageItemId = undefined;
this.hasStepActivity = false;
this.hasTextInCurrentStep = false;
this.hasToolActivitySinceAgentMessage = false;
this.resetStepToolCalls();
if (!this.started) {
@@ -503,10 +509,11 @@ export class CodexAdapter implements AgentEventAdapter {
private handleItemStarted(item: any): HeterogeneousAgentEvent[] {
if (!item?.id || !item?.type || item.type === 'agent_message') return [];
this.hasStepActivity = true;
this.hasToolActivitySinceAgentMessage = true;
const tool = toToolPayload(item);
this.pendingToolCalls.add(tool.id);
this.pendingToolCallStepIndex.set(tool.id, this.stepIndex);
return this.emitToolChunk(tool);
}
@@ -519,21 +526,30 @@ export class CodexAdapter implements AgentEventAdapter {
const events: HeterogeneousAgentEvent[] = [];
const shouldStartNewStep =
this.hasStepActivity && !!item.id && item.id !== this.currentAgentMessageItemId;
this.hasToolActivitySinceAgentMessage &&
!!item.id &&
item.id !== this.currentAgentMessageItemId;
if (shouldStartNewStep) {
this.stepIndex += 1;
this.resetStepToolCalls();
this.hasTextInCurrentStep = false;
events.push(this.makeEvent('stream_end', {}));
events.push(this.makeEvent('stream_start', { newStep: true, provider: CODEX_IDENTIFIER }));
}
const content =
this.hasTextInCurrentStep && item.id !== this.currentAgentMessageItemId
? `\n\n${item.text}`
: item.text;
this.currentAgentMessageItemId = item.id;
this.hasStepActivity = true;
this.hasTextInCurrentStep = true;
this.hasToolActivitySinceAgentMessage = false;
events.push(
this.makeEvent('stream_chunk', {
chunkType: 'text',
content: item.text,
content,
}),
);
@@ -543,14 +559,19 @@ export class CodexAdapter implements AgentEventAdapter {
if (!item.id) return [];
const events: HeterogeneousAgentEvent[] = [];
const pendingStepIndex = this.pendingToolCallStepIndex.get(item.id);
const belongsToCurrentStep =
pendingStepIndex === undefined || pendingStepIndex === this.stepIndex;
if (!this.pendingToolCalls.has(item.id)) {
const tool = toToolPayload(item);
this.pendingToolCallStepIndex.set(tool.id, this.stepIndex);
events.push(...this.emitToolChunk(tool));
}
this.pendingToolCalls.delete(item.id);
this.hasStepActivity = true;
this.pendingToolCallStepIndex.delete(item.id);
if (belongsToCurrentStep) this.hasToolActivitySinceAgentMessage = true;
events.push(this.makeEvent('tool_result', getToolResultData(item as CodexToolItem)));
events.push(
this.makeEvent('tool_end', {
@@ -562,6 +583,19 @@ export class CodexAdapter implements AgentEventAdapter {
return events;
}
private drainPendingToolEndEvents(): HeterogeneousAgentEvent[] {
const events = [...this.pendingToolCalls].map((toolCallId) =>
this.makeEvent('tool_end', {
isSuccess: false,
toolCallId,
}),
);
this.pendingToolCalls.clear();
this.pendingToolCallStepIndex.clear();
return events;
}
private emitToolChunk(tool: ToolCallPayload): HeterogeneousAgentEvent[] {
if (!this.stepToolCallIds.has(tool.id)) {
this.stepToolCallIds.add(tool.id);
@@ -32,6 +32,10 @@ export {
export { JsonlStreamProcessor } from './jsonlProcessor';
export {
CLAUDE_CODE_BASE_ARGS,
CODEX_BYPASS_APPROVALS_AND_SANDBOX_ARG,
CODEX_DEFAULT_EXECUTION_ARGS,
CODEX_EXECUTION_MODE_FLAGS,
CODEX_REQUIRED_ARGS,
spawnAgent,
type SpawnAgentHandle,
type SpawnAgentOptions,
@@ -179,7 +179,7 @@ describe('spawnAgent', () => {
expect(args[resumeIdx + 1]).toBe('cc-prev-123');
});
it('builds codex args with `exec` + json + skip-git-repo-check + full-auto', async () => {
it('builds codex args with `exec` + json + skip-git-repo-check + bypass approvals/sandbox', async () => {
nextFakeProc = createFakeProc().proc;
const { spawnAgent } = await import('./spawnAgent');
await spawnAgent({ agentType: 'codex', operationId: 'op-1', prompt: 'hello' });
@@ -189,7 +189,23 @@ describe('spawnAgent', () => {
expect(args[0]).toBe('exec');
expect(args).toContain('--json');
expect(args).toContain('--skip-git-repo-check');
expect(args).toContain('--dangerously-bypass-approvals-and-sandbox');
expect(args).not.toContain('--full-auto');
});
it('does not add the default codex execution mode when extraArgs already choose one', async () => {
nextFakeProc = createFakeProc().proc;
const { spawnAgent } = await import('./spawnAgent');
await spawnAgent({
agentType: 'codex',
extraArgs: ['--full-auto'],
operationId: 'op-1',
prompt: 'hello',
});
const { args } = spawnCalls[0];
expect(args).toContain('--full-auto');
expect(args).not.toContain('--dangerously-bypass-approvals-and-sandbox');
});
it('spawns the Windows executable resolved by the shared CLI spawn plan', async () => {
@@ -129,7 +129,18 @@ const CLAUDE_CODE_PERMISSION_ARGS = (): string[] =>
]
: ['--permission-mode', 'bypassPermissions'];
const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check', '--full-auto'] as const;
export const CODEX_REQUIRED_ARGS = ['--json', '--skip-git-repo-check'] as const;
export const CODEX_BYPASS_APPROVALS_AND_SANDBOX_ARG = '--dangerously-bypass-approvals-and-sandbox';
export const CODEX_DEFAULT_EXECUTION_ARGS = [CODEX_BYPASS_APPROVALS_AND_SANDBOX_ARG] as const;
export const CODEX_EXECUTION_MODE_FLAGS = [
'--full-auto',
CODEX_BYPASS_APPROVALS_AND_SANDBOX_ARG,
'--sandbox',
'-s',
] as const;
const hasAnyFlag = (args: string[], flags: readonly string[]) =>
args.some((arg) => flags.includes(arg as (typeof flags)[number]));
interface BuildSpawnArgsParams {
agentType: string;
@@ -157,10 +168,16 @@ const buildClaudeCodeArgs = ({
...extraArgs,
];
const buildCodexArgs = ({ extraArgs, inputArgs, resumeSessionId }: BuildSpawnArgsParams) =>
resumeSessionId
? ['exec', 'resume', ...CODEX_REQUIRED_ARGS, ...inputArgs, ...extraArgs, resumeSessionId, '-']
: ['exec', ...CODEX_REQUIRED_ARGS, ...inputArgs, ...extraArgs];
const buildCodexArgs = ({ extraArgs, inputArgs, resumeSessionId }: BuildSpawnArgsParams) => {
const executionModeArgs = hasAnyFlag(extraArgs, CODEX_EXECUTION_MODE_FLAGS)
? []
: [...CODEX_DEFAULT_EXECUTION_ARGS];
const optionArgs = [...CODEX_REQUIRED_ARGS, ...executionModeArgs, ...inputArgs, ...extraArgs];
return resumeSessionId
? ['exec', 'resume', ...optionArgs, resumeSessionId, '-']
: ['exec', ...optionArgs];
};
const buildSpawnArgs = (params: BuildSpawnArgsParams): string[] => {
switch (params.agentType) {
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ToolDetector } from '../../toolDetector';
import { LinuxSearchServiceImpl } from '../impl/linux';
vi.mock('node:os', () => ({
@@ -66,4 +67,22 @@ describe('UnixFileSearch glob fallback root', () => {
const [, options] = fgMock.mock.calls[0] as [string, { cwd: string }];
expect(options.cwd).toBe('/Users/test-home/Downloads');
});
it('uses fast-glob instead of find for globstar-compatible matching', async () => {
const toolDetector: ToolDetector = {
getBestTool: vi.fn().mockResolvedValue('find'),
};
const impl = new LinuxSearchServiceImpl(toolDetector);
await impl.glob({ pattern: '**/*skill*', scope: '/repo/packages' });
expect(fgMock).toHaveBeenCalledTimes(1);
expect(execaMock).not.toHaveBeenCalledWith('find', expect.anything(), expect.anything());
const [pattern, options] = fgMock.mock.calls[0] as [string, { cwd: string; ignore: string[] }];
expect(pattern).toBe('**/*skill*');
expect(options.cwd).toBe('/repo/packages');
expect(options.ignore).toContain('**/node_modules/**');
expect(options.ignore).toContain('**/.git/**');
});
});
@@ -244,13 +244,14 @@ export abstract class UnixFileSearch extends BaseFileSearch {
/**
* Perform glob pattern matching
* Uses fd > find > fast-glob fallback strategy
* Uses fd when available; falls back to fast-glob to preserve globstar semantics.
*/
async glob(params: GlobFilesParams): Promise<GlobFilesResult> {
const tool = await this.determineBestUnixTool();
logger.info(`Using glob tool: ${tool}`);
const globTool = tool === 'find' ? 'fast-glob' : tool;
logger.info(`Using glob tool: ${globTool}`);
return this.globWithUnixTool(tool, params);
return this.globWithUnixTool(globTool, params);
}
protected async globWithUnixTool(
@@ -262,7 +263,7 @@ export abstract class UnixFileSearch extends BaseFileSearch {
return this.globWithFd(params);
}
case 'find': {
return this.globWithFind(params);
return this.globWithFastGlob(params);
}
default: {
return this.globWithFastGlob(params);
@@ -296,8 +297,8 @@ export abstract class UnixFileSearch extends BaseFileSearch {
});
if (exitCode !== 0 && !stdout.trim()) {
logger.warn(`${logPrefix} fd glob failed with code ${exitCode}, falling back to find`);
return this.globWithFind(params);
logger.warn(`${logPrefix} fd glob failed with code ${exitCode}, falling back to fast-glob`);
return this.globWithFastGlob(params);
}
const files = stdout
@@ -318,8 +319,8 @@ export abstract class UnixFileSearch extends BaseFileSearch {
};
} catch (error) {
logger.error(`${logPrefix} fd glob failed:`, error);
logger.warn(`${logPrefix} Falling back to find`);
return this.globWithFind(params);
logger.warn(`${logPrefix} Falling back to fast-glob`);
return this.globWithFastGlob(params);
}
}
@@ -8,6 +8,7 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { inspectorTextStyles, shinyTextStyles } from '../../styles';
import { getRunCommandDisplayCommand } from '../../utils/runCommand';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
@@ -64,7 +65,8 @@ export const RunCommandInspector = memo<RunCommandInspectorProps>(
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading, translationKey }) => {
const { t } = useTranslation('plugin');
const description = args?.description || partialArgs?.description || args?.command || '';
const command = getRunCommandDisplayCommand(args?.command || partialArgs?.command);
const description = args?.description || partialArgs?.description || command;
if (isArgumentsStreaming) {
if (!description)
@@ -6,6 +6,7 @@ import { Block, Flexbox, Highlighter } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { getRunCommandDisplayCommand } from '../../utils/runCommand';
import AnsiOutput from './AnsiOutput';
const styles = createStaticStyles(({ css }) => ({
@@ -25,6 +26,7 @@ interface RunCommandArgs {
const RunCommand = memo<BuiltinRenderProps<RunCommandArgs, RunCommandState>>(
({ args, content, pluginState }) => {
const output = pluginState?.output || pluginState?.stdout || content;
const command = getRunCommandDisplayCommand(args?.command);
return (
<Flexbox className={styles.container} gap={8}>
@@ -36,7 +38,7 @@ const RunCommand = memo<BuiltinRenderProps<RunCommandArgs, RunCommandState>>(
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
variant={'borderless'}
>
{args?.command || ''}
{command}
</Highlighter>
{output && <AnsiOutput text={output} />}
{pluginState?.stderr && <AnsiOutput text={pluginState.stderr} />}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { getRunCommandDisplayCommand } from './runCommand';
describe('getRunCommandDisplayCommand', () => {
it('keeps plain commands unchanged', () => {
expect(getRunCommandDisplayCommand('git status --short')).toBe('git status --short');
});
it('unwraps zsh login shell commands', () => {
expect(getRunCommandDisplayCommand("/bin/zsh -lc 'git diff --stat'")).toBe('git diff --stat');
});
it('unwraps double-quoted bash commands', () => {
expect(getRunCommandDisplayCommand('/bin/bash -lc "git commit -m \\"fix\\""')).toBe(
'git commit -m "fix"',
);
});
it('unwraps env shell commands', () => {
expect(getRunCommandDisplayCommand("/usr/bin/env zsh -lc 'bun run type-check'")).toBe(
'bun run type-check',
);
});
it('supports sh -c wrappers', () => {
expect(getRunCommandDisplayCommand("sh -c 'printf ok'")).toBe('printf ok');
});
});
@@ -0,0 +1,29 @@
const SHELL_WRAPPER_PATTERN =
/^(?:\/usr\/bin\/env\s+)?(?:\/\S+\/)?(?:bash|sh|zsh)\s+(?:-lc|-c|-l\s+-c)\s+(\S[\s\S]*)$/;
const stripOuterShellQuotes = (value: string) => {
const trimmed = value.trim();
if (trimmed.length < 2) return trimmed;
const quote = trimmed[0];
if ((quote !== '"' && quote !== "'") || trimmed.at(-1) !== quote) return trimmed;
const body = trimmed.slice(1, -1);
if (quote === "'") return body.replaceAll("'\\''", "'");
return body
.replaceAll('\\"', '"')
.replaceAll('\\`', '`')
.replaceAll('\\$', '$')
.replaceAll('\\\\', '\\');
};
export const getRunCommandDisplayCommand = (command?: string) => {
const trimmed = command?.trim() || '';
if (!trimmed) return '';
const match = trimmed.match(SHELL_WRAPPER_PATTERN);
if (!match) return trimmed;
return stripOuterShellQuotes(match[1]) || trimmed;
};
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});
+28
View File
@@ -303,3 +303,31 @@ export interface DeviceProjectFileIndexResult {
source: 'git' | 'glob';
totalCount: number;
}
/**
* A single project skill (`.agents/skills` / `.claude/skills`) discovered on a
* remote device, returned by the `listProjectSkills` device RPC. Mirrors the
* desktop `ProjectSkillItem` (`@lobechat/electron-client-ipc`).
*/
export interface DeviceProjectSkillItem {
description?: string;
fileCount: number;
files: string[];
name: string;
/** Absolute path to the SKILL.md file on the device. */
path: string;
/** Directory containing the SKILL.md. */
skillDir: string;
source: '.agents/skills' | '.claude/skills';
}
/**
* Project skills listing for a directory on a remote device, returned by the
* `listProjectSkills` device RPC. Powers the Resources tab's skills group in
* device mode. Mirrors the desktop `ListProjectSkillsResult`.
*/
export interface DeviceListProjectSkillsResult {
root: string;
skills: DeviceProjectSkillItem[];
source: DeviceProjectSkillItem['source'] | null;
}
@@ -266,6 +266,18 @@ const WorkingDirectoryPicker = memo<WorkingDirectoryPickerProps>(({ agentId }) =
topicWorkingDirectory,
});
// Clear only makes sense when an agent-level override exists. The device-wide
// `deviceDefaultCwd` isn't clearable from here (it's a device setting), so
// gating on it would render a dead button when the cwd comes from the default.
const agentChoice = targetDeviceId
? agencyConfig?.workingDirByDevice?.[targetDeviceId]
: undefined;
const hasClearableSelection = !!(
topicWorkingDirectory ||
agentChoice ||
legacyAgentWorkingDirectory
);
const { clear, commit } = useCommitWorkingDirectory(agentId);
const removeDeviceWorkingDir = useDeviceStore((s) => s.removeDeviceWorkingDir);
@@ -283,7 +295,7 @@ const WorkingDirectoryPicker = memo<WorkingDirectoryPickerProps>(({ agentId }) =
<Flexbox gap={4} style={{ minWidth: 280 }}>
<Flexbox horizontal align={'center'} distribution={'space-between'}>
<div className={styles.sectionTitle}>{t('workingDirectory.recent')}</div>
{selectedDir && (
{hasClearableSelection && (
<div className={styles.clearText} onClick={() => void clear().then(() => setOpen(false))}>
{t('workingDirectory.clear')}
</div>
@@ -27,6 +27,10 @@ export const useCommitWorkingDirectory = (agentId: string) => {
const agencyConfig = useAgentStore(agentByIdSelectors.getAgencyConfigById(agentId));
const updateAgentConfigById = useAgentStore((s) => s.updateAgentConfigById);
const updateAgentRuntimeEnvConfigById = useAgentStore((s) => s.updateAgentRuntimeEnvConfigById);
const legacyAgentWorkingDirectory = useAgentStore(
(s) => s.localAgentWorkingDirectoryMap[agentId],
);
const activeTopicId = useChatStore((s) => s.activeTopicId);
const activeTopic = useChatStore((s) =>
@@ -44,14 +48,23 @@ export const useCommitWorkingDirectory = (agentId: string) => {
// agent's per-device choice so a new topic inherits it.
if (activeTopicId) {
await updateTopicMetadata(activeTopicId, { workingDirectory: newPath });
} else if (targetDeviceId) {
const prev = agencyConfig?.workingDirByDevice ?? {};
const nextMap = { ...prev };
if (newPath) nextMap[targetDeviceId] = newPath;
else delete nextMap[targetDeviceId];
await updateAgentConfigById(agentId, {
agencyConfig: { ...agencyConfig, workingDirByDevice: nextMap },
});
} else {
if (targetDeviceId) {
const prev = agencyConfig?.workingDirByDevice ?? {};
const nextMap = { ...prev };
if (newPath) nextMap[targetDeviceId] = newPath;
else delete nextMap[targetDeviceId];
await updateAgentConfigById(agentId, {
agencyConfig: { ...agencyConfig, workingDirByDevice: nextMap },
});
}
// Clearing the agent default must also drop the legacy per-agent value —
// otherwise it keeps re-supplying a stale cwd from a lower precedence
// level and Clear looks dead. (Only clears the localStorage map; no
// network round-trip since `workingDirectory` is stripped before send.)
if (!newPath && legacyAgentWorkingDirectory) {
await updateAgentRuntimeEnvConfigById(agentId, { workingDirectory: undefined });
}
}
// Record on the target device's recent list (not the device-wide default —
// a per-agent pick shouldn't repoint other agents on the same device).
@@ -64,7 +77,9 @@ export const useCommitWorkingDirectory = (agentId: string) => {
agencyConfig,
activeTopicId,
targetDeviceId,
legacyAgentWorkingDirectory,
updateAgentConfigById,
updateAgentRuntimeEnvConfigById,
updateTopicMetadata,
updateDeviceCwd,
],
@@ -0,0 +1,32 @@
import { Globe } from 'lucide-react';
import { memo, useState } from 'react';
interface FaviconIconProps {
domain: string;
size?: number;
}
/**
* Inline site favicon for generic external links. Falls back to a globe glyph
* when the favicon cannot be loaded.
*/
const FaviconIcon = memo<FaviconIconProps>(({ domain, size = 15 }) => {
const [failed, setFailed] = useState(false);
if (failed) return <Globe size={size} />;
return (
<img
alt=""
height={size}
src={`https://icons.duckduckgo.com/ip3/${domain}.ico`}
style={{ borderRadius: 3, objectFit: 'contain' }}
width={size}
onError={() => setFailed(true)}
/>
);
});
FaviconIcon.displayName = 'FaviconIcon';
export default FaviconIcon;
@@ -0,0 +1,23 @@
import { memo } from 'react';
interface LinearIconProps {
size?: number | string;
}
/** Official Linear logo mark (simple-icons), inherits `currentColor`. */
const LinearIcon = memo<LinearIconProps>(({ size = '1em' }) => (
<svg
fill="currentColor"
height={size}
role="img"
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
));
LinearIcon.displayName = 'LinearIcon';
export default LinearIcon;
@@ -0,0 +1,38 @@
'use client';
import { createStaticStyles } from 'antd-style';
import { memo, type ReactNode } from 'react';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
color: ${cssVar.colorLink};
text-decoration: none;
transition: color 0.15s;
&:hover {
color: ${cssVar.colorLinkHover};
}
`,
icon: css`
display: inline-flex;
margin-inline-end: 4px;
vertical-align: -0.15em;
`,
}));
interface LinkChipProps {
href?: string;
icon: ReactNode;
label: string;
}
const LinkChip = memo<LinkChipProps>(({ href, icon, label }) => (
<a className={styles.chip} href={href} rel="noopener noreferrer" target="_blank">
<span className={styles.icon}>{icon}</span>
{label}
</a>
));
LinkChip.displayName = 'LinkChip';
export default LinkChip;
@@ -0,0 +1,50 @@
'use client';
import { Github } from '@lobehub/icons';
import { Mail } from 'lucide-react';
import { memo } from 'react';
import { type MarkdownElementProps } from '../../type';
import { type LobeLinkKind } from '../parse';
import FaviconIcon from './FaviconIcon';
import LinearIcon from './LinearIcon';
import LinkChip from './LinkChip';
const ICON_SIZE = 15;
interface LobeLinkProperties {
linkDomain?: string;
linkHref?: string;
linkKind?: LobeLinkKind;
linkLabel?: string;
}
const Render = memo<MarkdownElementProps<LobeLinkProperties>>(({ node }) => {
const { linkHref, linkKind, linkLabel, linkDomain } = node?.properties || {};
const label = linkLabel || linkHref || '';
if (linkKind === 'github') {
return <LinkChip href={linkHref} icon={<Github size={ICON_SIZE} />} label={label} />;
}
if (linkKind === 'linear') {
return <LinkChip href={linkHref} icon={<LinearIcon size={ICON_SIZE} />} label={label} />;
}
if (linkKind === 'email') {
return <LinkChip href={linkHref} icon={<Mail size={ICON_SIZE} />} label={label} />;
}
return (
<LinkChip
href={linkHref}
icon={<FaviconIcon domain={linkDomain || ''} size={ICON_SIZE} />}
label={label}
/>
);
});
Render.displayName = 'LobeLinkRender';
export default Render;
@@ -0,0 +1,15 @@
import { type FC } from 'react';
import { type MarkdownElement, type MarkdownElementProps } from '../type';
import { LOBE_LINK_TAG } from './parse';
import { rehypeLobeLink } from './rehypePlugin';
import Render from './Render';
const LinkElement: MarkdownElement = {
Component: Render as FC<MarkdownElementProps>,
rehypePlugin: rehypeLobeLink,
scope: 'all',
tag: LOBE_LINK_TAG,
};
export default LinkElement;
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { parseLobeLink } from './parse';
describe('parseLobeLink', () => {
it('parses github pull request', () => {
expect(parseLobeLink('https://github.com/lobehub/lobehub/pull/15557')).toEqual({
canonicalLabel: 'lobehub/lobehub#15557',
kind: 'github',
});
});
it('parses github issue', () => {
expect(parseLobeLink('https://github.com/lobehub/lobehub/issues/15554')).toEqual({
canonicalLabel: 'lobehub/lobehub#15554',
kind: 'github',
});
});
it('parses github commit (short sha)', () => {
expect(parseLobeLink('https://github.com/lobehub/lobehub/commit/d36aa75701abc')).toEqual({
canonicalLabel: 'lobehub/lobehub@d36aa75',
kind: 'github',
});
});
it('parses github repo root', () => {
expect(parseLobeLink('https://github.com/lobehub/lobehub')).toEqual({
canonicalLabel: 'lobehub/lobehub',
kind: 'github',
});
});
it('parses linear issue', () => {
expect(parseLobeLink('https://linear.app/lobehub/issue/LOBE-10141/codex-pptx-preview')).toEqual(
{
canonicalLabel: 'LOBE-10141',
kind: 'linear',
},
);
});
it('parses github user / org pages with the github icon', () => {
expect(parseLobeLink('https://github.com/lobehub')).toEqual({
canonicalLabel: 'lobehub',
kind: 'github',
});
});
it('uses the full URL as label for generic http links', () => {
expect(parseLobeLink('https://example.com/foo')).toEqual({
canonicalLabel: 'https://example.com/foo',
domain: 'example.com',
kind: 'generic',
});
// bare github.com (no owner) → generic
expect(parseLobeLink('https://github.com')?.kind).toBe('generic');
});
it('labels npm packages by package name', () => {
expect(parseLobeLink('https://www.npmjs.com/package/@lobehub/ui')).toEqual({
canonicalLabel: '@lobehub/ui',
domain: 'npmjs.com',
kind: 'generic',
});
expect(parseLobeLink('https://www.npmjs.com/package/react/v/18.0.0')?.canonicalLabel).toBe(
'react',
);
});
it('labels figma links by file name', () => {
expect(parseLobeLink('https://www.figma.com/file/abc123/Design-File')?.canonicalLabel).toBe(
'Design File',
);
expect(parseLobeLink('https://www.figma.com/design/abc123/My-Board')?.canonicalLabel).toBe(
'My Board',
);
});
it('parses mailto links as email', () => {
expect(parseLobeLink('mailto:hi@example.com')).toEqual({
canonicalLabel: 'hi@example.com',
kind: 'email',
});
expect(parseLobeLink('mailto:hi@example.com?subject=Hello')?.canonicalLabel).toBe(
'hi@example.com',
);
});
it('ignores citation, footnote, relative and non-http hrefs', () => {
expect(parseLobeLink('citation-1')).toBeNull();
expect(parseLobeLink('#user-content-fn-1')).toBeNull();
expect(parseLobeLink('/foo/bar')).toBeNull();
expect(parseLobeLink(undefined)).toBeNull();
});
});
@@ -0,0 +1,101 @@
export const LOBE_LINK_TAG = 'lobeLink';
export type LobeLinkKind = 'github' | 'linear' | 'email' | 'generic';
export interface ParsedLobeLink {
/**
* Canonical label used when the link has no author-provided text, e.g.
* `lobehub/lobehub#15554` / `LOBE-10141` / `@lobehub/ui` / the full URL.
*/
canonicalLabel: string;
/** Host for generic links, used to fetch a favicon. */
domain?: string;
kind: LobeLinkKind;
}
const stripWww = (host: string) => host.replace(/^www\./, '');
/** npmjs.com/package/<name> → `<name>` (handles scoped packages and versions). */
const npmPackageName = (segments: string[]): string | undefined => {
const idx = segments.indexOf('package');
const rest = idx >= 0 ? segments.slice(idx + 1) : [];
if (rest.length === 0) return undefined;
return rest[0].startsWith('@') ? rest.slice(0, 2).join('/') : rest[0];
};
/** figma.com/(file|design)/<key>/<name> → the human file name. */
const figmaFileName = (segments: string[]): string | undefined => {
if ((segments[0] === 'file' || segments[0] === 'design') && segments[2]) {
try {
return decodeURIComponent(segments[2]).replaceAll('-', ' ');
} catch {
return segments[2];
}
}
return undefined;
};
/**
* Classify an href and derive a canonical short label.
*
* - GitHub repo / PR / issue / commit and Linear issues get a rich label.
* - npm packages / Figma files keep their favicon but get a friendly label.
* - `mailto:` links become an `email` chip.
* - Any other absolute http(s) link becomes a `generic` chip (favicon + full URL).
* - Citation links (`citation-1`), footnote refs, anchors and relative paths
* return `null` and keep the default link renderer untouched.
*/
export const parseLobeLink = (href?: string): ParsedLobeLink | null => {
if (!href) return null;
if (href.startsWith('mailto:')) {
const email = href.slice('mailto:'.length).split('?')[0];
return email ? { canonicalLabel: email, kind: 'email' } : null;
}
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
const host = stripWww(url.hostname);
const segments = url.pathname.split('/').filter(Boolean);
if (host === 'github.com') {
const [owner, repo, type, id] = segments;
if (owner) {
if (repo) {
if ((type === 'pull' || type === 'issues') && id) {
return { canonicalLabel: `${owner}/${repo}#${id}`, kind: 'github' };
}
if (type === 'commit' && id) {
return { canonicalLabel: `${owner}/${repo}@${id.slice(0, 7)}`, kind: 'github' };
}
return { canonicalLabel: `${owner}/${repo}`, kind: 'github' };
}
// user / org page → keep the GitHub icon, show the handle
return { canonicalLabel: owner, kind: 'github' };
}
// bare github.com → fall through to generic
}
if (host === 'linear.app') {
// workspace/issue/LOBE-123/slug
const issueIndex = segments.indexOf('issue');
const id = issueIndex >= 0 ? segments[issueIndex + 1] : undefined;
if (id) return { canonicalLabel: id.toUpperCase(), kind: 'linear' };
// fall through to generic
}
// Generic chip: favicon + a friendly label, falling back to the full URL.
const friendlyLabel =
(host === 'npmjs.com' && npmPackageName(segments)) ||
(host === 'figma.com' && figmaFileName(segments)) ||
href;
return { canonicalLabel: friendlyLabel, domain: host, kind: 'generic' };
};
@@ -0,0 +1,45 @@
import { SKIP, visit } from 'unist-util-visit';
import { LOBE_LINK_TAG, parseLobeLink } from './parse';
/** Recursively collect the visible text of a HAST node. */
const getNodeText = (node: any): string => {
if (!node) return '';
if (node.type === 'text') return String(node.value ?? '');
if (Array.isArray(node.children)) return node.children.map(getNodeText).join('');
return '';
};
/**
* Rehype plugin that rewrites GitHub / Linear anchor (`<a>`) elements into a
* custom `<lobeLink>` element so they can be rendered as rich inline chips.
*
* Anchors that are not GitHub / Linear links including citation links
* (`citation-1`) and footnote refs are left untouched and keep the default
* link renderer.
*/
export const rehypeLobeLink = () => (tree: any) => {
visit(tree, 'element', (node: any) => {
if (node.tagName !== 'a') return;
const href = node.properties?.href as string | undefined;
const parsed = parseLobeLink(href);
if (!parsed) return;
const text = getNodeText(node).trim();
// Prefer an author-provided label; fall back to the canonical short form
// when the link text is empty or just the raw URL.
const label = !text || text === href ? parsed.canonicalLabel : text;
node.tagName = LOBE_LINK_TAG;
node.children = [];
node.properties = {
linkDomain: parsed.domain,
linkHref: href,
linkKind: parsed.kind,
linkLabel: label,
};
return SKIP;
});
};
@@ -1,4 +1,5 @@
import ImageSearchRef from './ImageSearchRef';
import Link from './Link';
import LobeAgents from './LobeAgents';
import LobeArtifact from './LobeArtifact';
import LobeThinking from './LobeThinking';
@@ -25,4 +26,5 @@ export const markdownElements: MarkdownElement[] = [
UserFeedback,
ImageSearchRef,
LobeAgents,
Link,
];
@@ -24,7 +24,7 @@ export default defineFixtures({
],
fixtures: {
command_execution: single({
args: { command: 'bun run type-check' },
args: { command: "/bin/zsh -lc 'bun run type-check'" },
content: 'Checked 1247 files in 2.3s\nNo type errors found.',
pluginState: {
exitCode: 0,
+1 -1
View File
@@ -99,7 +99,7 @@ const Header = memo<{ inModal?: boolean; mobile?: boolean }>(({ mobile: isMobile
const handleFavoriteClick = async () => {
if (!isAuthenticated) {
await signIn();
await signIn('mcp');
return;
}
@@ -1,6 +1,8 @@
'use client';
import { type CSSProperties } from 'react';
import { ReactBlockPlugin } from '@lobehub/editor';
import { Editor } from '@lobehub/editor/react';
import { type CSSProperties, useMemo } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -24,10 +26,16 @@ const EditorCanvas = memo<EditorCanvasProps>(({ placeholder, style }) => {
const slashItems = useSlashItems();
const askCopilotItem = useAskCopilotItem(editor);
const extraPlugins = useMemo(
() => [Editor.withProps(ReactBlockPlugin, { anchorPadding: 0 })],
[],
);
return (
<SharedEditorCanvas
documentId={documentId}
editor={editor}
extraPlugins={extraPlugins}
placeholder={placeholder || t('pageEditor.editorPlaceholder')}
slashItems={slashItems}
style={style}
+2 -2
View File
@@ -1,6 +1,6 @@
'use client';
import { EditorProvider } from '@lobehub/editor/react';
import { DEFAULT_BLOCK_ANCHOR_PADDING, EditorProvider } from '@lobehub/editor/react';
import { Flexbox } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import type { FC, ReactNode } from 'react';
@@ -42,7 +42,7 @@ const styles = StyleSheet.create({
position: 'relative',
},
editorContent: {
overflowY: 'auto',
paddingInline: DEFAULT_BLOCK_ANCHOR_PADDING,
position: 'relative',
},
});
@@ -61,7 +61,7 @@ const Item = memo<DiscoverMcpItem>(({ name, description, icon, identifier }) =>
const handleInstall = async () => {
if (isCloudMcp && !isAuthenticated) {
try {
await signIn();
await signIn('mcp');
} catch {
return;
}
+20 -7
View File
@@ -3,7 +3,7 @@ import path from 'pathe';
import { useMemo } from 'react';
import { useClientDataSWR } from '@/libs/swr';
import { localFileService } from '@/services/electron/localFileService';
import { projectSkillService } from '@/services/projectSkill';
import { useChatStore } from '@/store/chat';
import type { SkillListItem } from './SkillsList';
@@ -21,15 +21,24 @@ export interface UseProjectSkillsResult {
* `.agents/skills/` / `.claude/skills/` in `workingDirectory`. Powers both
* the hetero `SkillsGroup` and the homogeneous `ProjectLevelSkills` section.
*
* Pass `undefined` to keep the hook inert (no fetch fires) — useful when the
* caller hasn't decided whether to render the section yet.
* `deviceId` picks the transport: when set, the scan runs on that remote device
* via the `device.listProjectSkills` RPC; otherwise it goes through local
* Electron IPC. Like the Files tab, remote mode lists skills but does not open
* previews (the device's filesystem isn't reachable by the local viewer).
*
* Pass `undefined` workingDirectory to keep the hook inert (no fetch fires) —
* useful when the caller hasn't decided whether to render the section yet.
*/
export const useProjectSkills = (workingDirectory: string | undefined): UseProjectSkillsResult => {
export const useProjectSkills = (
workingDirectory: string | undefined,
deviceId?: string,
): UseProjectSkillsResult => {
const openLocalFile = useChatStore((s) => s.openLocalFile);
const isRemote = !!deviceId;
const { data, isLoading } = useClientDataSWR<ListProjectSkillsResult>(
workingDirectory ? ['project-skills', workingDirectory] : null,
() => localFileService.listProjectSkills({ scope: workingDirectory! }),
const { data, isLoading } = useClientDataSWR<ListProjectSkillsResult | undefined>(
workingDirectory ? ['project-skills', deviceId ?? 'local', workingDirectory] : null,
() => projectSkillService.listProjectSkills({ deviceId, scope: workingDirectory! }),
{ revalidateOnFocus: false, shouldRetryOnError: false },
);
@@ -58,6 +67,9 @@ export const useProjectSkills = (workingDirectory: string | undefined): UseProje
}, [data?.skills]);
const onOpenFile = (item: SkillListItem, relativePath: string) => {
// A remote device has no filesystem the local viewer can open (matches the
// Files tab); device mode lists skills but does not preview them.
if (isRemote) return;
const skill = skillByDir.get(item.id);
if (!skill) return;
openLocalFile({
@@ -67,6 +79,7 @@ export const useProjectSkills = (workingDirectory: string | undefined): UseProje
};
const onOpenSkill = (item: SkillListItem) => {
if (isRemote) return;
const skill = skillByDir.get(item.id);
if (!skill) return;
openLocalFile({ filePath: skill.path, workingDirectory: previewRoot });
@@ -10,6 +10,8 @@ import { PRIVACY_URL, TERMS_URL } from '@/const/url';
import AuthCard from '@/features/AuthCard';
import { useIsDark } from '@/hooks/useIsDark';
import type { MarketAuthScene } from './scenes';
const styles = createStaticStyles(({ css }) => ({
container: css`
padding-block-start: 32px;
@@ -30,13 +32,28 @@ interface MarketAuthConfirmModalProps {
onCancel: () => void;
onConfirm: () => void;
open: boolean;
scene?: MarketAuthScene;
}
const MarketAuthConfirmModal = memo<MarketAuthConfirmModalProps>(
({ open, onConfirm, onCancel }) => {
({ open, onConfirm, onCancel, scene = 'default' }) => {
const { t } = useTranslation('marketAuth');
const isDarkMode = useIsDark();
// Resolve scene-specific copy, falling back to the generic community-profile
// wording when a scene has no dedicated key.
const ts = (key: string, options?: Record<string, unknown>): string => {
const fallback = t(`authorize.${key}` as any, options as any) as string;
if (scene === 'default') return fallback;
return t(
`authorize.scenes.${scene}.${key}` as any,
{
...options,
defaultValue: fallback,
} as any,
) as string;
};
const footer = (
<Text align={'center'} as={'div'} fontSize={13} type={'secondary'}>
<Trans
@@ -83,12 +100,12 @@ const MarketAuthConfirmModal = memo<MarketAuthConfirmModalProps>(
<AuthCard
footer={footer}
paddingBlock={'40px 20px'}
subtitle={t('authorize.subtitle')}
title={t('authorize.title')}
subtitle={ts('subtitle')}
title={ts('title')}
width={'100%'}
>
<Block padding={16} variant={'filled'}>
<Text align={'center'}>{t('authorize.description', { appName: BRANDING_NAME })}</Text>
<Text align={'center'}>{ts('description', { appName: BRANDING_NAME })}</Text>
</Block>
</AuthCard>
</Modal>
@@ -19,6 +19,7 @@ import { marketAuthEvents } from './events';
import MarketAuthConfirmModal from './MarketAuthConfirmModal';
import { MarketOIDC } from './oidc';
import ProfileSetupModal from './ProfileSetupModal';
import type { MarketAuthScene } from './scenes';
import {
type MarketAuthContextType,
type MarketAuthSession,
@@ -139,6 +140,7 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
const [oidcClient, setOidcClient] = useState<MarketOIDC | null>(null);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [authScene, setAuthScene] = useState<MarketAuthScene>('default');
const [showProfileSetupModal, setShowProfileSetupModal] = useState(false);
const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false);
const [pendingSignInResolve, setPendingSignInResolve] = useState<
@@ -391,7 +393,11 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
/**
* Sign-in method (shows confirmation dialog first)
*/
const signIn = useCallback(async (): Promise<number | null> => {
const signIn = useCallback(async (scene: MarketAuthScene = 'default'): Promise<number | null> => {
if (!useUserStore.getState().isSignedIn) {
throw new Error('LobeChat session required');
}
setAuthScene(scene);
return new Promise<number | null>((resolve, reject) => {
setPendingSignInResolve(() => resolve);
setPendingSignInReject(() => reject);
@@ -630,30 +636,33 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
* Attempts to refresh token first, then triggers signIn if refresh fails
* @returns true if successfully re-authenticated, false if user cancelled or failed
*/
const handleUnauthorized = useCallback(async (): Promise<boolean> => {
console.info('[MarketAuth] Handling unauthorized error, attempting recovery...');
const handleUnauthorized = useCallback(
async (scene: MarketAuthScene = 'default'): Promise<boolean> => {
console.info('[MarketAuth] Handling unauthorized error, attempting recovery...');
// First try to refresh the token
const refreshed = await refreshToken();
if (refreshed) {
console.info('[MarketAuth] Token refresh successful, recovered from 401');
return true;
}
// Refresh failed, need to re-authenticate
console.info('[MarketAuth] Token refresh failed, triggering signIn...');
try {
const accountId = await signIn();
if (accountId !== null) {
console.info('[MarketAuth] Re-authentication successful');
// First try to refresh the token
const refreshed = await refreshToken();
if (refreshed) {
console.info('[MarketAuth] Token refresh successful, recovered from 401');
return true;
}
return false;
} catch (error) {
console.error('[MarketAuth] Re-authentication failed:', error);
return false;
}
}, [refreshToken, signIn]);
// Refresh failed, need to re-authenticate
console.info('[MarketAuth] Token refresh failed, triggering signIn...');
try {
const accountId = await signIn(scene);
if (accountId !== null) {
console.info('[MarketAuth] Re-authentication successful');
return true;
}
return false;
} catch (error) {
console.error('[MarketAuth] Re-authentication failed:', error);
return false;
}
},
[refreshToken, signIn],
);
/**
* Restore session and fetch user info on initialization
@@ -710,11 +719,11 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
if (!refreshed) {
// Silent refresh failed — the Market OAuth token is genuinely expired.
// Show the Market auth modal so the user can re-authorize.
await handleUnauthorized();
await handleUnauthorized(event.scene);
}
return;
}
await handleUnauthorized();
await handleUnauthorized(event.scene);
});
return unsubscribe;
@@ -776,6 +785,7 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
{children}
<MarketAuthConfirmModal
open={showConfirmModal}
scene={authScene}
onCancel={handleCancelAuth}
onConfirm={handleConfirmAuth}
/>
@@ -5,10 +5,13 @@
* Market API 401 errors across the application.
*/
import type { MarketAuthScene } from './scenes';
export type MarketAuthEventType = 'market-unauthorized';
export interface MarketUnauthorizedEvent {
path: string;
scene: MarketAuthScene;
timestamp: number;
}
@@ -1,4 +1,5 @@
export { MarketAuthProvider, useMarketAuth } from './MarketAuthProvider';
export type { MarketAuthScene } from './scenes';
export type {
MarketAuthContextType,
MarketAuthSession,
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { pathToMarketAuthScene } from './scenes';
describe('pathToMarketAuthScene', () => {
it('maps sandbox execution paths to the sandbox scene', () => {
expect(pathToMarketAuthScene('market.execInSandbox')).toBe('sandbox');
});
it('maps Cloud MCP paths to the mcp scene', () => {
expect(pathToMarketAuthScene('market.callCloudMcpEndpoint')).toBe('mcp');
expect(pathToMarketAuthScene('market.installCloudMcp')).toBe('mcp');
});
it('maps publish/submit paths to the publish scene', () => {
expect(pathToMarketAuthScene('market.publishAgent')).toBe('publish');
expect(pathToMarketAuthScene('market.submitVersion')).toBe('publish');
});
it('falls back to the default scene for unknown paths', () => {
expect(pathToMarketAuthScene('market.followUser')).toBe('default');
expect(pathToMarketAuthScene('market.getUserProfile')).toBe('default');
});
});
@@ -0,0 +1,21 @@
/**
* Market Auth Scenes
*
* The Market auth modal can be triggered from different capabilities (running a
* tool in the sandbox, installing a Cloud MCP skill, publishing to the
* community, ...). The `scene` lets the modal show capability-specific copy
* while falling back to the generic community-profile copy when unknown.
*/
export type MarketAuthScene = 'default' | 'sandbox' | 'mcp' | 'publish';
/**
* Infer the scene from a tRPC procedure path (e.g. `market.execInSandbox`).
* Used by the 401 error link where only the request path is available.
*/
export const pathToMarketAuthScene = (path: string): MarketAuthScene => {
if (path.includes('execInSandbox')) return 'sandbox';
if (path.includes('CloudMcp') || path.includes('callCloudMcpEndpoint')) return 'mcp';
if (path.includes('publish') || path.includes('submit')) return 'publish';
return 'default';
};
+9 -2
View File
@@ -1,3 +1,5 @@
import type { MarketAuthScene } from './scenes';
export interface MarketUserInfo {
accountId: number;
clientId: string;
@@ -69,12 +71,17 @@ export interface MarketAuthContextType extends MarketAuthState {
/**
* Handle unauthorized (401) error from Market API
* Attempts to refresh token first, then triggers signIn if refresh fails
* @param scene - capability that triggered the auth, controls the modal copy
* @returns true if successfully re-authenticated, false if user cancelled or failed
*/
handleUnauthorized: () => Promise<boolean>;
handleUnauthorized: (scene?: MarketAuthScene) => Promise<boolean>;
openProfileSetup: (onSuccess?: (profile: MarketUserProfile) => void) => void;
refreshToken: () => Promise<boolean>;
signIn: () => Promise<number | null>;
/**
* Sign in to the Market.
* @param scene - capability that triggered the auth, controls the modal copy
*/
signIn: (scene?: MarketAuthScene) => Promise<number | null>;
signOut: () => Promise<void>;
}
+7
View File
@@ -43,14 +43,21 @@ const errorHandlingLink: TRPCLink<LambdaRouter> = () => {
if (isMarketApi) {
// Market API 401: emit event for MarketAuthProvider to handle
// Don't trigger LobeChat logout for market auth issues
const { getUserStoreState } = await import('@/store/user/store');
// Without a LobeChat session a market.* 401 is not a Market auth
// issue — let it bubble instead of triggering the auth modal
if (!getUserStoreState().isSignedIn) break;
const now = Date.now();
if (now - lastMarket401Time > MIN_401_INTERVAL) {
lastMarket401Time = now;
// Dynamically import to avoid circular dependencies
const { marketAuthEvents } =
await import('@/layout/AuthProvider/MarketAuth/events');
const { pathToMarketAuthScene } =
await import('@/layout/AuthProvider/MarketAuth/scenes');
marketAuthEvents.emit('market-unauthorized', {
path: op.path,
scene: pathToMarketAuthScene(op.path),
timestamp: now,
});
}
+19 -10
View File
@@ -29,16 +29,25 @@ const errorHandlingLink: TRPCLink<ToolsRouter> = () => {
// UNAUTHORIZED tRPC code maps to HTTP 401
const is401 = status === 401 || code === 'UNAUTHORIZED';
if (is401 && op.path.startsWith('market.')) {
const now = Date.now();
if (now - lastMarket401Time > MIN_401_INTERVAL) {
lastMarket401Time = now;
console.info('[toolsClient] Emitting market-unauthorized event for path:', op.path);
// Emit event for MarketAuthProvider to handle
const { marketAuthEvents } = await import('@/layout/AuthProvider/MarketAuth/events');
marketAuthEvents.emit('market-unauthorized', {
path: op.path,
timestamp: now,
});
const { getUserStoreState } = await import('@/store/user/store');
// Without a LobeChat session a market.* 401 is not a Market auth
// issue — let it bubble instead of triggering the auth modal
if (getUserStoreState().isSignedIn) {
const now = Date.now();
if (now - lastMarket401Time > MIN_401_INTERVAL) {
lastMarket401Time = now;
console.info('[toolsClient] Emitting market-unauthorized event for path:', op.path);
// Emit event for MarketAuthProvider to handle
const { marketAuthEvents } =
await import('@/layout/AuthProvider/MarketAuth/events');
const { pathToMarketAuthScene } =
await import('@/layout/AuthProvider/MarketAuth/scenes');
marketAuthEvents.emit('market-unauthorized', {
path: op.path,
scene: pathToMarketAuthScene(op.path),
timestamp: now,
});
}
}
}
+9
View File
@@ -6,6 +6,15 @@ export default {
'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.mcp.subtitle':
'Create a community profile to install and run this skill from the community.',
'authorize.scenes.mcp.title': 'Install Community Skill',
'authorize.scenes.publish.subtitle':
'Create a community profile to publish and manage your listing within the community.',
'authorize.scenes.publish.title': 'Publish to the Community',
'authorize.scenes.sandbox.subtitle':
'Create a community profile to run this tool in the community sandbox.',
'authorize.scenes.sandbox.title': 'Try the Community Sandbox',
'authorize.subtitle':
'Create a community profile to submit and manage listings within the community.',
'authorize.title': 'Create Community Profile',
@@ -239,7 +239,7 @@ const MarketToolAuthItem = memo<MarketToolAuthItemProps>(({ tool }) => {
const handleSignIn = async () => {
try {
await signIn();
await signIn('sandbox');
} catch (error) {
console.error('[ToolAuthAlert] Market sign in failed:', error);
}
@@ -252,214 +252,234 @@ const buildSkillBundleViews = (data: AgentDocumentListItem[]): SkillBundleView[]
};
interface AgentDocumentsGroupProps {
/** Bound remote device id (device mode); skills are then scanned over RPC. */
deviceId?: string;
style?: CSSProperties;
workingDirectory?: string;
}
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(({ style, workingDirectory }) => {
const { t } = useTranslation('chat');
const agentId = useAgentStore((s) => s.activeAgentId);
const isLocalEnabled = useAgentStore((s) =>
agentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(agentId)(s) : false,
);
const openDocument = useChatStore((s) => s.openDocument);
const [filter, setFilter] = useState<ResourceFilter>('skills');
const showProjectSkills = isLocalEnabled && !!workingDirectory;
// Mirror what each child component reads so the parent can decide the
// section layout (flat when a single source has items, sectioned otherwise).
// Both hooks are SWR-deduped against their respective child fetches.
const userSkillItems = useUserSkills();
const { items: projectSkillItems } = useProjectSkills(
showProjectSkills ? workingDirectory : undefined,
);
const {
data = [],
error,
isLoading,
mutate,
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documentsList(agentId) : null, () =>
agentDocumentService.getDocuments({ agentId: agentId! }),
);
const webData = useMemo(
() => data.filter((doc) => doc.category === AGENT_DOCUMENT_WEB_CATEGORY),
[data],
);
const documentsData = useMemo(
() => data.filter((doc) => doc.category === AGENT_DOCUMENT_CATEGORY),
[data],
);
const skillBundleViews = useMemo(() => buildSkillBundleViews(data), [data]);
const skillItems = useMemo<SkillListItem[]>(
() =>
skillBundleViews.map(({ bundle, files }) => ({
description: bundle.description ?? undefined,
fileCount: files.length,
files,
id: bundle.documentId,
name: bundle.title || bundle.filename || '',
})),
[skillBundleViews],
);
if (!agentId) return null;
if (isLoading) {
return (
<Center flex={1} paddingBlock={24}>
<NeuralNetworkLoading size={32} />
</Center>
const AgentDocumentsGroup = memo<AgentDocumentsGroupProps>(
({ deviceId, style, workingDirectory }) => {
const { t } = useTranslation('chat');
const agentId = useAgentStore((s) => s.activeAgentId);
const isLocalEnabled = useAgentStore((s) =>
agentId ? chatConfigByIdSelectors.isLocalSystemEnabledById(agentId)(s) : false,
);
}
const openDocument = useChatStore((s) => s.openDocument);
const [filter, setFilter] = useState<ResourceFilter>('skills');
if (error) {
return (
<Center flex={1} paddingBlock={24}>
<Text type={'danger'}>{t('workingPanel.resources.error')}</Text>
</Center>
// Local desktop reads skills over IPC; a bound device reads over RPC.
const showProjectSkills = (isLocalEnabled || !!deviceId) && !!workingDirectory;
// Mirror what each child component reads so the parent can decide the
// section layout (flat when a single source has items, sectioned otherwise).
// Both hooks are SWR-deduped against their respective child fetches.
const userSkillItems = useUserSkills();
const { items: projectSkillItems, isLoading: isProjectSkillsLoading } = useProjectSkills(
showProjectSkills ? workingDirectory : undefined,
deviceId,
);
}
const renderAgentSkillsList = () => (
<SkillsList
items={skillItems}
onOpenFile={(item, relativePath) => {
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
const docId = view?.pathToDocumentId.get(relativePath);
if (!docId) return;
const row = data.find((d) => d.documentId === docId);
openDocument(docId, row?.id);
}}
onOpenSkill={(item) => {
// Open the SKILL.md (skills/index child) when present; fall back to
// the bundle itself (orphan bundles surface for recovery).
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
const indexChild = data.find((doc) => doc.parentId === item.id && doc.isSkillIndex);
const targetDocId = indexChild?.documentId ?? view?.bundle.documentId ?? item.id;
const targetRow = data.find((d) => d.documentId === targetDocId);
openDocument(targetDocId, targetRow?.id);
}}
onSkillDragStart={(item, event) => {
// The runtime resolves these via the `agent-skills:<filename>`
// identifier (built from the shared const helper so the prefix stays
// in lockstep with the server-side resolver). Display label keeps
// the human-readable title.
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
const filename = view?.bundle.filename;
if (!filename) return;
startSkillDrag(event, {
category: 'agentSkill',
label: item.name,
type: buildAgentSkillIdentifier(filename),
});
}}
/>
);
const {
data = [],
error,
isLoading,
mutate,
} = useClientDataSWR(agentId ? agentDocumentSWRKeys.documentsList(agentId) : null, () =>
agentDocumentService.getDocuments({ agentId: agentId! }),
);
const renderSkills = () => {
// Sections render in fixed order — agent → project → user — and each one
// hides itself when it has nothing to show. When exactly one source has
// items we drop the group header and render the list flat (no redundant
// "User skills 1" label above a single row). When everything is empty we
// fall back to a single placeholder.
const hasAgent = skillItems.length > 0;
const hasProject = showProjectSkills && projectSkillItems.length > 0;
const hasUser = userSkillItems.length > 0;
const activeCount = (hasAgent ? 1 : 0) + (hasProject ? 1 : 0) + (hasUser ? 1 : 0);
const webData = useMemo(
() => data.filter((doc) => doc.category === AGENT_DOCUMENT_WEB_CATEGORY),
[data],
);
if (activeCount === 0) {
const documentsData = useMemo(
() => data.filter((doc) => doc.category === AGENT_DOCUMENT_CATEGORY),
[data],
);
const skillBundleViews = useMemo(() => buildSkillBundleViews(data), [data]);
const skillItems = useMemo<SkillListItem[]>(
() =>
skillBundleViews.map(({ bundle, files }) => ({
description: bundle.description ?? undefined,
fileCount: files.length,
files,
id: bundle.documentId,
name: bundle.title || bundle.filename || '',
})),
[skillBundleViews],
);
if (!agentId) return null;
if (isLoading) {
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
<Center flex={1} paddingBlock={24}>
<NeuralNetworkLoading size={32} />
</Center>
);
}
const flat = activeCount === 1;
if (error) {
return (
<Center flex={1} paddingBlock={24}>
<Text type={'danger'}>{t('workingPanel.resources.error')}</Text>
</Center>
);
}
return (
<Flexbox gap={16} style={{ paddingBottom: 16 }}>
{hasAgent &&
(flat ? (
renderAgentSkillsList()
) : (
<SkillSection
sectionHeader={{
count: skillItems.length,
title: t('workingPanel.skills.section.agent'),
}}
>
{renderAgentSkillsList()}
</SkillSection>
))}
{hasProject && (
<ProjectLevelSkills hideHeader={flat} workingDirectory={workingDirectory!} />
)}
{hasUser && <UserLevelSkills hideHeader={flat} />}
</Flexbox>
);
};
const renderDocuments = () => (
// Always render the tree for the Documents tab even when empty, so the
// toolbar (new folder / new doc) stays reachable.
<Flexbox flex={1} style={{ minHeight: 0 }}>
<DocumentExplorerTree
agentId={agentId}
data={documentsData}
mutate={mutate}
style={{ height: '100%' }}
const renderAgentSkillsList = () => (
<SkillsList
items={skillItems}
onOpenFile={(item, relativePath) => {
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
const docId = view?.pathToDocumentId.get(relativePath);
if (!docId) return;
const row = data.find((d) => d.documentId === docId);
openDocument(docId, row?.id);
}}
onOpenSkill={(item) => {
// Open the SKILL.md (skills/index child) when present; fall back to
// the bundle itself (orphan bundles surface for recovery).
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
const indexChild = data.find((doc) => doc.parentId === item.id && doc.isSkillIndex);
const targetDocId = indexChild?.documentId ?? view?.bundle.documentId ?? item.id;
const targetRow = data.find((d) => d.documentId === targetDocId);
openDocument(targetDocId, targetRow?.id);
}}
onSkillDragStart={(item, event) => {
// The runtime resolves these via the `agent-skills:<filename>`
// identifier (built from the shared const helper so the prefix stays
// in lockstep with the server-side resolver). Display label keeps
// the human-readable title.
const view = skillBundleViews.find((v) => v.bundle.documentId === item.id);
const filename = view?.bundle.filename;
if (!filename) return;
startSkillDrag(event, {
category: 'agentSkill',
label: item.name,
type: buildAgentSkillIdentifier(filename),
});
}}
/>
</Flexbox>
);
);
const renderSkills = () => {
// Sections render in fixed order — agent → project → user — and each one
// hides itself when it has nothing to show. When exactly one source has
// items we drop the group header and render the list flat (no redundant
// "User skills 1" label above a single row). When everything is empty we
// fall back to a single placeholder.
const hasAgent = skillItems.length > 0;
const hasProject = showProjectSkills && projectSkillItems.length > 0;
const hasUser = userSkillItems.length > 0;
const activeCount = (hasAgent ? 1 : 0) + (hasProject ? 1 : 0) + (hasUser ? 1 : 0);
if (activeCount === 0) {
// Project skills refetch on a working-directory switch (new SWR key →
// empty items while in flight). Show the loader instead of flashing the
// empty placeholder when there's nothing else to render yet.
if (showProjectSkills && isProjectSkillsLoading) {
return (
<Center flex={1} paddingBlock={24}>
<NeuralNetworkLoading size={32} />
</Center>
);
}
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.skills.empty')} icon={SkillsIcon} />
</Center>
);
}
const flat = activeCount === 1;
const renderWeb = () => {
if (webData.length === 0) {
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.resources.empty')} icon={GlobeIcon} />
</Center>
<Flexbox gap={16} style={{ paddingBottom: 16 }}>
{hasAgent &&
(flat ? (
renderAgentSkillsList()
) : (
<SkillSection
sectionHeader={{
count: skillItems.length,
title: t('workingPanel.skills.section.agent'),
}}
>
{renderAgentSkillsList()}
</SkillSection>
))}
{hasProject && (
<ProjectLevelSkills
deviceId={deviceId}
hideHeader={flat}
workingDirectory={workingDirectory!}
/>
)}
{hasUser && <UserLevelSkills hideHeader={flat} />}
</Flexbox>
);
}
return (
<Flexbox gap={8}>
{webData.map((doc) => (
<DocumentItem agentId={agentId} document={doc} key={doc.id} mutate={mutate} />
))}
};
const renderDocuments = () => (
// Always render the tree for the Documents tab even when empty, so the
// toolbar (new folder / new doc) stays reachable.
<Flexbox flex={1} style={{ minHeight: 0 }}>
<DocumentExplorerTree
agentId={agentId}
data={documentsData}
mutate={mutate}
style={{ height: '100%' }}
/>
</Flexbox>
);
};
return (
<Flexbox gap={12} style={style}>
<Flexbox horizontal gap={4} role={'tablist'}>
{FILTER_OPTIONS.map((option) => {
const active = filter === option.value;
return (
<div
aria-selected={active}
className={cx(styles.pillTab, active && styles.pillActive)}
key={option.value}
role={'tab'}
onClick={() => setFilter(option.value)}
>
{t(option.labelKey)}
</div>
);
})}
const renderWeb = () => {
if (webData.length === 0) {
return (
<Center flex={1} gap={8} paddingBlock={24}>
<Empty description={t('workingPanel.resources.empty')} icon={GlobeIcon} />
</Center>
);
}
return (
<Flexbox gap={8}>
{webData.map((doc) => (
<DocumentItem agentId={agentId} document={doc} key={doc.id} mutate={mutate} />
))}
</Flexbox>
);
};
return (
<Flexbox gap={12} style={style}>
<Flexbox horizontal gap={4} role={'tablist'}>
{FILTER_OPTIONS.map((option) => {
const active = filter === option.value;
return (
<div
aria-selected={active}
className={cx(styles.pillTab, active && styles.pillActive)}
key={option.value}
role={'tab'}
onClick={() => setFilter(option.value)}
>
{t(option.labelKey)}
</div>
);
})}
</Flexbox>
{filter === 'skills' && renderSkills()}
{filter === 'documents' && renderDocuments()}
{filter === 'web' && renderWeb()}
</Flexbox>
{filter === 'skills' && renderSkills()}
{filter === 'documents' && renderDocuments()}
{filter === 'web' && renderWeb()}
</Flexbox>
);
});
);
},
);
AgentDocumentsGroup.displayName = 'AgentDocumentsGroup';
@@ -5,6 +5,8 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill
import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList';
interface ProjectLevelSkillsProps {
/** Bound remote device id; when set, skills are scanned over RPC. */
deviceId?: string;
/**
* Skip the `SkillSection` wrapper (no header row). Set when the parent has
* collapsed to a single visible source and wants the list rendered flat.
@@ -13,42 +15,44 @@ interface ProjectLevelSkillsProps {
workingDirectory: string;
}
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(({ hideHeader, workingDirectory }) => {
const { t } = useTranslation('chat');
const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory);
const ProjectLevelSkills = memo<ProjectLevelSkillsProps>(
({ deviceId, hideHeader, workingDirectory }) => {
const { t } = useTranslation('chat');
const { items, onOpenFile, onOpenSkill } = useProjectSkills(workingDirectory, deviceId);
if (items.length === 0) return null;
if (items.length === 0) return null;
const list = (
<SkillsList
items={items}
onOpenFile={onOpenFile}
onOpenSkill={onOpenSkill}
onSkillDragStart={(item, event) => {
// Project skills are resolved by the underlying CLI agent itself, so
// we serialize them as a literal `/skill-name` (projectSkill chip).
startSkillDrag(event, {
category: 'projectSkill',
label: item.name,
type: item.name,
});
}}
/>
);
const list = (
<SkillsList
items={items}
onOpenFile={onOpenFile}
onOpenSkill={onOpenSkill}
onSkillDragStart={(item, event) => {
// Project skills are resolved by the underlying CLI agent itself, so
// we serialize them as a literal `/skill-name` (projectSkill chip).
startSkillDrag(event, {
category: 'projectSkill',
label: item.name,
type: item.name,
});
}}
/>
);
if (hideHeader) return list;
if (hideHeader) return list;
return (
<SkillSection
sectionHeader={{
count: items.length,
title: t('workingPanel.skills.section.project'),
}}
>
{list}
</SkillSection>
);
});
return (
<SkillSection
sectionHeader={{
count: items.length,
title: t('workingPanel.skills.section.project'),
}}
>
{list}
</SkillSection>
);
},
);
ProjectLevelSkills.displayName = 'ProjectLevelSkills';
@@ -6,14 +6,19 @@ import { startSkillDrag } from '@/features/ChatInput/InputEditor/ActionTag/skill
import { SkillSection, SkillsList, useProjectSkills } from '@/features/SkillsList';
interface SkillsGroupProps {
/** Bound remote device id; when set, skills are scanned over RPC. */
deviceId?: string;
workingDirectory: string;
}
const SkillsGroup = memo<SkillsGroupProps>(({ workingDirectory }) => {
const SkillsGroup = memo<SkillsGroupProps>(({ deviceId, workingDirectory }) => {
const { t } = useTranslation('chat');
const enabled = isDesktop && !!workingDirectory;
// Local desktop reads over IPC; a bound device reads over RPC. Either path
// makes the skills list reachable even when this client isn't the desktop.
const enabled = (isDesktop || !!deviceId) && !!workingDirectory;
const { isLoading, items, onOpenFile, onOpenSkill } = useProjectSkills(
enabled ? workingDirectory : undefined,
deviceId,
);
if (!enabled) return null;
@@ -1,22 +1,27 @@
import { Flexbox } from '@lobehub/ui';
import { memo } from 'react';
import { useEffectiveWorkingDirectory } from '@/hooks/useEffectiveWorkingDirectory';
import { useAgentStore } from '@/store/agent';
import { agentByIdSelectors, agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { agentSelectors } from '@/store/agent/selectors';
import AgentDocumentsGroup from './AgentDocumentsGroup';
import SkillsGroup from './SkillsGroup';
const ResourcesSection = memo(() => {
interface ResourcesSectionProps {
/** Bound remote device id (device mode); skills are then scanned over RPC. */
deviceId?: string;
}
const ResourcesSection = memo<ResourcesSectionProps>(({ deviceId }) => {
const isHetero = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const agentWorkingDirectory = useAgentStore((s) =>
activeAgentId ? agentByIdSelectors.getAgentWorkingDirectoryById(activeAgentId)(s) : undefined,
);
const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory);
const workingDirectory = topicWorkingDirectory || agentWorkingDirectory;
// Resolve the cwd the same way the runtime bar / WorkingSidebar do
// (`useEffectiveWorkingDirectory`). The old `topicCwd || agentCwd` pattern
// missed `workingDirByDevice[deviceId]` / `device.defaultCwd`, so a
// device-bound agent resolved to `undefined` here and the skills fetch never
// fired even though `deviceId` was set.
const workingDirectory = useEffectiveWorkingDirectory(activeAgentId);
return (
<Flexbox
@@ -27,9 +32,12 @@ const ResourcesSection = memo(() => {
paddingInline={'8px 12px'}
style={{ minHeight: 0 }}
>
{isHetero && workingDirectory && <SkillsGroup workingDirectory={workingDirectory} />}
{isHetero && workingDirectory && (
<SkillsGroup deviceId={deviceId} workingDirectory={workingDirectory} />
)}
{!isHetero && (
<AgentDocumentsGroup
deviceId={deviceId}
style={{ flex: 1, minHeight: 0 }}
workingDirectory={workingDirectory}
/>
@@ -200,7 +200,7 @@ const AgentWorkingSidebar = memo(() => {
width={'100%'}
>
<ProgressSection />
<ResourcesSection />
<ResourcesSection deviceId={remoteDeviceId} />
</Flexbox>
</Flexbox>
</Flexbox>
@@ -107,7 +107,7 @@ const PublishButton = memo<MarketPublishButtonProps>(({ action, onPublishSuccess
if (!isAuthenticated) {
try {
await signIn();
await signIn('publish');
// After authentication, proceed with ownership check and publish
await doPublish();
} catch (error) {
@@ -160,7 +160,7 @@ const Header = memo(() => {
onOk: async () => {
if (!isAuthenticated) {
try {
await signIn();
await signIn('publish');
await doPublish();
} catch (error) {
console.error(`[MarketPublishButton][${action}] Authorization failed:`, error);
@@ -42,7 +42,7 @@ const ActionButton = memo(() => {
// If this is a cloud MCP and user is not authenticated, request authorization first
if (isCloudMcp && !isAuthenticated) {
try {
await signIn();
await signIn('mcp');
} catch {
return; // Don't proceed with installation if auth fails
}
@@ -56,7 +56,7 @@ const PublishButton = memo<MarketPublishButtonProps>(
content: t('messages.loading', { ns: 'marketAuth' }),
key: 'market-auth',
});
const accountId = await signIn();
const accountId = await signIn('publish');
message.success({ content: buttonConfig.authSuccessMessage, key: 'market-auth' });
// Check ownership after authentication if marketIdentifier exists
@@ -107,7 +107,7 @@ const PublishButton = memo<GroupPublishButtonProps>(({ action, onPublishSuccess
if (!isAuthenticated) {
try {
await signIn();
await signIn('publish');
// After authentication, proceed with ownership check and publish
await doPublish();
} catch (error) {
@@ -20,7 +20,6 @@ import type React from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import AddSkillButton from '@/features/SkillStore/SkillList/AddSkillButton';
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
import { useToolStore } from '@/store/tool';
@@ -333,7 +332,6 @@ const SkillList = memo<SkillListProps>(
return (
<Center className={styles.container} paddingBlock={48}>
<Empty description={t('tab.skillDesc')} icon={SkillsIcon} title={t('tab.skillEmpty')} />
<AddSkillButton />
</Center>
);
}
@@ -561,9 +559,6 @@ const SkillList = memo<SkillListProps>(
renderUserAgentSkills(),
)}
<div style={{ marginTop: 8 }}>
<AddSkillButton />
</div>
</div>
);
},
+46 -15
View File
@@ -1,13 +1,17 @@
'use client';
import { Button, Icon } from '@lobehub/ui';
import { Button, DropdownMenu, Flexbox, Icon, Text } from '@lobehub/ui';
import { GithubIcon } from '@lobehub/ui/icons';
import { createStaticStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { Plus, Store } from 'lucide-react';
import { ChevronDown, FileArchive, Grid2x2Plus, Link, Store } from 'lucide-react';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AddConnectorModal } from '@/features/Connectors';
import ImportFromGithubModal from '@/features/SkillStore/SkillList/ImportFromGithubModal';
import ImportFromUrlModal from '@/features/SkillStore/SkillList/ImportFromUrlModal';
import UploadSkillModal from '@/features/SkillStore/SkillList/UploadSkillModal';
import NavHeader from '@/features/NavHeader';
import { createSkillStoreModal } from '@/features/SkillStore';
import { useToolStore } from '@/store/tool';
@@ -85,6 +89,9 @@ const Page = memo(() => {
const [selected, setSelected] = useState<SelectedTool | null>(null);
const [viewMode, setViewMode] = useState<SkillViewMode>('connector');
const [showAddConnector, setShowAddConnector] = useState(false);
const [showUrlModal, setUrlModal] = useState(false);
const [showGithubModal, setGithubModal] = useState(false);
const [showUploadModal, setUploadModal] = useState(false);
// Data sources for auto-select
const builtinTools = useToolStore((s) => s.builtinTools, isEqual);
@@ -93,7 +100,6 @@ const Page = memo(() => {
(s) => builtinToolSelectors.installedAllMetaList(s).map((tool) => tool.identifier),
isEqual,
);
// Auto-select first item when view changes or on load
useEffect(() => {
setSelected(null);
@@ -147,18 +153,40 @@ const Page = memo(() => {
</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{viewMode === 'connector' && (
<Button
icon={<Icon icon={Plus} />}
size="small"
title={t('connector.add.title', {
defaultValue: 'Add custom connector',
ns: 'tool',
})}
onClick={() => setShowAddConnector(true)}
/>
)}
<div style={{ display: 'flex', gap: 6 }} onClick={(e) => e.stopPropagation()}>
<DropdownMenu
nativeButton={false}
placement="bottomRight"
items={[
{
icon: <Icon icon={Link} />,
key: 'importUrl',
label: <Flexbox gap={2}><span>{t('tab.importFromUrl')}</span><Text style={{ fontSize: 12 }} type="secondary">{t('tab.importFromUrl.desc')}</Text></Flexbox>,
onClick: () => setUrlModal(true),
},
{
icon: <Icon icon={GithubIcon} />,
key: 'importGithub',
label: <Flexbox gap={2}><span>{t('tab.importFromGithub')}</span><Text style={{ fontSize: 12 }} type="secondary">{t('tab.importFromGithub.desc')}</Text></Flexbox>,
onClick: () => setGithubModal(true),
},
{
icon: <Icon icon={FileArchive} />,
key: 'uploadZip',
label: <Flexbox gap={2}><span>{t('tab.uploadZip')}</span><Text style={{ fontSize: 12 }} type="secondary">{t('tab.uploadZip.desc')}</Text></Flexbox>,
onClick: () => setUploadModal(true),
},
{ type: 'divider' as const },
{
icon: <Icon icon={Grid2x2Plus} />,
key: 'addConnector',
label: <Flexbox gap={2}><span>{t('connector.add.title', { defaultValue: 'Add Custom Connector', ns: 'tool' })}</span></Flexbox>,
onClick: () => setShowAddConnector(true),
},
]}
>
<Button icon={Grid2x2Plus} size="small" />
</DropdownMenu>
<Button icon={<Icon icon={Store} />} size="small" onClick={handleOpenStore} />
</div>
</div>
@@ -179,6 +207,9 @@ const Page = memo(() => {
</div>
)}
</div>
<ImportFromUrlModal open={showUrlModal} onOpenChange={setUrlModal} />
<ImportFromGithubModal open={showGithubModal} onOpenChange={setGithubModal} />
<UploadSkillModal open={showUploadModal} onOpenChange={setUploadModal} />
<AddConnectorModal open={showAddConnector} onClose={() => setShowAddConnector(false)} />
</>
);
@@ -790,6 +790,23 @@ export const createRuntimeExecutors = (
// {{sandbox_enabled}} — mirrors client-side check for lobe-cloud-sandbox.
const sandboxEnabled = String(resolved.enabledToolIds.includes('lobe-cloud-sandbox'));
// {{sandbox_uploaded_files}} — lists the topic/session files that are
// synced into the sandbox upload dir, so the agent knows they exist.
// Mirrors the bootstrap query in SandboxMiddlewareService.
let sandboxUploadedFiles = '';
if (sandboxEnabled === 'true' && ctx.serverDB && ctx.userId && lobehubSkillTopicId) {
try {
const { FileModel } = await import('@/database/models/file');
const { formatUploadedFilesPrompt } =
await import('@lobechat/builtin-tool-cloud-sandbox');
const fileModel = new FileModel(ctx.serverDB, ctx.userId);
const uploadedFiles = await fileModel.findFilesToInitInSandbox(lobehubSkillTopicId);
sandboxUploadedFiles = formatUploadedFilesPrompt(uploadedFiles);
} catch (error) {
log('Failed to resolve files for {{sandbox_uploaded_files}} substitution: %O', error);
}
}
// {{session_date}} — current date formatted for user's timezone.
const sessionDate = new Intl.DateTimeFormat('en-US', {
day: 'numeric',
@@ -879,6 +896,7 @@ export const createRuntimeExecutors = (
session_date: sessionDate,
// Creds tool variables
sandbox_enabled: sandboxEnabled,
sandbox_uploaded_files: sandboxUploadedFiles,
CREDS_LIST: credsListStr,
KLAVIS_SERVICES_LIST: klavisServicesListStr,
// Memory tool variables
+16
View File
@@ -270,6 +270,22 @@ export const deviceRouter = router({
return result ?? null;
}),
/**
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
* remote device, via the device's `listProjectSkills` RPC. Powers the
* Resources tab's skills group in device mode. Returns `null` when offline.
*/
listProjectSkills: deviceProcedure
.input(z.object({ deviceId: z.string(), scope: z.string() }))
.query(async ({ ctx, input }) => {
const result = await deviceGateway.listProjectSkills({
deviceId: input.deviceId,
scope: input.scope,
userId: ctx.userId,
});
return result ?? null;
}),
/**
* Revert a single file in a directory on a remote device, via the device's
* `revertGitFile` RPC.
+1
View File
@@ -233,6 +233,7 @@ const execInSandboxHandler = async ({
const sandboxService = createSandboxService({
fileService: ctx.fileService,
marketService: ctx.marketService,
serverDB: ctx.serverDB,
topicId,
userId,
});
+11 -4
View File
@@ -807,14 +807,21 @@ export class AiAgentService {
});
// Create an assistant message placeholder (shows spinner in the UI).
// For remote hetero agents (openclaw/hermes), override provider with the hetero type
// so the frontend can identify the platform and display the correct name in the model tag.
// Use the hetero type as the provider so the frontend can identify the
// platform and render the correct name in the model tag — for ALL hetero
// agents, not just remote ones. The agent's configured chat model/provider
// (e.g. deepseek) is meaningless for a CLI run: the real model is reported
// by the CLI via `stream_start` / `turn_metadata` and backfilled by
// `HeterogeneousPersistenceHandler`. Seeding the placeholder with the agent
// model leaked it into the model tag (and got re-applied at terminal) on
// the device / sandbox path; mirror the client (`conversationLifecycle`),
// which sets only the provider and leaves the model empty until the CLI
// reports it.
const assistantMsg = await this.messageModel.create({
agentId: resolvedAgentId,
content: LOADING_FLAT,
model,
parentId: parentMessageId ?? userMsg?.id,
provider: isRemoteHetero ? heteroType : provider,
provider: heteroType,
role: 'assistant',
threadId: appContext?.threadId ?? undefined,
topicId,
@@ -560,6 +560,120 @@ describe('DeviceGateway', () => {
});
});
describe('listProjectSkills', () => {
const configure = () => {
mockEnv.DEVICE_GATEWAY_URL = 'https://gateway.example.com';
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
};
it('should return undefined when not configured', async () => {
const proxy = new DeviceGateway();
const result = await proxy.listProjectSkills({
deviceId: 'dev-1',
scope: '/proj',
userId: 'user-1',
});
expect(result).toBeUndefined();
expect(mockClient.invokeRpc).not.toHaveBeenCalled();
});
it('passes the device result through and invokes the rpc with scope', async () => {
configure();
const data = {
root: '/proj',
skills: [
{
description: 'spa',
fileCount: 3,
files: ['SKILL.md'],
name: 'spa-routes',
path: '/proj/.agents/skills/spa-routes/SKILL.md',
skillDir: '/proj/.agents/skills/spa-routes',
source: '.agents/skills',
},
],
source: '.agents/skills',
};
mockClient.invokeRpc.mockResolvedValue({ data, success: true });
const proxy = new DeviceGateway();
const result = await proxy.listProjectSkills({
deviceId: 'dev-1',
scope: '/proj',
userId: 'user-1',
});
expect(result).toEqual(data);
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
{ deviceId: 'dev-1', timeout: 30_000, userId: 'user-1' },
{ method: 'listProjectSkills', params: { scope: '/proj' } },
);
});
it('returns undefined when the rpc reports failure', async () => {
configure();
mockClient.invokeRpc.mockResolvedValue({ error: 'offline', success: false });
const proxy = new DeviceGateway();
const result = await proxy.listProjectSkills({
deviceId: 'dev-1',
scope: '/proj',
userId: 'user-1',
});
expect(result).toBeUndefined();
});
it('returns undefined when the rpc succeeds without data', async () => {
configure();
mockClient.invokeRpc.mockResolvedValue({ success: true });
const proxy = new DeviceGateway();
const result = await proxy.listProjectSkills({
deviceId: 'dev-1',
scope: '/proj',
userId: 'user-1',
});
expect(result).toBeUndefined();
});
it('returns undefined on exception', async () => {
configure();
mockClient.invokeRpc.mockRejectedValue(new Error('timeout'));
const proxy = new DeviceGateway();
const result = await proxy.listProjectSkills({
deviceId: 'dev-1',
scope: '/proj',
userId: 'user-1',
});
expect(result).toBeUndefined();
});
it('forwards a custom timeout', async () => {
configure();
mockClient.invokeRpc.mockResolvedValue({
data: { root: '/proj', skills: [], source: null },
success: true,
});
const proxy = new DeviceGateway();
await proxy.listProjectSkills({
deviceId: 'dev-1',
scope: '/proj',
timeout: 60_000,
userId: 'user-1',
});
expect(mockClient.invokeRpc).toHaveBeenCalledWith(
{ deviceId: 'dev-1', timeout: 60_000, userId: 'user-1' },
{ method: 'listProjectSkills', params: { scope: '/proj' } },
);
});
});
describe('getClient (lazy initialization)', () => {
it('should return null when URL is missing', async () => {
mockEnv.DEVICE_GATEWAY_SERVICE_TOKEN = 'token';
@@ -21,6 +21,7 @@ import type {
DeviceGitWorkingTreeFiles,
DeviceGitWorkingTreePatches,
DeviceGitWorkingTreeStatus,
DeviceListProjectSkillsResult,
DeviceProjectFileIndexResult,
ProjectSkillMeta,
WorkspaceInitResult,
@@ -465,6 +466,41 @@ export class DeviceGateway {
}
}
/**
* Project skills (`.agents/skills` / `.claude/skills`) for a directory on a
* remote device via the `listProjectSkills` device RPC the Resources tab's
* skills group in device mode. Mirrors `getProjectFileIndex`; returns
* `undefined` when the gateway is unconfigured, the device is offline, or the
* call fails so the UI degrades to "no skills".
*/
async listProjectSkills(params: {
deviceId: string;
scope: string;
timeout?: number;
userId: string;
}): Promise<DeviceListProjectSkillsResult | undefined> {
const { userId, deviceId, scope, timeout = 30_000 } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceListProjectSkillsResult>(
{ deviceId, timeout, userId },
{ method: 'listProjectSkills', params: { scope } },
);
if (!result.success || !result.data) {
log('listProjectSkills: failed for deviceId=%s — %s', deviceId, result.error);
return undefined;
}
return result.data;
} catch (error) {
log('listProjectSkills: error for deviceId=%s — %O', deviceId, error);
return undefined;
}
}
/**
* List the remote branches (`refs/remotes/origin/*`) of a directory on a
* remote device via the `listGitRemoteBranches` device RPC, so the web/remote
@@ -675,6 +675,8 @@ export class HeterogeneousPersistenceHandler {
case 'stream_start': {
if (event.data?.newStep) {
await this.handleStepStart(state);
} else {
await this.handleStreamInit(state, event);
}
return;
}
@@ -701,6 +703,33 @@ export class HeterogeneousPersistenceHandler {
// ─── Per-event handlers ──────────────────────────────────────────────────
/**
* The adapter's FIRST `stream_start` (CC's system/init, `newStep` unset)
* carries the CLI's authoritative model/provider (e.g. claude-sonnet-x /
* 'claude-code'). Capture it into step state and backfill the placeholder
* assistant so the model tag shows the real CLI model from the very first
* turn even before (or entirely without) any usage-bearing `turn_metadata`.
*
* The placeholder is created with only `provider: heteroType` and no model
* (see `aiAgent.execAgent`), so without this the first turn would render an
* empty model until `turn_metadata` lands, and a usage-less run would never
* resolve a real model at all.
*/
private async handleStreamInit(state: OperationState, event: AgentStreamEvent) {
const { model, provider } = event.data ?? {};
const update: Record<string, any> = {};
if (model) {
state.lastModel = model;
update.model = model;
}
if (provider) {
state.lastProvider = provider;
update.provider = provider;
}
if (Object.keys(update).length === 0) return;
await this.deps.messageModel.update(state.currentAssistantMessageId, update);
}
private async handleTurnMetadata(state: OperationState, event: AgentStreamEvent) {
const { model, provider, usage } = event.data ?? {};
const subagentCtx = (event.data as any)?.subagent as SubagentEventContext | undefined;
@@ -265,19 +265,35 @@ describe('HeterogeneousPersistenceHandler — event branch coverage', () => {
// ─── stream_start ─────────────────────────────────────────────────────────
describe('stream_start', () => {
it('without newStep is a no-op (orchestrator already seeded the assistant id)', async () => {
it('without newStep (CLI init) backfills the placeholder with the CLI model/provider', async () => {
const h = createHarness();
const beforeUpdates = h.messageModel.update.mock.calls.length;
const beforeCreates = h.messageModel.create.mock.calls.length;
await ingest(h, [
buildEvent('stream_start', 0, {
assistantMessage: { id: 'asst-from-event' },
model: 'foo',
provider: 'bar',
model: 'claude-sonnet-4-5',
provider: 'claude-code',
}),
]);
// The init event carries the CLI's authoritative model/provider — it must
// backfill the placeholder (which was created with only `provider`, no
// model) so the model tag shows the real CLI model from the first turn,
// even without any usage-bearing turn_metadata.
const asst = h.messages.get(h.assistantMessageId);
expect(asst?.model).toBe('claude-sonnet-4-5');
expect(asst?.provider).toBe('claude-code');
// No new assistant row is created — only the placeholder is patched.
expect(h.messageModel.create.mock.calls.length).toBe(beforeCreates);
});
it('without newStep and no model/provider is a no-op', async () => {
const h = createHarness();
const beforeUpdates = h.messageModel.update.mock.calls.length;
const beforeCreates = h.messageModel.create.mock.calls.length;
await ingest(h, [buildEvent('stream_start', 0, {})]);
expect(h.messageModel.update.mock.calls.length).toBe(beforeUpdates);
expect(h.messageModel.create.mock.calls.length).toBe(beforeCreates);
});
@@ -0,0 +1,44 @@
import { SANDBOX_UPLOADED_FILES_DIR } from '@lobechat/builtin-tool-cloud-sandbox';
import { describe, expect, it } from 'vitest';
import { buildSandboxFilesInitCommand, SANDBOX_FILES_INIT_MARKER } from '../bootstrap';
describe('buildSandboxFilesInitCommand', () => {
it('only ensures the dir when there is nothing to download', () => {
expect(buildSandboxFilesInitCommand([])).toBe(`mkdir -p '${SANDBOX_UPLOADED_FILES_DIR}'`);
});
it('wraps downloads in an idempotent marker guard', () => {
const command = buildSandboxFilesInitCommand([
{ name: 'data.csv', url: 'https://files.example.com/a' },
]);
expect(command).toContain(`if [ ! -f '${SANDBOX_FILES_INIT_MARKER}' ]; then`);
expect(command).toContain(
`curl -fsSL 'https://files.example.com/a' -o '${SANDBOX_UPLOADED_FILES_DIR}/data.csv' || true`,
);
expect(command).toContain(`touch '${SANDBOX_FILES_INIT_MARKER}'`);
});
it('de-dupes downloads that resolve to the same sandbox path', () => {
const command = buildSandboxFilesInitCommand([
{ name: 'a/data.csv', url: 'https://files.example.com/a' },
{ name: 'b/data.csv', url: 'https://files.example.com/b' },
]);
const curlCount = command.split('curl ').length - 1;
expect(curlCount).toBe(1);
});
it('skips entries without a download url', () => {
const command = buildSandboxFilesInitCommand([{ name: 'data.csv', url: '' }]);
expect(command).toBe(`mkdir -p '${SANDBOX_UPLOADED_FILES_DIR}'`);
});
it('escapes single quotes in names and urls', () => {
const command = buildSandboxFilesInitCommand([{ name: "o'brien.txt", url: "https://x/a'b" }]);
expect(command).toContain(String.raw`o'\''brien.txt`);
expect(command).toContain(String.raw`'https://x/a'\''b'`);
});
});
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { LobeChatDatabase } from '@/database/type';
import type { FileService } from '@/server/services/file';
import type { MarketService } from '@/server/services/market';
import { SandboxMiddlewareService } from '../service';
import type { SandboxProvider } from '../types';
const findFilesToInitInSandbox = vi.fn();
vi.mock('@/database/models/file', () => ({
FileModel: vi.fn().mockImplementation(() => ({ findFilesToInitInSandbox })),
}));
const createProvider = (): SandboxProvider =>
({
capabilities: {
backgroundCommands: true,
exportFile: true,
files: true,
languages: ['python'],
persistentSession: true,
shell: true,
skillScripts: true,
},
callTool: vi.fn(async () => ({ result: {}, success: true })),
exportFileToUploadUrl: vi.fn(),
kind: 'onlyboxes',
}) satisfies SandboxProvider;
const createFileService = (): FileService =>
({
createCachedPreSignedUrlForPreview: vi.fn(async () => 'https://download.example.com/x'),
}) as unknown as FileService;
const baseOptions = () => ({
fileService: createFileService(),
marketService: {} as MarketService,
serverDB: {} as LobeChatDatabase,
topicId: 'topic-1',
userId: 'user-1',
});
describe('SandboxMiddlewareService file initialization', () => {
beforeEach(() => {
findFilesToInitInSandbox.mockReset();
findFilesToInitInSandbox.mockResolvedValue([
{ fileType: 'text/csv', id: 'f1', name: 'data.csv', size: 10, url: 'key-1' },
]);
});
it('syncs uploaded files into the sandbox before the first tool call', async () => {
const provider = createProvider();
const service = new SandboxMiddlewareService(provider, baseOptions());
await service.callTool('listFiles', { directoryPath: '/mnt/data' });
expect(findFilesToInitInSandbox).toHaveBeenCalledWith('topic-1');
expect(provider.callTool).toHaveBeenNthCalledWith(
1,
'runCommand',
expect.objectContaining({ command: expect.stringContaining('curl') }),
);
expect(provider.callTool).toHaveBeenNthCalledWith(2, 'listFiles', {
directoryPath: '/mnt/data',
});
});
it('only runs the sync once per service instance', async () => {
const provider = createProvider();
const service = new SandboxMiddlewareService(provider, baseOptions());
await service.callTool('listFiles', {});
await service.callTool('readFile', { path: '/mnt/data/data.csv' });
const runCommandCalls = (provider.callTool as ReturnType<typeof vi.fn>).mock.calls.filter(
([tool]) => tool === 'runCommand',
);
expect(runCommandCalls).toHaveLength(1);
});
it('skips the sync when there is no serverDB', async () => {
const provider = createProvider();
const service = new SandboxMiddlewareService(provider, {
...baseOptions(),
serverDB: undefined,
});
await service.callTool('listFiles', {});
expect(findFilesToInitInSandbox).not.toHaveBeenCalled();
expect(provider.callTool).toHaveBeenCalledTimes(1);
expect(provider.callTool).toHaveBeenCalledWith('listFiles', {});
});
it('does not sync when there are no uploaded files', async () => {
findFilesToInitInSandbox.mockResolvedValue([]);
const provider = createProvider();
const service = new SandboxMiddlewareService(provider, baseOptions());
await service.callTool('listFiles', {});
expect(provider.callTool).toHaveBeenCalledTimes(1);
expect(provider.callTool).toHaveBeenCalledWith('listFiles', {});
});
it('never blocks the tool call when the sync fails', async () => {
findFilesToInitInSandbox.mockRejectedValue(new Error('db down'));
const provider = createProvider();
const service = new SandboxMiddlewareService(provider, baseOptions());
await expect(service.callTool('listFiles', {})).resolves.toMatchObject({ success: true });
expect(provider.callTool).toHaveBeenCalledWith('listFiles', {});
});
it('skips files exceeding the size cap, matching what the prompt advertises', async () => {
findFilesToInitInSandbox.mockResolvedValue([
{
fileType: 'application/zip',
id: 'big',
name: 'huge.zip',
size: 200 * 1024 * 1024,
url: 'k',
},
]);
const provider = createProvider();
const service = new SandboxMiddlewareService(provider, baseOptions());
await service.callTool('listFiles', {});
// oversized file is filtered out → nothing to download → only the real tool runs
expect(provider.callTool).toHaveBeenCalledTimes(1);
expect(provider.callTool).toHaveBeenCalledWith('listFiles', {});
});
});
+48
View File
@@ -0,0 +1,48 @@
import {
SANDBOX_UPLOADED_FILES_DIR,
sandboxUploadedFilePath,
} from '@lobechat/builtin-tool-cloud-sandbox';
/** Marker file written once the uploaded files have been synced for a session. */
export const SANDBOX_FILES_INIT_MARKER = `${SANDBOX_UPLOADED_FILES_DIR}/.lobe-files-initialized`;
/** Timeout (ms) for the bootstrap download command. */
export const SANDBOX_INIT_TIMEOUT_MS = 120_000;
export interface SandboxInitDownload {
name: string;
/** A download URL (e.g. presigned) the sandbox can fetch with curl. */
url: string;
}
const shellQuote = (value: string): string => `'${value.replaceAll("'", String.raw`'\''`)}'`;
/**
* Build an idempotent shell command that downloads the given uploaded files into
* the sandbox upload directory. A marker file guards re-runs, so the command is
* a cheap no-op once the files have been synced for the current session.
*
* Downloads are best-effort: a single failed fetch does not abort the rest, and
* the marker is always written so the sync is not retried on every tool call.
*/
export const buildSandboxFilesInitCommand = (downloads: SandboxInitDownload[]): string => {
const dir = shellQuote(SANDBOX_UPLOADED_FILES_DIR);
const marker = shellQuote(SANDBOX_FILES_INIT_MARKER);
const seen = new Set<string>();
const curls: string[] = [];
for (const { name, url } of downloads) {
if (!url) continue;
const path = sandboxUploadedFilePath(name);
if (seen.has(path)) continue;
seen.add(path);
curls.push(`curl -fsSL ${shellQuote(url)} -o ${shellQuote(path)} || true`);
}
if (curls.length === 0) return `mkdir -p ${dir}`;
const body = [...curls, `touch ${marker}`].join('; ');
return `mkdir -p ${dir}; if [ ! -f ${marker} ]; then ${body}; fi`;
};
+74 -4
View File
@@ -1,10 +1,18 @@
import type {
SandboxCallToolResult,
SandboxExportFileResult,
import {
type SandboxCallToolResult,
type SandboxExportFileResult,
selectSandboxInitFiles,
} from '@lobechat/builtin-tool-cloud-sandbox';
import debug from 'debug';
import { sha256 } from 'js-sha256';
import { FileModel } from '@/database/models/file';
import {
buildSandboxFilesInitCommand,
SANDBOX_INIT_TIMEOUT_MS,
type SandboxInitDownload,
} from './bootstrap';
import type {
SandboxCommandResult,
SandboxProvider,
@@ -20,6 +28,8 @@ export class SandboxMiddlewareService implements SandboxService {
readonly capabilities: SandboxProviderCapabilities;
readonly kind: SandboxProviderKind;
private filesInitialized = false;
constructor(
private readonly provider: SandboxProvider,
private readonly options: SandboxServiceOptions,
@@ -28,10 +38,70 @@ export class SandboxMiddlewareService implements SandboxService {
this.kind = provider.kind;
}
callTool(toolName: string, params: Record<string, unknown>): Promise<SandboxCallToolResult> {
async callTool(
toolName: string,
params: Record<string, unknown>,
): Promise<SandboxCallToolResult> {
await this.ensureFilesInitialized();
return this.provider.callTool(toolName, params);
}
/**
* Sync the files the user uploaded in this topic/session into the sandbox the
* first time this service instance is used. Best-effort: any failure is
* swallowed so it never blocks the actual tool call.
*
* The downloaded command is guarded by an in-sandbox marker file, which is the
* single source of truth for idempotency: it is a cheap no-op once synced, and
* if the sandbox session is recycled the marker disappears so the next call
* re-syncs automatically. We intentionally do NOT cache the "done" state out of
* band (e.g. in Redis), because that could skip the re-sync after a recycle and
* leave the agent believing files exist when /mnt/data is empty.
*/
private async ensureFilesInitialized(): Promise<void> {
if (this.filesInitialized) return;
this.filesInitialized = true;
const { fileService, serverDB, topicId, userId } = this.options;
if (!serverDB || !fileService || !topicId || !userId) return;
if (!this.provider.capabilities.shell) return;
try {
const fileModel = new FileModel(serverDB, userId);
const files = selectSandboxInitFiles(await fileModel.findFilesToInitInSandbox(topicId));
if (files.length === 0) return;
const downloads = (
await Promise.all(
files.map(async (file): Promise<SandboxInitDownload | null> => {
const url = await fileService
.createCachedPreSignedUrlForPreview(file.url)
.catch(() => '');
return url ? { name: file.name, url } : null;
}),
)
).filter((item): item is SandboxInitDownload => item !== null);
if (downloads.length === 0) return;
const command = buildSandboxFilesInitCommand(downloads);
const result = await this.provider.callTool('runCommand', {
command,
timeout: SANDBOX_INIT_TIMEOUT_MS,
});
log(
'Sandbox file init for topic %s: %d files, success=%s',
topicId,
downloads.length,
result.success,
);
} catch (error) {
log('Sandbox file init failed for topic %s: %O', topicId, error);
}
}
async exportAndUploadFile(path: string, filename: string): Promise<SandboxExportFileResult> {
const { fileService, topicId } = this.options;
+3
View File
@@ -2,6 +2,7 @@ import type {
ISandboxService,
SandboxExportFileResult,
} from '@lobechat/builtin-tool-cloud-sandbox';
import type { LobeChatDatabase } from '@lobechat/database';
import type { FileService } from '@/server/services/file';
import type { MarketService } from '@/server/services/market';
@@ -16,6 +17,8 @@ export interface SandboxSessionContext {
export interface SandboxServiceOptions extends SandboxSessionContext {
fileService?: FileService;
marketService: MarketService;
/** Used to look up topic/session files when bootstrapping the sandbox. */
serverDB?: LobeChatDatabase;
}
export interface SandboxProviderCapabilities {
@@ -28,6 +28,7 @@ export const cloudSandboxRuntime: ServerRuntimeRegistration = {
const sandboxService = createSandboxService({
fileService,
marketService,
serverDB: context.serverDB,
topicId: context.topicId,
userId: context.userId,
});
@@ -19,6 +19,7 @@ import debug from 'debug';
import { AgentSkillModel } from '@/database/models/agentSkill';
import { FileModel } from '@/database/models/file';
import { UserModel } from '@/database/models/user';
import type { LobeChatDatabase } from '@/database/type';
import { filterBuiltinSkills } from '@/helpers/skillFilters';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { deviceGateway } from '@/server/services/deviceGateway';
@@ -44,6 +45,7 @@ class SkillServerRuntimeService implements SkillRuntimeService {
private marketService: MarketService;
private fileService: FileService;
private fileModel: FileModel;
private serverDB: LobeChatDatabase;
private topicId?: string;
private userId: string;
@@ -52,6 +54,7 @@ class SkillServerRuntimeService implements SkillRuntimeService {
fileService: FileService;
marketService: MarketService;
resourceService: SkillResourceService;
serverDB: LobeChatDatabase;
skillModel: AgentSkillModel;
topicId?: string;
userId: string;
@@ -61,6 +64,7 @@ class SkillServerRuntimeService implements SkillRuntimeService {
this.marketService = options.marketService;
this.fileService = options.fileService;
this.fileModel = options.fileModel;
this.serverDB = options.serverDB;
this.topicId = options.topicId;
this.userId = options.userId;
}
@@ -99,6 +103,7 @@ class SkillServerRuntimeService implements SkillRuntimeService {
const sandboxService = createSandboxService({
fileService: this.fileService,
marketService: this.marketService,
serverDB: this.serverDB,
topicId: this.topicId,
userId: this.userId,
});
@@ -181,6 +186,7 @@ class SkillServerRuntimeService implements SkillRuntimeService {
const sandboxService = createSandboxService({
fileService: this.fileService,
marketService: this.marketService,
serverDB: this.serverDB,
topicId: this.topicId,
userId: this.userId,
});
@@ -284,6 +290,7 @@ export const skillsRuntime: ServerRuntimeRegistration = {
fileService,
marketService,
resourceService,
serverDB: context.serverDB,
skillModel,
topicId: context.topicId,
userId: context.userId,
+26 -14
View File
@@ -1,6 +1,7 @@
import { LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator';
import { AgentBuilderIdentifier } from '@lobechat/builtin-tool-agent-builder';
import { AgentManagementIdentifier } from '@lobechat/builtin-tool-agent-management';
import { formatUploadedFilesPrompt } from '@lobechat/builtin-tool-cloud-sandbox';
import {
CredsIdentifier,
type CredSummary,
@@ -28,6 +29,7 @@ import type {
LobeToolManifest,
MemoryContext,
OnboardingContext,
OperationSkillSet,
PlanTodoConfig,
ToolDiscoveryConfig,
UserMemoryData,
@@ -57,7 +59,7 @@ import { getChatGroupStoreState } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import { getAiInfraStoreState } from '@/store/aiInfra';
import { getChatStoreState } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { getToolStoreState } from '@/store/tool';
import {
builtinToolSelectors,
@@ -642,6 +644,20 @@ export const contextEngineering = async ({
}
}
// Resolve enabled skills (await: pinned DB skills fetch their content on demand).
// In auto mode: expose all installed skills so the AI can discover and activate them.
// In manual mode: only expose user-selected skills (filtered by pluginIds).
let enabledSkills: OperationSkillSet['skills'] | undefined;
if (plugins) {
const skillSet = await resolveClientSkills(plugins);
if (isInAutoSkillMode) {
enabledSkills = skillSet.skills;
} else {
const selectedIds = new Set(plugins);
enabledSkills = skillSet.skills.filter((s) => selectedIds.has(s.identifier));
}
}
// Create MessagesEngine with injected dependencies
const engine = new MessagesEngine({
// Agent configuration
@@ -688,20 +704,9 @@ export const contextEngineering = async ({
// agent-document injectors when this is `false` (chat mode).
enableAgentMode: agentChatConfigSelectors.currentChatConfig(agentStoreState).enableAgentMode,
// Skills configuration
// In auto mode: expose all installed skills so the AI can discover and activate them
// In manual mode: only expose user-selected skills (filtered by pluginIds)
// Skills configuration (resolved above)
skillsConfig: {
enabledSkills: plugins
? (() => {
const skillSet = resolveClientSkills(plugins);
if (!isInAutoSkillMode) {
const selectedIds = new Set(plugins);
return skillSet.skills.filter((s) => selectedIds.has(s.identifier));
}
return skillSet.skills;
})()
: undefined,
enabledSkills,
},
// Tool Discovery configuration
@@ -735,6 +740,13 @@ export const contextEngineering = async ({
year: 'numeric',
}).format(new Date()),
sandbox_enabled: () => String(tools?.includes('lobe-cloud-sandbox') ?? false),
// NOTICE: required by builtin-tool-cloud-sandbox/src/systemRole.ts —
// lists the topic files synced into the sandbox upload dir. Read lazily
// from the chat store so we only pay the cost when the placeholder renders.
sandbox_uploaded_files: () =>
tools?.includes('lobe-cloud-sandbox')
? formatUploadedFilesPrompt(chatSelectors.currentUserFiles(getChatStoreState()))
: '',
// NOTICE(@nekomeowww): required by builtin-tool-memory/src/systemRole.ts
memory_effort: () => (userMemoryConfig ? (memoryContext?.effort ?? '') : ''),
// Current agent + topic identity — referenced by the LobeHub builtin
@@ -0,0 +1,184 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { agentSkillService } from '@/services/skill';
import { getToolStoreState } from '@/store/tool';
import { resolveClientSkills } from './skillEngineering';
vi.mock('@/store/tool', () => ({
getToolStoreState: vi.fn(),
}));
vi.mock('@/services/skill', () => ({
agentSkillService: {
getById: vi.fn(),
},
}));
// Keep all skills available in the test environment.
vi.mock('@/helpers/toolAvailability', () => ({
isBuiltinSkillAvailableInCurrentEnv: () => true,
}));
const mockedGetToolStoreState = vi.mocked(getToolStoreState);
const mockedGetById = vi.mocked(agentSkillService.getById);
const setToolState = (state: any) => {
mockedGetToolStoreState.mockReturnValue({
agentSkillDetailMap: {},
agentSkills: [],
builtinSkills: [],
...state,
} as any);
};
const findSkill = (
skills: { activated?: boolean; content?: string; identifier: string }[],
identifier: string,
) => skills.find((s) => s.identifier === identifier);
describe('resolveClientSkills', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('carries builtin skill content so pinned builtin skills can be injected', async () => {
setToolState({
builtinSkills: [
{
content: '<artifacts_guide>build UI</artifacts_guide>',
description: 'Generate interactive UI',
identifier: 'artifacts',
name: 'Artifacts',
source: 'builtin',
},
],
});
const result = await resolveClientSkills(['artifacts']);
expect(result.enabledPluginIds).toEqual(['artifacts']);
// activated must be set so SkillContextProvider injects content directly
// (the MessagesEngine path consumes these metas without running SkillResolver).
expect(findSkill(result.skills, 'artifacts')).toMatchObject({
activated: true,
content: '<artifacts_guide>build UI</artifacts_guide>',
identifier: 'artifacts',
});
});
it('fetches DB skill content for pinned skills', async () => {
setToolState({
agentSkills: [
{ description: 'A user skill', id: 'db-1', identifier: 'my-skill', name: 'My Skill' },
],
});
mockedGetById.mockResolvedValue({
content: 'full skill body',
id: 'db-1',
identifier: 'my-skill',
name: 'My Skill',
} as any);
const result = await resolveClientSkills(['my-skill']);
expect(mockedGetById).toHaveBeenCalledWith('db-1');
expect(findSkill(result.skills, 'my-skill')).toMatchObject({
activated: true,
content: 'full skill body',
identifier: 'my-skill',
});
});
it('appends the resource tree to pinned DB skill content', async () => {
setToolState({
agentSkills: [{ description: '', id: 'db-1', identifier: 'my-skill', name: 'My Skill' }],
});
mockedGetById.mockResolvedValue({
content: 'body',
id: 'db-1',
identifier: 'my-skill',
name: 'My Skill',
resources: { 'kb/readme.md': { fileHash: 'h', size: 1 } },
} as any);
const result = await resolveClientSkills(['my-skill']);
const skill = findSkill(result.skills, 'my-skill');
expect(skill?.content).toContain('body');
// resourcesTreePrompt output references the resource tree
expect(skill?.content).toContain('Available Resources');
expect(skill?.content).toContain('readme.md');
});
it('does NOT fetch content for non-pinned DB skills (auto mode bulk exposure)', async () => {
setToolState({
agentSkills: [
{ description: 'A user skill', id: 'db-1', identifier: 'my-skill', name: 'My Skill' },
],
});
// pluginIds empty => skill is exposed (available list) but not pinned
const result = await resolveClientSkills([]);
expect(mockedGetById).not.toHaveBeenCalled();
const skill = findSkill(result.skills, 'my-skill');
expect(skill?.content).toBeUndefined();
expect(skill?.activated).toBeFalsy();
});
it('does NOT pre-activate a pinned DB skill bundled as a ZIP', async () => {
// Bundled skills must go through activateSkill so the server mounts the bundle;
// pre-injecting content here would reference scripts/resources that are not mounted.
setToolState({
agentSkills: [
{
description: 'bundled',
id: 'db-1',
identifier: 'zip-skill',
name: 'Zip Skill',
zipFileHash: 'hash-abc',
},
],
});
const result = await resolveClientSkills(['zip-skill']);
expect(mockedGetById).not.toHaveBeenCalled();
const skill = findSkill(result.skills, 'zip-skill');
expect(skill?.content).toBeUndefined();
expect(skill?.activated).toBeFalsy();
});
it('prefers the cached skill detail over a network fetch', async () => {
setToolState({
agentSkillDetailMap: {
'db-1': { content: 'cached body', id: 'db-1', identifier: 'my-skill', name: 'My Skill' },
},
agentSkills: [{ description: '', id: 'db-1', identifier: 'my-skill', name: 'My Skill' }],
});
const result = await resolveClientSkills(['my-skill']);
expect(mockedGetById).not.toHaveBeenCalled();
expect(findSkill(result.skills, 'my-skill')).toMatchObject({
activated: true,
content: 'cached body',
});
});
it('degrades gracefully when a pinned DB skill content fetch fails', async () => {
setToolState({
agentSkills: [{ description: '', id: 'db-1', identifier: 'my-skill', name: 'My Skill' }],
});
mockedGetById.mockRejectedValue(new Error('network down'));
const result = await resolveClientSkills(['my-skill']);
// No throw; skill still listed (available, not activated), just without content.
const skill = findSkill(result.skills, 'my-skill');
expect(skill).toMatchObject({ identifier: 'my-skill' });
expect(skill?.content).toBeUndefined();
expect(skill?.activated).toBeFalsy();
});
});
+67 -6
View File
@@ -1,9 +1,28 @@
import type { OperationSkillSet } from '@lobechat/context-engine';
import { SkillEngine } from '@lobechat/context-engine';
import { resourcesTreePrompt } from '@lobechat/prompts';
import type { SkillItem } from '@lobechat/types';
import debug from 'debug';
import { isBuiltinSkillAvailableInCurrentEnv } from '@/helpers/toolAvailability';
import { agentSkillService } from '@/services/skill';
import { getToolStoreState } from '@/store/tool';
const log = debug('context-engine:resolveClientSkills');
/**
* Build the full content payload for a DB skill detail, appending its resource
* tree when present (mirrors the activateSkill executor output).
*/
const buildDbSkillContent = (detail: SkillItem): string | undefined => {
if (!detail.content) return undefined;
const hasResources = !!(detail.resources && Object.keys(detail.resources).length > 0);
return hasResources
? detail.content + '\n\n' + resourcesTreePrompt(detail.name, detail.resources!)
: detail.content;
};
/**
* Build a client-side OperationSkillSet via SkillEngine.
*
@@ -11,23 +30,65 @@ import { getToolStoreState } from '@/store/tool';
* 1. Builtin skills (e.g., Artifacts) - from toolStore.builtinSkills
* 2. DB skills (user/market) - from toolStore.agentSkills
*
* Pinned skills (ids in `pluginIds`) carry their full `content` so the
* SkillContextProvider can inject it directly into the system prompt instead of
* only listing them under `<available_skills>`. Builtin content is already in
* memory; DB content is fetched on demand (store cache first) and only for the
* pinned skills, to avoid bulk network calls when auto mode exposes every skill.
*
* Uses isBuiltinSkillAvailableInCurrentEnv as the enableChecker to
* filter platform-specific skills (e.g., agent-browser on desktop only).
*/
export const resolveClientSkills = (pluginIds?: string[]): OperationSkillSet => {
export const resolveClientSkills = async (pluginIds?: string[]): Promise<OperationSkillSet> => {
const toolState = getToolStoreState();
const pinnedIds = new Set(pluginIds ?? []);
// Builtin skills keep their full content in the store, so it is always cheap
// to carry along. Pinned skills are marked `activated` so SkillContextProvider
// injects their content directly; non-pinned ones stay in <available_skills>.
const builtinMetas = (toolState.builtinSkills || []).map((s) => ({
activated: pinnedIds.has(s.identifier) && !!s.content,
content: s.content,
description: s.description,
identifier: s.identifier,
name: s.name,
}));
const dbMetas = (toolState.agentSkills || []).map((s) => ({
description: s.description ?? '',
identifier: s.identifier,
name: s.name,
}));
const dbMetas = await Promise.all(
(toolState.agentSkills || []).map(async (s) => {
const meta = {
description: s.description ?? '',
identifier: s.identifier,
name: s.name,
};
// Only pinned DB skills need full content for direct injection; the list
// query (SkillListItem) does not carry content, so fetch it on demand.
if (!pinnedIds.has(s.identifier)) return meta;
// Skills bundled as a ZIP (scripts/resources) must be activated via the
// activateSkill tool so the server mounts their bundle for execScript /
// readReference — that runtime mount is keyed off stepContext.activatedSkills,
// which operation-level pinning does not populate. Pre-injecting their
// content would instruct the model to run scripts from an unmounted bundle,
// so leave bundled skills in <available_skills> and let the model activate them.
if (s.zipFileHash) return meta;
try {
const detail =
toolState.agentSkillDetailMap?.[s.id] ?? (await agentSkillService.getById(s.id));
const content = detail && buildDbSkillContent(detail);
// Mark activated only when content is available, otherwise the skill would
// be excluded from both the activated and the <available_skills> lists.
return content ? { ...meta, activated: true, content } : meta;
} catch (error) {
// A single skill's content fetch must never break the whole request;
// degrade gracefully by listing the skill without injected content.
log('Failed to load content for pinned skill %s: %O', s.identifier, error);
return meta;
}
}),
);
const skillEngine = new SkillEngine({
enableChecker: (skill) => isBuiltinSkillAvailableInCurrentEnv(skill.identifier),
+27
View File
@@ -0,0 +1,27 @@
import type { ListProjectSkillsResult } from '@lobechat/electron-client-ipc';
import { lambdaClient } from '@/libs/trpc/client';
import { localFileService } from '@/services/electron/localFileService';
/**
* Project skills chokepoint. Picks the transport per call from `deviceId`: a
* remote / web target goes through the `device.listProjectSkills` RPC; the local
* desktop talks to Electron over IPC. UI / store only see this service the
* electron-vs-lambda decision never leaks up. (Parallels `projectFileService`.)
*/
class ProjectSkillService {
/** List `.agents/skills` / `.claude/skills` for a working directory. */
async listProjectSkills({
deviceId,
scope,
}: {
deviceId?: string;
scope: string;
}): Promise<ListProjectSkillsResult | undefined> {
return deviceId
? ((await lambdaClient.device.listProjectSkills.query({ deviceId, scope })) ?? undefined)
: localFileService.listProjectSkills({ scope });
}
}
export const projectSkillService = new ProjectSkillService();