mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 11:40:07 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f60c078c | |||
| 96d19fe403 | |||
| 5dd0f0c0c9 | |||
| dfb70c1e87 | |||
| 7ad6e2aa25 | |||
| 3986223b25 | |||
| ea246d6e17 | |||
| f5458e1ad9 | |||
| 251e2ede5e | |||
| 337e7f244c | |||
| eae47f527c | |||
| dfdf844761 | |||
| cca01451f9 | |||
| d2cd9ef023 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+204
-184
@@ -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';
|
||||
|
||||
|
||||
+36
-32
@@ -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';
|
||||
|
||||
|
||||
+7
-2
@@ -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;
|
||||
|
||||
+18
-10
@@ -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>
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -233,6 +233,7 @@ const execInSandboxHandler = async ({
|
||||
const sandboxService = createSandboxService({
|
||||
fileService: ctx.fileService,
|
||||
marketService: ctx.marketService,
|
||||
serverDB: ctx.serverDB,
|
||||
topicId,
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+21
-5
@@ -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', {});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -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`;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user